Fechei o artigo Um Método, Vários Comportamentos — Funções Virtuais e Polimorfismo com dois fios à mostra e uma promessa de amarrá-los. O primeiro fio era aquele = 0 coladinho em algumas funções virtuais, que as tornava "puras". O segundo era o destrutor virtual, sem o qual destruir objetos polimórficos vaza recursos — o bug que plantei de propósito. Hoje pagamos os dois. Você vai aprender a transformar uma classe base em um contrato que obriga as derivadas a implementarem certos métodos (a classe abstrata), e a garantir que a destruição de objetos acessados pela base seja sempre correta (o destrutor virtual). São os dois pilares que separam "polimorfismo que funciona por acaso" de "polimorfismo projetado com segurança".
Funções virtuais puras: a base que exige, não implementa
No exemplo dos animais do artigo anterior, fala() foi declarada virtual void fala() const = 0;. Esse = 0 a torna uma função virtual pura: uma função que a base declara mas não implementa, delegando obrigatoriamente a implementação às derivadas. Uma classe que tenha ao menos uma função virtual pura é uma classe abstrata, e ela tem uma propriedade fundamental — não pode ser instanciada:
#include <iostream>
class Animal {
public:
virtual void fala() const = 0; // pura: Animal não sabe como um animal genérico fala
virtual ~Animal() = default;
};
int main() {
// Animal a; // ERRO: não se pode instanciar uma classe abstrata.
// O que seria "o som de um animal genérico"? Nada. Por isso é proibido.
return 0;
}
Isso faz todo o sentido conceitual: "um animal" sem espécie não tem som próprio; só um Cachorro ou um Gato concretos falam. A classe abstrata captura essa ideia — ela define o que todo animal deve saber fazer (falar), sem dizer como. É um contrato: qualquer classe que herde de Animal precisa implementar fala(), ou ela também será abstrata e não poderá ser instanciada.
Interfaces: contratos puros
Levando a ideia ao limite, temos a interface: uma classe abstrata composta apenas de funções virtuais puras, sem dados nem implementação. Ela descreve uma capacidade que várias classes não relacionadas podem oferecer. É um dos usos mais limpos e defensáveis da herança:
#include <iostream>
#include <memory>
#include <vector>
// Uma INTERFACE: só métodos puros, nenhum dado. Um contrato de capacidade.
class Desenhavel {
public:
virtual void desenha() const = 0;
virtual double area() const = 0;
virtual ~Desenhavel() = default; // destrutor virtual — obrigatório aqui, veja adiante
};
class Circulo : public Desenhavel {
public:
Circulo(double r) : raio_(r) {}
void desenha() const override { std::cout << "O (círculo)\n"; }
double area() const override { return 3.14159 * raio_ * raio_; }
private:
double raio_;
};
class Retangulo : public Desenhavel {
public:
Retangulo(double l, double a) : larg_(l), alt_(a) {}
void desenha() const override { std::cout << "[] (retângulo)\n"; }
double area() const override { return larg_ * alt_; }
private:
double larg_, alt_;
};
// Uma função que opera sobre QUALQUER Desenhavel — presente ou futuro.
void relatorio(const std::vector<std::unique_ptr<Desenhavel>>& formas) {
for (const auto& f : formas) {
f->desenha();
std::cout << " área: " << f->area() << '\n';
}
}
int main() {
std::vector<std::unique_ptr<Desenhavel>> formas;
formas.push_back(std::make_unique<Circulo>(2.0));
formas.push_back(std::make_unique<Retangulo>(3.0, 4.0));
relatorio(formas);
return 0;
}
Saída:
O (círculo)
área: 12.5664
[] (retângulo)
área: 12
A função relatorio não conhece Circulo nem Retangulo — só o contrato Desenhavel. Você pode adicionar um Triangulo amanhã, e relatorio funcionará sem uma linha de mudança. Interfaces são a forma mais desacoplada e extensível de polimorfismo, e são o uso de herança que as Core Guidelines mais recomendam.
O destrutor virtual: pagando a dívida do artigo Um Método, Vários Comportamentos — Funções Virtuais e Polimorfismo
Agora o bug plantado. Quando você destrói um objeto derivado através de um ponteiro da base, o C++ precisa saber que existe um destrutor de derivada a chamar. Se o destrutor da base não for virtual, apenas o destrutor da base roda — o da derivada é ignorado, e tudo que a derivada alocou vaza. Isto é comportamento indefinido, e é sutil porque muitas vezes "parece funcionar":
#include <iostream>
#include <memory>
class Base {
public:
// SEM virtual aqui — o BUG.
~Base() { std::cout << "~Base\n"; }
};
class Derivada : public Base {
public:
~Derivada() { std::cout << "~Derivada\n"; } // este NÃO roda!
};
int main() {
std::unique_ptr<Base> p = std::make_unique<Derivada>();
// Ao destruir p, chama-se delete sobre um Base*.
// Sem destrutor virtual, só ~Base roda; ~Derivada é PULADO.
return 0;
}
Saída (errada):
~Base
O ~Derivada nunca foi chamado. Se Derivada possuísse um recurso (um buffer, um arquivo, um unique_ptr membro), ele vazaria. A correção é uma linha — tornar o destrutor da base virtual:
class Base {
public:
virtual ~Base() { std::cout << "~Base\n"; } // agora virtual
};
Com isso, a saída vira o correto ~Derivada seguido de ~Base — a ordem de destruição do artigo Uma Classe que Nasce de Outra — Herança e a Cadeia de Construção, agora funcionando polimorficamente. A regra é absoluta e você deve gravá-la: toda classe destinada a ser usada como base polimórfica (que tem funções virtuais) precisa de um destrutor virtual. Por isso escrevi virtual ~Animal() = default; e virtual ~Desenhavel() = default; em todos os exemplos — não era decoração. O = default pede o destrutor gerado pelo compilador, mas o virtual garante o despacho dinâmico na destruição.
Isto também explica, retroativamente, a ressalva do artigo Uma Classe que Nasce de Outra — Herança e a Cadeia de Construção sobre não herdar de std::vector: containers da STL não têm destrutor virtual, justamente porque não foram feitos para herança polimórfica. Herdar deles e destruir pela base seria o bug que acabamos de ver.
Função virtual pura transforma a base em contrato: ela passa a exigir comportamento em vez de fornecer, e o tipo deixa de poder ser instanciado sozinho. O destrutor virtual paga a dívida deixada pelo polimorfismo — destruir pela interface da base, sem ele, executa apenas o destrutor da base e abandona o que a derivada havia alocado, sem erro nem aviso. A regra é curta: se a classe tem alguma função virtual, o destrutor dela também precisa ser virtual.
Deste ponto se enxerga o caminho andado desde o encapsulamento: dados protegidos por invariantes, objetos que nascem em ordem previsível, classes que derivam de outras com critério, comportamento escolhido em tempo de execução e, agora, contratos que a linguagem faz cumprir. O que era "classe que embrulha um recurso" virou tipo projetado.
Fontes e leituras recomendadas
- Bjarne Stroustrup, A Tour of C++ (3ª ed.), seções sobre classes abstratas e hierarquias: a visão do criador sobre interfaces como contratos.
- cppreference.com/w/cpp/language/abstract_class: a referência técnica sobre funções virtuais puras e classes abstratas.
- Scott Meyers, Effective C++ (3ª ed.), Item 7 ("Declare destructors virtual in polymorphic base classes"): o argumento definitivo sobre o destrutor virtual, com os detalhes do comportamento indefinido.
- ISO C++ Core Guidelines, regras C.121 ("If a base class is used as an interface, make it a pure abstract class") e C.35 ("A base class destructor should be either public and virtual, or protected and non-virtual"): as diretrizes centrais da aula.
- cppreference.com/w/cpp/language/destructor: os detalhes de quando destrutores são chamados e a semântica do despacho virtual na destruição.
Exercícios
Exercício 1
Crie uma interface Pagavel com um método puro virtual double valor_a_pagar() const = 0 e um destrutor virtual. Implemente-a em duas classes (Funcionario e Fornecedor) com cálculos diferentes, e escreva uma função que some o valor_a_pagar() de um vetor de Pagavel.
Ver resposta
✓ Resposta: A interface e o somatório:
#include <iostream>
#include <memory>
#include <vector>
class Pagavel {
public:
virtual double valor_a_pagar() const = 0;
virtual ~Pagavel() = default;
};
class Funcionario : public Pagavel {
public:
Funcionario(double salario) : salario_(salario) {}
double valor_a_pagar() const override { return salario_; }
private:
double salario_;
};
class Fornecedor : public Pagavel {
public:
Fornecedor(double nota, double imposto) : nota_(nota), imposto_(imposto) {}
double valor_a_pagar() const override { return nota_ * (1.0 + imposto_); }
private:
double nota_, imposto_;
};
double total(const std::vector<std::unique_ptr<Pagavel>>& contas) {
double soma = 0.0;
for (const auto& c : contas) soma += c->valor_a_pagar();
return soma;
}
int main() {
std::vector<std::unique_ptr<Pagavel>> contas;
contas.push_back(std::make_unique<Funcionario>(3000.0));
contas.push_back(std::make_unique<Fornecedor>(1000.0, 0.1));
std::cout << "total a pagar: " << total(contas) << '\n'; // 4100
return 0;
}
total opera só sobre o contrato Pagavel, indiferente aos tipos concretos.
Exercício 2
Explique por que a classe abaixo não pode ser instanciada e o que é preciso fazer para poder criar objetos utilizáveis a partir dela.
class Base {
public:
virtual void executa() = 0;
virtual ~Base() = default;
};
Ver resposta
✓ Resposta: Não pode ser instanciada porque tem uma função virtual pura (executa() = 0), o que a torna abstrata — o compilador proíbe criar um objeto de um tipo que não sabe executar todas as suas operações. Para criar objetos utilizáveis, é preciso derivar uma classe que implemente executa() com override; essa derivada concreta pode então ser instanciada:
class Concreta : public Base {
public:
void executa() override { /* implementação real */ }
};
// Concreta c; // agora OK
Exercício 3
O programa a seguir vaza um recurso. Identifique a causa exata (relembrando o artigo Um Método, Vários Comportamentos — Funções Virtuais e Polimorfismo) e corrija com uma única alteração.
#include <memory>
#include <iostream>
class Recurso {
public:
~Recurso() { std::cout << "recurso liberado\n"; }
};
class ServicoBase {
public:
~ServicoBase() { std::cout << "~ServicoBase\n"; }
};
class Servico : public ServicoBase {
public:
~Servico() { std::cout << "~Servico\n"; }
private:
Recurso r_;
};
int main() {
std::unique_ptr<ServicoBase> p = std::make_unique<Servico>();
return 0;
}
Ver resposta
✓ Resposta: A causa é o destrutor não-virtual na base sendo destruído por um ponteiro à base:
#include <iostream>
#include <memory>
class ServicoBase {
public:
~ServicoBase() { std::cout << "~ServicoBase\n"; } // BUG: não é virtual
};
class Servico : public ServicoBase {
public:
~Servico() { std::cout << "~Servico\n"; }
};
int main() {
std::unique_ptr<ServicoBase> p = std::make_unique<Servico>();
return 0; // sem virtual, só ~ServicoBase roda; ~Servico é pulado
}
A única alteração que conserta:
class ServicoBase {
public:
virtual ~ServicoBase() { std::cout << "~ServicoBase\n"; } // agora virtual
};
Com o destrutor virtual, destruir p chama ~Servico e depois ~ServicoBase, na ordem correta, sem vazar nada que Servico possuísse.
Exercício 4
Distinga, com um exemplo de cada, uma classe abstrata (que tem alguns métodos implementados e ao menos um puro) de uma interface (só métodos puros). Quando você preferiria cada uma?
Ver resposta
✓ Resposta: Distinção com exemplos:
// CLASSE ABSTRATA: mistura implementado + puro.
class Relatorio {
public:
void cabecalho() const { /* implementado: comum a todos */ }
virtual void corpo() const = 0; // puro: cada relatório preenche
virtual ~Relatorio() = default;
};
// INTERFACE: só puros, nenhuma implementação nem dado.
class Serializavel {
public:
virtual std::string serializa() const = 0;
virtual ~Serializavel() = default;
};
Prefira a classe abstrata quando há comportamento comum a compartilhar entre as derivadas (o cabecalho() aqui), evitando duplicação. Prefira a interface quando quer apenas declarar uma capacidade que classes não relacionadas podem oferecer, com máximo desacoplamento e sem impor nenhuma implementação — inclusive permitindo que uma classe implemente várias interfaces.
Exercício 5
Considere a regra "destrutor da base público e virtual, ou protegido e não-virtual" (Core Guideline C.35). Explique o raciocínio por trás da segunda alternativa (protegido e não-virtual) e em que situação ela é usada.
Ver resposta
✓ Resposta: A segunda alternativa — destrutor protegido e não-virtual — serve para bases que não devem ser destruídas polimorficamente. Ao tornar o destrutor protected, você impede que alguém faça delete num ponteiro da base (o código externo não enxerga o destrutor), então não há risco de pular o destrutor da derivada; e por não ser destruída pela base, ela não precisa do custo do destrutor virtual. Usa-se isso em classes-base concebidas como mixin ou base de implementação, onde os objetos são sempre manipulados e destruídos como o tipo derivado concreto, nunca através de um ponteiro da base. É a forma de dizer "herde de mim para reusar, mas não me destrua polimorficamente".