← voltar ao índice

· 10 min de leitura · por Tiago

Herança e Polimorfismo: Guia Prático

Se você já passou por um curso de orientação a objetos, provavelmente aprendeu que herança é uma das quatro colunas do paradigma, ao lado de encapsulamento, abstração e polimorfismo. O problema é que a forma como isso costuma ser ensinado, com exemplos de Animal, Cachorro e Gato, esconde uma realidade desconfortável: na prática profissional, herança é usada com muito mais moderação do que os livros sugerem. Times experientes tendem a evitar hierarquias profundas e preferem composição, interfaces e delegação sempre que possível.

Este post não é contra herança. Ela existe por um motivo e, usada no lugar certo, resolve problemas de forma elegante. O objetivo aqui é dar um guia prático para você reconhecer quando ela é a ferramenta certa e quando ela vai te morder mais tarde, algo que só se aprende depois de manter por alguns anos um sistema que abusou dela.

Por que herança existe e por que ela é sedutora

A ideia original da herança é modelar uma relação de "é um" (is-a). Um Gerente é um Funcionario, um Retangulo é uma Forma. Ao herdar, você reaproveita código automaticamente: campos, métodos e comportamento da classe base passam a existir na classe derivada sem que você precise reescrever nada. Isso é extremamente atraente no início de um projeto, porque parece eliminar duplicação com pouquíssimo esforço.

O polimorfismo entra como consequência natural disso: se Gerente e Funcionario compartilham uma base comum, você pode tratar uma lista de Funcionario de forma genérica e deixar que cada subtipo execute seu próprio comportamento quando um método virtual é chamado. É um mecanismo poderoso, e é justamente por isso que ele é abusado: parece resolver tudo, então times acabam usando herança como primeira ferramenta em vez de última.

O problema real: acoplamento entre classe base e derivada

A herança cria um dos acoplamentos mais fortes que existem em orientação a objetos. Quando uma classe herda de outra, ela não depende apenas da interface pública da base, ela depende também de detalhes de implementação, da ordem de inicialização, dos invariantes internos e até de decisões que o autor da classe base tomou pensando em outro contexto. Esse fenômeno tem nome: fragile base class problem (problema da classe base frágil). Uma mudança aparentemente inofensiva na classe base pode quebrar silenciosamente todas as subclasses, mesmo que a assinatura pública não tenha mudado.

Veja um exemplo clássico que ilustra bem o risco:

public class Colecao<T>
{
    private readonly List<T> _itens = new();

    public virtual void Adicionar(T item)
    {
        _itens.Add(item);
    }

    public virtual void AdicionarVarios(IEnumerable<T> itens)
    {
        foreach (var item in itens)
        {
            Adicionar(item);
        }
    }
}

public class ColecaoComContador<T> : Colecao<T>
{
    public int TotalAdicionados { get; private set; }

    public override void Adicionar(T item)
    {
        TotalAdicionados++;
        base.Adicionar(item);
    }
}

Nesse código, ColecaoComContador sobrescreve Adicionar para contar quantos itens foram inseridos. Só que AdicionarVarios, herdado da base, chama Adicionar internamente. Como Adicionar é virtual e foi sobrescrito, o contador é incrementado corretamente também durante AdicionarVarios, o que parece bom à primeira vista. O problema é que essa correção depende inteiramente de um detalhe de implementação da classe base (o fato de AdicionarVarios chamar Adicionar internamente). Se um dia alguém "otimizar" AdicionarVarios para inserir os itens diretamente na lista interna sem passar por Adicionar, o contador da subclasse silenciosamente para de funcionar, e nenhum teste de compilação vai acusar isso. A subclasse está refém de uma decisão de implementação que nunca foi documentada como contrato.

Violação do princípio de substituição de Liskov

Outro sinal claro de que a herança está sendo usada de forma errada é a violação do princípio de substituição de Liskov (LSP), que diz que qualquer instância da subclasse deve poder substituir uma instância da classe base sem quebrar o comportamento esperado pelo código cliente. O exemplo mais repetido na literatura, mas didático o suficiente para valer a pena revisitar, é o de retângulo e quadrado:

public class Retangulo
{
    public virtual double Largura { get; set; }
    public virtual double Altura { get; set; }

    public double Area() => Largura * Altura;
}

public class Quadrado : Retangulo
{
    public override double Largura
    {
        get => base.Largura;
        set { base.Largura = value; base.Altura = value; }
    }

    public override double Altura
    {
        get => base.Altura;
        set { base.Altura = value; base.Largura = value; }
    }
}

Matematicamente, um quadrado é um retângulo, então a herança parece fazer sentido semântico. Mas do ponto de vista de comportamento, ela quebra qualquer código que dependa da premissa de que largura e altura de um Retangulo variam de forma independente:

void RedimensionarParaTeste(Retangulo r)
{
    r.Largura = 5;
    r.Altura = 4;
    Debug.Assert(r.Area() == 20); // falha se r for um Quadrado
}

Se r for um Quadrado, a asserção falha, porque alterar Altura também alterou Largura por baixo dos panos. O código cliente escreveu uma expectativa razoável baseada no contrato de Retangulo, e a subclasse quebrou essa expectativa. Isso não é um bug de implementação, é um sinal de que a relação "é um" ali é enganosa: um quadrado é um caso especial de retângulo em termos de definição geométrica, mas não em termos de comportamento mutável, e é o comportamento que importa para orientação a objetos, não a taxonomia do mundo real.

Composição no lugar de herança

A alternativa que resolve a maior parte desses problemas é a composição: em vez de uma classe herdar comportamento de outra, ela mantém uma referência a um objeto colaborador e delega a ele o trabalho necessário. O princípio informal "prefira composição a herança" existe justamente porque composição produz acoplamento muito mais fraco, já que a classe que usa o colaborador depende apenas de uma interface pública, nunca de detalhes internos.

Reescrevendo o exemplo da coleção com contador usando composição:

public interface IColecao<T>
{
    void Adicionar(T item);
    void AdicionarVarios(IEnumerable<T> itens);
}

public class ColecaoSimples<T> : IColecao<T>
{
    private readonly List<T> _itens = new();

    public void Adicionar(T item) => _itens.Add(item);

    public void AdicionarVarios(IEnumerable<T> itens)
    {
        foreach (var item in itens) Adicionar(item);
    }
}

public class ColecaoComContador<T> : IColecao<T>
{
    private readonly IColecao<T> _interna;
    public int TotalAdicionados { get; private set; }

    public ColecaoComContador(IColecao<T> interna)
    {
        _interna = interna;
    }

    public void Adicionar(T item)
    {
        TotalAdicionados++;
        _interna.Adicionar(item);
    }

    public void AdicionarVarios(IEnumerable<T> itens)
    {
        foreach (var item in itens) Adicionar(item);
    }
}

Aqui, ColecaoComContador não herda nada, ela envolve uma implementação de IColecao<T> e controla completamente como cada operação incrementa o contador. Não existe mais dependência oculta de como a implementação interna resolve AdicionarVarios, porque essa lógica agora vive explicitamente na própria classe decoradora. Esse padrão, aliás, tem nome: Decorator. Ele é um dos exemplos mais claros de como composição resolve com elegância um problema que herança resolve de forma frágil.

Polimorfismo sem herança de implementação

Um ponto importante que costuma passar despercebido é que polimorfismo não exige herança de classe. Em C#, interfaces já entregam polimorfismo completo, e em linguagens como Go isso é levado ao extremo, já que a linguagem nem sequer tem herança de classes, só interfaces implícitas. O polimorfismo por interface, aliás, costuma ser a escolha mais segura, porque você ganha o comportamento de "tratar tipos diferentes de forma uniforme" sem herdar nenhum detalhe de implementação junto.

Um exemplo prático de como isso aparece em código real é a implementação do padrão Strategy:

public interface ICalculadoraDeFrete
{
    decimal Calcular(Pedido pedido);
}

public class FreteSedex : ICalculadoraDeFrete
{
    public decimal Calcular(Pedido pedido) => pedido.PesoKg * 12.5m;
}

public class FretePac : ICalculadoraDeFrete
{
    public decimal Calcular(Pedido pedido) => pedido.PesoKg * 6.8m;
}

public class ServicoDeCheckout
{
    private readonly ICalculadoraDeFrete _calculadora;

    public ServicoDeCheckout(ICalculadoraDeFrete calculadora)
    {
        _calculadora = calculadora;
    }

    public decimal CalcularTotal(Pedido pedido)
    {
        return pedido.Subtotal + _calculadora.Calcular(pedido);
    }
}

Note que FreteSedex e FretePac não compartilham nenhuma classe base além da interface. Isso significa que cada implementação é livre para evoluir sem carregar bagagem de uma hierarquia comum, e o ServicoDeCheckout continua funcionando com qualquer nova estratégia de frete que aparecer no futuro, bastando implementar a interface. Esse é o mesmo polimorfismo que a herança oferece, só que sem o acoplamento estrutural entre as implementações concretas.

Quando herança ainda é a escolha certa

Dizer para evitar herança não significa que ela deva ser banida. Existem cenários em que ela continua sendo a ferramenta mais simples e correta, e reconhecer esses cenários é tão importante quanto reconhecer os problemáticos.

Herança funciona bem quando a relação "é um" é verdadeiramente estável e comportamental, não apenas taxonômica. Um bom teste mental é perguntar: toda operação que funciona com a classe base também funciona corretamente, sem surpresas, com a subclasse? Se a resposta for sim de forma consistente, a herança é segura. Isso costuma acontecer em hierarquias pequenas, controladas por um único time, onde as subclasses existem apenas para especializar comportamento sem alterar invariantes da base.

Um exemplo legítimo é o uso de classes abstratas para implementar variações de um algoritmo dentro do padrão Template Method:

public abstract class ProcessadorDeRelatorio
{
    public void Executar()
    {
        var dados = CarregarDados();
        var formatado = Formatar(dados);
        Salvar(formatado);
    }

    protected abstract IEnumerable<Registro> CarregarDados();
    protected abstract string Formatar(IEnumerable<Registro> dados);

    protected virtual void Salvar(string conteudo)
    {
        File.WriteAllText("relatorio.txt", conteudo);
    }
}

public class RelatorioCsv : ProcessadorDeRelatorio
{
    protected override IEnumerable<Registro> CarregarDados() => Repositorio.Buscar();
    protected override string Formatar(IEnumerable<Registro> dados) =>
        string.Join("\n", dados.Select(d => $"{d.Id},{d.Nome}"));
}

Aqui a classe base define um algoritmo fixo (Executar) e delega apenas os passos variáveis para as subclasses, sem que as subclasses precisem entender ou depender de detalhes internos além do contrato explícito dos métodos abstratos. É um uso disciplinado de herança, restrito a um único nível, sem sobrescrita de comportamento já implementado na base, apenas preenchimento de lacunas propositalmente deixadas abertas.

Herança de interface (implementar uma interface, não herdar de uma classe concreta) praticamente não sofre desses problemas, porque não existe implementação compartilhada para causar acoplamento oculto. Por isso, em bases de código modernas em C#, é comum ver hierarquias de classes concretas praticamente extintas, substituídas por interfaces e composição, reservando classes abstratas para casos bem delimitados como o Template Method acima.

Guia prático de decisão

Quando estiver diante da dúvida entre herdar e compor, algumas perguntas ajudam a decidir com mais segurança. Se a resposta para "toda operação da classe base continua válida na subclasse, sem exceções escondidas" for incerta, é sinal de alerta. Se você está herdando principalmente para reaproveitar código, sem que exista de fato uma relação polimórfica onde o código cliente trata os tipos de forma intercambiável, prefira extrair esse código para uma classe auxiliar e usar composição. Se a hierarquia pode crescer para mais de dois níveis, ou se subclasses de times diferentes vão herdar da mesma base, o risco de fragile base class cresce proporcionalmente, e composição costuma ser mais segura a longo prazo.

Por outro lado, use herança de classe concreta quando o número de subtipos é pequeno e estável, quando todos são mantidos pelo mesmo time, e quando o comportamento sobrescrito não depende de detalhes de implementação da base além do que está explicitamente documentado como ponto de extensão, como acontece no Template Method. Use interfaces para polimorfismo sempre que a única coisa que você precisa é tratar tipos diferentes de forma uniforme, sem nenhum código compartilhado, porque isso remove o acoplamento estrutural por completo.

Conclusão

Herança e polimorfismo continuam sendo conceitos centrais da orientação a objetos, mas a maturidade de um desenvolvedor se mostra justamente na hora de reconhecer que herança de implementação é uma ferramenta de uso restrito, não a primeira escolha para reaproveitar código. Os problemas que ela introduz, como o acoplamento entre classe base e derivada e a facilidade de violar o princípio de substituição de Liskov, não são teóricos: eles aparecem em sistemas reais como bugs sutis que só se manifestam meses depois, quando alguém altera a classe base sem imaginar o efeito colateral nas subclasses. Composição, interfaces e padrões como Strategy e Decorator resolvem a mesma necessidade de reaproveitamento e polimorfismo com um acoplamento muito mais controlável. A recomendação prática, portanto, não é "nunca use herança", mas sim "prefira composição por padrão e reserve herança de implementação para os casos em que a relação comportamental entre base e subclasse é genuinamente estável e bem compreendida".

← voltar ao índice