Ú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:
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:
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:
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:
O programa quebra! Só precisamos alocar memória para o lines acima:
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
Tópicos relacionados¶
- C++ evitar o construtor padrão | C++那些事
- Idiom de lista de inicialização de classes em C++ | C++那些事
- C++: templates de classe em vez de templates de função | C++那些事
- Idiom copy-and-swap em C++ | C++那些事
- Injeção de dependência em C++ | C++那些事
- C++ enum class enumerações fortemente tipadas | C++那些事
- C++ Header-Only versus separação de implementação | C++那些事
- Idiom de avaliação preguiçosa em C++ | C++那些事
- Idiom de consulta em tabela em C++ | C++那些事
- Ponteiros para membros e type info em C++ | C++那些事