Um Método, Vários Comportamentos — Funções Virtuais e Polimorfismo

Um Método, Vários Comportamentos — Funções Virtuais e Polimorfismo

Uma função que recebe uma forma qualquer e manda desenhá-la, sem saber se é círculo ou retângulo: é isso que virtual torna possível, adiando a escolha do método para o tempo de execução. Aqui estão o mecanismo por trás disso, o custo real em ponteiro e indireção, e o que acontece dentro do construtor.
Linguagem C++

11 min de leitura

No artigo Uma Classe que Nasce de Outra — Herança e a Cadeia de Construção aprendemos a derivar classes e terminei com um aviso severo: herança sem propósito é acoplamento tóxico. Hoje entra o propósito que a redime. Imagine uma função que recebe "uma forma" e manda desenhá-la, sem saber nem se importar se é um círculo, um quadrado ou um triângulo — e cada uma se desenha do seu jeito. Esse é o polimorfismo, e ele é a razão de existir da herança em C++. Em C, você simularia isso à mão, com um enum de tipo e um switch gigante, ou com ponteiros para função dentro de structs — trabalhoso e frágil. O C++ oferece o mecanismo embutido: as funções virtuais. Ao fim desta aula, você escreverá código que opera sobre a interface da base e executa o comportamento da derivada, decidido em tempo de execução.

O problema: chamar o método certo sem saber o tipo exato

Comece pelo que não funciona como esperado. Se você guarda um objeto derivado através de um ponteiro ou referência da base e chama um método comum, o C++ chama a versão da base — porque a decisão é tomada pelo tipo declarado, em tempo de compilação:

#include <iostream>

class Forma {
public:
    void desenha() const { std::cout << "desenho genérico\n"; }
};

class Circulo : public Forma {
public:
    void desenha() const { std::cout << "desenho um círculo\n"; }
};

int main() {
    Circulo c;
    Forma& f = c;        // referência da BASE para um objeto derivado
    f.desenha();         // imprime "desenho genérico" — NÃO o do círculo!
    return 0;
}

Isso quase nunca é o que queremos. Ao segurar um Circulo por uma referência Forma&, esperávamos que desenha() fizesse o desenho do círculo. A palavra que conserta isso é virtual.

virtual: a decisão migra para o tempo de execução

Marque o método da base como virtual, e o C++ passa a decidir em tempo de execução qual versão chamar, olhando o tipo real do objeto, não o tipo declarado do ponteiro/referência. Esse mecanismo se chama despacho dinâmico:

#include <iostream>

class Forma {
public:
    // 'virtual' habilita o despacho dinâmico: a versão da classe REAL será chamada.
    virtual void desenha() const { std::cout << "desenho genérico\n"; }
    virtual ~Forma() = default;   // destrutor virtual — explicado no próximo artigo
};

class Circulo : public Forma {
public:
    // 'override' avisa o compilador: "isto substitui um virtual da base".
    void desenha() const override { std::cout << "desenho um círculo\n"; }
};

class Quadrado : public Forma {
public:
    void desenha() const override { std::cout << "desenho um quadrado\n"; }
};

int main() {
    Circulo c;
    Quadrado q;
    Forma& f1 = c;
    Forma& f2 = q;
    f1.desenha();    // "desenho um círculo"  — despacho dinâmico!
    f2.desenha();    // "desenho um quadrado" — cada um se desenha
    return 0;
}

Agora cada objeto se desenha do seu jeito, mesmo acessado por uma referência Forma&. Duas palavras-chave merecem atenção. virtual na base liga o despacho dinâmico. override na derivada é uma rede de segurança do C++11: ela declara sua intenção de substituir um método virtual, e o compilador verifica — se você errar a assinatura (um const a menos, um tipo trocado), em vez de criar silenciosamente um método novo que nunca é chamado, o compilador acusa erro. Use override sempre que sobrescrever; é de graça e pega bugs reais.

O polimorfismo em ação: tratar muitos como um

O poder aparece quando você guarda objetos de tipos diferentes numa mesma coleção da base e os trata uniformemente. Como precisamos de ponteiros (não dá para guardar tipos diferentes por valor num mesmo vetor), usamos os ponteiros inteligentes da Fase 2 — repare como tudo se conecta:

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

class Animal {
public:
    virtual void fala() const = 0;   // '= 0' torna a função PURA (abstrata) — próximo artigo
    virtual ~Animal() = default;
};

class Cachorro : public Animal {
public:
    void fala() const override { std::cout << "Au au\n"; }
};
class Gato : public Animal {
public:
    void fala() const override { std::cout << "Miau\n"; }
};
class Vaca : public Animal {
public:
    void fala() const override { std::cout << "Muuu\n"; }
};

int main() {
    // Um vetor de ponteiros à base guarda tipos derivados diferentes.
    std::vector<std::unique_ptr<Animal>> bicho;
    bicho.push_back(std::make_unique<Cachorro>());
    bicho.push_back(std::make_unique<Gato>());
    bicho.push_back(std::make_unique<Vaca>());

    // O laço não sabe (nem precisa saber) o tipo concreto de cada animal.
    for (const auto& a : bicho)
        a->fala();   // cada um fala do seu jeito — despacho dinâmico
    return 0;
}

Saída:

Au au
Miau
Muuu

Este é o coração do polimorfismo: o laço for escreve a->fala() uma única vez, e o comportamento correto de cada animal é executado. Adicionar um novo animal — um Pato — não exige tocar no laço; basta criar a classe. Compare com a versão C que você conhece: um switch (tipo) que precisaria ser editado a cada novo caso, espalhado por todo lugar que trata animais. O polimorfismo elimina esses switch e concentra a variação nas classes.

O custo honesto do virtual

Nada é de graça, e prometo sempre o trade-off. O despacho dinâmico tem um custo pequeno: cada objeto de uma classe com funções virtuais carrega um ponteiro escondido (para a vtable, uma tabela de funções virtuais), e cada chamada virtual faz uma indireção a mais que uma chamada comum. Na prática, esse custo é minúsculo e quase sempre irrelevante — mas ele existe, e é por isso que o C++ não torna tudo virtual por padrão (ao contrário de algumas linguagens). A filosofia da casa, de novo: você paga só pelo que usa. Se um método nunca será sobrescrito, deixá-lo não-virtual economiza essa indireção. A regra prática: torne virtual o que precisa de polimorfismo, e só isso.

Há também uma armadilha que preciso plantar agora, porque ela é séria e é o tema do próximo artigo. No exemplo dos animais, guardamos unique_ptr<Animal> que na verdade apontam para Cachorro, Gato, Vaca. Quando esses ponteiros forem destruídos, o unique_ptr chamará delete sobre um Animal*. Para que o destrutor correto (o do Cachorro, etc.) rode, o destrutor da base precisa ser virtual — e foi por isso que escrevi virtual ~Animal() = default;. Sem isso, o comportamento é indefinido e recursos vazam. Guarde essa observação: ela é a espinha do artigo Contratos e Destruição Segura — Classes Abstratas e Destrutores Virtuais.

virtual adia a escolha do método para o tempo de execução, e é isso que permite escrever uma função que trabalha com "uma forma" sem conhecer os tipos concretos. O custo existe e é modesto: um ponteiro por objeto e uma indireção por chamada, que impede a expansão em linha. O detalhe que derruba mais gente não é o custo, é o esquecimento — chamada virtual dentro do construtor ou do destrutor executa a versão da classe que está sendo construída, não a da derivada, porque a parte derivada ainda não existe.

Fontes e leituras recomendadas

  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre hierarquias de classes: a apresentação de funções virtuais e despacho dinâmico pelo criador.
  • cppreference.com/w/cpp/language/virtual: a referência técnica sobre funções virtuais, override, final e o mecanismo de despacho.
  • Scott Meyers, Effective C++ (3ª ed.), Item 34 ("Differentiate between inheritance of interface and inheritance of implementation"): a distinção fina entre o que uma virtual promete.
  • ISO C++ Core Guidelines, regras C.128 ("Virtual functions should specify exactly one of virtual, override, or final") e C.130: as diretrizes oficiais sobre como declarar sobrescritas com segurança.
  • cppreference.com/w/cpp/language/override: os detalhes do especificador override e por que ele previne bugs de assinatura.

Exercícios

Exercício 1

Crie uma base Instrumento com um método virtual void toca() const e duas derivadas (Violao, Bateria) que o sobrescrevam com override. Guarde ponteiros à base num std::vector<std::unique_ptr<Instrumento>> e toque todos num laço.

Ver resposta

✓ Resposta: A hierarquia polimórfica:

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

class Instrumento {
public:
    virtual void toca() const = 0;
    virtual ~Instrumento() = default;
};
class Violao : public Instrumento {
public:
    void toca() const override { std::cout << "plim plim (violão)\n"; }
};
class Bateria : public Instrumento {
public:
    void toca() const override { std::cout << "tum tá (bateria)\n"; }
};

int main() {
    std::vector<std::unique_ptr<Instrumento>> banda;
    banda.push_back(std::make_unique<Violao>());
    banda.push_back(std::make_unique<Bateria>());
    for (const auto& i : banda) i->toca();
    return 0;
}

Cada instrumento toca do seu jeito através do mesmo i->toca().

Exercício 2

Remova a palavra virtual do exemplo Forma/Circulo desta aula e diga, sem rodar, o que f.desenha() passa a imprimir quando f é Forma& ligada a um Circulo. Explique a causa.

Ver resposta

✓ Resposta: Sem virtual, f.desenha() imprime "desenho genérico" (a versão de Forma). A causa: sem virtual, a escolha do método é feita em tempo de compilação pelo tipo declarado da referência (Forma&), não pelo tipo real do objeto (Circulo). Sem despacho dinâmico, o compilador vê "uma Forma" e chama Forma::desenha, ignorando que por baixo há um Circulo.

Exercício 3

O código abaixo compila, mas override revela um bug. Adicione override onde apropriado e explique qual erro o compilador passa a apontar.

class Base {
public:
    virtual void processa(int x) const { }
    virtual ~Base() = default;
};
class Derivada : public Base {
public:
    void processa(int x) { }   // pretendia sobrescrever... será que sobrescreve?
};
Ver resposta

✓ Resposta: O bug é que Derivada::processa(int x) não é const, enquanto o virtual da base é processa(int x) const — assinaturas diferentes, então ela não sobrescreve; cria um método novo, e chamadas polimórficas continuariam indo para o da base. Ao adicionar override:

class Derivada : public Base {
public:
    void processa(int x) override { }   // ERRO de compilação!
};

O compilador acusa algo como "method does not override a base class method", porque não existe na base um processa(int) não-const para sobrescrever. A correção é alinhar a assinatura: void processa(int x) const override { }. É exatamente o tipo de bug silencioso que override foi criado para expor.

Exercício 4

Explique, com base no custo do despacho dinâmico, por que o C++ não torna todos os métodos virtuais por padrão, e dê um exemplo de método que faz sentido deixar não-virtual.

Ver resposta

✓ Resposta: Porque cada função virtual acrescenta uma indireção (via vtable) a cada chamada e um ponteiro escondido a cada objeto — um custo pequeno, mas real. Tornar tudo virtual imporia esse custo universalmente, contrariando o princípio "você paga só pelo que usa". Exemplo de método que faz sentido não ser virtual: um getter trivial como int Ponto::x() const { return x_; }, que nunca será sobrescrito e se beneficia de ser inlined pelo compilador — torná-lo virtual só adicionaria overhead sem ganho.

Exercício 5

Escreva uma função livre void apresenta(const Animal& a) (usando a hierarquia Animal/Cachorro/Gato da aula) que chame a.fala(). Demonstre que a mesma função, chamada com um Cachorro e com um Gato, produz saídas diferentes. Explique por que isso é polimorfismo e como você faria o equivalente em C.

Ver resposta

✓ Resposta: A função e a demonstração:

#include <iostream>
// (reusando Animal/Cachorro/Gato com fala() virtual da aula)
void apresenta(const Animal& a) {
    std::cout << "o animal diz: ";
    a.fala();               // despacho dinâmico decide qual fala() roda
}
int main() {
    Cachorro c;
    Gato g;
    apresenta(c);   // o animal diz: Au au
    apresenta(g);   // o animal diz: Miau
    return 0;
}

Isso é polimorfismo porque apresenta foi escrita uma vez, contra a interface Animal, e produz comportamentos diferentes conforme o tipo real do argumento — sem nenhum if ou switch sobre o tipo. Em C, o equivalente exigiria um enum TipoAnimal dentro do struct e um switch (a->tipo) { case CACHORRO: ...; case GATO: ...; } dentro de apresenta, que você teria de editar a cada novo animal, com o risco de esquecer um case. As funções virtuais substituem esse switch manual por um mecanismo automático e extensível.

Comentários

Mais em Linguagem C++

Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática
Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática

Uma classe que adquire um recurso ao nascer e o devolve ao morrer, sem um…

O Fio que Une Tudo — Iteradores como Conceito Central
O Fio que Une Tudo — Iteradores como Conceito Central

Um ponteiro generalizado: é isso que o iterador é, e é o que permite escrever…

Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Pong
Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Pong

Detectar que dois retângulos se tocam são quatro comparações; responder à…