Herança e Polimorfismo

Herança e Polimorfismo

Herança, super, polimorfismo e classes abstratas em Python. Com o diamante que revela o que super realmente faz — ele segue o MRO do objeto, não a classe pai — e um defeito de projeto no exemplo de pagamentos: emitir o recibo cobrava o cartão outra vez.
Python

• • 21 min de leitura

No artigo Classes e Objetos: os fundamentos da Orientação a Objetos criamos classes independentes. Mas e quando duas classes compartilham características comuns? Reescrever o mesmo código em cada uma viola um princípio fundamental do desenvolvimento de software: DRY — Don't Repeat Yourself. Herança resolve esse problema permitindo que uma classe herde atributos e métodos de outra, e polimorfismo permite que objetos de tipos diferentes respondam ao mesmo comando de formas distintas.

Herança Simples

Uma classe filha herda tudo da classe pai e pode estender ou modificar esse comportamento:

class Animal:
    def __init__(self, nome, idade):
        self.nome  = nome
        self.idade = idade

    def apresentar(self):
        return f"Eu sou {self.nome} e tenho {self.idade} ano(s)."

    def emitir_som(self):
        return "..."


class Cachorro(Animal):
    def emitir_som(self):
        return "Au au!"

    def buscar(self, objeto):
        return f"{self.nome} buscou o {objeto}!"


class Gato(Animal):
    def emitir_som(self):
        return "Miau!"

    def arranhar(self):
        return f"{self.nome} arranhou o sofá."


rex    = Cachorro("Rex",    3)
mimi   = Gato("Mimi",   5)

print(rex.apresentar())     # Eu sou Rex e tenho 3 ano(s).
print(rex.emitir_som())     # Au au!
print(rex.buscar("graveto")) # Rex buscou o graveto!
print(mimi.emitir_som())    # Miau!

Cachorro e Gato herdam apresentar() de Animal sem precisar reescrevê-lo. Cada uma sobrescreve emitir_som() com seu próprio comportamento — isso é sobrescrita de método (method overriding).

A Função super()

super() permite chamar métodos da classe pai a partir da classe filha — essencial para estender o construtor:

class Veiculo:
    def __init__(self, marca, modelo, ano):
        self.marca  = marca
        self.modelo = modelo
        self.ano    = ano

    def descricao(self):
        return f"{self.ano} {self.marca} {self.modelo}"


class Carro(Veiculo):
    def __init__(self, marca, modelo, ano, portas):
        super().__init__(marca, modelo, ano)   # chama __init__ do pai
        self.portas = portas

    def descricao(self):
        base = super().descricao()             # reutiliza o método do pai
        return f"{base} ({self.portas} portas)"


class CarroEletrico(Carro):
    def __init__(self, marca, modelo, ano, portas, autonomia_km):
        super().__init__(marca, modelo, ano, portas)
        self.autonomia_km = autonomia_km

    def descricao(self):
        base = super().descricao()
        return f"{base} — Elétrico, {self.autonomia_km}km de autonomia"


civic = Carro("Honda", "Civic", 2023, 4)
model3 = CarroEletrico("Tesla", "Model 3", 2024, 4, 560)

print(civic.descricao())
# 2023 Honda Civic (4 portas)

print(model3.descricao())
# 2024 Tesla Model 3 (4 portas) — Elétrico, 560km de autonomia

Polimorfismo

Polimorfismo significa "muitas formas". Em Python, objetos de classes diferentes podem ser tratados de forma uniforme se implementarem os mesmos métodos:

class Circulo:
    def __init__(self, raio):
        self.raio = raio

    def area(self):
        import math
        return math.pi * self.raio ** 2

    def perimetro(self):
        import math
        return 2 * math.pi * self.raio


class Retangulo:
    def __init__(self, largura, altura):
        self.largura = largura
        self.altura  = altura

    def area(self):
        return self.largura * self.altura

    def perimetro(self):
        return 2 * (self.largura + self.altura)


class Triangulo:
    def __init__(self, base, altura, lado_a, lado_b, lado_c):
        self.base    = base
        self.altura  = altura
        self.lados   = (lado_a, lado_b, lado_c)

    def area(self):
        return (self.base * self.altura) / 2

    def perimetro(self):
        return sum(self.lados)


# Polimorfismo em ação — mesma interface, comportamentos diferentes
formas = [
    Circulo(5),
    Retangulo(4, 6),
    Triangulo(3, 4, 3, 4, 5),
]

for forma in formas:
    nome = type(forma).__name__
    print(f"{nome:12} | Área: {forma.area():7.2f} | Perímetro: {forma.perimetro():.2f}")

Saída:

Circulo      | Área:   78.54 | Perímetro: 31.42
Retangulo    | Área:   24.00 | Perímetro: 20.00
Triangulo    | Área:    6.00 | Perímetro: 12.00

Nenhuma das classes herda da outra — mesmo assim podemos tratá-las uniformemente. Isso é o duck typing do Python: se o objeto tem o método area(), ele pode ser usado onde area() for chamado. "Se anda como pato e grasna como pato, é um pato."

Classes Abstratas

Quando você quer garantir que subclasses implementem determinados métodos, use o módulo abc:

from abc import ABC, abstractmethod

class Forma(ABC):
    """Classe base abstrata — não pode ser instanciada diretamente.

    A checagem acontece na INSTANCIAÇÃO, não na definição: uma subclasse
    que esqueça de implementar um método abstrato é criada sem erro, e só
    levanta TypeError quando alguém tenta construir um objeto dela.
    """

    @abstractmethod
    def area(self):
        """Toda forma deve implementar area()."""
        pass

    @abstractmethod
    def perimetro(self):
        """Toda forma deve implementar perimetro()."""
        pass

    def descricao(self):
        """Método concreto — herdado por todas as subclasses."""
        return (f"{type(self).__name__}: "
                f"área={self.area():.2f}, "
                f"perímetro={self.perimetro():.2f}")


class Quadrado(Forma):
    def __init__(self, lado):
        self.lado = lado

    def area(self):
        return self.lado ** 2

    def perimetro(self):
        return 4 * self.lado


class Hexagono(Forma):
    def __init__(self, lado):
        self.lado = lado

    def area(self):
        import math
        return (3 * math.sqrt(3) / 2) * self.lado ** 2

    def perimetro(self):
        return 6 * self.lado


# Forma()     # TypeError — não pode instanciar classe abstrata
q = Quadrado(5)
h = Hexagono(4)

print(q.descricao())  # Quadrado: área=25.00, perímetro=20.00
print(h.descricao())  # Hexagono: área=41.57, perímetro=24.00

Herança Múltipla e MRO

Python suporta herança múltipla — uma classe pode herdar de várias classes ao mesmo tempo:

class Nadador:
    def nadar(self):
        return f"{self.nome} está nadando."

class Corredor:
    def correr(self):
        return f"{self.nome} está correndo."

class Ciclista:
    def pedalar(self):
        return f"{self.nome} está pedalando."

class Triatleta(Nadador, Corredor, Ciclista):
    def __init__(self, nome):
        self.nome = nome

    def competir(self):
        return [self.nadar(), self.pedalar(), self.correr()]


atleta = Triatleta("Carlos")
for atividade in atleta.competir():
    print(atividade)

# Carlos está nadando.
# Carlos está pedalando.
# Carlos está correndo.

Quando há ambiguidade sobre qual método usar, Python segue o MRO — Method Resolution Order, calculado pelo algoritmo C3:

print(Triatleta.__mro__)
# (<class '__main__.Triatleta'>, <class '__main__.Nadador'>,
#  <class '__main__.Corredor'>, <class '__main__.Ciclista'>,
#  <class 'object'>)

Só que no exemplo acima o MRO não decide nada: Nadador, Corredor e Ciclista não têm método algum em comum, então não há ambiguidade a resolver. A ordem importa quando duas classes da hierarquia definem o mesmo método — a situação que se chama diamante, porque dois caminhos partem de uma base comum e voltam a se encontrar:

class A:
    def quem(self): return "A"

class B(A):
    def quem(self): return "B -> " + super().quem()

class C(A):
    def quem(self): return "C -> " + super().quem()

class D(B, C):
    pass

print(D().quem())                       # B -> C -> A
print([k.__name__ for k in D.__mro__])  # ['D', 'B', 'C', 'A', 'object']

Este é o ponto que quase todo material erra ao explicar super(): lendo a classe B isoladamente, é natural concluir que super().quem() chama A.quem, já que B herda de A. Não é o que acontece. O super() não significa a classe pai; significa o próximo da fila do MRO do objeto em questão. Quando quem está sendo usado é um D, o próximo depois de B é C, e por isso A é visitada uma única vez, no fim. É essa propriedade que faz a herança múltipla funcionar de forma cooperativa em Python, e é por isso que um método que participa de uma hierarquia deve chamar super() mesmo parecendo não ter para onde ir.

O algoritmo que calcula essa ordem, o C3, também sabe recusar o impossível. Se duas bases exigirem ordens contraditórias, o erro aparece na definição da classe, não no uso:

class X(B, C): pass
class Y(C, B): pass
class Z(X, Y): pass
# TypeError: Cannot create a consistent method resolution order (MRO)
# for bases B, C

Vale notar ainda que os três papéis do exemplo do triatleta usam self.nome sem nunca declará-lo — eles contam com um __init__ que só existe na classe de baixo. Instanciar Nadador() sozinho e chamar nadar() levanta AttributeError. É o padrão mixin, legítimo e muito usado, mas com um contrato implícito que precisa estar documentado: a classe não funciona sozinha, e espera que quem a combine forneça certos atributos.

Verificando Herança

rex = Cachorro("Rex", 3)

print(isinstance(rex, Cachorro))  # True
print(isinstance(rex, Animal))    # True — herança
print(isinstance(rex, Gato))      # False

print(issubclass(Cachorro, Animal))  # True
print(issubclass(Gato, Cachorro))    # False

Exemplo Completo: Sistema de Pagamentos

from abc import ABC, abstractmethod

class MetodoPagamento(ABC):
    def __init__(self, titular):
        self.titular = titular

    @abstractmethod
    def processar(self, valor):
        pass

    @abstractmethod
    def descricao(self):
        pass

    def recibo(self, valor, status):
        # recebe o status pronto: emitir recibo não pode processar pagamento
        return (f"Recibo — {self.titular}\n"
                f"Método: {self.descricao()}\n"
                f"Valor:  R${valor:.2f}\n"
                f"Status: {status}")


class CartaoCredito(MetodoPagamento):
    def __init__(self, titular, numero, limite):
        super().__init__(titular)
        self.numero = f"**** **** **** {numero[-4:]}"
        self.limite = limite
        self._gasto = 0

    def processar(self, valor):
        if self._gasto + valor > self.limite:
            return "RECUSADO — limite insuficiente"
        self._gasto += valor
        return "APROVADO"

    def descricao(self):
        return f"Cartão de Crédito {self.numero}"


class Pix(MetodoPagamento):
    def __init__(self, titular, chave):
        super().__init__(titular)
        self.chave = chave

    def processar(self, valor):
        return "APROVADO — transferência instantânea"

    def descricao(self):
        return f"Pix ({self.chave})"


class Boleto(MetodoPagamento):
    def __init__(self, titular):
        super().__init__(titular)

    def processar(self, valor):
        return "PENDENTE — aguardando compensação"

    def descricao(self):
        return "Boleto Bancário"


pagamentos = [
    CartaoCredito("Ana",    "1234567890123456", 2000),
    Pix("Bruno",  "bruno@email.com"),
    Boleto("Carla"),
]

valores = [150.00, 89.90, 320.00]

for metodo, valor in zip(pagamentos, valores):
    status = metodo.processar(valor)     # cobra uma vez, aqui
    print(metodo.recibo(valor, status))  # e só formata
    print("-" * 40)

A separação acima não é preciosismo. Na versão natural de escrever essa classe, o recibo monta o texto chamando self.processar(valor) lá dentro — e como processar do cartão soma ao _gasto, emitir o recibo cobra o cartão. Reimprimir, mandar por e-mail ou registrar no log, cada um custa uma cobrança nova:

c = CartaoCredito("Ana", "1234567890123456", 2000)

c.recibo(150.00)   # APROVADO   — _gasto = 150
c.recibo(150.00)   # APROVADO   — _gasto = 300, cobrou de novo
c.recibo(1800.00)  # RECUSADO   — o limite estourou por causa da reimpressão

A regra que evita a classe inteira desse problema: um método ou consulta ou altera, nunca as duas coisas. Quem tem nome de consulta — recibo, descricao, total, qualquer @property — deve poder ser chamado dez vezes seguidas sem mudar nada. Quando o nome promete uma coisa e o corpo faz outra, o defeito não é encontrável por leitura de quem chama, porque quem chama está confiando no nome.

Herança e polimorfismo resolvem problemas distintos, e em Python o segundo raramente depende do primeiro. A herança serve para reaproveitar implementação, e é uma ferramenta a usar com parcimônia: cada nível novo amarra a filha à forma interna da mãe. O polimorfismo, esse sim, é o que se busca o tempo todo — e o duck typing o entrega sem hierarquia nenhuma, bastando que os objetos respondam aos mesmos métodos, como as três formas geométricas deste artigo, que não herdam umas das outras e ainda assim são tratadas pelo mesmo laço. Quando o contrato precisa ser garantido em vez de combinado, o ABC com @abstractmethod obriga a implementação — lembrando que a checagem ocorre na instanciação, não na definição da subclasse.

Duas correções de entendimento ficam deste artigo. O super() não significa a classe pai, e sim o próximo da fila do MRO do objeto: numa hierarquia em diamante, o super() escrito dentro de uma classe pode levar a uma irmã dela, não à mãe, e é essa propriedade que faz a herança múltipla funcionar de forma cooperativa. E um método que consulta não pode alterar: o recibo do exemplo de pagamentos processava a cobrança ao montar o texto, de modo que reimprimir um recibo cobrava o cartão outra vez — defeito impossível de achar lendo quem chama, porque quem chama está confiando no nome do método.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um sistema de faturamento tem um método fatura(valor) que monta o texto da fatura e, ao montá-lo, consulta self.processar(valor) para incluir o status. O time de suporte reclama que alguns clientes foram cobrados duas e três vezes, sempre os que ligaram pedindo segunda via. Explique a relação e enuncie a regra de projeto que evita esta classe inteira de defeitos.

Ver resposta

✓ Resposta: O método processar não apenas informa um status: ele altera estado, somando o valor ao gasto acumulado do cartão. Como o fatura o chama para preencher a linha de status, cada emissão de fatura executa uma cobrança nova. Quem pediu segunda via recebeu, junto com o documento, mais um débito — e a terceira ligação gerou a terceira cobrança. Com um cartão de limite 2000 e uma compra de 150 reimpressa uma vez, o gasto vai a 300, e uma compra legítima de 1800 que deveria caber passa a ser recusada por limite insuficiente, o que espalha o sintoma para transações sem relação nenhuma com a segunda via. A regra que evita a classe inteira é a separação entre comando e consulta: um método ou responde alguma coisa ou muda alguma coisa, nunca as duas. Tudo que tem nome de consulta — fatura, recibo, descricao, total, e sobretudo qualquer @property — precisa poder ser chamado dez vezes seguidas sem consequência. O motivo de esse defeito ser tão caro é que ele é invisível do lado de quem chama: o código que emite a segunda via está correto e legível, e confia no nome do método, que é exatamente o contrato quebrado. A correção é processar uma vez, no ponto onde a decisão de cobrar é tomada, e passar o resultado adiante: status = metodo.processar(valor) seguido de metodo.fatura(valor, status). Vale acrescentar que, em sistemas de pagamento reais, a proteção não para aí — a operação recebe uma chave de idempotência, de modo que a mesma requisição repetida por reenvio de rede ou por clique duplo seja reconhecida e não cobre de novo.

Exercício 2

Dado o código abaixo, diga o que D().quem() imprime e justifique. A resposta intuitiva — que super() dentro de B chama A, já que B herda de A — está errada.

class A:
    def quem(self): return "A"

class B(A):
    def quem(self): return "B -> " + super().quem()

class C(A):
    def quem(self): return "C -> " + super().quem()

class D(B, C):
    pass
Ver resposta

✓ Resposta: Imprime B -> C -> A. A chave é que super() não significa a classe pai da classe onde estou escrevendo; significa o próximo da fila do MRO do objeto que está sendo usado. O MRO de D é [D, B, C, A, object], calculado pelo algoritmo C3, que garante que uma classe apareça sempre antes de suas bases e que a ordem das bases na declaração seja respeitada. Chamando D().quem(), a busca começa em D, que não define o método, e encontra B.quem. Dentro de B, o super() consulta a posição de B no MRO do objeto, que é um D, e o próximo da fila ali é C — não A. Por isso a cadeia continua em C.quem, cujo super() finalmente alcança A. O resultado é que A é visitada uma única vez, apesar de haver dois caminhos até ela, que é precisamente o problema que o diamante cria em linguagens sem MRO. Note que a mesma classe B se comporta de forma diferente conforme o objeto: B().quem() devolve B -> A, porque no MRO de B o próximo é mesmo A. Daí a consequência prática que vale carregar: um método que participa de uma hierarquia deve chamar super() mesmo quando parece não haver ninguém adiante, porque não é a classe que decide quem vem depois — é o objeto. Isso vale especialmente para __init__: uma classe que inicializa seus atributos e não chama super().__init__() interrompe a cadeia e deixa as demais bases sem inicializar, o que na herança múltipla produz atributos faltando sem erro imediato. Vale saber, por fim, que o C3 também recusa o impossível: bases que exigem ordens contraditórias produzem TypeError: Cannot create a consistent method resolution order já na definição da classe.

Exercício 3

Uma classe base define @abstractmethod def salvar(self). Um desenvolvedor cria uma subclasse e esquece de implementar o método. O código passa na importação, passa na revisão, passa no linter, e só quebra em produção. Explique em que momento a verificação acontece e que prática de teste teria pegado isso antes.

Ver resposta

✓ Resposta: O @abstractmethod não é verificado quando a subclasse é definida, e sim quando alguém tenta instanciá-la. Definir class Incompleta(Base): pass sem implementar o método abstrato não produz erro nenhum: a classe é criada normalmente e o módulo importa sem reclamação. Só na primeira chamada a Incompleta() vem o TypeError: Can't instantiate abstract class Incompleta without an implementation for abstract method 'salvar'. O mecanismo explica o porquê: o ABCMeta acumula, num conjunto chamado __abstractmethods__, os nomes ainda não implementados, e é o object.__new__ que consulta esse conjunto na hora de construir o objeto. Isso também é o que torna possível uma hierarquia intermediária — uma classe abstrata pode herdar de outra e implementar só parte dos métodos, permanecendo abstrata. A consequência prática é que importar um módulo não valida nada, e é por isso que revisão e linter passaram: para o leitor humano a classe parece completa, e a ferramenta estática não costuma perseguir a cadeia de bases abstratas. A prática de teste que pegaria é simples e barata: todo caminho de código precisa ter pelo menos um teste que construa o objeto. Um teste que apenas importa o módulo e verifica que a classe existe não exercita o __new__ e não descobre nada. Em bases com muitas implementações de um mesmo contrato, vale um teste parametrizado que percorra Base.__subclasses__() e instancie cada uma, o que transforma o esquecimento em falha de teste automática no dia em que a subclasse nova for escrita. Ferramentas de verificação de tipos, como o mypy, também acusam esse caso na análise estática, e é um dos argumentos práticos a favor de adotá-las.

Exercício 4

Um projeto define class Nadador: com o método nadar que usa self.nome, sem que a classe declare esse atributo em lugar nenhum. Funciona porque só é usada como base de Triatleta, que define self.nome no construtor. Um colega tenta reaproveitar Nadador sozinho em outro módulo e recebe AttributeError. Avalie o projeto original e diga como tornar o contrato explícito.

Ver resposta

✓ Resposta: O padrão em si é legítimo e tem nome: mixin. Uma classe mixin fornece comportamento para ser combinado com outras e não se destina a existir sozinha — por isso é normal que ela use atributos que não cria. O problema do código original não é o padrão, é que o contrato ficou implícito: nada no nome, na documentação ou na assinatura avisa que Nadador exige um self.nome fornecido por quem a combinar, e a única forma de descobrir é ler o corpo dos métodos ou tropeçar no AttributeError, como o colega tropeçou. Há três formas de explicitar, combináveis entre si. A primeira, e mais barata, é a convenção de nome mais uma linha de documentação: chamar a classe de NadadorMixin e escrever no docstring que ela requer nome comunica a intenção a quem vai reaproveitar, que é exatamente quem precisa da informação. A segunda é declarar a dependência de forma verificável por ferramenta, anotando nome: str no corpo da classe — sem atribuir valor, o que cria apenas uma anotação em __annotations__ e permite ao mypy acusar a combinação que não fornece o atributo. A terceira, quando o contrato é maior que um atributo, é declarar um Protocol do módulo typing descrevendo o que o mixin espera, e anotar com ele. Vale notar a alternativa de projeto que às vezes é melhor que todas: se nome é comum a todos os papéis, ele pertence a uma base compartilhada, e o que parecia herança múltipla de três mixins vira uma base com nome mais os comportamentos. Herança múltipla resolve bem o caso de somar capacidades ortogonais; quando as partes compartilham estado, costuma ser sinal de que a modelagem quer outra forma — muitas vezes composição, com o objeto guardando os papéis em vez de herdá-los.

Exercício 5

As três formas geométricas deste artigo — Circulo, Retangulo e Triangulo — não herdam de nenhuma classe comum e ainda assim são percorridas pelo mesmo laço. Explique por que isso funciona, e discuta quando vale a pena introduzir uma classe base abstrata que elas passem a herdar, e quando isso é apenas cerimônia.

Ver resposta

✓ Resposta: Funciona porque o Python resolve chamadas de método em tempo de execução, procurando o nome no objeto que recebeu a chamada, sem consultar declaração de tipo nenhuma. O laço pede forma.area(), e cada objeto responde com o seu — não há verificação prévia de que os três pertençam a um mesmo tipo, e não é preciso que pertençam. É o duck typing: o que define a compatibilidade é o conjunto de métodos que o objeto responde, não a linhagem de onde ele veio. Essa é a diferença mais marcante em relação a linguagens de tipagem nominal, em que o polimorfismo exige uma interface declarada e implementada explicitamente. Sobre quando introduzir a base abstrata, a resposta depende de quem escreve as subclasses e de quando o erro precisa aparecer. Vale a pena quando as implementações são muitas, escritas por pessoas diferentes ou ao longo do tempo: o ABC com @abstractmethod transforma o esquecimento de um método em TypeError na instanciação, com mensagem que nomeia o método faltante, em vez de um AttributeError mais adiante, em outro arquivo, quando aquele caminho finalmente for exercitado. Vale também quando há implementação a compartilhar de fato — no sistema de pagamentos deste artigo, o método que monta o recibo é o mesmo para todos os meios, e a base existe por isso, não pela formalidade do contrato. É cerimônia, por outro lado, quando as classes são poucas, moram no mesmo arquivo, foram escritas juntas e não compartilham código nenhum: aí a base abstrata acrescenta um arquivo, uma importação e um nível de indireção sem tornar impossível nenhum erro que já não fosse improvável. Há ainda uma terceira via que costuma ser a melhor dos dois mundos em código moderno: declarar um Protocol do módulo typing, que descreve os métodos exigidos e permite ao verificador de tipos conferir a compatibilidade estaticamente, sem obrigar as classes a herdarem de nada — o contrato fica explícito e verificável, e o acoplamento não aumenta.

Comentários

Mais em Python

Tuplas e Sets: imutabilidade e unicidade
Tuplas e Sets: imutabilidade e unicidade

Tuplas e sets parecem variações da lista, mas resolvem problemas distintos…

Algoritmos de Ordenação e Busca
Algoritmos de Ordenação e Busca

Os algoritmos clássicos de ordenação e busca, com o que cada um ensina a…

Introdução ao Desenvolvimento Web com Flask
Introdução ao Desenvolvimento Web com Flask

Flask começa com uma rota e uma função, e os erros aparecem no que ele deixa…