RAII y punteros inteligentes en C++

Resuelto Rob Kam asked hace 15 años • 6 respuestas

En la práctica con C++, ¿qué es RAII , qué son los punteros inteligentes , cómo se implementan en un programa y cuáles son los beneficios de usar RAII con punteros inteligentes?

Rob Kam avatar Dec 27 '08 23:12 Rob Kam
Aceptado

Un ejemplo simple (y quizás usado en exceso) de RAII es una clase Archivo. Sin RAII, el código podría verse así:

File file("/path/to/file");
// Do stuff with file
file.close();

Es decir, debemos asegurarnos de cerrar el archivo una vez hayamos terminado con él. Esto tiene dos inconvenientes: en primer lugar, siempre que usemos Archivo, tendremos que llamar a File::close(); si nos olvidamos de hacer esto, retendremos el archivo más tiempo del necesario. El segundo problema es ¿qué pasa si se lanza una excepción antes de cerrar el archivo?

Java resuelve el segundo problema usando una cláusula finalmente:

try {
    File file = new File("/path/to/file");
    // Do stuff with file
} finally {
    file.close();
}

o desde Java 7, una declaración de prueba con recursos:

try (File file = new File("/path/to/file")) {
   // Do stuff with file
}

C++ resuelve ambos problemas usando RAII, es decir, cerrando el archivo en el destructor de File. Siempre que el objeto Archivo se destruya en el momento adecuado (que debería ser de todos modos), el cierre del archivo está a cargo de nosotros. Entonces, nuestro código ahora se ve así:

File file("/path/to/file");
// Do stuff with file
// No need to close it - destructor will do that for us

Esto no se puede hacer en Java ya que no hay garantía de cuándo se destruirá el objeto, por lo que no podemos garantizar cuándo se liberará un recurso como un archivo.

En punteros inteligentes: muchas veces, simplemente creamos objetos en la pila. Por ejemplo (y robando un ejemplo de otra respuesta):

void foo() {
    std::string str;
    // Do cool things to or using str
}

Esto funciona bien, pero ¿qué pasa si queremos devolver str? Podríamos escribir esto:

std::string foo() {
    std::string str;
    // Do cool things to or using str
    return str;
}

Entonces, ¿qué hay de malo en eso? Bueno, el tipo de retorno es std::string, por lo que significa que devolvemos por valor. Esto significa que copiamos str y de hecho devolvemos la copia. Esto puede resultar caro y es posible que deseemos evitar el coste de copiarlo. Por lo tanto, se nos podría ocurrir la idea de regresar por referencia o por puntero.

std::string* foo() {
    std::string str;
    // Do cool things to or using str
    return &str;
}

Desafortunadamente, este código no funciona. Estamos devolviendo un puntero a str, pero str se creó en la pila, por lo que se eliminará una vez que salgamos de foo(). En otras palabras, cuando la persona que llama recibe el puntero, es inútil (y posiblemente peor que inútil ya que usarlo podría causar todo tipo de errores extraños)

Entonces, ¿cuál es la solución? Podríamos crear str en el montón usando new; de esa manera, cuando se complete foo(), str no se destruirá.

std::string* foo() {
    std::string* str = new std::string();
    // Do cool things to or using str
    return str;
}

Por supuesto, esta solución tampoco es perfecta. La razón es que hemos creado str, pero nunca la eliminamos. Puede que esto no sea un problema en un programa muy pequeño, pero en general queremos asegurarnos de eliminarlo. Podríamos simplemente decir que la persona que llama debe eliminar el objeto una vez que haya terminado con él. La desventaja es que la persona que llama tiene que administrar la memoria, lo que agrega complejidad adicional y podría equivocarse, lo que provocaría una pérdida de memoria, es decir, no eliminar el objeto aunque ya no sea necesario.

Aquí es donde entran los punteros inteligentes. El siguiente ejemplo utiliza share_ptr. Le sugiero que observe los diferentes tipos de punteros inteligentes para saber qué es lo que realmente desea usar.

shared_ptr<std::string> foo() {
    shared_ptr<std::string> str = new std::string();
    // Do cool things to or using str
    return str;
}

Ahora,shared_ptr contará el número de referencias a str. Por ejemplo

shared_ptr<std::string> str = foo();
shared_ptr<std::string> str2 = str;

Ahora hay dos referencias a la misma cadena. Una vez que no queden referencias restantes a str, se eliminará. Como tal, ya no tendrás que preocuparte por eliminarlo tú mismo.

Edición rápida: como han señalado algunos de los comentarios, este ejemplo no es perfecto por (¡al menos!) dos razones. En primer lugar, debido a la implementación de cadenas, copiar una cadena tiende a resultar económico. En segundo lugar, debido a lo que se conoce como optimización del valor de retorno con nombre, devolver por valor puede no ser costoso ya que el compilador puede hacer algo de inteligencia para acelerar las cosas.

Entonces, probemos un ejemplo diferente usando nuestra clase Archivo.

Digamos que queremos utilizar un archivo como registro. Esto significa que queremos abrir nuestro archivo en modo de solo agregar:

File file("/path/to/file", File::append);
// The exact semantics of this aren't really important,
// just that we've got a file to be used as a log

Ahora, configuremos nuestro archivo como registro para un par de objetos más:

void setLog(const Foo & foo, const Bar & bar) {
    File file("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Desafortunadamente, este ejemplo termina horriblemente: el archivo se cerrará tan pronto como finalice este método, lo que significa que foo y bar ahora tienen un archivo de registro no válido. Podríamos construir un archivo en el montón y pasar un puntero al archivo tanto a foo como a bar:

void setLog(const Foo & foo, const Bar & bar) {
    File* file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Pero entonces ¿quién es responsable de eliminar el archivo? Si ninguno de los dos elimina el archivo, entonces tendremos una pérdida de memoria y de recursos. No sabemos si foo o bar terminarán primero con el archivo, por lo que no podemos esperar que ninguno de los dos elimine el archivo. Por ejemplo, si foo elimina el archivo antes de que bar haya terminado con él, bar ahora tiene un puntero no válido.

Entonces, como habrás adivinado, podríamos usar punteros inteligentes para ayudarnos.

void setLog(const Foo & foo, const Bar & bar) {
    shared_ptr<File> file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Ahora, nadie necesita preocuparse por eliminar el archivo: una vez que foo y bar hayan terminado y ya no tengan ninguna referencia al archivo (probablemente debido a que foo y bar se destruyeron), el archivo se eliminará automáticamente.

Michael Williamson avatar Dec 27 '2008 16:12 Michael Williamson

RAII Este es un nombre extraño para un concepto simple pero asombroso. Mejor es el nombre Gestión de recursos con alcance limitado (SBRM). La idea es que a menudo asignas recursos al comienzo de un bloque y necesitas liberarlos a la salida de un bloque. Salir del bloque puede ocurrir mediante un control de flujo normal, saltando fuera de él e incluso mediante una excepción. Para cubrir todos estos casos, el código se vuelve más complicado y redundante.

Sólo un ejemplo haciéndolo sin SBRM:

void o_really() {
     resource * r = allocate_resource();
     try {
         // something, which could throw. ...
     } catch(...) {
         deallocate_resource(r);
         throw;
     }
     if(...) { return; } // oops, forgot to deallocate
     deallocate_resource(r);
}

Como puede ver, hay muchas maneras en que podemos ser engañados. La idea es que encapsulamos la gestión de recursos en una clase. La inicialización de su objeto adquiere el recurso ("La adquisición de recursos es inicialización"). En el momento en que salimos del bloque (alcance del bloque), el recurso se libera nuevamente.

struct resource_holder {
    resource_holder() {
        r = allocate_resource();
    }
    ~resource_holder() {
        deallocate_resource(r);
    }
    resource * r;
};

void o_really() {
     resource_holder r;
     // something, which could throw. ...
     if(...) { return; }
}

Eso es bueno si tiene clases propias que no tienen como único fin asignar/desasignar recursos. La asignación sería sólo una preocupación adicional para realizar su trabajo. Pero tan pronto como solo desee asignar/desasignar recursos, lo anterior se vuelve complicado. Tienes que escribir una clase envolvente para cada tipo de recurso que adquieras. Para facilitar esto, los punteros inteligentes le permiten automatizar ese proceso:

shared_ptr<Entry> create_entry(Parameters p) {
    shared_ptr<Entry> e(Entry::createEntry(p), &Entry::freeEntry);
    return e;
}

Normalmente, los punteros inteligentes son envoltorios finos alrededor de nuevo/eliminar que simplemente llaman deletecuando el recurso que poseen sale del alcance. Algunos punteros inteligentes, como Shared_ptr, le permiten indicarles el llamado eliminador, que se utiliza en lugar de delete. Eso le permite, por ejemplo, administrar identificadores de ventanas, recursos de expresiones regulares y otras cosas arbitrarias, siempre que le informe a Shared_ptr sobre el eliminador correcto.

Existen diferentes punteros inteligentes para diferentes propósitos:

único_ptr

es un puntero inteligente que posee un objeto exclusivamente. No está en desarrollo, pero probablemente aparecerá en el próximo estándar C++. No se puede copiar , pero admite la transferencia de propiedad . Algún código de ejemplo (siguiente C++):

Código:

unique_ptr<plot_src> p(new plot_src); // now, p owns
unique_ptr<plot_src> u(move(p)); // now, u owns, p owns nothing.
unique_ptr<plot_src> v(u); // error, trying to copy u

vector<unique_ptr<plot_src>> pv; 
pv.emplace_back(new plot_src); 
pv.emplace_back(new plot_src);

A diferencia de auto_ptr, Unique_ptr se puede colocar en un contenedor, porque los contenedores podrán contener tipos que no se pueden copiar (pero sí se pueden mover), como flujos y también Unique_ptr.

alcance_ptr

es un puntero inteligente de impulso que no se puede copiar ni mover. Es perfecto para usar cuando desea asegurarse de que los punteros se eliminen cuando salen del alcance.

Código:

void do_something() {
    scoped_ptr<pipe> sp(new pipe);
    // do something here...
} // when going out of scope, sp will delete the pointer automatically. 

ptr_compartido

es para propiedad compartida. Por lo tanto, es copiable y móvil. Varias instancias de puntero inteligente pueden poseer el mismo recurso. Tan pronto como el último puntero inteligente que posee el recurso salga del alcance, el recurso se liberará. Algún ejemplo del mundo real de uno de mis proyectos:

Código:

shared_ptr<plot_src> p(new plot_src(&fx));
plot1->add(p)->setColor("#00FF00");
plot2->add(p)->setColor("#FF0000");
// if p now goes out of scope, the src won't be freed, as both plot1 and 
// plot2 both still have references. 

Como puede ver, la fuente de la trama (función fx) es compartida, pero cada una tiene una entrada separada, en la que configuramos el color. Hay una clase débil_ptr que se usa cuando el código necesita hacer referencia al recurso propiedad de un puntero inteligente, pero no necesita ser propietario del recurso. En lugar de pasar un puntero sin formato, deberías crear un archivo débil_ptr. Lanzará una excepción cuando note que intenta acceder al recurso mediante una ruta de acceso débil_ptr, aunque ya no haya ningún share_ptr propietario del recurso.

Johannes Schaub - litb avatar Dec 27 '2008 22:12 Johannes Schaub - litb

La premisa y las razones son simples en concepto.

RAII es el paradigma de diseño para garantizar que las variables manejen toda la inicialización necesaria en sus constructores y toda la limpieza necesaria en sus destructores. Esto reduce toda la inicialización y limpieza a un solo paso.

C++ no requiere RAII, pero cada vez se acepta más que el uso de métodos RAII producirá un código más robusto.

La razón por la que RAII es útil en C++ es que C++ gestiona intrínsecamente la creación y destrucción de variables a medida que entran y salen del alcance, ya sea a través del flujo de código normal o mediante el desenredado de la pila desencadenado por una excepción. Eso es un obsequio en C++.

Al vincular toda la inicialización y limpieza a estos mecanismos, tiene la seguridad de que C++ también se encargará de este trabajo por usted.

Hablar de RAII en C++ generalmente lleva a la discusión de punteros inteligentes, porque los punteros son particularmente frágiles cuando se trata de limpieza. Al administrar la memoria asignada al montón adquirida de malloc o nueva, generalmente es responsabilidad del programador liberar o eliminar esa memoria antes de que se destruya el puntero. Los punteros inteligentes utilizarán la filosofía RAII para garantizar que los objetos asignados al montón se destruyan cada vez que se destruya la variable del puntero.

Drew Dormann avatar Dec 27 '2008 17:12 Drew Dormann

El puntero inteligente es una variación de RAII. RAII significa que la adquisición de recursos es inicialización. El puntero inteligente adquiere un recurso (memoria) antes de su uso y luego lo descarta automáticamente en un destructor. Suceden dos cosas:

  1. Asignamos memoria antes de usarla, siempre, incluso cuando no nos apetece; es difícil hacerlo de otra manera con un puntero inteligente. Si esto no sucede, intentará acceder a la memoria NULL, lo que provocará un bloqueo (muy doloroso).
  2. Liberamos memoria incluso cuando hay un error. Ningún recuerdo queda colgado.

Por ejemplo, otro ejemplo es el socket de red RAII. En este caso:

  1. Abrimos el socket de red antes de usarlo, siempre, incluso cuando no tenemos ganas; es difícil hacerlo de otra manera con RAII. Si intenta hacer esto sin RAII, puede abrir un socket vacío para, por ejemplo, una conexión MSN. Entonces es posible que un mensaje como "hagámoslo esta noche" no se transfiera, los usuarios no echarán un polvo y usted corre el riesgo de que lo despidan.
  2. Cerramos el socket de red incluso cuando hay un error. No se deja ningún socket colgado, ya que esto podría evitar que el mensaje de respuesta "seguro que estaré abajo" regrese al remitente.

Ahora, como puedes ver, RAII es una herramienta muy útil en la mayoría de los casos, ya que ayuda a las personas a tener sexo.

Las fuentes C++ de punteros inteligentes se cuentan por millones en la red, incluidas las respuestas anteriores a mí.

mannicken avatar Dec 27 '2008 17:12 mannicken

Boost tiene varios de estos, incluidos los de Boost.Interprocess para memoria compartida. Simplifica enormemente la administración de la memoria, especialmente en situaciones que provocan dolores de cabeza, como cuando tienes 5 procesos que comparten la misma estructura de datos: cuando todos terminan con una porción de memoria, quieres que se libere automáticamente y no tener que quedarte ahí sentado tratando de resolverlo. quién debería ser responsable de solicitar deleteuna parte de la memoria, para que no termine con una pérdida de memoria o un puntero que se libera dos veces por error y puede corromper todo el montón.

Jason S avatar Dec 27 '2008 17:12 Jason S