Ir para o conteúdo

Última atualização:2026-10-12

C++ RAII gerenciamento de recursos

0. Introdução

Na linguagem C, há três tipos de alocação de memória: estática, automática e dinâmica. Variáveis estáticas são constantes embutidas no arquivo-fonte; como têm tamanho conhecido e nunca mudam, elas não são tão interessantes. A alocação automática pode ser vista como alocação na pilha — quando um bloco léxico é entrada, espaço é alocado, e quando o bloco sai, o espaço é liberado. A sua característica mais importante está diretamente ligada a isso. Antes do C99, as variáveis alocadas automaticamente precisavam ter tamanho conhecido em tempo de compilação. Isso significava que qualquer string, lista, mapa e qualquer estrutura derivada dessas tinha de viver na memória dinâmica, no heap.

Os programadores usam quatro operações básicas para alocar e liberar explicitamente a memória dinâmica: malloc, realloc, calloc e free. Os dois primeiros não fazem nenhuma inicialização, e a memória pode conter lixo. Todos, exceto free, podem falhar. Nesse caso, retornam um ponteiro nulo, cujo acesso é comportamento indefinido; no melhor dos casos, o seu programa quebra. No pior dos casos, o seu programa parece funcionar por um tempo, processando dados-lixo antes de quebrar.

Por exemplo:

int main() {
   char *str = (char *) malloc(7); 
   strcpy(str, "toptal");
   printf("char array = \"%s\" @ %u\n", str, str);

   str = (char *) realloc(str, 11);
   strcat(str, ".com");
   printf("char array = \"%s\" @ %u\n", str, str);

   free(str);

   return(0);
}

Saída:

char array = "toptal" @ 2762894960
char array = "toptal.com" @ 2762894960

Embora o código seja simples, já contém um antipadrão e uma decisão questionável. Na vida real, você não deveria escrever o número de bytes diretamente, e sim usar a função sizeof. De modo semelhante, alocamos o array de char * com exatamente o dobro do tamanho da string de que precisamos (uma vez a mais que o comprimento da string, para contemplar o terminador nulo), o que é uma operação bastante cara. Um programa mais sofisticado poderia construir um buffer de string maior, permitindo que o tamanho da string crescesse.

1. A invenção do RAII: uma nova esperança

Todo gerenciamento manual é, no mínimo, desagradável. Em meados dos anos 1980, Bjarne Stroustrup inventou para a sua recém-lançada linguagem C++ um novo paradigma. Ele o chamou de "resource acquisition is initialization", cuja percepção fundamental é a seguinte: pode-se dar a um objeto construtor e destrutor que o compilador invoca automaticamente nos momentos adequados — uma forma muito mais conveniente de gerenciar a memória de determinado objeto. — e a técnica também é útil para recursos que não são memória.

Isso significa que o exemplo acima, em C++, fica mais conciso:

int main() {
   std::string str = std::string ("toptal");
   std::cout << "string object: " << str << " @ " << &str << "\n";

   str += ".com";
   std::cout << "string object: " << str << " @ " << &str << "\n";

   return(0);
}

Saída:

string object: toptal @ 0x7fffa67b9400
string object: toptal.com @ 0x7fffa67b9400

No exemplo acima, não há gerenciamento manual de memória! O objeto string é construído, o método sobrecarregado é chamado e ele é destruído automaticamente na saída da função. Infelizmente, essa mesma simplicidade também acarreta outros problemas. Vejamos um exemplo em detalhe:

vector<string> read_lines_from_file(string &file_name) {
    vector<string> lines;
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines.push_back(line);
    }

    file_handle.close();

    return lines;
}

int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    int count = read_lines_from_file(file_name).size();
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

Saída:

File makefile contains 37 lines.

Isso parece muito simples. O vector é preenchido, retornado e chamado. Porém, como programadores eficientes e preocupados com desempenho, alguns pontos nos incomodam: no return, por causa do uso de semântica de valor, o vector é copiado para um novo vector pouco antes de ser destruído.

No C++ moderno, isso não é mais um requisito estrito. O C++ 11 introduziu o conceito de move semantics, no qual a origem fica em um estado válido (para que ainda possa ser destruída corretamente), mas não especificado. Para o compilador, o retorno de chamada é o caso mais fácil de otimizar para uma movimentação semântica, pois ele sabe que a fonte será destruída pouco antes de qualquer acesso posterior. Porém, o objetivo do exemplo é ilustrar por que, no final dos anos 1980 e início dos 1990, as pessoas inventaram um monte de linguagens com garbage collector, numa época em que a move semantics do C++ não estava disponível.

Para arquivos com grande volume de dados, isso pode ficar caro. Vamos otimizá-lo retornando apenas um ponteiro. A sintaxe muda um pouco, mas o restante do código é igual:

vector<string> * read_lines_from_file(string &file_name) {
    vector<string> * lines;
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines->push_back(line);
    }

    file_handle.close();

    return lines;
}
int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    int count = read_lines_from_file(file_name).size();
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

Saída:

Segmentation fault (core dumped)

O programa quebra! Só precisamos alocar memória para o lines acima:

vector<string> * lines = new vector<string>;

Assim ele roda!

Infelizmente, embora pareça perfeito, ainda há uma falha: ele vaza memória. Em C++, um ponteiro para o heap deve ser manualmente deletado quando não for mais necessário; caso contrário, assim que o último ponteiro sair de escopo, essa memória torna-se inutilizável e só será recuperada quando o sistema operacional a gerenciar, ao final do processo. O C++ moderno idiomático usaria aqui o unique_ptr, que implementa o comportamento desejado: ele deleta o objeto apontado quando o ponteiro sai de escopo. Esse comportamento, porém, só fez parte da linguagem a partir do C++11.

Aqui, dá para usar diretamente a sintaxe anterior ao C++11, alterando apenas o main:

vector<string> * read_lines_from_file(string &file_name) {
    vector<string> * lines = new vector<string>;
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines->push_back(line);
    }

    file_handle.close();

    return lines;
}

int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    vector<string> * file_lines = read_lines_from_file(file_name);
    int count = file_lines->size();
    delete file_lines;
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

Alocando e liberando a memória manualmente.

Infelizmente, à medida que o programa cresce para além do escopo acima, torna-se rapidamente muito mais difícil racionar sobre quando e onde um ponteiro deve ser deletado. Quando uma função retorna um ponteiro, agora ele é seu? Você deve deletá-lo você mesmo ao terminar, ou ele pertence a alguma estrutura de dados que será liberada de uma só vez mais tarde? Errar de um lado, vazamento de memória; errar do outro, você acabou de corromper a estrutura de dados em questão e possivelmente outras, pois elas tentarão desreferenciar ponteiros que não são mais válidos.

2. "Use um garbage collector, flyboy!"

O garbage collector não é uma tecnologia nova. Foi inventado por John McCarthy em 1959, para o Lisp. Em 1980, com o surgimento do Smalltalk-80, a coleta de lixo começou a se tornar mainstream. Mas os anos 1990 representaram o verdadeiro florescimento da tecnologia: entre 1990 e 2000 foram lançadas diversas linguagens, todas usando uma ou outra forma de coleta de lixo: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml e C# são as mais conhecidas.

O que é coleta de lixo? Em suma, é um conjunto de técnicas para automatizar o gerenciamento manual de memória. Geralmente é oferecida como biblioteca para linguagens com gerenciamento manual de memória (como C e C++), mas é mais comum em linguagens que a exigem. A maior vantagem é que o programador simplesmente não precisa pensar em memória. Tudo é abstraído. Por exemplo, o equivalente em Python do nosso código de leitura de arquivo acima é este:

def read_lines_from_file(file_name):
    lines = []
    with open(file_name) as fp: 
        for line in fp:
            lines.append(line)
    return lines

if __name__ == '__main__':
    import sys
    file_name = sys.argv[1]
    count = len(read_lines_from_file(file_name))
    print("File {} contains {} lines.".format(file_name, count))

O array de linhas surge na primeira atribuição e é retornado sem ser copiado para o escopo chamador. Devido ao tempo indeterminado, ele será limpo pelo garbage collector em algum momento depois de sair desse escopo. Curiosamente, em Python, o RAII para recursos não-memória não é idiomático. É permitido — poderíamos simplesmente escrever fp = open(file_name) em vez de usar o bloco with e deixar o GC limpar. Mas o padrão recomendado é usar context managers sempre que possível, para que possam ser liberados em tempo determinístico.

Embora o gerenciamento de memória seja simplificado, o custo é alto. Na coleta de lixo por contagem de referências, todas as atribuições de variáveis e saídas de escopo acumulam um pequeno custo para atualizar as referências. Em sistemas mark-and-sweep, toda a execução do programa é pausada em intervalos imprevisíveis enquanto o GC varre a memória. Isso costuma ser chamado de evento stop-the-world. Implementações como a do Python, que usam os dois sistemas ao mesmo tempo, sofrem as duas penalidades. Esses problemas reduzem a aplicabilidade das linguagens com coleta de lixo em cenários onde o desempenho é crítico ou se exige tempo real. Mesmo no programa-brinquedo a seguir, dá para ver a degradação real de desempenho:

$ make cpp && time ./c++ makefile
g++ -o c++ c++.cpp
File makefile contains 38 lines.
real    0m0.016s
user    0m0.000s
sys     0m0.015s

$ time python3 python3.py makefile
File makefile contains 38 lines.

real    0m0.041s
user    0m0.015s
sys     0m0.015s

O tempo real da versão em Python é quase o triplo da versão em C++. Embora nem toda essa diferença possa ser atribuída à coleta de lixo, ela continua sendo considerável.

3. Propriedade: o despertar do RAII

Sabemos que o tempo de vida de um objeto é determinado pelo seu escopo. Porém, às vezes precisamos criar um objeto independente do escopo que o criou — o que é útil, ou muito útil. Em C++, o operador new é usado para criar esse objeto. Para destruir o objeto, usa-se o operador delete. Objetos criados pelo operador new são alocados dinamicamente, ou seja, na memória dinâmica (também chamada de heap ou free store). Assim, um objeto criado por new continua a existir até ser explicitamente destruído com delete.

Alguns erros que podem ocorrer ao usar new e delete são:

  • vazamento de objeto (ou memória): alocar um objeto com new e esquecer de deletá-lo.

  • exclusão prematura (ou referência pendurada): manter outro ponteiro para o objeto, deletá-lo, enquanto ainda há outros ponteiros referenciando-o.

  • exclusão dupla: tentar deletar o mesmo objeto duas vezes.

Em geral, variáveis de escopo são preferidas. Mas o RAII pode ser usado como alternativa a new e delete para fazer um objeto existir independentemente do seu escopo. A técnica consiste em atribuir o ponteiro a um objeto alocado no heap e colocá-lo em um objeto handle/manager. Este último tem um destrutor que se encarrega de destruir aquele objeto. Isso garante que o objeto esteja disponível para qualquer função que queira acessá-lo e que ele seja destruído ao final do tempo de vida do objeto handle, sem limpeza explícita.

Exemplos da biblioteca padrão de C++ que usam RAII são std :: string e std :: vector.

Considere este código:

void fn(const std::string& str)
{
    std::vector<char> vec;
    for (auto c : str)
        vec.push_back(c);
    // do something
}

Ao criar o vector e empurrar elementos nele, você não precisa se preocupar em alocar e desalocar a memória desses elementos. O vector usa new para alocar espaço para os seus elementos no heap e delete para liberar esse espaço. Como usuário do vector, você não precisa conhecer os detalhes de implementação e confia que o vector não vaza. Neste caso, o vector é o objeto handle dos seus elementos.

Outros exemplos de RAII na biblioteca padrão são std :: shared_ptr, std :: unique_ptr e std :: lock_guard.

Outro nome para essa técnica é SBRM, abreviação de Scope-Bound Resource Management.

Agora, vamos modificar o exemplo de leitura de arquivo acima:

#include <iostream>
#include <vector>
#include <cstring>
#include <fstream>
#include <bits/unique_ptr.h>

using namespace std;
unique_ptr<vector<string>> read_lines_from_file(string &file_name) {
    unique_ptr<vector<string>> lines(new vector<string>);
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines->push_back(line);
    }

    file_handle.close();

    return lines;
}
int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    int count = read_lines_from_file(file_name).get()->size();
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

4. Só no final você percebe a verdadeira força do RAII.

Desde a invenção do compilador, o gerenciamento manual de memória é o pesadelo que os programadores tentam evitar. O RAII é um padrão promissor, mas, sem algumas soluções estranhas, simplesmente não podia ser usado para objetos alocados no heap, e por isso o C++ sofreu. Assim, nos anos 1990 houve uma explosão de linguagens com coleta de lixo, criadas para tornar a vida do programador mais agradável, ainda que ao custo de desempenho.

Por fim, o RAII resume-se a:

  • os recursos são liberados no destrutor

  • as instâncias da classe são alocadas na pilha

  • os recursos são adquiridos no construtor.

RAII significa "resource acquisition is initialization".

Exemplos comuns são:

  • operações com arquivos

  • smart pointers

  • mutexes

5. Artigos de referência

1. https://www.toptal.com/software/eliminating-garbage-collector#remote-developer-job

2. https://stackoverflow.com/questions/2321511/what-is-meant-by-resource-acquisition-is-initialization-raii

Tópicos relacionados

Comentários