Pular para conteúdo

11. Polimorfismo e sobrecarga de operadores

Sem vídeo correspondente

Os vídeos 28–29 cobrem herança, mas não cobrem virtual/polimorfismo — que é o assunto principal deste capítulo, e o que torna herança realmente útil na maioria dos casos.

Herança: revisão rápida

Herança permite que uma classe reaproveite campos e métodos de outra:

class Animal {
protected:            // como private, mas classes filhas tambem enxergam
    std::string nome_;

public:
    Animal(std::string nome) : nome_(nome) {}

    std::string nome() const { return nome_; }

    std::string emite_som() const {
        return "...";
    }
};

class Cachorro : public Animal {
public:
    Cachorro(std::string nome) : Animal(nome) {}   // chama o construtor da classe base

    std::string emite_som() const {
        return "Au au!";
    }
};

Cachorro é um Animal — herda nome() sem precisar reescrever, e sobrescreve emite_som() com seu próprio comportamento. protected é como private, mas classes filhas também têm acesso (diferente de private, que nem as filhas enxergam).

O problema: herança sem virtual não é polimórfica de verdade

void apresenta(const Animal& a) {
    std::cout << a.nome() << " diz " << a.emite_som() << "\n";
}

Cachorro rex("Rex");
apresenta(rex);

O que isso imprime? Você esperaria "Rex diz Au au!" — afinal, rex é um Cachorro, e Cachorro::emite_som() diz "Au au!". Mas sem virtual, o C++ decide em tempo de compilação qual emite_som() chamar, baseado no tipo da referência/ponteiro (const Animal&), não no tipo real do objeto. O resultado real é "Rex diz ..." — a versão de Animal, não a de Cachorro. Isso se chama ligação estática (static binding), e quase nunca é o que você quer quando trabalha com uma hierarquia de classes.

virtual: ligação dinâmica

A correção é marcar o método na classe base como virtual, e usar override na classe filha:

class Animal {
protected:
    std::string nome_;

public:
    Animal(std::string nome) : nome_(nome) {}
    std::string nome() const { return nome_; }

    virtual std::string emite_som() const {
        return "...";
    }

    virtual ~Animal() {}   // destrutor virtual -- explicado abaixo, e ESSENCIAL
};

class Cachorro : public Animal {
public:
    Cachorro(std::string nome) : Animal(nome) {}

    std::string emite_som() const override {   // override confirma que estamos sobrescrevendo
        return "Au au!";
    }
};

class Gato : public Animal {
public:
    Gato(std::string nome) : Animal(nome) {}

    std::string emite_som() const override {
        return "Miau!";
    }
};

Agora apresenta(rex) imprime "Rex diz Au au!" de verdade: com virtual, o C++ decide em tempo de execução qual versão chamar, baseada no tipo real do objeto apontado/referenciado — isso é ligação dinâmica, e é o que a palavra "polimorfismo" significa na prática: o mesmo código (a.emite_som()) se comporta diferente dependendo do objeto real por trás da referência.

override não é obrigatório para o polimorfismo funcionar, mas é fortemente recomendado: se você escrever override num método que na verdade não sobrescreve nada na classe base (erro de digitação no nome, assinatura diferente), o compilador acusa erro em vez de silenciosamente criar um método novo, não relacionado, que nunca é chamado por polimorfismo. Sem override, esse tipo de erro é silencioso e muito difícil de depurar.

Por que isso importa: uma coleção polimórfica

O caso de uso mais comum de polimorfismo é guardar objetos de tipos diferentes (mas relacionados por herança) numa mesma coleção, e chamar o mesmo método em todos, cada um respondendo do seu jeito:

#include <vector>
#include <memory>
#include <iostream>

std::vector<std::unique_ptr<Animal>> zoologico;
zoologico.push_back(std::make_unique<Cachorro>("Rex"));
zoologico.push_back(std::make_unique<Gato>("Mia"));

for (const auto& animal : zoologico) {
    std::cout << animal->nome() << " diz " << animal->emite_som() << "\n";
}
// Rex diz Au au!
// Mia diz Miau!

Repare: std::vector<std::unique_ptr<Animal>>, não std::vector<Animal>. Isso é essencial — um vector<Animal> guardaria cópias de Animal (um problema chamado "object slicing": a parte Cachorro/Gato de cada objeto seria cortada fora, sobrando só a parte Animal). Guardar ponteiros (aqui, unique_ptr para manter RAII — regra do zero, capítulo anterior) preserva o tipo real de cada objeto, e é isso que faz o polimorfismo funcionar dentro da coleção.

Classe abstrata: uma interface

Quando a classe base não faz sentido sozinha — só existe para definir "o que toda classe filha precisa saber fazer" —, ela pode ter um método virtual puro, marcado com = 0:

class Forma {
public:
    virtual double area() const = 0;    // metodo virtual puro -- sem implementacao aqui
    virtual ~Forma() {}
};

class Circulo : public Forma {
private:
    double raio_;

public:
    explicit Circulo(double raio) : raio_(raio) {}
    double area() const override { return 3.14159 * raio_ * raio_; }
};

class Quadrado : public Forma {
private:
    double lado_;

public:
    explicit Quadrado(double lado) : lado_(lado) {}
    double area() const override { return lado_ * lado_; }
};

Forma é uma classe abstrata: você não pode criar um Forma f; diretamente (o compilador barra, porque area() não tem implementação) — só pode criar Circulo, Quadrado, ou qualquer outra classe filha que implemente area(). Forma funciona como uma interface: um contrato que diz "toda forma sabe calcular sua área", sem se comprometer com como. Isso vai ser exatamente o padrão usado na Unidade 7 para desacoplar lógica de hardware (uma interface ISensor, com implementações reais e "falsas" para teste).

Destrutor virtual: por que é essencial

Repare que Animal e Forma, acima, têm virtual ~Animal() {}/virtual ~Forma() {}. Isso não é acidente. Considere:

Forma* f = new Circulo(5.0);
delete f;   // qual destrutor roda?

Se ~Forma() não for virtual, delete f chama só ~Forma(), e o destrutor de Circulo nunca roda — se Circulo tivesse algum recurso próprio para liberar, vazaria. Com destrutor virtual, delete f corretamente chama ~Circulo() primeiro (que libera o que for específico de Circulo) e depois ~Forma(). Regra fixa: toda classe pensada para ser usada polimorficamente (com virtual em algum método) precisa de destrutor virtual.

virtual ~Base() {} e virtual ~Base() = default; são a mesma coisa

Você vai encontrar as duas formas por aí — inclusive na Lista 11 e no simulado da Avaliação 2, que usam a segunda. = default pede ao compilador o destrutor que ele geraria de qualquer forma, agora marcado virtual; {} escreve à mão um corpo vazio, que faz exatamente o mesmo. Para os fins deste curso são equivalentes, e você pode responder com a que preferir.

Sobrecarga de operadores

C++ permite definir o que operadores como ==, <, +, << fazem para seus próprios tipos — é o que faz std::string s1 == s2 ou std::cout << meuObjeto funcionarem de forma natural.

class Fracao {
private:
    int numerador_;
    int denominador_;

public:
    Fracao(int num, int den) : numerador_(num), denominador_(den) {}

    bool operator==(const Fracao& outra) const {
        return numerador_ * outra.denominador_ == outra.numerador_ * denominador_;
    }

    Fracao operator+(const Fracao& outra) const {
        return Fracao(numerador_ * outra.denominador_ + outra.numerador_ * denominador_,
                      denominador_ * outra.denominador_);
    }

    friend std::ostream& operator<<(std::ostream& saida, const Fracao& f) {
        saida << f.numerador_ << "/" << f.denominador_;
        return saida;
    }
};

Fracao a(1, 2);
Fracao b(1, 3);
Fracao c = a + b;         // usa operator+
std::cout << c << "\n";   // usa operator<< -- imprime "5/6"
bool iguais = (a == b);    // usa operator==

operator<< precisa ser friend (ou uma função livre fora da classe) porque o operando da esquerda é std::ostream&, não Fracao — não é um método que Fracao "chama em si mesma", e sim uma função que recebe os dois lados. friend dá a essa função acesso aos campos privados de Fracao, mesmo não sendo um método da classe.

Regra prática: sobrecarregue operadores quando eles tornam o código genuinamente mais legível e correspondem à semântica matemática/intuitiva esperada (+ para somar frações faz sentido; + para "concatenar" dois Retangulo não faria). Não sobrecarregue por sobrecarregar.

Erros comuns

Método sobrescrito não é chamado por polimorfismo ("imprime a versão errada") — geralmente falta virtual na classe base. Sem ele, a chamada é resolvida em tempo de compilação pelo tipo da referência, não pelo tipo real do objeto.

"error: cannot declare variable ‘f’ to be of abstract type ‘Forma’" — você tentou instanciar diretamente uma classe com método virtual puro (= 0). Isso é esperado: crie uma classe filha que implemente o(s) método(s) puro(s).

Vazamento ao usar delete num ponteiro de classe base sem destrutor virtual — descrito acima. Toda classe base usada polimorficamente precisa de virtual ~Classe() {}, mesmo vazio.

Guardar objetos por valor numa coleção polimórfica ("object slicing") — std::vector<Animal> corta a parte específica de cada subclasse. Use std::vector<std::unique_ptr<Animal>> (ou ponteiros/referências, dependendo do caso).

Resumo

  • Herança sem virtual usa ligação estática — decidida em tempo de compilação pelo tipo da referência, quase nunca o que você quer.
  • virtual na base + override na filha: ligação dinâmica — decidida em tempo de execução pelo tipo real do objeto. Isso é polimorfismo.
  • Classe abstrata (método virtual puro, = 0) define uma interface — um contrato sem implementação, implementado pelas classes filhas.
  • Toda classe usada polimorficamente precisa de destrutor virtual, senão delete por um ponteiro da classe base vaza o que for específico da classe filha.
  • Coleção polimórfica: vector<unique_ptr<Base>>, nunca vector<Base> (object slicing).
  • Sobrecarga de operadores (==, +, <<, ...) deixa o código mais natural quando corresponde à semântica esperada do tipo.