Aula 15: Composição vs. Herança

Construindo relações robustas entre objetos.

O Que Você Vai Aprender

Nesta aula, você explorará um princípio fundamental da Programação Orientada a Objetos (POO): a relação entre objetos através da Composição e como ela se compara à Herança. Entenderá o porquê de "preferir composição à herança" em muitos cenários de design de software.

🤝 Entender a Composição

Compreender o relacionamento "tem um" e como ele é implementado em POO.

🆚 Comparar com Herança

Distinguir a composição da herança ("é um") e quando usar cada uma.

💡 Princípio de Design

Entender o famoso princípio "prefira a composição à herança" e suas vantagens.

Composição vs. Herança: Tipos de Relacionamento

Em Programação Orientada a Objetos, a forma como os objetos se relacionam é crucial para um bom design. Existem duas abordagens principais para criar esses relacionamentos: **Composição** e **Herança**.

Composição: O Relacionamento "Tem Um"

Um objeto contém ou utiliza outros objetos. Isso representa uma relação "tem um".

  • **Exemplo:** Uma `Casa` tem um `Quarto`. Um `Carro` tem um `Motor`.
  • **Vantagem:** Maior flexibilidade e reutilização de código, pois os componentes podem ser facilmente trocados ou reutilizados em diferentes contextos.
Image of House has Room

Herança: O Relacionamento "É Um"

Uma classe deriva de outra, herdando suas características e comportamentos. Isso representa uma relação "é um".

  • **Exemplo:** Um `Cachorro` é um `Animal`. Um `Carro` é um `Veículo`.
  • **Uso:** Ideal quando há uma clara hierarquia de tipos e a subclasse é realmente uma versão mais específica da superclasse.
Image of Dog is an Animal

Princípio Famoso: "Prefira a Composição à Herança." Este é um princípio de design de software que sugere que, na maioria dos casos, usar a composição para reutilizar funcionalidades resulta em designs mais flexíveis e menos acoplados do que a herança.

Exemplo Prático: Composição (Casa e Quarto)

Vamos ver a composição em ação com o exemplo de uma `Casa` que "tem" vários `Quartos`. A `Casa` não herda de `Quarto`, mas sim contém objetos `Quarto` como parte de sua estrutura.

Código Python para Composição

# classes.py
class Quarto:
    def __init__(self, nome, area):
        self.nome = nome
        self.area = area

    def __str__(self):
        return f"Quarto: {self.nome} ({self.area} m²)"

class Casa:
    def __init__(self, endereco):
        self.endereco = endereco
        self.quartos = [] # Uma Casa "tem um" ou "tem vários" Quartos

    def adicionar_quarto(self, quarto):
        if isinstance(quarto, Quarto):
            self.quartos.append(quarto)
            # print(f"Quarto '{quarto.nome}' adicionado à casa em {self.endereco}.")
        else:
            print("Isso não é um quarto válido!")

    def calcular_area_total(self):
        total_area = sum(q.area for q in self.quartos)
        return total_area

    def listar_quartos(self):
        if not self.quartos:
            return "Nenhum quarto nesta casa."
        return "\n".join([str(q) for q in self.quartos])

# Exemplo de uso:
# meu_quarto1 = Quarto("Sala de Estar", 30)
# meu_quarto2 = Quarto("Cozinha", 15)
# minha_casa = Casa("Rua da Programação, 123")
# minha_casa.adicionar_quarto(meu_quarto1)
# minha_casa.adicionar_quarto(meu_quarto2)
# print(minha_casa.listar_quartos())
# print(f"Área total da casa: {minha_casa.calcular_area_total()} m²")

Simulador: Casa e Quartos (Composição)

Crie uma casa e adicione quartos a ela. Veja como a casa "tem" os quartos.

Exemplo: Herança (Animal)

Para contraste, aqui está um breve exemplo de herança. Um `Cachorro` é um tipo mais específico de `Animal` e, portanto, herda comportamentos gerais de `Animal` (como fazer som) e pode especializá-los.

Código Python para Herança

# classes.py
class Animal:
    def __init__(self, nome):
        self.nome = nome

    def fazer_som(self):
        return "Som genérico de animal"

class Cachorro(Animal): # Cachorro "é um" Animal
    def __init__(self, nome, raca):
        super().__init__(nome) # Chama o construtor da classe pai
        self.raca = raca

    def fazer_som(self): # Sobrescreve o método da classe pai
        return "Au au!"

# Exemplo de uso:
# meu_cachorro = Cachorro("Buddy", "Golden Retriever")
# print(meu_cachorro.nome) # Saída: Buddy
# print(meu_cachorro.fazer_som()) # Saída: Au au!
#
# um_animal = Animal("Leo")
# print(um_animal.fazer_som()) # Saída: Som genérico de animal

Na herança, a subclasse (Cachorro) reutiliza o código da superclasse (Animal) através de uma relação de especialização.

Vantagens da Composição

Embora a herança seja útil para hierarquias "é um", a composição frequentemente leva a designs mais robustos e flexíveis. Aqui estão algumas de suas principais vantagens:

🤸 Maior Flexibilidade

Composição permite alterar o comportamento de um objeto em tempo de execução, trocando os componentes. Com herança, o comportamento é fixo na hierarquia da classe.

♻️ Reutilização de Código Aprimorada

Componentes (objetos que são "tidos por" outros) são unidades independentes que podem ser facilmente reutilizadas em diversas classes, sem as restrições da hierarquia de herança.

⬇️ Acoplamento Fraco

Objetos compostos são menos dependentes um do outro. Alterações em um componente geralmente não exigem grandes modificações no objeto que o contém, ao contrário da herança, onde mudanças na classe pai podem afetar todas as subclasses.

🚫 Menos Problemas de Herança Múltipla

Linguagens como Python permitem herança múltipla, mas ela pode levar a complexidades (problema do diamante). Composição oferece uma alternativa mais limpa para combinar funcionalidades sem os riscos da herança múltipla.

Desafios para Continuar

Agora que você explorou a composição e a herança, é hora de aplicar esses conceitos! Resolva estes problemas no seu ambiente de desenvolvimento Python para solidificar seu aprendizado.

  • 1. Composição: Carro e Motor

    Crie duas classes em Python: `Motor` e `Carro`.

    • A classe `Motor` deve ter atributos como `tipo` (ex: "Combustão", "Elétrico") e `potencia`. Deve ter um método `ligar()` que imprime "Motor ligado!".
    • A classe `Carro` deve ter atributos como `modelo` e `ano`. Em vez de herdar de `Motor`, um `Carro` deve conter uma instância de `Motor` (composição).
    • A classe `Carro` deve ter um método `iniciar_viagem()` que chama o método `ligar()` do seu objeto `Motor` interno.

    # Exemplo de Estrutura:
    class Motor:
        def __init__(self, tipo, potencia):
            self.tipo = tipo
            self.potencia = potencia
        
        def ligar(self):
            print(f"Motor {self.tipo} ligado!")
    
    class Carro:
        def __init__(self, modelo, ano, motor): # 'motor' é uma instância de Motor
            self.modelo = modelo
            self.ano = ano
            self.motor = motor # Composição: Carro tem um Motor
    
        def iniciar_viagem(self):
            print(f"Iniciando viagem com o {self.modelo}...")
            self.motor.ligar()
    
    # Criar e testar:
    # motor_eletrico = Motor("Elétrico", "200cv")
    # meu_carro = Carro("Tesla Model 3", 2023, motor_eletrico)
    # meu_carro.iniciar_viagem()
                                
  • 2. Discutir: Qual Abordagem para um Sistema de Notificações?

    Imagine que você está projetando um sistema de notificações que pode enviar mensagens por SMS, E-mail ou Push (aplicativo).

    • Você criaria uma classe `NotificacaoBase` e faria `NotificacaoSMS`, `NotificacaoEmail`, `NotificacaoPush` herdarem dela (Herança)?
    • Ou você criaria uma classe `Notificacao` que `tem um` (compõe) um `SenderSMS`, um `SenderEmail` e um `SenderPush`?
    **Discuta:** Qual abordagem seria mais flexível se no futuro você precisar adicionar novos métodos de envio (ex: WhatsApp, Telegram) ou se um método de envio precisar mudar sua implementação interna?