Contratos sem Implementação — Interfaces e Classes Abstratas

Contratos sem Implementação — Interfaces e Classes Abstratas

Classes abstratas e interfaces em Java: métodos abstratos, o Template Method, contratos com implements, injeção de dependência pelo construtor, métodos default, static e private, o conflito entre defaults, a regra da visibilidade e como escolher entre as duas ferramentas.
Java

• • 17 min de leitura

Como declarar o que um tipo deve fazer sem dizer como, e por que essa separação é o que permite trocar peças de um sistema sem desmontá-lo.

"Do Geral ao Específico" terminou com duas perguntas. A primeira: por que deveria ser possível criar uma "conta genérica", se no banco toda conta é corrente ou poupança? A segunda: como tratar da mesma forma coisas que não têm parentesco nenhum, como um e-mail e um SMS, que sabem "enviar uma mensagem" de maneiras completamente diferentes? As duas respostas usam a mesma ideia, separar o que um tipo faz de como ele faz. Esta aula apresenta as duas ferramentas do Java para isso: as classes abstratas e as interfaces.

Classes abstratas: o tronco que não vira árvore

Vamos mudar de exemplo por um momento, para um que deixa a ideia mais nítida. Um programa de desenho lida com formas geométricas. Toda forma tem uma área e um perímetro, e toda forma sabe se descrever. Mas não existe "uma forma" em geral, só círculos, retângulos e triângulos. E a área de uma forma genérica não tem fórmula.

Uma classe abstrata representa exatamente isso: um conceito que agrupa características comuns, mas que não pode existir sozinho.

public abstract class Forma {
    private final String nome;

    protected Forma(String nome) {
        this.nome = nome;
    }

    public abstract double area();
    public abstract double perimetro();

    public String descrever() {
        return "%s com área %.2f e perímetro %.2f".formatted(nome, area(), perimetro());
    }
}

Três coisas são novas:

  • abstract na classe proíbe criar objetos dela diretamente.
  • Os métodos abstract têm só a assinatura, terminada em ponto e vírgula, sem corpo. Eles declaram que toda forma tem área e perímetro, sem dizer como calculá-los.
  • O construtor é protected, porque só as subclasses vão chamá-lo, pelo super(...).

Tentar criar uma forma genérica dá erro:

E1.java:1: error: Forma is abstract; cannot be instantiated
        Forma f = new Forma("x");
                  ^

As subclasses concretas precisam implementar todos os métodos abstratos:

public class Circulo extends Forma {
    private final double raio;

    public Circulo(double raio) {
        super("Círculo");
        this.raio = raio;
    }

    @Override
    public double area() {
        return Math.PI * raio * raio;
    }

    @Override
    public double perimetro() {
        return 2 * Math.PI * raio;
    }
}
public class Retangulo extends Forma {
    private final double largura;
    private final double altura;

    public Retangulo(double largura, double altura) {
        super("Retângulo");
        this.largura = largura;
        this.altura = altura;
    }

    @Override
    public double area() {
        return largura * altura;
    }

    @Override
    public double perimetro() {
        return 2 * (largura + altura);
    }
}

Uma subclasse que esquece um dos métodos não compila, e o compilador diz qual está faltando:

Triangulo.java:1: error: Triangulo is not abstract and does not override abstract method perimetro() in Forma

Agora o código que usa as formas:

public class Principal {
    public static void main(String[] args) {
        Forma[] formas = {new Circulo(1.0), new Retangulo(3.0, 4.0)};
        double areaTotal = 0;
        for (Forma forma : formas) {
            IO.println(forma.descrever());
            areaTotal += forma.area();
        }
        IO.println("Área total: %.2f".formatted(areaTotal));
    }
}
Círculo com área 3.14 e perímetro 6.28
Retângulo com área 12.00 e perímetro 14.00
Área total: 15.14

(Num computador configurado em português, os números aparecem com vírgula, como vimos em "Operadores, Texto e Conversa com o Usuário".)

Repare no método descrever. Ele é concreto, escrito uma vez na classe abstrata, e chama area() e perimetro(), que são abstratos. Quando é executado num círculo, as chamadas vão para as versões do círculo, pelo despacho dinâmico da aula anterior. É o Template Method de novo, agora de forma mais pura: a classe abstrata define o esqueleto e deixa lacunas obrigatórias para as subclasses preencherem.

Voltando ao banco: se toda conta é corrente ou poupança, ContaBancaria deveria ser abstract. Todo o código comum continua lá, as subclasses continuam herdando tudo, e a "conta genérica" deixa de poder ser criada. Um método como tarifaDeSaque poderia até virar abstrato, obrigando cada tipo de conta a declarar explicitamente a própria tarifa, mesmo que seja zero.

Interfaces: um contrato puro

A classe abstrata resolve o caso de uma família de tipos com um ancestral comum. Mas e quando tipos sem parentesco precisam oferecer a mesma operação? Um sistema quer notificar o usuário quando ele guarda dinheiro. A notificação pode ir por e-mail, por SMS ou, durante os testes, não ir para lugar nenhum. Esses três "notificadores" não têm dados em comum e não compartilham código. Têm só uma capacidade em comum: enviar uma mensagem para alguém.

Uma interface declara essa capacidade:

public interface Notificador {
    void enviar(String destinatario, String mensagem);
}

Uma interface é uma lista de métodos que um tipo se compromete a oferecer. Os métodos declarados sem corpo são implicitamente public e abstract, e não é preciso escrever nenhuma das duas palavras. Uma classe assina o contrato com implements:

public class NotificadorPorEmail implements Notificador {
    @Override
    public void enviar(String destinatario, String mensagem) {
        IO.println("[e-mail para " + destinatario + "] " + mensagem);
    }
}
public class NotificadorPorSms implements Notificador {
    private static final int LIMITE = 30;

    @Override
    public void enviar(String destinatario, String mensagem) {
        String texto = mensagem.length() > LIMITE
                ? mensagem.substring(0, LIMITE - 3) + "..."
                : mensagem;
        IO.println("[SMS para " + destinatario + "] " + texto);
    }
}

(Num sistema real, os métodos chamariam um serviço de e-mail ou uma operadora. Aqui imprimem na tela, para podermos ver o que acontece.)

Agora vem a parte importante: a classe que usa a notificação não sabe, e não precisa saber, qual notificador está usando.

public class Cofre {
    private final String dono;
    private final Notificador notificador;
    private double saldo;

    public Cofre(String dono, Notificador notificador) {
        this.dono = dono;
        this.notificador = notificador;
    }

    public void guardar(double valor) {
        saldo += valor;
        notificador.enviar(dono, "Você guardou R$ %.2f. Saldo: R$ %.2f".formatted(valor, saldo));
    }
}
Cofre cofre1 = new Cofre("ana@exemplo.com", new NotificadorPorEmail());
Cofre cofre2 = new Cofre("+55 81 99999-0000", new NotificadorPorSms());

cofre1.guardar(100);
cofre2.guardar(250.5);
[e-mail para ana@exemplo.com] Você guardou R$ 100.00. Saldo: R$ 100.00
[SMS para +55 81 99999-0000] Você guardou R$ 250.50. Sal...

A classe Cofre depende apenas do contrato Notificador. Ela recebe o notificador pronto no construtor, em vez de criá-lo. Isso tem consequências enormes:

  • Trocar a forma de notificar não muda o Cofre. Um notificador por WhatsApp é uma classe nova que implementa a interface, e o Cofre funciona com ela sem que nenhuma linha dele seja alterada.
  • Testar fica fácil. Num teste, o Cofre pode receber um notificador que não envia nada, ou que só registra as mensagens para conferência, sem disparar e-mails de verdade. É o tema de "Dublês e Disciplina".
  • As dependências ficam explícitas. Basta olhar o construtor do Cofre para saber que ele precisa de um notificador.

Essa técnica de entregar a um objeto as peças de que ele precisa, em vez de deixá-lo criá-las, chama-se injeção de dependência. Ela é tão central que frameworks inteiros foram construídos para automatizá-la, e você vai usar um deles em "Spring Boot pela Primeira Vez". Por trás de tudo está a ideia desta aula: dependa de contratos, não de implementações.

Uma classe, vários contratos

Uma classe pode estender uma única superclasse, mas pode implementar quantas interfaces quiser:

public class ContaPoupanca extends ContaBancaria implements Rentavel, Tributavel { ... }

É assim que o Java contorna a falta de herança múltipla da aula anterior. A herança de classe diz o que um objeto é. As interfaces dizem o que ele sabe fazer, e um objeto pode saber fazer muitas coisas. A biblioteca padrão está cheia de interfaces desse tipo. Comparable significa "sei me comparar com outro do meu tipo" e aparece em "Ordem e Critério". AutoCloseable significa "tenho recursos para liberar ao terminar" e é a base de "Recursos que se Fecham Sozinhos". Runnable significa "sou uma tarefa que pode ser executada" e está no centro da Fase 7.

Métodos default, static e private

Até o Java 7, interfaces só podiam ter métodos abstratos. Isso tinha um problema grave: acrescentar um método a uma interface publicada quebrava todas as classes que a implementavam, porque nenhuma delas tinha o método novo. Desde o Java 8, uma interface pode ter métodos default, com implementação:

public interface Notificador {
    void enviar(String destinatario, String mensagem);

    default void enviarParaTodos(String[] destinatarios, String mensagem) {
        for (String destinatario : destinatarios) {
            enviar(destinatario, mensagem);
        }
    }

    static Notificador silencioso() {
        return new NotificadorSilencioso();
    }
}

Toda classe que implementa Notificador ganha enviarParaTodos automaticamente, sem escrever nada, e pode sobrescrevê-lo se tiver uma forma mais eficiente, como um serviço de e-mail que aceita vários destinatários numa só chamada. O método default é escrito apenas em termos do método abstrato, e por isso funciona com qualquer implementação.

Notificador email = new NotificadorPorEmail();
email.enviarParaTodos(new String[] {"a@x.com", "b@x.com"}, "Manutenção às 22h");
[e-mail para a@x.com] Manutenção às 22h
[e-mail para b@x.com] Manutenção às 22h

Interfaces também podem ter métodos static, como silencioso(), que devolve um notificador que não faz nada, útil em testes. Eles são chamados pelo nome da interface: Notificador.silencioso(). Desde o Java 9, uma interface pode ter também métodos private, auxiliares para os métodos default, que as implementações não enxergam.

Uma interface não pode ter campos de instância, isto é, estado. Pode ter constantes, e todo campo declarado numa interface é automaticamente public static final.

O conflito dos defaults

Com métodos default, reaparece o problema da herança múltipla: o que acontece quando duas interfaces oferecem um default com o mesmo nome?

interface Voador {
    default String mover() { return "voando"; }
}

interface Nadador {
    default String mover() { return "nadando"; }
}

class Robo implements Voador, Nadador { }
Teste.java:13: error: types Voador and Nadador are incompatible;
  class Robo inherits unrelated defaults for mover() from types Voador and Nadador

O Java não escolhe por você. A classe precisa sobrescrever o método e decidir, podendo chamar explicitamente qualquer uma das versões com a sintaxe Interface.super.metodo():

class Pato implements Voador, Nadador {
    @Override
    public String mover() {
        return Voador.super.mover() + " e " + Nadador.super.mover();
    }
}

new Pato().mover() devolve "voando e nadando". A ambiguidade que o C++ resolve com regras complexas, o Java resolve obrigando o programador a declarar a intenção.

Implementações são sempre públicas

Como os métodos abstratos de uma interface são implicitamente public, as implementações também precisam ser. Esquecer o public dá um erro que confunde na primeira vez:

C.java:3: error: medir() in C cannot implement medir() in Medida
    void medir() { }
         ^
  attempting to assign weaker access privileges; was public

A regra geral é que uma sobrescrita nunca pode reduzir a visibilidade do método original. Se o contrato promete que qualquer um pode chamar o método, uma implementação não pode escondê-lo. Isso violaria a substituição de Liskov da aula anterior.

Classe abstrata ou interface?

As duas ferramentas se sobrepõem, e a escolha nem sempre é óbvia. Um resumo:

Classe abstrata Interface
Uma classe pode ter uma só como mãe quantas quiser
Pode ter estado (campos) sim não, só constantes
Pode ter construtor sim não
Métodos com implementação sim sim (default, static, private)
Relação expressa "é um" "sabe fazer"
Ideal para família de tipos com código e estado comuns capacidades compartilhadas por tipos sem parentesco

Na dúvida, comece pela interface. Ela é mais flexível: não gasta a única herança de classe disponível, não impõe estado e pode ser implementada por qualquer classe, inclusive por classes que já têm outra mãe. Quando várias implementações passarem a repetir o mesmo código e o mesmo estado, uma classe abstrata que implementa a interface pode reunir essa parte comum. A biblioteca padrão faz exatamente isso: a interface List define o contrato das listas, e a classe abstrata AbstractList oferece uma implementação parcial para quem quiser criar uma lista nova.

Com interfaces e classes abstratas, você tem agora todas as peças do polimorfismo: tipos que prometem comportamentos, implementações diferentes para cada promessa, e código que trabalha com as promessas sem conhecer as implementações. Até aqui, o polimorfismo apareceu como um efeito colateral de cada aula, no laço de contas, no descrever das formas e no Cofre com seus notificadores.

Fontes e leituras recomendadas

  • The Java Language Specification, Java SE 25, capítulo 9, "Interfaces": https://docs.oracle.com/javase/specs/jls/se25/html/jls-9.html. A definição completa de interfaces, incluindo as regras de resolução de conflitos entre métodos default (seção 9.4.1).
  • Oracle, The Java Tutorials, "Interfaces and Inheritance": https://docs.oracle.com/javase/tutorial/java/IandI/index.html. A trilha do tutorial com interfaces, métodos default e classes abstratas, incluindo uma seção sobre quando usar cada uma.
  • Joshua Bloch, Effective Java, 3ª edição, itens 20 e 21: "Prefer interfaces to abstract classes" e "Design interfaces for posterity". O segundo discute os riscos de acrescentar métodos default a interfaces já publicadas.
  • Martin Fowler, "Inversion of Control Containers and the Dependency Injection pattern" (2004): https://martinfowler.com/articles/injection.html. O artigo que deu nome à injeção de dependência. É longo, mas as primeiras seções mostram o problema que ela resolve com um exemplo simples.
  • Brian Goetz, "Interface evolution via virtual extension methods" (2011): https://cr.openjdk.org/~briangoetz/lambda/Defender%20Methods%20v4.pdf. O documento de projeto dos métodos default, escrito pelo arquiteto da linguagem, com a motivação e as regras de conflito.

Exercícios

Exercício 1

Complete a classe Triangulo, subclasse de Forma, com os três lados como campos. Use a fórmula de Heron para a área: com s = (a + b + c) / 2, a área é √(s(s − a)(s − b)(s − c)). O construtor deve recusar lados que não formam um triângulo, isto é, quando um lado é maior ou igual à soma dos outros dois.

Ver resposta

✓ Resposta:

public class Triangulo extends Forma {
    private final double a;
    private final double b;
    private final double c;

    public Triangulo(double a, double b, double c) {
        super("Triângulo");
        if (a <= 0 || b <= 0 || c <= 0 || a >= b + c || b >= a + c || c >= a + b) {
            throw new IllegalArgumentException(
                "Lados não formam um triângulo: " + a + ", " + b + ", " + c);
        }
        this.a = a;
        this.b = b;
        this.c = c;
    }

    @Override
    public double area() {
        double s = perimetro() / 2;
        return Math.sqrt(s * (s - a) * (s - b) * (s - c));
    }

    @Override
    public double perimetro() {
        return a + b + c;
    }
}

new Triangulo(3, 4, 5).descrever() produz "Triângulo com área 6.00 e perímetro 12.00". A validação usa a desigualdade triangular. Sem ela, lados como 1, 2 e 10 fariam o produto dentro da raiz ficar negativo, e a área seria NaN, como vimos em "Cada Valor em sua Caixa". A validação vem depois do super(...), porque a chamada ao construtor da mãe precisa ser a primeira instrução. area() reaproveita perimetro() em vez de repetir a soma. E, graças ao método descrever herdado, o triângulo já sabe se apresentar sem nenhuma linha extra.

Exercício 2

Para cada situação, diga se usaria uma classe abstrata ou uma interface, e justifique: (a) animais de um zoológico, que têm nome, idade e peso e emitem sons diferentes; (b) a capacidade de ser exportado para PDF, compartilhada por relatórios, notas fiscais e contratos; (c) funcionários de uma empresa, que têm salário-base e calculam bônus de formas diferentes conforme o cargo.

Ver resposta

✓ Resposta: (a) Classe abstrata Animal. Todos os animais compartilham estado (nome, idade, peso), que uma interface não pode ter, e formam uma família genuína com relação "é um". O som seria um método abstrato. (b) Interface ExportavelParaPdf. Relatórios, notas e contratos não têm parentesco nem estado comum, só uma capacidade compartilhada. Além disso, cada um provavelmente já tem a própria superclasse, e a interface não disputa a única herança disponível. (c) Classe abstrata Funcionario, com o salário-base como campo, os métodos comuns (como calcular o salário total somando o bônus) implementados, e o cálculo do bônus abstrato. Se, no futuro, terceirizados ou estagiários, que não são funcionários, também precisarem calcular pagamentos, vale extrair uma interface Remunerado implementada por Funcionario e pelas outras classes. É o padrão da biblioteca: interface para o contrato e classe abstrata para a implementação parcial.

Exercício 3

Escreva uma classe NotificadorRegistrador que implemente Notificador e, em vez de enviar mensagens, guarde-as num array interno, com um método para consultar quantas mensagens foram "enviadas" e qual foi a última. Por que ela seria útil para testar o Cofre?

Ver resposta

✓ Resposta:

import java.util.Arrays;

public class NotificadorRegistrador implements Notificador {
    private String[] mensagens = new String[4];
    private int quantidade = 0;

    @Override
    public void enviar(String destinatario, String mensagem) {
        if (quantidade == mensagens.length) {
            mensagens = Arrays.copyOf(mensagens, mensagens.length * 2);
        }
        mensagens[quantidade++] = destinatario + ": " + mensagem;
    }

    public int getQuantidade() {
        return quantidade;
    }

    public String getUltima() {
        return quantidade == 0 ? null : mensagens[quantidade - 1];
    }
}

Num teste, cria-se o registrador, entrega-se ao Cofre, chama-se guardar(100) e confere-se que getQuantidade() é 1 e que getUltima() contém "R$ 100". O teste verifica que o Cofre notifica corretamente sem enviar e-mails reais, sem depender de rede e em milissegundos. Isso só é possível porque o Cofre depende da interface e recebe o notificador pelo construtor. Se criasse um NotificadorPorEmail internamente, não haveria como substituí-lo. Um objeto que finge ser outro durante um teste é chamado de dublê de teste, e ferramentas como o Mockito criam dublês automaticamente, como você vai ver em "Dublês e Disciplina".

Exercício 4

A classe Robo da aula não compila por implementar Voador e Nadador, que têm defaults conflitantes para mover(). Corrija-a de modo que um robô se mova apenas voando, e explique por que o Java não escolhe uma das versões automaticamente.

Ver resposta

✓ Resposta:

class Robo implements Voador, Nadador {
    @Override
    public String mover() {
        return Voador.super.mover();
    }
}

new Robo().mover() devolve "voando". O Java não escolhe automaticamente porque qualquer regra fixa, como "vale a primeira interface da lista", faria o comportamento depender de um detalhe arbitrário, como a ordem em que as interfaces foram escritas. Pior: o conflito pode surgir depois, quando alguém acrescenta um default a uma das interfaces, e uma classe que funcionava mudaria de comportamento sem aviso. Tornando o conflito um erro de compilação, o Java obriga quem escreve a classe a decidir de forma explícita, e a decisão fica registrada no código. Há uma exceção: se uma das interfaces estender a outra, a versão mais específica vence sem conflito, porque aí a relação entre elas deixa clara a intenção.

Exercício 5

Explique por que uma implementação de método de interface não pode ter visibilidade menor que public, relacionando a regra com a substituição de Liskov vista em "Do Geral ao Específico".

Ver resposta

✓ Resposta: Os métodos abstratos de uma interface são implicitamente public: o contrato promete que qualquer código que tenha uma referência do tipo da interface pode chamá-los. Se uma implementação pudesse declarar o método com acesso de pacote, por exemplo, um código de outro pacote que recebesse um Notificador e chamasse enviar quebraria justamente quando o objeto fosse daquela implementação. A implementação não poderia substituir a interface em todos os lugares, e isso é exatamente a violação do princípio da substituição. A regra vale para qualquer sobrescrita, inclusive de classes: é possível ampliar a visibilidade (um método protected pode virar public na subclasse), porque isso só acrescenta possibilidades, mas nunca reduzi-la. O compilador aplica a regra e transforma uma possível falha em tempo de execução num erro de compilação.

Comentários

Mais em Java

Moldes e Instâncias — Classes, Objetos e Construtores
Moldes e Instâncias — Classes, Objetos e Construtores

Classes e objetos em Java: campos, new e referências, o heap, null e a…

Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma
Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma

Nesta aula você desenha uma fase no formato do editor Tiled, carrega esse mapa…

O Programa que se Examina — Anotações e Reflexão
O Programa que se Examina — Anotações e Reflexão

Como declarar anotações próprias em Java, escolher a retenção certa e usar a…