Pular para conteúdo

Orientação a Objetos

Orientação a Objetos (OO) é um paradigma de programação — uma forma de organizar código — alternativa ao paradigma procedural (onde tudo é função e variável solta). A ideia central: modelar o problema em termos de objetos que combinam estado (os dados que carregam) e comportamento (o que sabem fazer), de um jeito mais próximo de como pensamos sobre o mundo real.

Os exemplos desta página usam Java como linguagem, mas os conceitos valem para qualquer linguagem orientada a objetos (Python, C#, Kotlin, ...). O que é específico da sintaxe Java fica em Linguagens de Programação → Java.

De onde vem a OO e por que usá-la

Origem. A OO nasceu da simulação: para modelar sistemas reais (filas, tráfego, fábricas) é natural descrever entidades que mudam de estado ao longo do tempo e se relacionam (discrete event simulation). Em 1962, na Noruega, Ole-Johan Dahl e Kristen Nygaard criaram o SIMULA (versão final, SIMULA 67), a primeira linguagem com classes, objetos e herança — pensada para ser orientada a problemas, e não ao computador (ganharam o prêmio Turing em 2001). Nos anos 1970, Alan Kay, no Xerox PARC, criou o Smalltalk (e popularizou os termos "objeto" e "mensagem") para computadores pessoais; de lá vieram C++, Java, C#, Python e as demais.

O paradigma anterior (estruturado/procedural) usa três estruturas — sequência, decisão e repetição — e separa dados de operações. Funciona bem para problemas pequenos; num controle de estoque, com produto, venda, cliente e dezenas de operações, os dados e as funções de cada conceito ficam misturados em um mesmo código, e a modularização por funções fica cada vez mais complexa. Em C, reaproveitar dados exige struct e repetição (um Pagamento dentro de Debito e de Credito, e mais cópias a cada novo tipo).

Motivo para usar OO Como a OO ajuda
Reúso De comportamento (métodos) e de informação (atributos), por herança, composição e polimorfismo
Coesão Dados e operações de um conceito ficam juntos em uma classe
Baixo acoplamento Classes dependem de abstrações (interfaces, classes abstratas), e uma mudança não se espalha
Menor gap semântico Reduz a distância entre o problema (mundo real/domínio) e a solução (código): o código fala Cliente, Pedido, Pagamento

Os conceitos da OO se agrupam em estruturais (classe, atributo, método, objeto, tipos), relacionais (herança, associação, interface) e organizacionais (pacotes e visibilidades).

Classe e objeto

Uma classe é um molde: define quais atributos (dados) e métodos (comportamentos) um tipo de objeto vai ter. Um objeto é uma instância concreta desse molde — cada instância tem seus próprios valores para os atributos.

public class Livro {
    String nome;
    String descricao;
    double valor;
    String isbn;
}

Isso só define o molde. Para existir de verdade (na memória, em tempo de execução), precisamos instanciar um objeto com a palavra-chave new:

Livro livro = new Livro();
livro.nome = "Java 8 Prático";
livro.valor = 59.90;

Definição: Classe x Objeto

Uma classe é a especificação (o que um objeto desse tipo deve ter e como deve se comportar); um objeto é uma instância concreta dessa especificação, com seus próprios valores. A mesma classe Livro pode gerar milhares de objetos Livro diferentes — cada um com seu nome, valor e ISBN próprios.

Uma classe também pode ter, como atributo, outra classe — por exemplo, um Livro pode ter um Autor. Isso é chamado de composição: montar um objeto a partir de outros objetos, em vez de repetir os mesmos campos em lugares diferentes.

Definição: Membros da classe

Nome coletivo para tudo que pode ser declarado dentro de uma classe: atributos (variáveis), métodos e construtores. "Membro" é o termo genérico usado quando a distinção entre os três não importa para o que está sendo discutido.

Referência, não valor

Ponto que costuma confundir quem vem de tipos primitivos: uma variável de objeto guarda uma referência (um endereço de memória) para onde o objeto vive, não o objeto em si.

graph LR
    v1["autor"] --> obj1["Autor { nome: Rodrigo Turini }"]
    v2["autor2"] --> obj2["Autor { nome: Rodrigo Turini }"]

Isso tem duas consequências que caem bastante em entrevista:

  • Comparar com == compara referência, não conteúdo. Dois objetos Autor criados separadamente, mesmo com os mesmos dados, são != entre si com == — porque vivem em endereços de memória diferentes. (Comparar conteúdo é outro assunto: em Java, isso é o método equals, que por padrão também compara referência a menos que a classe sobrescreva esse comportamento.)
  • Atribuir um objeto a outra variável não copia o objeto — copia a referência. Depois de livro.autor = autor, tanto autor quanto livro.autor apontam para o mesmo objeto na memória. Mudar um atributo desse Autor por qualquer um dos dois caminhos afeta o mesmo objeto.

Isso é o oposto do que acontece com tipos primitivos, onde atribuir sempre copia o valor.

Definição: Pilha de execução (stack) e heap

A JVM guarda variáveis locais e o controle de chamadas de método na pilha de execução (stack) — cada método chamado empilha seu próprio quadro, com suas próprias variáveis locais, removido quando o método retorna. Os objetos em si (criados com new) vivem no heap, uma área de memória separada e compartilhada; o que fica na pilha, para uma variável de objeto, é só a referência (o "endereço") que aponta para o objeto no heap.

Java é sempre pass-by-value — mesmo para objetos

Um dos pontos mais mal-entendidos da linguagem: Java nunca passa parâmetros por referência — passa sempre por valor. A pegadinha é que, para uma variável de objeto, o "valor" copiado para o parâmetro é a própria referência (o endereço), não o objeto. Isso tem uma consequência importante:

static void test(Exam exam) {
    exam.timeLimit = 210; // MUDA o objeto original — os dois apontam pra ele
}

static void test2(Exam exam) {
    exam = new Exam();     // troca só a referência LOCAL a este método
    exam.timeLimit = 520;  // afeta só o objeto novo, não o original
}
  • Mudar um atributo do objeto através do parâmetro afeta o objeto original — porque a cópia da referência ainda aponta para o mesmo objeto no heap.
  • Reatribuir o próprio parâmetro (exam = new Exam(); ou exam = null;) só troca para onde a cópia local da referência aponta — a variável original, no método que chamou, continua apontando para o objeto de sempre. Depois que test2 retorna, a referência original está intacta.

Ou seja: dá para mutar o objeto apontado através de um parâmetro, mas não dá para fazer a variável original do chamador apontar para outro objeto de dentro do método chamado — isso é, na prática, o que diferenciaria pass-by-reference de verdade (que Java não tem).

Métodos: isolando comportamento

Sem métodos, todo comportamento (como montar uma mensagem, calcular um desconto) fica espalhado pelo código que usa o objeto — e repetido, se mais de um lugar precisar do mesmo comportamento. Um método isola esse comportamento dentro da própria classe:

public class Livro {
    String nome;
    double valor;

    void mostrarDetalhes() {
        System.out.println("Nome: " + nome);
        System.out.println("Valor: " + valor);
    }
}

Um método pode:

  • Não retornar nada (void) — só executa uma ação.
  • Receber parâmetros — dados que vêm de fora na hora de chamar o método:
    public void aplicaDescontoDe(double porcentagem) {
        valor -= valor * porcentagem;
    }
    
  • Retornar um valor, com return — o tipo de retorno declarado na assinatura do método precisa bater com o que é devolvido:
    boolean temAutor() {
        return autor != null;
    }
    

Definição: Assinatura de um método

O nome do método mais os tipos (e ordem) dos parâmetros — não inclui o tipo de retorno nem o nome dos parâmetros. aplicaDescontoDe(double) e aplicaDescontoDe(int) são assinaturas diferentes (permitindo sobrecarga); mudar só o nome de um parâmetro ou só o tipo de retorno não cria uma assinatura nova.

Além da assinatura, um método pode levar outros modificadores — todos opcionais, mas em uma ordem fixa quando combinados: modificadores tipoDeRetorno nome(parâmetros) throws Exceções. Em Java (além do modificador de visibilidade, ver Encapsulamento):

Modificador Efeito
final não pode ser sobrescrito por subclasses (ver Herança)
abstract obriga subclasses a implementar; sem corpo (ver Classe abstrata)
static pertence à classe, não à instância (ver Java)
synchronized trava a instância para acesso concorrente (multithread)
native implementado em código nativo (fora da JVM), via JNI
strictfp força ponto flutuante a seguir o padrão IEEE 754 à risca, para portabilidade

Parâmetros não têm valor padrão em Java — todos são obrigatórios em toda chamada (diferente de outras linguagens que aceitam parâmetro opcional/com valor default). O único modificador aceito num parâmetro é final, impedindo reatribuição dentro do método. Argumentos passados também sofrem promoção de tipo (widening) e polimorfismo, como qualquer atribuição:

void primitivo(double d) { }
primitivo(10);  // int promovido a double automaticamente

void referencia(Object o) { }
referencia(new Car()); // Car é um Object, por polimorfismo

Retorno: um método com tipo de retorno diferente de void precisa garantir que todo caminho possível termine em return (com um valor compatível) ou lance uma exceção — o compilador não avalia o valor de condições para decidir isso, só a estrutura do código, então até um if/else if que cobre todos os casos "na prática" dá erro de compilação se não tiver um return (ou throw) coletando o caso restante:

String method(int a) {
    if (a > 0) {
        return "> 0";
    } else if (a <= 0) {
        return "<= 0";
    }
    // erro de compilação: compilador não sabe que essas duas condições cobrem tudo
}

O retorno de um método pode ser ignorado por quem chama, mesmo que não seja void — mas o contrário não vale: não é possível atribuir a uma variável o "retorno" de um método void.

Definição: this

Palavra-chave que se refere ao próprio objeto, dentro de um método. É opcional na maioria dos casos, mas necessária quando o nome de um parâmetro é igual ao nome de um atributo da classe — sem this, o compilador entende que você está falando do parâmetro (menor escopo), não do atributo:

public void aplicaDescontoDe(double valor) {
    this.valor -= this.valor * valor; // this.valor = atributo; valor = parâmetro
}
Mesmo quando não é estritamente necessário, usar this para acessar atributos é considerado boa prática — deixa explícito, pra quem lê, que aquilo é um atributo da classe.

Encapsulamento

Encapsulamento é o pilar de OO que trata de esconder os detalhes internos de uma classe, expondo só o que é necessário. Na prática, isso significa: atributos ficam privados (o modificador de visibilidade private), e o acesso a eles acontece só através de métodos.

public class Livro {
    private double valor; // não é mais acessível diretamente de fora da classe

    public double getValor() {
        return valor;
    }

    public void setValor(double valor) {
        this.valor = valor;
    }
}

Definição: Getter e Setter

Métodos convencionais para ler (get) e atribuir (set) um atributo privado. Um getX() não recebe parâmetro e retorna o atributo; um setX(valor) recebe um parâmetro do mesmo tipo do atributo e o atualiza. É a forma padrão de dar acesso controlado a um atributo private sem quebrar o encapsulamento — mas criar um getter/setter para todo atributo, sempre, não é obrigatório nem sempre desejável: crie-os apenas quando existir uma necessidade real de leitura/escrita externa.

Um bom teste para saber se uma classe está bem encapsulada: olhando de fora pra ela, você consegue responder o quê ela faz, mas não como ela faz. Os métodos públicos de uma classe (sua interface) são a única forma de interagir com seus objetos — por isso, mudar a implementação interna de um método não deveria afetar quem o usa.

Definição: Modificadores de acesso

Palavras-chave que controlam de onde um atributo, método, construtor ou classe pode ser acessado. Do mais restrito ao mais aberto, cada nível inclui o anterior:

  • private — só dentro da própria classe.
  • default (sem palavra-chave nenhuma) — também dentro do mesmo pacote inteiro.
  • protected — também para subclasses, mesmo que estejam em outro pacote (ver Herança).
  • public — de qualquer lugar do projeto.

Regra prática: classes costumam ser public (senão ficam invisíveis para outros pacotes); atributos costumam ser private (encapsulamento); o que fica protected ou default é decisão pontual, quando existe uma razão específica para abrir esse acesso a subclasses ou ao pacote.

Detalhes que costumam pegar de surpresa:

  • Só é permitido um modificador de acesso por vez (private public int x; é erro de compilação).
  • Uma classe/interface top-level (não aninhada dentro de outra) só aceita public ou default — nunca private nem protected. Classes aninhadas (nested/inner classes) podem usar os quatro, por serem membros da classe que as contém.
  • Variáveis locais e parâmetros não podem ter modificador de acesso (só aceitam outros modificadores, como final) — acesso só faz sentido para membros de classe.
  • Se a classe é default (invisível fora do pacote), isso vale para tudo dentro dela, não importa que os membros individuais sejam public — o acesso à classe é sempre o primeiro filtro.

Definição: protected entre pacotes — só através de this ou do próprio tipo

Um detalhe fino: uma subclasse em outro pacote só acessa um membro protected herdado através de this (implícito ou explícito) ou de uma referência do seu próprio tipo (ou subtipo) — nunca através de uma referência tipada como a superclasse, mesmo vindo de dentro da própria subclasse:

package shape;
public class Shape {
    protected double side;
}
package another;
class Triangle extends Shape {
    public void printSide() {
        System.out.println(side);         // ok — acesso implícito via this
        Shape myself = (Shape) this;
        System.out.println(myself.side);   // erro de compilação!
    }
}
Isso acontece porque, uma vez tipada como Shape (a superclasse, de outro pacote), a referência myself só enxerga o que Shape expõe naquele pacote — e side não é public lá. this/Triangle funciona porque o acesso é resolvido pelo tipo real (Triangle), não pela superclasse.

A palavra default em Java tem múltiplos significados dependendo de onde aparece (caso padrão de switch, valor padrão de uma @Annotation, default method de interface) — mas não existe como palavra-chave para declarar acesso: para dar acesso default a um membro, você simplesmente omite qualquer modificador; não existe default int x;.

Construtor

Um construtor é um método especial, chamado no momento em que um objeto é criado com new — tem o mesmo nome da classe e não declara tipo de retorno:

public class Livro {
    public Livro() {
        System.out.println("novo livro criado");
    }
}

Se você não declarar nenhum construtor, o compilador cria um construtor vazio (sem parâmetros, sem corpo) automaticamente — é por isso que new Livro() sempre funciona, mesmo sem você ter escrito um construtor. Todo código dentro do construtor roda toda vez que um objeto daquele tipo é criado.

Definição: Construtor x método com o mesmo nome da classe

É permitido ter um método com o mesmo nome da classe — o que o diferencia de um construtor é só a presença de um tipo de retorno (mesmo void já basta):

class Executor {
    Executor() { }        // construtor: sem tipo de retorno
    void Executor() { }   // método comum: tem tipo de retorno (void)
}
Um construtor pode ter um return; vazio (sem valor) para interromper sua execução mais cedo — o que não é permitido é um return valor; com valor, já que construtor não tem tipo de retorno.

O construtor gerado automaticamente pelo compilador (quando nenhum é declarado) tem a mesma visibilidade da classe e, se houver superclasse, chama super() implicitamente como primeira instrução — mesmo sem você escrever nada disso.

A ordem de inicialização de uma instância é sempre: 1. atributos recebem seus valores default; 2. atributos com inicializador na própria declaração (int i = 15;) são inicializados, na ordem em que aparecem no arquivo; 3. o corpo do construtor roda.

Definição: Perigo de chamar método sobrescrevível dentro de um construtor

Chamar, de dentro de um construtor, um método de instância que pode ser sobrescrito por uma subclasse é arriscado: se uma subclasse sobrescrever esse método e o novo código acessar um atributo daquela subclasse — que ainda não foi inicializado, porque o construtor da subclasse nem começou a rodar —, o resultado é tipicamente um NullPointerException em tempo de construção do objeto:

class Base {
    String name;
    Base() {
        test();          // chama um método que PODE estar sobrescrito
        name = "guilherme";
    }
    void test() {
        System.out.println("testing");
    }
}
class Filho extends Base {
    @Override
    void test() {
        System.out.println(name.length()); // NullPointerException — name ainda é null
    }
}
Se test() for private (não pode ser sobrescrito — binding é resolvido em tempo de compilação, sempre para a versão da própria classe), esse risco desaparece. Regra prática: evite chamar métodos sobrescrevíveis (não private, não final, não static) de dentro de um construtor.

Construtores também recebem parâmetros — útil para obrigar que uma dependência seja passada logo na criação do objeto, em vez de deixar o objeto existir num estado incompleto:

public class Livro {
    private Autor autor;

    public Livro(Autor autor) {
        this.autor = autor;
    }
}

Importante: assim que você declara qualquer construtor com parâmetro, o compilador para de gerar o construtor vazio automaticamente. Se seu código também precisar permitir criar o objeto sem argumentos, você precisa declarar esse construtor vazio explicitamente.

Definição: Sobrecarga (Overload)

Ter mais de um método (ou construtor) com o mesmo nome na mesma classe, desde que a lista de parâmetros seja diferente (em quantidade ou tipo). O compilador decide qual versão chamar de acordo com os argumentos passados, em tempo de compilação. Construtores podem ser sobrecarregados do mesmo jeito que métodos comuns.

Regras de resolução que costumam confundir:

  • Só o tipo de retorno não basta para diferenciar uma sobrecarga — dois métodos com mesmo nome e mesmos parâmetros, mas retornos diferentes, é erro de compilação (ambíguo demais: quem chama sem usar o retorno não teria como o compilador saber qual invocar).
  • Quando mais de uma versão sobrecarregada é compatível com os argumentos passados (por widening ou por polimorfismo), o compilador escolhe a mais específica — o tipo que exige menos "promoção":
    void method(Object o) { }
    void method(String s) { }
    method("random"); // chama method(String) — mais específico que Object
    
    Para forçar a versão mais genérica mesmo tendo uma mais específica disponível, use casting explícito no argumento: method((Object) "random").
  • Se nenhuma versão for claramente mais específica (ex.: sobrecargas simétricas method(String, double) e method(double, String), chamadas com dois int quaisquer — ambos os parâmetros podem ser promovidos igualmente para os dois lados), a chamada é ambígua e não compila.

Quando uma classe tem mais de um construtor, um pode delegar para o outro com this(...) — útil para evitar repetir a mesma lógica de inicialização em cada versão:

public Livro(Autor autor) {
    this(); // chama o construtor sem parâmetros primeiro
    this.autor = autor;
}

public Livro() {
    this.isbn = "000-00-00000-00-0";
}

Regras importantes sobre this(...):

  • Só pode ser a primeira instrução do construtor — e só pode aparecer uma vez (não é possível ter duas chamadas this(...) seguidas no mesmo construtor).
  • Os argumentos passados podem envolver expressões e chamadas a métodos estáticos, mas não a métodos de instância — o objeto ainda não foi construído, então não existe this de verdade para invocar um método de instância nele ainda.
  • Uma cadeia de this(...) que chama a si mesma, direta ou indiretamente, não compila (o compilador detecta a recursão de construtores). Uma recursão que não seja via this(...), no entanto — como criar um new da própria classe de dentro do construtor — compila normalmente e estoura em StackOverflowError em tempo de execução, já que o compilador não analisa esse tipo de ciclo.

Um construtor também pode ser private — um padrão comum para forçar a criação de objetos só através de um método estático (factory method) da própria classe, em vez de new direto:

class Test {
    private Test() { }

    public static Test cria() {
        return new Test();
    }
}

Um uso comum de construtor é inicializar um atributo com um valor padrão, em vez de deixá-lo null:

public Livro(Autor autor) {
    this.autor = autor;
    this.isbn = "000-00-00000-00-0";
}

Isso importa porque, diferente dos tipos primitivos — que sempre têm um valor padrão (0 para números, false para boolean) — atributos de tipo objeto (como String) começam como null quando não são explicitamente inicializados.

Vantagens do paradigma

Comparado com escrever tudo dentro de um único método main (estilo procedural), isolar comportamento em classes e objetos traz:

  • Menos repetição — o comportamento vive em um único lugar (o método), não espalhado em toda parte do código que precisa dele.
  • Manutenção mais fácil — mudar uma regra de negócio significa mudar em um lugar só, não em todos os pontos onde ela foi repetida.
  • Conexão natural entre dado e comportamento — em vez de dados soltos manipulados por funções externas, o objeto sabe o que ele é e o que sabe fazer.

Esses ganhos ficam ainda mais evidentes com os próximos pilares do paradigma: herança e polimorfismo.

Relações entre objetos: associação, agregação e composição

Objetos colaboram. Escolher a relação certa depende de propriedade e ciclo de vida: o objeto apenas conhece o outro, compartilha-o ou o tem como parte inseparável.

Relação Pergunta Ciclo de vida UML simplificada
Associação Um objeto conhece ou usa outro? Independentes A -> B
Agregação O todo agrupa partes que existem sozinhas? A parte pode existir sem o todo A o-- B
Composição A parte pertence ao todo e não faz sentido sem ele? A parte acompanha o ciclo de vida do todo A *-- B

Definição: Relação 'tem-um' (has-a)

Quando um objeto contém ou referencia outro como atributo (um Pedido tem itens; um Carro tem um motor). Contrasta com a relação "é-um" (is-a), que é a herança (um Gerente é um Funcionario). Para "tem-um", prefira composição; veja Herança ou composição?.

class Pedido {
    private final List<ItemPedido> itens = new ArrayList<>();

    public void adicionar(Produto produto, int quantidade) {
        itens.add(new ItemPedido(produto, quantidade));   // Pedido cria e possui os itens
    }

    public double total() {
        return itens.stream().mapToDouble(ItemPedido::subtotal).sum();
    }
}

Aqui Pedido ⟷ ItemPedido é composição (o item nasce e morre com o pedido), e ItemPedido → Produto é associação (o produto continua existindo no catálogo).

Exemplo: Turma, Aluno e Matrícula

Aluno existe sozinho (se a turma for encerrada, ele continua existindo); a Matricula depende da ligação entre Aluno e Turma e só faz sentido enquanto as duas existem.

Erros comuns: usar herança quando a relação é "tem-um"; criar dependência circular sem necessidade; deixar qualquer classe modificar a lista interna (devolva uma cópia ou uma visão imutável — veja Encapsulamento).

Herança e polimorfismo

Herança permite que uma classe reaproveite atributos e métodos de outra, declarando que é um tipo dela. Em Java, isso se expressa com extends:

public class Ebook extends Livro {
    private String waterMark;

    public Ebook(Autor autor) {
        super(autor);
    }
}

Ebook (a subclasse) herda tudo que Livro (a superclasse) tem — mesmo sem declarar nada além do que é específico dela (waterMark). A palavra super delega para o construtor (ou método) da superclasse, evitando repetir uma lógica que já existe lá.

Definição: Herança única (Java)

Diferente de C++, uma classe em Java só pode estender uma superclasse diretamente (sem herança múltipla). Uma classe pode, porém, herdar de uma classe que já herda de outra, formando uma cadeia — mas cada elo dessa cadeia aumenta o acoplamento entre as classes, então vale a pena usar com moderação.

Duas restrições que o compilador garante:

  • Uma classe final não pode ser estendida — extends sobre uma classe final é erro de compilação. (Uma classe pode, ao mesmo tempo, ser ela mesma final e estender outra classe normalmente — a restrição é só sobre quem tenta herdar dela.)
  • Se a superclasse não tiver um construtor sem argumentos, toda subclasse é obrigada a declarar pelo menos um construtor que chame explicitamente super(argumentos) compatível com algum construtor da mãe — o construtor implícito do compilador só chama super() sem argumentos, e isso não compila se esse construtor não existir na superclasse.

Sobrescrevendo métodos (@Override)

Uma subclasse pode redefinir o comportamento de um método herdado — isso é sobrescrita (override), diferente de sobrecarga (que é ter o mesmo nome com parâmetros diferentes):

@Override
public boolean aplicaDescontoDe(double porcentagem) {
    if (porcentagem > 0.15) {
        return false;
    }
    return super.aplicaDescontoDe(porcentagem);
}

A anotação @Override é opcional, mas recomendada: ela não muda o comportamento em tempo de execução, só faz o compilador confirmar que aquele método realmente existe na superclasse e está sendo sobrescrito corretamente — evitando o erro sutil de "criar sem querer um método novo" por errar a assinatura.

Repare que, dentro do método sobrescrito, dá pra chamar a versão original com super.metodo(...) — útil quando você quer complementar o comportamento da superclasse, não substituí-lo por completo.

Definição: Regras completas para uma sobrescrita válida

Para o compilador aceitar um método como sobrescrita real (e não um método novo, ou um erro), todas essas regras precisam valer ao mesmo tempo:

  • Mesmo nome, exatamente.
  • Mesmos tipos de parâmetro, na mesma ordem (o nome dos parâmetros pode mudar).
  • Retorno igual, ou covariante — a classe filha pode devolver um tipo mais específico (um subtipo) do que a mãe declarou, mas só para tipos de referência (retorno covariante não existe para tipos primitivos):
    class A { List<String> metodo() { ... } }
    class B extends A {
        @Override
        ArrayList<String> metodo() { ... } // ok: ArrayList é subtipo de List
    }
    
  • Visibilidade igual ou mais aberta que a da mãe — nunca mais restrita. Métodos de interface são implicitamente public, então a implementação também precisa ser public (não pode "afrouxar para baixo").
  • Exceptions checked iguais ou menos abrangentes que as da mãe (pode lançar menos, ou versões mais específicas na hierarquia de Exception — nunca uma mais genérica nem uma nova sem relação). Exceptions unchecked (RuntimeException e suas filhas) não têm essa restrição — podem ser adicionadas livremente.
  • Não é possível sobrescrever um método final da superclasse.
  • O método sobrescrito pode ser declarado abstract — empurrando a obrigação de implementar adiante na hierarquia (ver Classe abstrata).

O nome técnico completo para "qual versão de um método sobrescrito roda" é virtual method invocation — o binding (qual código realmente executa) só é resolvido em tempo de execução; em tempo de compilação, só se sabe a assinatura.

Definição: Quando um método 'sobrescrito' não é sobrescrita de verdade

Se o método da superclasse for private, ou tiver visibilidade default e a subclasse estiver em outro pacote, a subclasse simplesmente não enxerga esse método herdado — então declarar um método de mesmo nome/assinatura na subclasse não é sobrescrita nem hiding: é um método novo, totalmente independente, sem nenhuma relação com o da mãe. Consequência prática: qual método roda passa a depender de qual referência/pacote faz a chamada — o mesmo tipo de binding "errado" que costuma pegar quem assume que todo método de mesmo nome está relacionado por herança.

Polimorfismo

Polimorfismo é a capacidade de tratar objetos de tipos diferentes (mas relacionados por herança) de forma uniforme, através do tipo da superclasse:

public void adiciona(Livro livro) { // aceita Livro, Ebook, LivroFisico, ...
    livro.aplicaDescontoDe(0.05);
}

O ponto-chave: qual versão do método é executada é decidido em tempo de execução (runtime), com base no tipo real do objeto — não no tipo da variável/parâmetro usado para referenciá-lo. Mesmo adiciona recebendo um Livro, se o objeto passado for de fato um Ebook, é o aplicaDescontoDe do Ebook que roda.

graph TD
    A["adiciona(Livro livro)"] -->|"objeto real é Ebook"| B["Ebook.aplicaDescontoDe()"]
    A -->|"objeto real é LivroFisico"| C["LivroFisico (herda de Livro)"]

Isso é o que permite CarrinhoDeCompras ter um único método adiciona(Livro livro) em vez de um método sobrecarregado para cada subtipo de Livro — cada objeto sabe se comportar como o seu próprio tipo, mesmo referenciado de forma mais genérica.

Se for necessário o comportamento específico de uma subclasse (que não existe na superclasse), é preciso um casting explícito de volta para o tipo concreto — e isso é arriscado: só funciona se o objeto realmente for daquele tipo em tempo de execução.

Definição: O tipo da referência limita quais métodos podem ser chamados

Polimorfismo decide qual implementação roda, mas não muda quais métodos você pode chamar — isso continua limitado ao que o tipo declarado da referência expõe, mesmo que o objeto real tenha mais métodos:

class Vehicle { void turnOn() { ... } }
class Car extends Vehicle { void turnOn() { ... } void turnOff() { ... } }

Vehicle v = new Car(); // referência do tipo Vehicle, objeto real é Car
v.turnOn();             // ok — Vehicle declara turnOn (roda a versão de Car)
v.turnOff();            // erro de compilação — Vehicle não declara turnOff
((Car) v).turnOff();    // ok, com casting explícito de volta para Car

Casting entre tipos relacionados por herança

Considere três classes: Vehicle (mãe), Car e Motorcycle (as duas filhas, "irmãs" entre si, sem relação direta uma com a outra):

  • Upcasting (filha → mãe) é sempre automático, nunca precisa de casting explícito — é justamente o polimorfismo (Vehicle v = new Car();).
  • Downcasting (mãe → filha) sempre precisa de casting explícito, e o compilador só aceita se existir um caminho possível na hierarquia — mesmo que não seja garantido:
    Vehicle v = new Car();
    Motorcycle m = (Motorcycle) v; // compila (existe Vehicle que é Motorcycle)... 
                                     // ...mas lança ClassCastException em runtime, pois
                                     // o objeto real (Car) não é uma Motorcycle
    
  • Casting entre tipos sem relação nenhuma na hierarquia (ex.: Car para Motorcycle diretamente, sabendo que uma classe não pode herdar de duas) é erro de compilação — o compilador consegue provar que é impossível, então nem tenta em tempo de execução:
    Car c = new Car();
    Motorcycle m = (Motorcycle) c; // erro de compilação — Car nunca pode ser Motorcycle
    

Definição: Casting de classe para interface quase sempre compila

Diferente do casting entre duas classes, um casting de uma classe para uma interface que ela não implementa geralmente compila mesmo assim — porque o compilador não pode descartar a possibilidade de existir, em algum lugar, uma subclasse que implemente essa interface também:

class Car extends Vehicle { }
Car c = new Car();
Runnable r = (Runnable) c; // compila (Car não é final, poderia ter uma subclasse
                             // que implementasse Runnable) — ClassCastException em runtime
A única exceção: se a classe for final (não pode ter subclasses) e ela mesma não implementar a interface, não existe caminho possível nenhum, e o casting não compila — igual ao casting entre classes sem relação.

Definição: instanceof

Operador que testa se uma referência aponta para um objeto de um tipo (ou subtipo) específico, devolvendo boolean — sem lançar exceção, mesmo quando dá false. Só não compila se os tipos envolvidos forem obviamente incompatíveis (ex.: uma String não pode ser instanceof List, o compilador já sabe isso estaticamente). Muito usado logo antes de um casting explícito, para evitar ClassCastException.

Prático, mas use com moderação: código cheio de if (x instanceof Y) em cadeia, decidindo comportamento pelo tipo concreto, geralmente é sinal de uma modelagem fraca — é exatamente o tipo de decisão que o polimorfismo (sobrescrita de método) deveria estar resolvendo sozinho, sem precisar perguntar "que tipo é você?" em tempo de execução.

Definição: O que não é polimórfico: static e atributos

Polimorfismo (resolução em tempo de execução, pelo tipo real do objeto) vale só para métodos de instância sobrescritos. Dois casos parecidos, mas que funcionam diferente:

  • Métodos static: não existe herança de método estático de verdade — uma subclasse pode declarar um método static de mesmo nome, mas isso é redefinição (method hiding), não sobrescrita. A chamada é resolvida em tempo de compilação, pelo tipo da referência (ver Java):
    class W { static void method() { System.out.println("w"); } }
    class Z extends W { static void method() { System.out.println("z"); } }
    
    W referenciaW = new Z();
    referenciaW.method(); // "w" — decidido pelo tipo da referência (W), não pelo objeto real (Z)
    
  • Atributos: também não são polimórficos. Se a subclasse declara um atributo com o mesmo nome de um da superclasse, os dois coexistem como campos diferentes — não é sobrescrita, é shadowing de atributo. Qual dos dois você enxerga depende do tipo da referência usada para acessá-lo, exatamente como em static, não do tipo real do objeto. Dentro da própria subclasse, this.atributo acessa o campo dela mesma e super.atributo acessa explicitamente o da superclasse:
    class Vehicle { double speed = 30; }
    class Car extends Vehicle {
        double speed = 50; // esconde o "speed" de Vehicle, não sobrescreve
        void print() {
            System.out.println(this.speed);  // 50 — campo da própria classe
            System.out.println(super.speed); // 30 — campo da superclasse
        }
    }
    

Consequência prática: abstract não é permitido em método static (não existiria "subclasse obrigada a implementar" sem herança real de método), e super.metodo() não pode ser usado dentro de um método static (não há um this/objeto atual para servir de ponto de partida da busca na hierarquia).

Herança ou composição?

Preferir composição (uma classe tem outra, como atributo) a herança (uma classe é outra) quando possível — herança aumenta o acoplamento entre superclasse e subclasses e tende a comprometer o encapsulamento (para dar acesso a atributos da superclasse às subclasses, é comum precisar afrouxar private para protected — ver Modificadores de acesso, na seção de Encapsulamento acima). Herança faz mais sentido quando existe uma relação genuína de "é um" bem estável; composição é geralmente mais flexível para "tem um"/"usa um".

Classe abstrata

Às vezes uma superclasse existe só para ser herdada — nunca deveria virar objeto sozinha. No exemplo deste guia, Livro é sempre LivroFisico, Ebook ou outro tipo concreto; "um Livro genérico, sem mais nada" não representa nada de real no domínio.

Uma classe abstrata (abstract class) impede exatamente isso: o compilador não deixa instanciá-la diretamente com new, mesmo que ela continue funcionando normalmente como tipo para variáveis, parâmetros e polimorfismo:

public abstract class Livro {
    // atributos e métodos concretos normalmente
}
Livro livro = new Livro(); // erro de compilação: Cannot instantiate the type Livro

Além de impedir instanciação direta, uma classe abstrata pode declarar métodos abstratos — um método sem corpo, que toda subclasse concreta é obrigada a implementar:

public abstract class Livro {
    public abstract boolean aplicaDescontoDe(double porcentagem);
}

public class LivroFisico extends Livro {
    @Override
    public boolean aplicaDescontoDe(double porcentagem) {
        // implementação obrigatória
        return true;
    }
}

Isso resolve um problema real de manter várias subclasses: sem método abstrato, é fácil esquecer de sobrescrever um comportamento numa subclasse nova e ela acabar herdando um padrão que não faz sentido para ela. Com o método abstrato, o compilador garante que nenhuma subclasse concreta escapa dessa responsabilidade.

Definição: Regras de classe/método abstrato

  • Uma classe pode ser abstrata sem ter nenhum método abstrato (só impede instanciação).
  • Um método abstrato só pode existir dentro de uma classe abstrata.
  • Uma classe abstrata pode ter métodos abstratos e concretos ao mesmo tempo.
  • Toda subclasse concreta (não abstrata) é obrigada a implementar todos os métodos abstratos herdados — a menos que ela também seja declarada abstract, empurrando essa obrigação adiante na hierarquia.

Interface

Uma interface é um contrato: define quais métodos uma classe deve ter, sem dizer como eles são implementados. É parecida com uma classe abstrata que só tem métodos abstratos — mas resolve um problema que classe abstrata não resolve, o de acoplar classes que não têm relação de herança genuína entre si.

public interface Produto {
    double getValor();
}

public class Livro implements Produto {
    // é obrigado a implementar getValor()
}

public class Revista implements Produto {
    // é obrigado a implementar getValor()
}

Diferenças-chave em relação a herdar de uma classe abstrata:

  • Uma classe usa implements (em vez de extends) para uma interface, e pode implementar várias interfaces ao mesmo tempo — diferente de herança de classe, que em Java é única.
  • Até o Java 7, uma interface não podia ter métodos com implementação — só assinaturas de métodos, implicitamente public abstract (não precisa escrever esses modificadores).
  • Uma interface pode extends múltiplas outras interfaces ao mesmo tempo — só para classes concretas essa restrição de herança única existe:
    interface A extends Runnable {}
    interface B extends Serializable {}
    interface C extends Runnable, Serializable {} // ok — várias de uma vez
    
    Mas uma interface nunca usa implements — só extends, mesmo estendendo várias.

Definição: Constantes em interface

Uma interface pode declarar campos, mas eles são sempre implicitamente public static final — ou seja, são constantes compartilhadas, não atributos de instância (interface não guarda estado de objeto):

interface Autenticavel {
    int TAMANHO_MINIMO_SENHA = 8; // implicitamente public static final
    void autenticar(String login, String senha);
}

graph TD
    P["«interface» Produto"] --- Livro
    P --- Revista
    Pr["«interface» Promocional"] --- Livro
    Pr --- Revista

Isso permite reduzir acoplamento: em vez de forçar Livro e Revista a compartilhar uma superclasse comum só para ter polimorfismo, cada uma assina só os contratos (interfaces) que realmente fazem sentido para ela — por exemplo, nem todo Produto precisa ser Promocional.

Novidades do Java 8: default methods

Desde o Java 8, uma interface pode ter métodos com implementação — um default method, marcado com a palavra default:

public interface Promocional {
    boolean aplicaDescontoDe(double porcentagem);

    default boolean aplicaDescontoDe10Porcento() {
        return aplicaDescontoDe(0.1);
    }
}

Isso permite evoluir uma interface (adicionar um método novo) sem quebrar todo mundo que já a implementa — sem default, toda classe existente que implementasse Promocional pararia de compilar até implementar o método novo.

Definição: Interface funcional

Interface com um único método abstrato (default methods não contam). Pode ser marcada explicitamente com @FunctionalInterface — o compilador então valida que a regra é respeitada e acusa erro se alguém adicionar um segundo método abstrato. É o que viabiliza expressões lambda e method references em Java (um lambda é, por baixo dos panos, uma implementação de uma interface funcional).

Definição: 'Herança múltipla' de interfaces

Default methods aproximam Java de herança múltipla, mas não é a mesma coisa: eles não podem acessar atributos de instância (interfaces não têm estado) — ou seja, não há compartilhamento de estado entre interfaces, só reaproveitamento de comportamento sem dependência de dados.

Abstração: modelar o essencial

Abstrair é representar só o que importa para o problema. Uma classe abstrata pode reunir o contrato (métodos abstratos) e uma implementação compartilhada (métodos concretos) — aplicando o padrão Template Method (ver Padrões Arquiteturais): o método final fixa o roteiro e as subclasses preenchem só os passos que variam.

abstract class Pagamento {
    public final boolean executar(double valor) {     // roteiro fixo
        if (valor <= 0) return false;
        validar(valor);
        processar(valor);
        return true;
    }
    protected void validar(double valor) { }          // gancho opcional
    protected abstract void processar(double valor);  // cada subclasse implementa
}

class PagamentoPix extends Pagamento {
    protected void processar(double valor) { System.out.println("PIX processado"); }
}
Construção Característica
Classe concreta Pode ser instanciada
Classe abstrata Não é instanciada; serve de base
Método abstrato Declara o que deve ser implementado
Método concreto Já tem comportamento

Erros comuns: criar abstrações antes de existir variação real; colocar na classe base detalhes de todas as subclasses; confundir "abstrato" com "incompleto".

Interface x classe abstrata: como escolher

Interface Classe abstrata
Contrato de capacidade; várias implementações, mesmo sem hierarquia comum Base com estado e comportamento comuns
implements: assume a obrigação extends: herda a base
  • Prefira interfaces pequenas e coesas (evite dezenas de métodos).
  • O serviço deve depender do contrato, não do banco ou de uma classe concreta (Servico → Repositorio ← RepositorioEmMemoria/RepositorioBanco).
  • Uma interface sem contrato nem variação útil é ruído.
interface Repositorio<T> {
    void salvar(T objeto);
    Optional<T> buscarPorId(long id);
}

class RepositorioEmMemoria implements Repositorio<Produto> {
    private final Map<Long, Produto> dados = new HashMap<>();
    public void salvar(Produto p) { dados.put(p.getId(), p); }
    public Optional<Produto> buscarPorId(long id) { return Optional.ofNullable(dados.get(id)); }
}

Estudo de caso: loja virtual (OO de ponta a ponta)

Missão: modelar catálogo, carrinho, pedido, pagamento e notificação sem concentrar tudo em uma classe gigante. Resultado esperado: classes coesas, relações explícitas e pontos de extensão.

1. Descubra o domínio

Comece pelos substantivos e regras do problema, não pelas telas. Um substantivo só vira classe quando tem identidade, estado ou comportamento relevante.

2. Distribua as responsabilidades

Classe Responsabilidade
Produto Conhecer preço atual e disponibilidade
Carrinho Adicionar, remover e calcular prévia
Pedido Congelar itens e total da compra
Pagamento Autorizar a cobrança
RepositorioPedido Salvar e recuperar pedidos
Notificador Comunicar a confirmação ao cliente

Evite: um LojaService que faz catálogo, estoque, carrinho, cobrança, banco e e-mail.

3. Conecte os objetos por contratos

classDiagram
    class FinalizadorPedido {
        +finalizar(pedido)
    }
    class Pagamento {
        <<interface>>
        +cobrar(valor)
    }
    class Notificador {
        <<interface>>
        +enviar(msg)
    }
    FinalizadorPedido --> Pagamento : usa
    FinalizadorPedido --> Notificador : usa
    Pagamento <|.. PagamentoPix
    Notificador <|.. Email

Esse desenho permite trocar PIX por cartão e e-mail por SMS sem alterar a regra do pedido, e testar o fluxo com implementações falsas e rápidas.

4. Modele o pedido (o objeto protege o próprio estado)

class Pedido {
    private final List<ItemPedido> itens;
    private StatusPedido status = StatusPedido.CRIADO;

    Pedido(List<ItemPedido> itens) {
        if (itens.isEmpty()) throw new IllegalArgumentException("Pedido vazio");
        this.itens = List.copyOf(itens);          // cópia defensiva: ninguém altera por fora
    }

    double total() {
        return itens.stream().mapToDouble(ItemPedido::subtotal).sum();
    }

    void marcarComoPago() {
        if (status != StatusPedido.CRIADO) throw new IllegalStateException();
        status = StatusPedido.PAGO;               // transição validada pelo próprio pedido
    }
}

Decisões de design: List.copyOf impede alteração externa da coleção; o pedido nunca nasce vazio; a transição de status é validada pelo próprio objeto.

5. Coordene por contratos

interface Pagamento   { void cobrar(double valor); }
interface Notificador { void enviar(String mensagem); }

class FinalizadorPedido {
    private final Pagamento pagamento;
    private final Notificador notificador;

    FinalizadorPedido(Pagamento pagamento, Notificador notificador) {
        this.pagamento = pagamento;
        this.notificador = notificador;
    }

    void finalizar(Pedido pedido) {
        pagamento.cobrar(pedido.total());
        pedido.marcarComoPago();
        notificador.enviar("Pedido confirmado");
    }
}

O finalizador coordena; o pedido protege o estado; pagamento e notificação variam por meio de interfaces (injeção de dependência pelo construtor).

6. Teste o comportamento

Teste regras observáveis, cada cenário com preparação, ação e resultado:

Cenário Esperado
Criar pedido sem itens Rejeitar
Dois itens Total soma os subtotais
Finalizar Cobrar o valor total; depois marcar como pago
Sucesso Enviar confirmação
Cobrança falha Não confirmar nem notificar

Use implementações falsas (fakes) de Pagamento e Notificador para verificar as chamadas sem serviços externos (ver Qualidade).

7. Evolua sem desmontar

Uma boa estrutura recebe novas regras em pontos previsíveis: novo pagamento (PagamentoCartao), novo canal (NotificadorSms), cupom (política de desconto), frete (contrato CalculadoraFrete), persistência (RepositorioPedido no banco) e auditoria (observador de eventos). Sinal de saúde: adicionar uma opção exige criar uma classe pequena, não editar dezenas de condicionais.

Quadro de decisão e checklist

Pergunta Use
Preciso criar algo concreto? Classe + objeto
Preciso proteger uma regra? Encapsulamento
É uma relação "é-um"? Herança (com cautela)
É uma relação "tem-um"? Composição / associação
Várias respostas ao mesmo pedido? Polimorfismo
Quero definir uma capacidade? Interface
Há base comum e implementação parcial? Classe abstrata
Um detalhe externo pode mudar? Depender de contrato

Regra de ouro

POO não é decorar palavras: é distribuir responsabilidades para que cada objeto proteja suas próprias regras. A pergunta-guia é "qual objeto deveria conhecer esta informação e proteger esta regra?" — se a resposta é sempre Main, as responsabilidades ainda não foram modeladas.

Checklist final: cada classe tem um propósito claro; o estado importante está protegido; as relações representam o domínio; as variações dependem de contratos; os testes descrevem comportamento. Próximo passo: escolher um projeto real e desenhar os objetos antes de abrir a IDE.

Quinze boas práticas de OO

# Boa prática Em resumo
1 Coesão e acoplamento Cada classe com um propósito; dependa de uma abstração (Pagamento), não de cada forma concreta (Debito, Cartao)
2 Strings com parcimônia Não guarde estrutura em texto (endereço inteiro numa String): crie Endereco com logradouro, numero, bairro; buscar dentro de texto é frágil e fere a coesão
3 Seja objetivo, não preveja o futuro Não crie Pessoa → PessoaFisica se só existirá pessoa física (YAGNI); herança sem necessidade só acopla a subclasse à superclasse
4 Crie métodos com carinho Poucos parâmetros (agrupe em objeto), nome claro, uma tarefa; parâmetros desassociados aumentam o acoplamento
5 Conheça e use coleções Em vez de arrays manuais, List, Set, Map e suas garantias (ordem, unicidade, busca por chave)
6 Sobrescreva equals, hashCode e toString Os "três mosqueteiros": sem equals/hashCode, coleções e comparações se comportam de forma inesperada; sem toString, a exibição é ilegível; espalhar a lógica de igualdade pelo código fere o encapsulamento. equals e hashCode devem ser consistentes entre si
7 Às vezes, associe em vez de herdar Reúso não exige herança: composição é mais flexível (Herança x composição)
8 Evite herança ou limite-a final (ou sealed) em classes que não devem ser estendidas; final em métodos que não podem ser sobrescritos; final em atributos os torna constantes
9 Encapsulamento Atributos privados; trate quatro situações: acesso de leitura, de escrita, e as coleções/objetos mutáveis expostos
10 Interface x classe abstrata no momento certo Interface: contrato, múltiplas implementações, "herança múltipla"; abstrata: base comum com implementação parcial e estado (quadro)
11 Evite especializar o já especializado Cadeias profundas de herança concreta → concretas herdando de concretas ficam frágeis; uma abstrata herdando de concreta não faz sentido
12 Membros estáticos com parcimônia Métodos estáticos não participam de polimorfismo nem do estado do objeto ("quebra de interface"); evite-os nas entidades de negócio, e use-os em utilitários sem estado
13 Entenda a clonagem de objetos Atribuir copia a referência, não o objeto; para copiar, use construtor de cópia ou clone() consciente da cópia rasa x profunda
14 Use as facilidades da linguagem for-each em vez de índices, try-with-resources, Optional, StringBuilder... Quem chega do estruturado leva hábitos que a linguagem já resolve melhor
15 Siga as convenções Java: camelCase para métodos e variáveis, PascalCase para classes, MAIUSCULAS_COM_UNDERSCORE para constantes (Clean Code)

Aprofundamentos

  • A classe Object: raiz de todas as classes em Java; fornece equals, hashCode, toString, getClass, clone (e finalize, obsoleto). Qualquer objeto é um Object.
  • Classes internas: membro (dentro da classe, acessa seus atributos), estática aninhada (não depende de uma instância da externa), local (dentro de um método) e anônima (sem nome, criada na hora; hoje muitas viram lambdas).
  • Ligação dinâmica (polimorfismo): o método executado é escolhido em tempo de execução pelo tipo real do objeto, e não pelo tipo da variável — é o que permite pagamento.pagar() funcionar para qualquer subtipo.
  • Depois da OO: padrões de projeto (Padrões), refatoração (Boas práticas) e UML (Diagramas UML).