Apuntadores inteligentes

Publicado por

A diferencia de Java, C++ carece de un sistema interno que permita la gestión automática de memoria dinámica. Afortunadamente, las últimas versiones de la biblioteca estándar de C++ incluyen mecanismos para lograr ese cometido, los cuales hacen uso de la técnica conocida como conteo de referencias. Dicha funcionalidad está implementada por nuevos tipos de apuntadores, llamados apuntadores inteligentes; estos facilitan el trabajo y evitan muchos de los problemas que comúnmente suceden al utilizar memoria dinámica de forma incorrecta.

¿Qué son y por qué debería usarlos?


Un apuntador inteligente, excluyendo la forma en que son declarados, se utiliza de manera prácticamente idéntica a un apuntador regular, su funcionalidad se hace visible al utilizar memoria dinámica. La manera común de trabajar con apuntadores, en ese caso, implica que el programador tenga que controlar la asignación y liberación de memoria manualmente, lo cual en muchas ocasiones se vuelve complicado y, potencialmente, origina una serie de errores, tales como:

  • Referencias Colgantes

    Ocurre cuando se libera la memoria que ocupaba un objeto cuando aún existe algún apuntador haciendo referencia a la dirección donde se encontraba, por lo que la posición de memoria a la que este apunta, es ahora invalida.

  • Fuga de memoriaEs muy común que no se libere la memoria ocupada, bien sea por descuido o por una ejecución anormal del programa, por ejemplo, cuando ocurre una excepción mal manejada.
  • Liberar más de una vez la misma porción de memoria

    Liberar la memoria más de una vez puede provocar que ésta se corrompa, lo cual ocurre generalmente cuando se tienen dos apuntadores distintos apuntando hacia el mismo objeto y se liberan ambos.

El uso de apuntadores inteligentes evita que estos problemas sucedan, ya que se encargan de la liberación de memoria de forma automática, proporcionando una funcionalidad análoga a la del recolector de basura en Java, por lo que el programador, una vez creados, se puede despreocupar de su mantenimiento.
Entonces, una vez dicho lo anterior, un apuntador inteligente es un tipo de apuntador que “envuelve” a uno regular, controlando el tiempo de vida del objeto al que apunta, automáticamente, reduciendo considerablemente los errores, pero manteniendo la eficiencia que un apuntador proporciona.

Tipos de apuntadores inteligentes


La biblioteca estándar define tres tipos de apuntadores inteligentes, shared_ptr, unique_ptr y weak_ptr.

  • shared_ptr
    Permite la existencia de múltiples apuntadores hacia el mismo objeto, liberando la memoria solo cuando ninguno haga ya referencia a dicho objeto, o cuando todos los apuntadores se vuelvan inaccesibles.
  • unique_ptr
    Restringe el número de apuntadores hacia un mismo objeto a solo uno, liberando la memoria en los mismos casos que shared_ptr.
  • weak_ptr
    Se trata de un caso especial ya que no puede apuntar directamente a un objeto, sino que se tiene que crear a partir de un shared_ptr, a través del cual se controla al objeto.

Uso de apuntadores inteligentes


Es importante mencionar que la clase memory es la encargada de definir a los apuntadores inteligentes, por lo que se debe incluir en las directivas de preprocesador antes de hacer uso de ellos.

shared_ptr


La asignación de una porción de memoria a un shared_ptr puede ser de dos formas, haciendo uso del operador new o mediante la función make_shared.

  • Uso del operador new

El uso del operador new con shared_ptr sólo es posible mediante inicialización directa, esto se debe a que new regresa un apuntador de tipo regular, por lo que no es posible asignárselo por copia a shared_ptr.

shared_ptr<int> sptr(new int(7)); // correcto, se usa inicialización directa
shared_ptr<int> sptr = new int(7); // incorrecto, los tipos no son compatibles

Se utilizó un int como ejemplo, sin embargo, cualquier tipo de dato puede ir en su lugar.

  • Uso de make_shared

Éste es el método recomendado, ya que, por razones que se explicarán después, resulta más eficiente que el operador new.

/*  correcto se proporcionan los argumentos necesarios para crear el objeto */
shared_ptr<Objeto> sptr(make_shared<Objeto>(arg0, arg1, ...)); 

/* correcto también, en este caso los tipos sí son compatibles */
shared_ptr<Objeto> sptr = make_shared<Objeto>(arg0, arg1, ...);

En este caso ambos son correctos, ya que make_shared regresa un apuntador shared_ptr, por lo que sí es posible la asignación por copia.

Otra ventaja del uso de este método es que permite el uso del tipo “auto”, por ejemplo:

auto sptr = make_shared<Objeto>(arg0, arg1, ...);

Lo cual hace más rápida y fácil la escritura.

Una vez creado, se puede hacer uso de él tal como un apuntador regular, pudiendo usar normalmente, por ejemplo, los operadores “*” y “->

  • Comportamiento

Como se dijo anteriormente, shared_ptr permite que más de un apuntador de este tipo apunte a la misma porción de memoria (compartan el mismo objeto), liberándola cuando no se pueda acceder a ella (debido a que se cambió el valor de los shared_ptr a otro) o porque los propios shared_ptr son inaccesibles debido a un cambio de ámbito.

auto sptr0 = make_shared<int>(7); // nuevo shared_ptr a un entero 
auto sptr1 = sptr0; // sptr1 apunta hacia donde mismo que sptr0 

sptr0 = make_shared<int>(9); /* sptr0 apunta hacia un nuevo entero, el anterior entero(7) no se liberará porque aún se puede acceder a él a través de sptr1 */ 

sptr1 = make_shared<int>(11); /* tanto sptr0 como sptr1 apuntan a objetos distintos al original (7) y no hay manera ya de acceder a él, por lo que la porción de memoria donde estaba el entero 7 se liberará automáticamente */ 

sptr0 = nullptr; // se libera la memoria donde está el entero 9 
sptr1 = nullptr; // se libera la memoria donde está el entero 11
  • Funcionamiento

Para comprender un poco más el comportamiento del apuntador shared_ptr, convendría explicar cómo funciona.

El proceso inicia reservando espacio para el objeto que será creado, al que llamaremos objeto controlado, una vez hecho esto, shared_ptr crea un segundo objeto, llamado manejador de objeto, el cual contiene:

algo que no es codigo va aqui

El apuntador shared_ptr realmente apunta al manejador de objeto, no al objeto controlado, siendo posible acceder al segundo a partir del primero.

shared_ptr
Figura 1. Representación gráfica de un shared_ptr

Gracias a la sobrecarga de operadores, es posible actualizar los contadores cada que se realiza una copia. A continuación, se da un ejemplo en el que se explica el mecanismo interno de shared_ptr.

auto sptr0 = make_shared<int>(7); /* Es el primer shared_ptr, por lo que se crea un
                                     modelo como el que se muestra en la Figura 1. */
auto sptr1 = sptr0 /* Es el segundo shared_ptr y apunta hacia el mismo manejador de 
                      objeto, por lo que el contador de shared_ptr se incrementa de
                      nuevo en 1 */
make_shared
Figura 2.
sptr0 = make_shared<int>(9); /* El primer shared_ptr apunta ahora a un nuevo objeto,
                                pero antes de hacerlo, decrementa el valor del
                                contador shared_ptr del manejador al que
                                apuntaba, en una unidad. */
make_shared
Figura 3.
weak_ptr<int> wptr = sptr1; /* Un nuevo apuntador weak_ptr apunta hacia el mismo
                               manejador de objeto que sptr1, sumando una unidad
                               al contador correspondiente */
weak_ptr
Figura 4.
sptr1 = make_shared<int>(11); /* sptr1, apunta a otro objeto, pero antes decrementa
                                 el valor del contador shared_ptr, como el contador
                                 shared_ptr llego a cero, el objeto controlado es
                                 eliminado y la memoria ocupada liberada, sin embargo,
                                 como weak_ptr es mayor a cero, el manejador de objeto
                                 no es desechado aún. */
make_shared
Figura 5.
wptr = sptr1; /* El valor del contador weak_ptr es ahora también cero al suceder
                 esto, el manejador será también eliminado. */
wptr
Figura 6.

Retomando el punto sobre el operador new en contra de make_shared, la razón por la cual el segundo es más eficiente, se debe a que de esa forma se hace una sola solicitud de reserva de memoria, lo suficientemente grande para contener tanto al objeto controlado, como al manejador de objeto, en contraposición a new, que requiere dos reservas por separado. Dado que la operación de reserva de memoria es relativamente lenta, se prefiere make_shared.

unique_ptr


A diferencia de shared_ptr, el mecanismo de unique_ptr es mucho más sencillo, debido que este último no acepta que ningún otro apuntador haga referencia a la misma porción de memoria, haciendo innecesaria la creación de un manejador de objeto y todo el trabajo asociado a él.

De la misma forma que shared_ptr, cuando un unique_ptr es inaccesible o cambió de objeto, el objeto al que apuntaba es eliminado automáticamente.

La declaración es como sigue:

unique_ptr uptr(new int(7));

A partir de C++14, existe también una función llamada make_unique, aunque esta no representa ninguna ventaja en cuanto a eficiencia, permite utilizar el tipo auto y la asignación por copia, aparte de proporcionar uniformidad en la declaración con respecto a shared_ptr.

auto uptr = make_unique<int>(7);

A pesar de no poder asignar a un unique_ptr el valor de otro haciendo un duplicado, sí es posible transferirlo, es decir, asignar la referencia que un unique_ptr guarda a otro, eliminando la del primero. Esto se logra por medio de la función release() de unique_ptr.

unique_ptr uptr0(new int(7));
unique_ptr uptr1(uptr0.release()); // elimina la referencia uptr0 y la asigna uptr1

Se debe tener cuidado al utilizar la función release(), ya que esta regresa la dirección a la cual unique_ptr apunta, pero no libera la memoria, por lo cual sólo debe usarse para transferir el valor a otro apuntador inteligente, de otra manera se perdería la referencia, inhabilitando incluso la liberación manual, y provocando entonces una fuga de memoria.

La pregunta ahora sería ¿para qué queremos unique_ptr si tenemos shared_ptr?
La principal razón es la simpleza de unique_ptr, la cual hace que usarlo no suponga un gasto extra de recursos —tal como sí ocurre con shared_ptr— por lo que se obtiene liberación automática de memoria de forma gratuita. Su utilidad también puede cambiar dependiendo de la aplicación en la que se use.

weak_ptr


Se trata de un tipo especial de apuntador inteligente, ya que por sí mismo no puede acceder a ningún objeto, sino que lo tiene que hacer a través de un shared_ptr.

weak_ptr wptr(new int(7)); /* No valido, solo puede apuntar a un objeto a tráves de 
                              shared_ptr */

Tal como se vio en la explicación de shared_ptr:

auto sptr0 = make_shared<int>(7);
weak_ptr<int> wptr = sptr0; // Uso valido de weak_ptr

La razón de ser de este tipo de apuntador tal vez no es evidente en principio. Un weak_ptr no controla el tiempo de vida del objeto al que apunta, tiene que ser inicializado por medio de un shared_ptr, y ni siquiera es posible acceder al objeto apuntado directamente mediante los operadores de indirección comunes.

La función principal de un weak_ptr es la de permitir saber si un objeto aún existe, sin hacer que este permanezca en memoria cuando ya no es usado por nadie más. También permiten solucionar problemas relacionados con referencias circulares, en donde objetos que ya no son utilizados se apuntan entre ellos, impidiendo que se liberen.

weak_ptr
Figura 7.

Por ejemplo, en la figura 7 se puede observar que existen tres shared_ptr hacia objetos distintos, donde cada objeto tiene un apuntador hacia otro; si se utilizara un apuntador distinto a weak_ptr, estos objetos nunca serían eliminados, aun cuando todos los shared_ptr dejaran de hacer referencia a ellos, tal como se muestra a continuación.

shared_ptr
Figura 8.

Esto se conoce como referencia circular o anillo, y el problema radica en que todavía existen apuntador entre los objetos, por lo que se mantienen en memoria, pero al no existir ya ningún apuntador externo hacia ellos, es imposible accederlos y no será posible liberar esa memoria nunca.

Regresando a la figura 7, el uso de weak_ptr permite solucionar este problema, ya que estos apuntadores no interfieren en la vida del objeto, sino que actúan solo como observador, por lo que en un escenario como el de la figura 8, la memoria sí sería liberada.

Para saber si un objeto aún existe, es necesario llamar a la función lock()

auto sptr = wptr.lock();

Esta función regresa un shared_ptr al objeto apuntado —si es que existe— en caso contrario regresa nullptr.

Consideraciones


Es muy importante no mezclar apuntadores regulares con inteligentes, ya que podría causar los mismos problemas que se mencionaron anteriormente.

De igual manera, hay que considerar el tipo de apuntador que se utiliza al hacer uso de ellos como argumento en funciones, ya que, por ejemplo, no sería posible enviar un unique_ptr como argumento, debido a que se tendría que crear una copia de este, lo cual es inválido. Una solución sería utilizar la función release(), sin embargo, esto complicaría innecesariamente su manejo, por lo que una mejor solución sería simplemente utilizar shared_ptr.

Fin


Como se pudo observar, el uso de apuntadores inteligentes facilita enormemente el uso de memoria dinámica, con un coste realmente insignificante, en algunos casos nulo, comparado con sus beneficios, lo cual hace que no exista razón para no utilizarlos.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *