Menos errores
- Limpieza automática. Tal y como ilustra el código del anterior artículo de la serie, usar punteros inteligentes que liberan la memoria automáticamente puede ahorrar unas cuantas lineas de código. La importancia no radica en cuantas pulsaciones ahorramos, sino en reducir la probabilidad de error: no es necesario recordar la liberación del puntero, y por lo tanto no hay margen de error para que se pueda olvidar.
- Inicialización automática. Otra cosa buena es que no es necesario inicializar un puntero
auto_ptraNULL, el constructor por defecto se encarga de ello. Una cosa menos que el programador necesita recordar. - Punteros olvidados. Un error muy común de la gestión de memoria es el de los punteros olvidados: un puntero que apunta a una zona de memoria que ya ha sido eliminada.
El siguiente código ilustra la situación:
1 2 3 4 5 6 | MyClass* p(new MyClass); MyClass* q = p; delete p; p->DoSomething(); // Vigila! p está ahora en el limbo! p = NULL; // p ya apunta correctamente q->DoSomething(); // Ojo! q continua en el limbo! |
En la implementación de auto_ptr, esto se soluciona asignando NULL al puntero interno cuando se copia:
1 2 3 4 5 6 7 8 9 10 | template <class T> auto_ptr<T>& auto_ptr<T>::operator=(auto_ptr<T>& rhs) { if (this != &rhs) { delete ptr; ptr = rhs.ptr; rhs.ptr = NULL; } return *this; } |
Otros tipos de punteros inteligentes pueden seguir distintas estrategias de copiado. Estas son algunas de las técnicas más habituales para manejar la situación q = p, donde p y q son punteros inteligentes:
- Crear una nueva copia del objeto apuntado por
p, y hacer queqapunte a esa copia. - Transferencia de propiedad: Hacemos que
pyqapunten al mismo objeto, pero transferimos la responsabilidad de limpiar la memoria reservada (“propiedad”) desdepaq. - Contador de referencias: Se mantiene un contador de los punteros inteligentes que apuntan a un mismo objeto, y se elimina la memoria cuando este contador llega a cero. De manera que la sentencia
q = pprovoca que el contador apuntado porpse incremente en uno. Scott Meyers ofrece una implementación para el contador de referencias en su libro More Effective C++. - Enlazado de referencias: Similar a la técnica del contador de referencias, sólo que en lugar de mantener un contador, mantiene una lista circular doblemente enlazada de todos los punteros que apuntan al mismo objeto.
- COW (Copy-on-write): Se usa un contador de referencias o la lista mientras que el objeto apuntado no se modifique. En el instante en que el objeto se va a modificar, se copia y se modifica la copia.
Todas estas técnicas ayudan a sanear el código de punteros olvidados. Cada una tiene sus beneficios y sus desventajas. En la siguiente parte de esta serie de artículos se discute acerca de la conveniencia del tipo de puntero inteligente para diferentes situaciones.
Manejo de excepciones y punteros
Veamos de nuevo el ejemplo:
1 2 3 4 5 | void foo() { MyClass* p(new MyClass); p->DoSomething(); delete p; } |
¿Qué ocurre si DoSomething() lanza una excepción? Todas las lineas después de la llamada al método no se ejecutarán y p nunca va a ser eliminado correctamente. Si tenemos suerte esto sólo provocará una fuga de memoria. Sin embargo, es posible que MyClass sea responsable de liberar otros recursos en su propio destructor (archivos, threads, transacciones, mutex, etc…) y el hecho de no llamarlo puede provocar problemas serios de concurrencia.
Si usamos un puntero inteligente, sin embargo, p va a ser liberado cuando salga de su ámbito, sin importar si sigue su linea habitual de ejecución o durante el desenrollado de la pila causado por la excepción.
¿Pero es posible escribir código con excepciones que se comporte de manera segura usando punteros tradicionales? Seguramente sí, pero no merece la pena el esfuerzo teniendo alternativas mucho más sencillas.
Este código ilustra el uso de punteros tradicionales junto a excepciones:
1 2 3 4 5 6 7 8 9 10 11 12 13 | void foo() { MyClass* p; try { p = new MyClass; p->DoSomething(); delete p; } catch (...) { delete p; throw; } } |
Considerablemente más engorroso de programar y mantener que la versión con smart pointers.
Recolección de basura
C++ no proporciona un recolector automático de basura como si lo hacen otros lenguajes de programación (Java, C#, etc.) así que los punteros inteligentes pueden usarse para este propósito. De entre las diferentes técnicas de recolección de basura, el esquema más simple es el contador de referencias o las referencias enlazadas vistas anteriormente, pero es posible implementar mecanismos mucho más sofisticados de recolección de basura con los punteros inteligentes.
Más información sobre la recolección de basura y sus diferentes implementaciones.
Eficiencia
Los punteros inteligentes pueden emplearse para un uso eficiente de la memoria disponible y acortar los ciclos de reserva y liberación de memoria.
Una técnica habitual para usar memoria de una forma más eficiente es la llamada copiar al escribir (copy-on-write, abreviado a veces como COW). Consiste en compartir el mismo objeto entre varios punteros COW, mientras el objeto compartido no se modifique y los accesos sean sólo de lectura. Cuando en algún punto del programa se intenta modificar el objeto (escritura), el puntero COW crea una nueva copia y la modifica en lugar de tocar el objeto original.
La clase estándar de C++ string se implementa habitualmente usando esta técnica (ver la cabecera <string>).
1 2 3 4 5 | string s("Hello"); string t = s; // t y s apuntan al mismo buffer de caracteres t += " world!"; // se reserva un nuevo buffer para t antes de // añadir " world!", por lo tanto s permanece sin cambio. |
Los esquemas de reserva de memoria optimizados tienen sentido cuando podemos establecer una serie de suposiciones sobre los objetos que van a ser instanciados o el entorno operativo en el que se ejecutará. Por ejemplo, podríamos suponer que todos los objetos reservados ocupan el mismo tamaño, o su vida va a transcurrir en un solo hilo de ejecución. Aunque es posible implementar esquemas de reserva optimizados usando la sobrecarga de los operadores new y delete específicos de clase, los punteros inteligentes nos proporcionan la libertad de elegir el esquema de reserva adecuado para cada tipo de objeto, en lugar de emplear el mismo mecanismo para todos los objetos de una clase. Por tanto es posible elegir diferentes aproximaciones de reserva de memoria optimizadas para diferentes entornos operativos y aplicaciones, sin modificar el código de la clase.
Contenedores STL
La librería estándar de C++ incluye una serie de contenedores y algoritmos conocidos como Standard Template Library (STL). La STL fue diseñada para ser genérica (puede utilizarse para contener cualquier tipo de objeto) y eficiente (no incurre en gastos adicionales comparado con otras alternativas). Para alcanzar estos dos objetivos de diseño, los contenedores STL almacenan sus objetos por valor. Esto significa que si tenemos un contenedor STL que almacena objetos de la clase Base, por definición no puede almacenar objetos de una clase derivada de Base.
1 2 3 4 5 6 7 8 9 | class Base { /*...*/ }; class Derived : public Base { /*...*/ }; Base b; Derived d; vector<Base> v; v.push_back(b); // OK v.push_back(d); // error |
¿Qué podemos hacer si necesitamos almacenar un lote de objetos de diferentes clases? La solución más simple es tener una colección de punteros a dichas clases:
1 2 3 4 5 6 7 8 9 | vector<Base*> v; v.push_back(new Base); // OK v.push_back(new Derived); // OK // hay que limpiar la memoria despues! for (vector<Base*>::iterator i = v.begin(); i != v.end(); ++i) { delete *i; } |
El problema de esta solución es después de trabajar con el contenedor, necesitamos liberar manualmente todos los objetos almacenados en él. Este proceso es muy propenso a errores y volvemos a tener problemas con las excepciones tal y como se vio en el punto anterior.
Los punteros inteligentes proporcionan una posible solución a este problema, como podemos ver más abajo.
1 2 3 4 5 | vector< boost::shared_ptr<Base> > v; v.push_back(new Base); // OK v.push_back(new Derived); // OK // la limpieza es automatica ... |
Como el puntero inteligente se limpia automáticamente, no es necesario eliminar manualmente los objetos apuntados.
Cuidado: Los contenedores STL pueden copiar y borrar sus elementos sin aviso previo (por ejemplo, cuando se redimensionan a si mismos). Por tanto, todas las copias de un elemento tienen que ser equivalentes, o es probable que una copia errónea sea la que sobreviva a este proceso de copia y borrado. Esto significa que algunos tipos de punteros inteligentes no son apropiados para utilizarse conjuntamente con los contenedores STL, concretamente el estándar
auto_ptry otros punteros de transferencia de propiedad.
Este punto se discute en detalle en el artículo C++ Guru of the Week #25.
Una solución a este problema es el empleo de “contenedores inteligentes” a medida o emplear soluciones como los shared_ptr de Boost. El uso específico de los smart pointers de Boost se verá más adelante.
Artículos de esta serie:
- Smart pointers en C++: ¿Qué son? (parte 1 de 3)
- Smart pointers en C++: ¿Por qué debería usarlos? (parte 2 de 3)
- Smart pointers en C++: ¿Qué tipo de smart pointer emplear? (parte 3 de 3) [próximamente]
