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 único free escrito à mão. O padrão tem nome, RAII, e quase todo o C++ moderno é variação dele. O artigo constrói isso passo a passo: a struct que ganha comportamento, o destrutor que o escopo chama, a lista de inicialização.
Linguagem C++

11 min de leitura

Encerramos a Fase 1 no artigo Adeus NULL, Bem-vindo à Dedução — nullptr e auto e vimos, de relance, o std::string se limpar sozinho ao fim do escopo. Agora começa a Fase 2 — o coração do C++ — e o objetivo é transformar aquele truque em técnica sua. Ao fim desta aula você terá escrito uma classe que adquire um recurso quando nasce e o devolve quando morre, automaticamente, sem um free ou um fclose à vista. Esse padrão tem nome, RAII, e uma vez que ele entra na sua cabeça, você passa a enxergar quase todo o C++ moderno como variações dele. É a aula mais importante do curso até aqui; leia com calma.

Structs que ganham comportamento: a classe

Você conhece struct do C — um agrupamento de dados. Em C++, struct e class são quase a mesma coisa (a única diferença técnica é que membros de struct são públicos por padrão e de class, privados — voltaremos a isso na Fase 3). O que muda de verdade é que, em C++, um tipo pode ter funções especiais que o compilador chama automaticamente em momentos-chave da vida do objeto. Duas nos interessam hoje: o construtor, chamado quando o objeto nasce, e o destrutor, chamado quando ele morre.

#include <iostream>

class Sensor {
public:
    // CONSTRUTOR: mesmo nome da classe, sem tipo de retorno.
    // Roda automaticamente quando um Sensor é criado.
    Sensor() {
        std::cout << "Sensor ligado\n";
    }

    // DESTRUTOR: nome da classe com ~ na frente, sem parâmetros.
    // Roda automaticamente quando o Sensor é destruído.
    ~Sensor() {
        std::cout << "Sensor desligado\n";
    }
};

int main() {
    std::cout << "antes\n";
    {
        Sensor s;                 // aqui o construtor roda: "Sensor ligado"
        std::cout << "usando o sensor\n";
    }                             // aqui 's' morre: o destrutor roda: "Sensor desligado"
    std::cout << "depois\n";
    return 0;
}

A saída é:

antes
Sensor ligado
usando o sensor
Sensor desligado
depois

Observe o ponto crucial: você não chamou o construtor nem o destrutor. O compilador os inseriu — o construtor no ponto onde s foi criado, o destrutor no } onde s saiu de escopo. Esse "no fim do escopo, automaticamente" é o motor do RAII.

RAII de verdade: amarrando um recurso ao objeto

O exemplo do sensor só imprime. O poder aparece quando o construtor adquire um recurso e o destrutor o libera. Vamos envolver um arquivo — algo que em C você abre com fopen e é obrigado a fechar com fclose, sob pena de vazar o descritor:

#include <cstdio>
#include <iostream>

// Uma classe que "possui" um arquivo: abre no construtor, fecha no destrutor.
class ArquivoTexto {
public:
    // O construtor ADQUIRE o recurso: abre o arquivo.
    ArquivoTexto(const char* caminho) {
        f_ = std::fopen(caminho, "w");
        if (f_ == nullptr)
            std::cout << "falha ao abrir " << caminho << '\n';
        else
            std::cout << "arquivo aberto\n";
    }

    // O destrutor LIBERA o recurso: fecha o arquivo, se aberto.
    ~ArquivoTexto() {
        if (f_ != nullptr) {
            std::fclose(f_);
            std::cout << "arquivo fechado\n";
        }
    }

    void escreve(const char* texto) {
        if (f_ != nullptr)
            std::fputs(texto, f_);
    }

private:
    std::FILE* f_ = nullptr;   // o recurso que a classe possui
};

int main() {
    {
        ArquivoTexto arq("saida.txt");   // abre
        arq.escreve("linha 1\n");
        arq.escreve("linha 2\n");
    }   // ← ao sair do escopo, o destrutor fecha o arquivo AUTOMATICAMENTE
    std::cout << "trabalho concluído\n";
    return 0;
}

Compare com o C que você escreveria: fopen, checar NULL, usar, e nunca, jamais, esquecer o fclose — inclusive nos caminhos de erro, onde é tão fácil esquecer. Aqui, o fclose está amarrado ao fim de vida do objeto. Não importa como o escopo termine — chegando ao }, um return no meio, ou até uma exceção sendo lançada (Fase 6) —, o destrutor roda e o arquivo fecha. Essa garantia é o que torna o RAII tão robusto: a limpeza não depende da sua disciplina, depende da linguagem.

Construtores com parâmetros e a lista de inicialização

O construtor de ArquivoTexto recebeu um argumento (caminho). Construtores podem ter parâmetros como qualquer função, e é assim que você configura o objeto no nascimento. Há uma sintaxe especial e preferida para inicializar membros, a lista de inicialização de membros, que vem depois de dois-pontos:

#include <iostream>
#include <string>

class Ponto {
public:
    // Inicializa x_ e y_ ANTES do corpo do construtor rodar.
    // Esta é a forma idiomática e mais eficiente.
    Ponto(int x, int y) : x_(x), y_(y) {
        std::cout << "Ponto(" << x_ << ", " << y_ << ") criado\n";
    }

    void mostra() const {   // 'const' aqui promete não alterar o objeto (Artigo A Disciplina do const)
        std::cout << "(" << x_ << ", " << y_ << ")\n";
    }

private:
    int x_;
    int y_;
};

int main() {
    Ponto p(3, 4);   // chama Ponto(int, int)
    p.mostra();      // (3, 4)
    return 0;
}

A lista : x_(x), y_(y) inicializa os membros diretamente, antes de o corpo { } rodar. É melhor do que atribuir dentro do corpo (x_ = x;) porque inicializa de uma vez, em vez de criar o membro vazio e depois atribuir — uma diferença que fica importante quando os membros são tipos caros de construir. Adote a lista de inicialização como padrão. E note o const no método mostra(): ele promete, no espírito do artigo A Disciplina do const — Promessas que o Compilador Cobra, que olhar o ponto não o modifica — permitindo chamá-lo até em objetos const.

A honestidade sobre o que ainda falta

Você acabou de escrever uma classe que gerencia um recurso, e isso é enorme. Mas há uma armadilha que preciso plantar agora para colher no próximo artigo. O que acontece se você copiar um ArquivoTexto? Por padrão, o compilador copia o membro f_ — o ponteiro — e agora dois objetos acham que possuem o mesmo arquivo. Quando ambos forem destruídos, ambos chamarão fclose no mesmo descritor: fechar duas vezes é um bug clássico e sério. Esse perigo — o de classes que possuem recursos e são copiadas ingenuamente — é exatamente o que a Regra dos Três/Cinco resolve, e é o tema do artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco. Por ora, guarde a pergunta: quem possui o recurso, e o que acontece na cópia? Ela será a chave da próxima aula.

O que faz o RAII funcionar não é o destrutor em si: é a garantia de que ele será chamado — pelo fim do escopo, pela exceção que sobe, pelo caminho de erro que ninguém lembrou de escrever. É essa certeza que transforma liberar recurso de tarefa sua em consequência da linguagem, e é o que o free no fim da função nunca deu, porque bastava um return no meio para pulá-lo. Quanto à lista de inicialização, ela não é preferência de estilo: membros const e referências só podem ser inicializados ali, e o resto pagaria por uma construção seguida de atribuição.

Fontes e leituras recomendadas

  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre classes e gerenciamento de recursos: a introdução canônica a construtores, destrutores e RAII pelo criador da linguagem.
  • cppreference.com/w/cpp/language/raii: a página que define formalmente o idioma RAII e lista os tipos da biblioteca que o empregam.
  • cppreference.com/w/cpp/language/constructor e /w/cpp/language/destructor: as referências técnicas das duas funções especiais desta aula.
  • ISO C++ Core Guidelines, seção "R" (Resource management), regras R.1 ("Manage resources automatically using RAII") e C.40/C.41 sobre construtores: as diretrizes que sustentam tudo o que vimos.
  • Scott Meyers, Effective C++ (3ª ed.), Item 13 ("Use objects to manage resources"): o argumento clássico a favor de embrulhar recursos em objetos RAII.

Exercícios

Exercício 1

Escreva uma classe Cronometro cujo construtor imprime "iniciado" e cujo destrutor imprime "parado". No main, crie um objeto dentro de um bloco { } e observe quando cada mensagem aparece.

Ver resposta

✓ Resposta: A classe:

#include <iostream>
class Cronometro {
public:
    Cronometro()  { std::cout << "iniciado\n"; }
    ~Cronometro() { std::cout << "parado\n"; }
};

int main() {
    std::cout << "antes\n";
    {
        Cronometro c;              // "iniciado"
        std::cout << "medindo\n";
    }                              // "parado" — no fim do bloco
    std::cout << "depois\n";
    return 0;
}

A mensagem "parado" aparece exatamente no }, provando que o destrutor roda no fim do escopo, sem chamada explícita.

Exercício 2

Dê à classe Ponto da aula um segundo construtor, sem parâmetros, que inicialize x_ e y_ com zero (usando lista de inicialização). Demonstre os dois construtores no main.

Ver resposta

✓ Resposta: Dois construtores (uma sobrecarga, no espírito do artigo Um Nome, Vários Sentidos — Sobrecarga de Funções):

#include <iostream>
class Ponto {
public:
    Ponto(int x, int y) : x_(x), y_(y) {}
    Ponto() : x_(0), y_(0) {}          // construtor padrão, sem parâmetros
    void mostra() const { std::cout << "(" << x_ << ", " << y_ << ")\n"; }
private:
    int x_, y_;
};

int main() {
    Ponto origem;        // usa Ponto()
    Ponto p(3, 4);       // usa Ponto(int, int)
    origem.mostra();     // (0, 0)
    p.mostra();          // (3, 4)
    return 0;
}

Exercício 3

Explique, em suas palavras, por que o RAII garante a liberação do recurso mesmo quando a função retorna no meio do caminho — algo que o fopen/fclose manual do C não garante sem cuidado extra.

Ver resposta

✓ Resposta: Porque a chamada do destrutor não está escrita em nenhum ponto específico do seu código — ela é inserida pelo compilador em todos os caminhos pelos quais o objeto pode sair de escopo. Se a função retorna cedo, o objeto sai de escopo ali, e o destrutor roda ali. Com fopen/fclose manual, o fclose está escrito num lugar fixo; se um return antecipado pula esse lugar, o arquivo vaza — e é preciso lembrar de fechar antes de cada return, ou recorrer a goto cleanup, o velho padrão do C. O RAII elimina essa disciplina manual: a liberação segue o objeto, não uma linha específica.

Exercício 4

A classe abaixo tem um vazamento potencial. Identifique-o e reescreva-a no estilo RAII, movendo a liberação para o destrutor.

#include <cstdlib>
class Buffer {
public:
    Buffer(int n) { dados_ = (int*) std::malloc(n * sizeof(int)); }
    int* dados_;
    // ... e o free? quem chama?
};
Ver resposta

✓ Resposta: O vazamento: Buffer aloca com malloc no construtor mas nunca libera — quem criar um Buffer vaza a memória. A versão RAII adiciona um destrutor que libera:

#include <cstdlib>
#include <iostream>
class Buffer {
public:
    Buffer(int n) : dados_(static_cast<int*>(std::malloc(n * sizeof(int)))) {}
    ~Buffer() {
        std::free(dados_);          // libera automaticamente ao fim de vida
        std::cout << "buffer liberado\n";
    }
private:
    int* dados_ = nullptr;
};

int main() {
    { Buffer b(100); }              // aloca no {, libera no }
    return 0;
}

(Nota honesta: esta classe ainda é vulnerável à cópia, exatamente o tema do próximo artigo — e, na prática, usaríamos std::vector ou um ponteiro inteligente em vez de malloc. Aqui o malloc serve só para ilustrar o par aquisição/liberação.)

Exercício 5

Na classe ArquivoTexto da aula, o que aconteceria se, no main, você escrevesse ArquivoTexto b = arq; (copiando um objeto já criado)? Descreva o problema concreto que surge quando ambos os objetos forem destruídos, ligando-o à pergunta que a aula deixou para o próximo artigo.

Ver resposta

✓ Resposta: ArquivoTexto b = arq; cria b como uma cópia de arq. Como não definimos como copiar, o compilador copia membro a membro: o ponteiro f_ de b recebe o mesmo valor de f_ de arq — os dois objetos passam a apontar para o mesmo FILE. Quando o escopo termina, os dois destrutores rodam, e cada um chama std::fclose(f_) sobre o mesmo descritor. Fechar o mesmo arquivo duas vezes é comportamento indefinido (pode corromper o estado da biblioteca de C ou travar). É exatamente a pergunta que a aula plantou — "quem possui o recurso, e o que acontece na cópia?" — e a Regra dos Três/Cinco, no artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco, é a resposta: quando sua classe possui um recurso, você precisa dizer explicitamente como ela se copia (ou proibir a cópia), para que dois donos jamais tentem liberar a mesma coisa.

Comentários

Mais em Linguagem C++

O Primeiro Pixel — Janela, Desenho e Input com raylib
O Primeiro Pixel — Janela, Desenho e Input com raylib

A primeira janela traz de volta uma lição antiga em contexto novo: ela é um…

O Cadeado que Protege — std::mutex e lock_guard
O Cadeado que Protege — std::mutex e lock_guard

O cadeado resolve a corrida serializando o acesso, e o lock_guard garante que…

O Array que Cresce Sozinho — std::vector a Fundo
O Array que Cresce Sozinho — std::vector a Fundo

Memória contígua e cache do processador explicam por que o vector é o…