Última actualización:2026-10-12
C++ RAII gestión de recursos¶
0.Introducción¶
En C existen tres tipos de asignación de memoria: estática, automática y dinámica. Las variables estáticas son constantes embebidas en el archivo fuente; como tienen un tamaño conocido y nunca cambian, no resultan tan interesantes. La asignación automática puede considerarse asignación en la pila: el espacio se asigna al entrar en un bloque léxico y se libera al salir de él. Su característica más importante está directamente relacionada con esto. Antes de C99, las variables con asignación automática necesitaban conocer su tamaño en tiempo de compilación. Esto significaba que cualquier cadena, lista, mapa y cualquier estructura derivada de estos debía vivir en memoria dinámica del montón.
Los programadores asignan y liberan memoria dinámica de forma explícita con cuatro operaciones básicas: malloc, realloc, calloc y free. Las dos primeras no realizan inicialización alguna y la memoria puede contener basura. Exceptuando free, todas pueden fallar. En ese caso devuelven un puntero nulo, cuyo acceso es comportamiento indefinido; en el mejor de los casos, tu programa se cae. En el peor, tu programa parece funcionar un tiempo, procesando datos basura antes del fallo.
Por ejemplo:
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);
}
Salida:
Aunque el código es sencillo, ya contiene un antipatrón y una decisión cuestionable. En la vida real no deberías escribir el número de bytes directamente, sino usar la función sizeof. De manera similar, asignamos el array char * exactamente al doble del tamaño de la cadena que necesitamos (el doble de la longitud de la cadena, para tener en cuenta el terminador nulo), lo cual es una operación bastante cara. Un programa más sofisticado podría construir un búfer de cadena mayor, permitiendo que el tamaño de la cadena crezca.
1.La invención de RAII: una nueva esperanza¶
Como mínimo, toda gestión manual es desagradable. A mediados de los 80, Bjarne Stroustrup inventó un nuevo paradigma para su flamante lenguaje C++. Lo llamó «la adquisición de recursos es la inicialización», y su idea fundamental es la siguiente: se puede especificar que los objetos tengan constructores y destructores, invocados automáticamente por el compilador en el momento adecuado, lo que ofrece un método mucho más cómodo para gestionar la memoria de un objeto dado cuando hace falta, y la técnica también resulta útil para recursos que no son memoria.
Esto significa que el ejemplo anterior en C++ queda más 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);
}
Salida:
En el ejemplo anterior ¡no hay gestión manual de memoria! Se construye el objeto string, se invocan los métodos sobrecargados y se destruye automáticamente al salir de la función. Desgraciadamente, esa misma simplicidad trae otros problemas. Veamos un ejemplo en detalle:
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;
}
Salida:
Parece muy sencillo. El vector se llena, se devuelve y se consume. Sin embargo, como programadores eficientes que se preocupan por el rendimiento, algunos problemas aquí nos inquietan: en la sentencia return, debido al uso de semántica de valor, el vector se copia a un nuevo vector poco antes de ser destruido.
En el C++ moderno esto ya no es un requisito estricto. C++ 11 introdujo el concepto de semántica de movimiento, en la que el origen queda en un estado válido (para que aún pueda destruirse correctamente) pero no especificado. Para el compilador, el retorno es el caso más fácil de optimizar hacia un movimiento semántico, pues sabe que el origen se destruirá poco antes de cualquier acceso posterior. Sin embargo, el propósito del ejemplo es ilustrar por qué en los últimos 80 y principios de los 90 se inventó un montón de lenguajes con recolección de basura, en una época en la que la semántica de movimiento de C++ no estaba disponible.
Para archivos con un volumen de datos grande, esto puede salir caro. Optimicémoslo devolviendo solo un puntero. La sintaxis cambia un poco, pero el resto del código es 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;
}
Salida:
¡El programa se cae! Solo tenemos que asignar memoria para ese lines de antes:
¡Así ya funciona!
Desgraciadamente, por muy perfecto que parezca, sigue teniendo un defecto: pierde memoria. En C++, un puntero al montón debe borrarse manualmente cuando ya no se necesita; de lo contrario, en cuanto el último puntero sale del ámbito, esa memoria queda inutilizable y no se recupera hasta que el sistema operativo la gestione al final del proceso. El C++ moderno idiomático usaría aquí unique_ptr, que implementa el comportamiento deseado: borra el objeto apuntado cuando el puntero sale del ámbito. Sin embargo, ese comportamiento no formó parte del lenguaje hasta C++11.
Aquí podemos usar directamente la sintaxis anterior a C++11, cambiando solo 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;
}
Asignar y liberar la memoria manualmente.
Desgraciadamente, a medida que el programa crece más allá del alcance anterior, razonar sobre cuándo y dónde debe borrarse el puntero se vuelve rápidamente mucho más difícil. Cuando una función devuelve un puntero, ¿pasas a ser su dueño? ¿Debes borrarlo tú mismo al terminar, o pertenece a alguna estructura de datos que se liberará de una sola vez más adelante? Si te equivocas por un lado, fuga de memoria; si te equivocas por el otro, has corrompido la estructura de datos en cuestión y posiblemente otras, porque intentan desreferenciar punteros que ya no son válidos.
2.«¡Usa un recolector de basura, flyboy!»¶
Los recolectores de basura no son una tecnología nueva. Los inventó John McCarthy en 1959 para Lisp. En 1980, con la llegada de Smalltalk-80, la recolección de basura empezó a ser mainstream. Pero los 90 representaron el verdadero florecimiento de la técnica: entre 1990 y 2000 se publicaron multitud de lenguajes, todos usando una u otra forma de recolección de basura: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml y C# son los más conocidos.
¿Qué es la recolección de basura? En pocas palabras, es un conjunto de técnicas para automatizar la gestión manual de memoria. Suele ofrecerse como biblioteca para lenguajes con gestión manual de memoria (como C y C++), pero es más habitual en lenguajes que la necesitan de serie. La mayor ventaja es que el programador no tiene que pensar en la memoria en absoluto. Todo queda abstraído. Por ejemplo, el equivalente en Python de nuestro código de lectura de archivos anterior es 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))
El array de líneas aparece al asignarlo por primera vez y se devuelve sin copiarlo al ámbito que llama. Debido al momento indeterminado, el recolector de basura lo limpia en algún instante posterior a salir de ese ámbito. Curiosamente, en Python el RAII para recursos que no son memoria no es idiomático. Está permitido —podríamos simplemente escribir fp = open(file_name) en lugar del bloque with y dejar que el GC limpie—, pero el patrón recomendado es usar gestores de contexto siempre que sea posible, para que puedan liberarse en un momento determinado.
A pesar de simplificar la gestión de memoria, el precio es alto. En la recolección por conteo de referencias, todas las asignaciones de variables y salidas de ámbito adquieren un pequeño coste para actualizar las referencias. En los sistemas mark-and-sweep, toda la ejecución del programa se pausa a intervalos impredecibles mientras el GC limpia la memoria. Esto suele llamarse evento de parada del mundo. Implementaciones como Python, que usan ambos sistemas a la vez, sufren las dos penalizaciones. Estos problemas reducen la aplicabilidad de los lenguajes con recolección de basura en situaciones donde el rendimiento es crítico o se necesitan aplicaciones en tiempo real. Incluso en el siguiente programa de juguete puede verse la degradación real de rendimiento:
$ 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
El tiempo real de la versión en Python es casi el triple que el de la versión en C++. Aunque no toda la diferencia es atribuible a la recolección de basura, sigue siendo considerable.
3.Propriedad: el despertar de RAII¶
Sabemos que el tiempo de vida de un objeto lo determina su ámbito. Pero a veces necesitamos crear un objeto que sea útil, o muy útil, con independencia del ámbito en que se creó. En C++ se usa el operador new para crear tales objetos. Para destruir el objeto puede usarse el operador delete. Los objetos creados con new tienen asignación dinámica, es decir, se asignan en memoria dinámica (también llamada montón o free store). Así pues, los objetos creados con new siguen existiendo hasta que se destruyen explícitamente con delete.
Algunos errores que pueden ocurrir al usar new y delete son:
-
Fuga de objetos (o memoria): asignar un objeto con new y olvidar borrarlo.
-
Borrado prematuro (o referencia colgante): se conserva otro puntero al objeto, se borra el objeto, y sin embargo aún hay otros punteros referenciándolo.
-
Doble borrado: intentar borrar dos veces un mismo objeto.
Normalmente, las variables de ámbito son las preferidas. Pero RAII puede usarse como alternativa a new y delete para que el objeto exista con independencia de su ámbito. La técnica consiste en asignar el puntero a un objeto en el montón y colocarlo en un objeto gestor/manejador. Este último tiene un destructor que se encargará de destruir el objeto. Así se garantiza que el objeto esté disponible para cualquier función que quiera acceder a él y que el objeto se destruya al final del tiempo de vida del objeto gestor, sin limpieza explícita.
Ejemplos de uso de RAII procedentes de la biblioteca estándar de C++ son std::string y std::vector.
Considera este código:
void fn(const std::string& str)
{
std::vector<char> vec;
for (auto c : str)
vec.push_back(c);
// do something
}
Al crear el vector y empujar elementos en él, no tienes que preocuparte de asignar y liberar la memoria de dichos elementos. El vector usa new para asignar espacio en el montón a sus elementos y delete para liberarlo. Como usuario del vector, no necesitas conocer los detalles de implementación y confías en que el vector no pierde memoria. En este caso, el vector es el objeto gestor de sus elementos.
Otros ejemplos de RAII en la biblioteca estándar son std::shared_ptr, std::unique_ptr y std::lock_guard.
Otro nombre para esta técnica es SBRM, abreviatura de Scope-Bound Resource Management (gestión de recursos ligada al ámbito).
Ahora modificamos el ejemplo anterior de lectura de archivos:
#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.Solo al final te das cuenta del verdadero poder de RAII.¶
Desde la invención del compilador, la gestión manual de memoria es la pesadilla que los programadores han intentado evitar. RAII es un patrón prometedor, pero adolece en C++ porque, sin raros rodeos, sencillamente no puede usarse para objetos asignados en el montón. Por eso en los 90 se produjo la explosión de lenguajes con recolección de basura, pensados para hacer la vida del programador más agradable, aun a costa de rendimiento.
Finalmente, RAII se resume así:
-
Los recursos se liberan en el destructor
-
Las instancias de la clase se asignan en la pila
-
El recurso se adquiere en el constructor.
RAII significa «Resource Acquisition Is Initialization» (la adquisición de recursos es la inicialización).
Ejemplos habituales son:
-
Operaciones con archivos
-
Punteros inteligentes
-
Mutexes
5.Artículos de referencia¶
1. https://www.toptal.com/software/eliminating-garbage-collector#remote-developer-job
Temas relacionados¶
- C++ Evitar el constructor por defecto | C++那些事
- C++ Idioma de la lista de inicialización de clases | C++那些事
- C++: plantillas de clase antes que plantillas de función | C++那些事
- C++ Idioma copy-and-swap | C++那些事
- C++ Inyección de dependencias | C++那些事
- C++ enum class enumeraciones fuertemente tipadas | C++那些事
- C++ Header-Only frente a separación de implementación | C++那些事
- C++ Idioma de evaluación perezosa | C++那些事
- C++ Idioma de la tabla de consulta | C++那些事
- C++ Punteros a miembros e información de tipos | C++那些事