Herança: Quando um Tipo É uma Versão Especializada de Outro

Herança: Quando um Tipo É uma Versão Especializada de Outro

Herança em C#: classe base e derivada, construtor com base, virtual e override, despacho dinâmico, classes e membros abstratos, protected e sealed, a armadilha do new, object como raiz, testes de tipo com is, exceções próprias e quando preferir composição.
Linguagem C#

• • 16 min de leitura

E-books, livros impressos e audiolivros são todos produtos da Livraria: compartilham quase tudo e diferem em pontos precisos, e a herança existe para expressar exatamente isso.

Em "Propriedades e Encapsulamento" deixamos a classe Livro bem protegida, mas presa a um único formato: o livro impresso, com estoque físico. A Livraria quer vender e-books, que têm título, autor e preço, mas nenhum estoque (vender mil cópias de um arquivo não esgota nada) e nenhum peso para frete. Copiar Livro para uma classe Ebook duplicaria validações e regras, e na primeira mudança de regra uma das cópias ficaria para trás. A herança permite declarar o que é comum uma única vez, numa classe base, e especializar apenas o que difere.

Classe base e classe derivada

A sintaxe é um dois-pontos após o nome da classe:

class Produto
{
    public string Titulo { get; }
    public decimal Preco { get; set; }

    public Produto(string titulo, decimal preco)
    {
        Titulo = titulo;
        Preco = preco;
    }
}

class Ebook : Produto
{
    public double TamanhoMb { get; }

    public Ebook(string titulo, decimal preco, double tamanhoMb)
        : base(titulo, preco)       // chama o construtor de Produto primeiro
    {
        TamanhoMb = tamanhoMb;
    }
}

Leia class Ebook : Produto como "Ebook é um Produto". Um Ebook tem tudo que Produto tem (Titulo, Preco) e mais o que declara (TamanhoMb). O : base(...) no construtor encaminha os argumentos ao construtor da classe base, que roda antes do corpo do construtor derivado. Assim, as validações de título e preço, escritas uma vez em Produto, valem automaticamente para toda classe derivada.

var ebook = new Ebook("Dom Casmurro", 19.90m, 2.4);
Console.WriteLine(ebook.Titulo);      // herdado de Produto
Console.WriteLine(ebook.TamanhoMb);   // próprio de Ebook

O C# permite herdar de uma única classe base. Não existe herança múltipla de classes, como em C++. Essa restrição é deliberada: ela evita ambiguidades clássicas, como o que fazer quando duas bases têm um método com o mesmo nome, e vamos ver na próxima aula que as interfaces cobrem, com muito menos risco, a maioria dos casos em que se desejaria herança múltipla.

virtual e override: especializando comportamento

Herdar dados é a parte fácil. A força da herança está em permitir que cada tipo responda à mesma pergunta de um jeito diferente. O frete, por exemplo: e-books não pagam frete, e livros impressos pagam conforme o peso. A classe base declara o método como virtual, o que significa que as derivadas podem substituí-lo:

class Produto
{
    // ...
    public virtual decimal Frete(int quantidade) => 0m;   // padrão: sem frete
}

class LivroImpresso : Produto
{
    public int PesoGramas { get; }
    // ...
    public override decimal Frete(int quantidade) =>
        10m + 0.02m * PesoGramas * quantidade;           // R$ 10 fixos + R$ 0,02 por grama
}

Ebook não precisa fazer nada: herda o frete zero. LivroImpresso substitui (override) o cálculo. A mágica aparece quando tratamos os objetos pelo tipo base:

List<Produto> carrinho =
[
    new Ebook("Dom Casmurro", 19.90m, 2.4),
    new LivroImpresso("Vidas Secas", 38.00m, pesoGramas: 300),
];

foreach (Produto p in carrinho)
    Console.WriteLine($"{p.Titulo}: frete {p.Frete(1):C}");
Dom Casmurro: frete R$ 0,00
Vidas Secas: frete R$ 16,00

A variável p é do tipo Produto, mas a chamada p.Frete(1) executa a versão do tipo real de cada objeto. Isso se chama despacho dinâmico, e é o coração do polimorfismo: o código que percorre o carrinho não sabe, nem precisa saber, quais formatos existem. Amanhã entra um tipo Audiolivro e esse laço continua funcionando sem mudar uma linha.

Se você vem do C, a ideia é familiar: é o que se faz com uma struct que carrega ponteiros para funções, escolhidos conforme o "tipo" do registro. O C# monta essa tabela de ponteiros (a vtable) automaticamente, e o compilador garante que ela está sempre coerente.

Classes e membros abstratos

Faz sentido criar um objeto Produto puro, que não é nem impresso nem e-book? Não. Produto existe para ser base, não para ser instanciado. Marcá-lo como abstract torna isso uma regra:

abstract class Produto
{
    // ...
    public abstract string Formato { get; }
    public abstract void Vender(int quantidade);
}

Uma classe abstrata não pode ser instanciada (new Produto(...) é o erro CS0144). E um membro abstract não tem implementação: é uma obrigação imposta às derivadas. Toda classe concreta que herdar de Produto precisa implementar Formato e Vender, ou o programa não compila. Compare com virtual: um membro virtual tem uma implementação padrão que pode ser substituída; um abstrato não tem implementação nenhuma e deve ser implementado.

Faz sentido que Vender seja abstrato: um livro impresso vende baixando estoque, um e-book vende registrando um download, e não existe uma forma "padrão" razoável.

protected, base e sealed

Na aula anterior mencionamos o modificador protected: um membro protegido é visível na própria classe e nas derivadas, mas não para o resto do mundo. É útil para ajudantes que as derivadas precisam, mas que não fazem parte da interface pública. Construtores de classes abstratas costumam ser protected, já que só as derivadas os chamam.

Dentro de um override, base.Metodo() chama a versão da classe base. É útil para estender em vez de substituir por completo:

public override string ToString() => $"{base.ToString()}, {Estoque} em estoque";

E sealed faz o caminho inverso do abstract: uma classe sealed não pode ser herdada. É uma boa escolha padrão para classes concretas que não foram desenhadas para extensão. Herdar de uma classe que não foi pensada para isso é frágil, como veremos adiante. Como bônus, o JIT pode otimizar chamadas a métodos de classes seladas, porque sabe que nenhuma derivada vai substituí-los.

A armadilha do new: esconder não é substituir

Existe uma forma de declarar na derivada um método com o mesmo nome da base sem virtual/override. O resultado é bem diferente:

class Base
{
    public void Ola() => Console.WriteLine("Olá da Base");
    public virtual void Tchau() => Console.WriteLine("Tchau da Base");
}

class Derivada : Base
{
    public new void Ola() => Console.WriteLine("Olá da Derivada");
    public override void Tchau() => Console.WriteLine("Tchau da Derivada");
}

Base b = new Derivada();
b.Ola();      // Olá da Base
b.Tchau();    // Tchau da Derivada

override substitui: a chamada segue o tipo real do objeto. new apenas esconde: a chamada segue o tipo declarado da variável. Como b é declarada como Base, b.Ola() chama a versão da base, mesmo que o objeto seja uma Derivada. Esconder métodos quase nunca é o que se quer, e produz um comportamento que depende de como a variável foi declarada. O compilador avisa (CS0108) quando você esconde sem querer. O new explícito só serve para dizer "sim, eu sei", e deve ser raríssimo.

object: a raiz de tudo

Toda classe em C# herda, direta ou indiretamente, de System.Object (ou simplesmente object). É daí que vêm os métodos que todo objeto tem: ToString(), que substituímos em "Classes e Objetos", Equals() e GetHashCode(), que decidem igualdade e voltam na aula sobre record, e GetType(), que informa o tipo real em tempo de execução. O override do nosso ToString agora faz sentido completo: ToString é um método virtual de object, e nós o substituímos.

Descobrindo o tipo real: is e padrões de tipo

Às vezes você tem um Produto e precisa de algo específico de um tipo derivado. O operador is testa o tipo e, com um padrão, já entrega a variável convertida:

foreach (Produto p in carrinho)
{
    if (p is LivroImpresso impresso)
        Console.WriteLine($"{impresso.Titulo} pesa {impresso.PesoGramas} g");
}

A expressão switch que conhecemos em "O Programa Que Escolhe e Repete" também aceita padrões de tipo:

string icone = p switch
{
    Ebook => "[digital]",
    LivroImpresso { PesoGramas: > 1000 } => "[impresso, pesado]",
    LivroImpresso => "[impresso]",
    _ => "[?]"
};

O segundo braço combina um padrão de tipo com um padrão de propriedade: "um LivroImpresso cujo peso passa de 1000 g". Os padrões ganham uma aula própria em breve.

Um aviso de desenho: se o seu código vive testando is TipoX para decidir o que fazer, provavelmente aquele comportamento deveria ser um método virtual ou abstract, respondido por cada tipo. Testes de tipo são legítimos na borda do sistema (exibição, serialização), mas são um sinal de alerta no meio da regra de negócio.

Exceções próprias

Em "Quando Algo Dá Errado" prometemos exceções próprias quando conhecêssemos a herança. Uma exceção é só uma classe que deriva de Exception, ou de uma derivada mais específica:

class EstoqueInsuficienteException : InvalidOperationException
{
    public EstoqueInsuficienteException(string titulo, int disponivel, int solicitado)
        : base($"há apenas {disponivel} exemplar(es) de \"{titulo}\"; foram pedidos {solicitado}.")
    {
        Titulo = titulo;
        Disponivel = disponivel;
        Solicitado = solicitado;
    }

    public string Titulo { get; }
    public int Disponivel { get; }
    public int Solicitado { get; }
}

Duas vantagens sobre a InvalidOperationException genérica. Quem trata pode capturar especificamente esse caso (catch (EstoqueInsuficienteException ex)) sem confundi-lo com outros problemas de estado. E a exceção carrega dados: a interface pode oferecer "quer levar os 2 disponíveis?" usando ex.Disponivel, em vez de extrair números de dentro de uma mensagem de texto. Derivar de InvalidOperationException, e não diretamente de Exception, mantém compatível todo código que já capturava o tipo mais genérico.

Quando não usar herança

A herança é poderosa e, justamente por isso, é a ferramenta de orientação a objetos mais usada indevidamente. Três critérios ajudam.

O teste do "é um". LivroImpresso é um Produto? Sim, em todos os sentidos. Pedido é uma List<ItemPedido>? Não: um pedido tem uma lista de itens, mas também tem cliente, desconto e regras que uma lista não tem. Herdar de List exporia Clear, Insert e dezenas de métodos que contornariam as regras do pedido. Quando a relação é "tem um", use composição (um campo), como fizemos em Pedido.

Classe base frágil. Cada classe derivada depende de detalhes da base. Uma mudança aparentemente inocente na base (a ordem em que um método chama outro, por exemplo) pode quebrar derivadas que você nem conhece. Hierarquias profundas multiplicam esse risco. Prefira hierarquias rasas, de um ou dois níveis, como a nossa.

Herança para reaproveitar código. Se a única razão para herdar é "a classe X já tem um método que eu quero usar", o caminho certo é quase sempre compor: ter um X como campo e chamá-lo. O livro clássico Design Patterns, de 1994, resumiu a lição numa frase que continua valendo: prefira composição à herança de classes.

Juntando tudo: a hierarquia de produtos

abstract class Produto
{
    private static int _proximoId = 1;

    protected Produto(string titulo, string autor, decimal preco)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(titulo);
        ArgumentException.ThrowIfNullOrWhiteSpace(autor);
        Id = _proximoId++;
        Titulo = titulo.Trim();
        Autor = autor.Trim();
        Preco = preco;
    }

    public int Id { get; }
    public string Titulo { get; }
    public string Autor { get; }

    public decimal Preco
    {
        get;
        set
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            field = value;
        }
    }

    public abstract string Formato { get; }
    public abstract void Vender(int quantidade);
    public virtual decimal Frete(int quantidade) => 0m;

    public override string ToString() => $"#{Id} {Titulo} ({Autor}), {Formato}, {Preco:C}";
}

sealed class LivroImpresso : Produto
{
    public LivroImpresso(string titulo, string autor, decimal preco, int pesoGramas, int estoque = 0)
        : base(titulo, autor, preco)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pesoGramas);
        ArgumentOutOfRangeException.ThrowIfNegative(estoque);
        PesoGramas = pesoGramas;
        Estoque = estoque;
    }

    public int PesoGramas { get; }
    public int Estoque { get; private set; }
    public override string Formato => "impresso";

    public override void Vender(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        if (quantidade > Estoque)
            throw new EstoqueInsuficienteException(Titulo, Estoque, quantidade);
        Estoque -= quantidade;
    }

    public void Repor(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        Estoque += quantidade;
    }

    public override decimal Frete(int quantidade) => 10m + 0.02m * PesoGramas * quantidade;

    public override string ToString() => $"{base.ToString()}, {Estoque} em estoque";
}

sealed class Ebook : Produto
{
    public Ebook(string titulo, string autor, decimal preco, double tamanhoMb)
        : base(titulo, autor, preco)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(tamanhoMb);
        TamanhoMb = tamanhoMb;
    }

    public double TamanhoMb { get; }
    public int Downloads { get; private set; }
    public override string Formato => "e-book";

    public override void Vender(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        Downloads += quantidade;   // cópias digitais não esgotam
    }
}

E o programa que trata todos de maneira uniforme:

using System.Globalization;

CultureInfo.CurrentCulture = new CultureInfo("pt-BR");

List<Produto> catalogo =
[
    new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 2),
    new Ebook("Dom Casmurro", "Machado de Assis", 19.90m, tamanhoMb: 2.4),
    new LivroImpresso("Grande Sertão: Veredas", "Guimarães Rosa", 89.00m, pesoGramas: 1200, estoque: 1),
];

foreach (Produto p in catalogo)
{
    Console.WriteLine(p);
    try
    {
        p.Vender(2);
        Console.WriteLine($"   vendidos 2; frete: {p.Frete(2):C}");
    }
    catch (EstoqueInsuficienteException ex)
    {
        Console.WriteLine($"   recusado: {ex.Message} (dá para levar {ex.Disponivel})");
    }
}
#1 Dom Casmurro (Machado de Assis), impresso, R$ 59,90, 2 em estoque
   vendidos 2; frete: R$ 24,00
#2 Dom Casmurro (Machado de Assis), e-book, R$ 19,90
   vendidos 2; frete: R$ 0,00
#3 Grande Sertão: Veredas (Guimarães Rosa), impresso, R$ 89,00, 1 em estoque
   recusado: há apenas 1 exemplar(es) de "Grande Sertão: Veredas"; foram pedidos 2. (dá para levar 1)

O laço não contém um único if sobre o formato. Cada produto sabe vender a si mesmo, calcular o próprio frete e se descrever. Os fretes conferem: para Dom Casmurro impresso, 10 + 0,02 × 350 × 2 = 24.

A herança resolveu bem o caso em que os tipos compartilham identidade: todo livro impresso e todo e-book são produtos, com título, autor e preço. Mas a Livraria logo precisa de comportamentos comuns entre coisas que não têm parentesco nenhum. Um desconto pode vir de uma regra por volume, de um cupom ou de uma campanha de fim de ano. São conceitos sem nada em comum além de uma capacidade: calcular quanto abater de um pedido. Forçá-los numa hierarquia seria inventar um parentesco falso. Para capacidades compartilhadas por tipos sem parentesco, o C# tem um contrato mais leve e mais flexível que uma classe base: a interface.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie a classe Audiolivro, derivada de Produto, com a duração em minutos e o nome do narrador. Ela não tem estoque nem frete, e o ToString deve mostrar a duração no formato "7h30min".

Ver resposta

✓ Resposta:

sealed class Audiolivro : Produto
{
    public Audiolivro(string titulo, string autor, decimal preco, int duracaoMinutos, string narrador)
        : base(titulo, autor, preco)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(duracaoMinutos);
        ArgumentException.ThrowIfNullOrWhiteSpace(narrador);
        DuracaoMinutos = duracaoMinutos;
        Narrador = narrador.Trim();
    }

    public int DuracaoMinutos { get; }
    public string Narrador { get; }
    public int Vendas { get; private set; }
    public override string Formato => "audiolivro";

    public override void Vender(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        Vendas += quantidade;
    }

    public override string ToString() =>
        $"{base.ToString()}, {DuracaoMinutos / 60}h{DuracaoMinutos % 60:00}min, narrado por {Narrador}";
}

Frete não é sobrescrito, então herda o zero de Produto. Formato e Vender são obrigatórios, porque são abstratos. Esqueça um deles e o compilador recusa a classe. A duração usa divisão inteira e resto: 450 minutos viram 7h30min. O formato :00 garante dois dígitos nos minutos (7h05min, e não 7h5min). O laço do programa principal funciona com audiolivros sem nenhuma alteração: é o polimorfismo pagando o investimento.

Exercício 2

Preveja a saída de cada chamada e explique:

class Animal
{
    public virtual string Som() => "...";
    public string Nome() => "animal";
}
class Cachorro : Animal
{
    public override string Som() => "au";
    public new string Nome() => "cachorro";
}

Animal a = new Cachorro();
Cachorro c = new Cachorro();
Console.WriteLine($"{a.Som()} {a.Nome()} {c.Som()} {c.Nome()}");
Ver resposta

✓ Resposta: Imprime au animal au cachorro. Som é virtual e sobrescrito, então segue o tipo real do objeto, que nos dois casos é Cachorro: "au" e "au". Nome não é virtual e foi apenas escondido com new, então segue o tipo declarado da variável: a é declarada como Animal e chama a versão de Animal, e c é declarada como Cachorro e chama a versão escondida. O mesmo objeto responde de formas diferentes conforme a variável usada para acessá-lo, e é exatamente esse comportamento confuso que torna o new desaconselhado.

Exercício 3

Por que var p = new Produto("X", "Y", 10m); não compila? Cite dois motivos, um sobre a classe e outro sobre o construtor.

Ver resposta

✓ Resposta: Primeiro, Produto é abstract, e classes abstratas não podem ser instanciadas diretamente (erro CS0144). Elas existem para ser base, e seus membros abstratos não têm implementação: o que p.Vender(1) faria? Segundo, o construtor de Produto é protected, acessível apenas às classes derivadas, então mesmo sem o abstract a chamada de fora seria recusada. As duas proteções comunicam a mesma intenção por caminhos diferentes. O abstract é o que carrega o significado: "este tipo é incompleto por natureza".

Exercício 4

Dada uma List<Produto>, calcule o peso total dos livros impressos usando um padrão de tipo, e a quantidade de itens que não têm frete.

Ver resposta

✓ Resposta:

int pesoTotal = 0;
int semFrete = 0;

foreach (Produto p in catalogo)
{
    if (p is LivroImpresso impresso)
        pesoTotal += impresso.PesoGramas;

    if (p.Frete(1) == 0m)
        semFrete++;
}

Console.WriteLine($"Peso dos impressos: {pesoTotal} g");
Console.WriteLine($"Itens sem frete: {semFrete}");

Os dois cálculos ilustram a diferença discutida na aula. O peso é uma informação exclusiva de LivroImpresso, então o teste de tipo é legítimo: só impressos têm peso. Já "tem frete?" é uma pergunta que todo produto sabe responder, então usamos o método virtual, em vez de testar p is Ebook || p is Audiolivro. A segunda forma quebraria em silêncio no dia em que surgisse um novo formato sem frete. A primeira continua correta, porque cada tipo responde por si.

Exercício 5

Um colega propõe class Pedido : List<ItemPedido> para "ganhar de graça" os métodos de lista. Dê três problemas dessa decisão e descreva a alternativa.

Ver resposta

✓ Resposta: Primeiro, encapsulamento quebrado: Pedido passa a expor publicamente Clear, Insert, RemoveAt e o indexador com escrita, e qualquer código pode adicionar itens sem passar pelas validações ou esvaziar um pedido já pago. Segundo, falha no teste do "é um": um pedido não é uma lista. Ele tem cliente, política de desconto, status e total, e usar um pedido onde se espera uma lista qualquer (passá-lo a um método que ordena ou embaralha listas) faria sentido para o compilador e nenhum para o negócio. Terceiro, acoplamento a uma implementação: se amanhã os itens precisarem ficar num dicionário indexado por produto, trocar a base quebra todo código que dependia de métodos de List. A alternativa é a composição, como fizemos em "Propriedades e Encapsulamento": um campo private readonly List<ItemPedido> _itens, uma propriedade IReadOnlyList<ItemPedido> Itens somente leitura e métodos com nome de negócio (Adicionar, Remover) que aplicam as regras.

Comentários

Mais em Linguagem C#

Do Arquivo Único à Solução: Namespaces, Projetos e Pacotes NuGet
Do Arquivo Único à Solução: Namespaces, Projetos e Pacotes NuGet

Namespaces, projetos e NuGet em C#: namespace de arquivo e global using…

ASP.NET Core por dentro: hosting, pipeline de middleware e Minimal APIs
ASP.NET Core por dentro: hosting, pipeline de middleware e Minimal APIs

O que acontece entre o socket e o seu código no ASP.NET Core: Kestrel e host…

Worker Services e mensageria com RabbitMQ: trabalho fora da requisição
Worker Services e mensageria com RabbitMQ: trabalho fora da requisição

Os efeitos da venda saem da requisição: BackgroundService com Channel…