Programação 2: OOP Avançada e Estruturas
Polimorfismo e Composição
Flexibilidade com Polimorfismo e Composição
Na programação orientada a objetos, nem todos os relacionamentos são criados da mesma forma. Herdar de uma classe (uma relação "é um") é poderoso, mas pode criar hierarquias rígidas e difíceis de manter. E se precisássemos de mais flexibilidade? É aqui que entram o polimorfismo e a composição.
O polimorfismo dinâmico nos permite tratar objetos de diferentes classes de maneira uniforme, desde que compartilhem uma superclasse ou interface comum. Imagine uma lista de FormaGeometrica. Essa lista pode conter Circulo, Quadrado e Triangulo. Podemos iterar sobre a lista e chamar um método desenhar() em cada objeto, e cada um saberá como se desenhar corretamente. Isso é possível através do upcasting, que é o ato de tratar um objeto de uma subclasse como uma instância de sua superclasse.
// Upcasting implícito e seguro
FormaGeometrica minhaForma = new Circulo();
// A variável 'minhaForma' é do tipo FormaGeometrica,
// mas o objeto real na memória é um Circulo.
minhaForma.desenhar(); // Chama o método desenhar() da classe Circulo
O processo inverso, o , ocorre quando tentamos converter uma referência de superclasse de volta para seu tipo de subclasse original. Isso é uma operação que exige cuidado. Você deve ter certeza de que o objeto é realmente do tipo para o qual você está fazendo o cast, caso contrário, receberá uma exceção em tempo de execução. O operador instanceof é seu aliado aqui, permitindo uma verificação segura antes da conversão.
Object obj = "Olá, Mundo!"; // Upcasting de String para Object
// Downcasting inseguro, lançaria uma exceção:
// Integer numero = (Integer) obj;
// Downcasting seguro com verificação
if (obj instanceof String) {
String texto = (String) obj; // Downcasting explícito
System.out.println(texto.toUpperCase());
}
Essa capacidade de substituição é formalizada pelo Princípio de Substituição de Liskov (LSP). Ele afirma que objetos de uma superclasse devem ser substituíveis por objetos de suas subclasses sem quebrar a aplicação. O upcasting funciona porque as subclasses garantem (ou deveriam garantir) que cumprem o contrato da superclasse.
Definindo Contratos: Interfaces vs. Classes Abstratas
Para que o polimorfismo funcione, precisamos de um contrato compartilhado. Em Java, esse contrato pode ser definido usando uma classe abstrata ou uma interface.
Classes Abstratas são ideais quando você quer fornecer uma implementação base comum. Elas podem ter estado (atributos) e métodos concretos. Pense nelas como um esqueleto parcial que as subclasses completam. Uma classe só pode estender uma única classe abstrata.
Interfaces, por outro lado, definem um contrato de "o que" um objeto pode fazer, sem dizer "como". Elas tradicionalmente continham apenas assinaturas de métodos. Desde o Java 8, elas também podem incluir default methods (métodos com uma implementação padrão) e static methods. Uma classe pode implementar múltiplas interfaces, o que as torna extremamente flexíveis para definir capacidades. Por exemplo, um objeto pode ser Serializavel, Comparavel e Executavel ao mesmo tempo.
| Característica | Classe Abstrata | Interface |
|---|---|---|
| Herança Múltipla | Não (só pode estender uma) | Sim (pode implementar várias) |
| Estado (Atributos) | Pode ter atributos de instância | Não pode (apenas constantes static final) |
| Construtores | Pode ter | Não pode |
| Tipos de Métodos | Abstratos e concretos | Abstratos, default, static e private (desde Java 9) |
| Relacionamento | "é um" (is-a) | "pode fazer" (can-do) |
Prefira Composição à Herança
A herança cria um forte acoplamento entre a superclasse e a subclasse. Mudanças na superclasse podem quebrar inesperadamente as subclasses. Um princípio de design fundamental é "preferir composição à herança".
significa que uma classe "tem uma" outra classe (contém uma instância dela) para delegar tarefas. Em vez de a classe Carro herdar de uma classe Motor, a classe Carro tem um objeto Motor. Isso torna o sistema muito mais flexível. Se você quiser trocar um motor a gasolina por um elétrico, basta injetar um objeto MotorEletrico em vez de um MotorCombustao, sem alterar a classe Carro.
A herança é um mecanismo de acoplamento: ela vincula duas entidades com regras bem definidas sobre visibilidade, despacho de método e assim por diante.
Vamos ver um exemplo prático. Imagine um sistema de processamento de pagamentos. Poderíamos usar herança, com classes como PagamentoCartao e PagamentoBoleto herdando de uma classe Pagamento. Mas e se quisermos adicionar um novo método de pagamento, como Pix, que também precisa de validação de segurança? A hierarquia começa a ficar complicada.
Com a composição, o cenário é mais limpo. Temos uma classe ProcessadorDePagamento que recebe um objeto do tipo MetodoDePagamento via construtor. MetodoDePagamento é uma interface.
// Interface que define o contrato
public interface MetodoDePagamento {
String processar(double valor);
}
// Implementações concretas
public class CartaoCredito implements MetodoDePagamento {
public String processar(double valor) {
return "Pagamento de R$ " + valor + " processado com cartão de crédito.";
}
}
public class Pix implements MetodoDePagamento {
public String processar(double valor) {
return "Pagamento de R$ " + valor + " processado com Pix.";
}
}
// Classe que usa a composição
public class ProcessadorDePagamento {
private final MetodoDePagamento metodo;
// O comportamento é injetado via construtor
public ProcessadorDePagamento(MetodoDePagamento metodo) {
this.metodo = metodo;
}
public void executarPagamento(double valor) {
String resultado = metodo.processar(valor);
System.out.println(resultado);
}
}
// Uso
ProcessadorDePagamento processadorCartao = new ProcessadorDePagamento(new CartaoCredito());
processadorCartao.executarPagamento(100.0);
ProcessadorDePagamento processadorPix = new ProcessadorDePagamento(new Pix());
processadorPix.executarPagamento(50.0);
Observe como ProcessadorDePagamento não sabe nada sobre os detalhes do CartaoCredito ou Pix. Ele apenas interage com a interface MetodoDePagamento. Para adicionar um novo método, basta criar uma nova classe que implemente a interface e passá-la para o processador. Isso é polimorfismo e composição trabalhando juntos para criar um código flexível, sustentável e fácil de estender.
No contexto da programação orientada a objetos, qual das seguintes afirmações descreve melhor o princípio de "preferir composição à herança"?
Considere o seguinte cenário: você tem uma lista de objetos do tipo FormaGeometrica. Essa lista contém instâncias de Circulo, Quadrado e Triangulo, todas subclasses de FormaGeometrica. Ao iterar sobre a lista e chamar o método desenhar() em cada elemento, o método correto para cada forma específica é executado. Qual conceito de POO torna isso possível?