Gestión de memoria (IV)

Almacenamiento libre (free store)


El lenguaje C++ permite la asignación dinámica de memoria en el sector de almacenamiento libre (free store) mediante expresiones de tipo new. En contraste con el alojamiento de variables locales en la pila del usuario, el tiempo de vida de los objetos alojados dinámicamente no se encuentra limitado al ámbito en que fueron creados, de forma que la memoria debe ser reclamada explícitamente a través de una expresión delete o, muy raramente, mediante un recolector de basura [1].

Ante una expresión

   X* p = new X{/* argumentos */};

el código generado por el compilador resulta ser básicamente equivalente a:

   X* p;    {       // alojamos sizeof(X) bytes en el free store       // (posible lanzamiento de std::bad_alloc bajo fallo de alojamiento):       void* loc = operator new(sizeof(X));                                   // (A)       try {          // construimos el objeto X en el espacio alojado:          ::new(loc) X{/* argumentos */};                                     // (B)          p = static_cast<X*>(loc); // asignamos el puntero si la construcción resultó exitosa       }       catch (...) { // si el constructor lanza una excepción...          operator delete(loc); // desalojamos la memoria y                      (C)          throw;                // relanzamos la excepción                       (D)       }    }

Observemos la separación estricta entre la asignación del espacio de memoria y la inicialización del objeto en el mismo. Se invoca, primeramente, al operador operator new con el fin de asignar un bloque de memoria convenientemente alineado de (como mínimo) sizeof(X) bytes de extensión (ver el punto A). Por defecto, de no existir suficiente espacio en memoria, se notificará el error mediante el lanzamiento de una excepción de tipo std::bad_alloc. Por contra, si la operación tiene éxito, la función retornará un puntero no nulo (de tipo X*) al bloque de memoria asignado. Es en dicha dirección donde se construirá el objeto de tipo X (B). La inicialización del objeto se encierra en un bloque try con el fin de que, de emitirse una excepción durante su construcción, ésta sea capturada y manejada en un bloque catch; ello permite liberar la memoria preasignada en (A) antes de relanzar la excepción (ver C y D). Para más información, puedes visitar el enlace [2].
Una expresión de la forma X* p = new X;, siendo X una clase definida por el programador, deberá inicializar al objeto invocando a su constructor por defecto X::X() (o realizar una inicialización de agregado en el caso de agregados). Por contra, de tratarse de un tipo primitivo, el valor no será inicializado (permaneciendo, por tanto, arbitrario) salvo que se solicite de forma explícita su inicialización a cero mediante la inclusión de un par de llaves adicionales en la expresión, como en int* p = new int{};.
De existir versiones específicas para operator new y operator delete en la clase X (implementadas como funciones miembro públicas y estáticas; véase el cuadro de código inferior), serán éstas las invocadas en los puntos (A) y (C). Su inclusión se enmarca en el uso de técnicas avanzadas de gestión de memoria, detección de errores o control del uso del free store [3,4]. De requerirse el empleo de las funciones de alojamiento en el espacio de nombres global, las versiones proporcionadas en la clase pueden ser omitidas con sentencias como de la forma X* p = ::new X;.

   class X {    public:       // ...       // funciones miembro estáticas de forma implícita:       void* operator new(std::size_t n);       void operator delete(void* p) noexcept;       void* operator new[](std::size_t n);       void operator delete[](void* p) noexcept;    };    

Por su parte, X* p = new X[n]{}; aloja una matriz unidimensional de n objetos inicializados en el free store y equivale básicamente a:

   X* p;    {       auto loc = static_cast<X*>(operator new[](n*sizeof(X)));       auto i = std::size_t{};       try {          for (; i < n; ++i)             ::new(static_cast<void*>(loc + i)) X{};  // (A)          p = loc;       }       catch (...) {          for (auto j = std::size_t{}; j < i; ++j)          (loc + j)->~X();                         // (B)          operator delete[](loc);                  // (C)          throw;                                   // (D)       }    }

De lanzarse una excepción durante la construcción de uno cualquiera de los objetos de la matriz unidimensional (A), todos los objetos construidos satisfactoriamente con antelación a éste serían destruidos invocándose al destructor de la clase X (B). A continuación, se procedería a liberar la memoria asignada inicialmente para la matriz (C) y se reemitiría la excepción (D).

Al hacerse uso de una expresión X* p = new X[n]; (sin llaves finales) con una clase X definida por el programador, los n objetos son inicializados mediante una llamada al constructor por defecto X::X(), salvo en el caso de agregados, donde tiene lugar una inicialización de agregado. En contraste, de trabajarse con un tipo primitivo, los elementos permanecen arbitrarios, salvo que se solicite de forma explícita su inicialización a cero mediante la inclusión de un par de llaves adicionales en la expresión, como en int* p = new int[n]{}.

Observemos que la variable de retorno en una expresión new es un puntero al tipo de objeto construido, proporcionando su dirección en memoria. Dicho puntero es una variable local y, como tal, su duración de almacenamiento finaliza al concluir el ámbito en que fue definida. No ocurre así con el objeto referenciado, que seguirá almacenado en el free store hasta ser destruido (y su espacio en memoria desalojado) explícitamente mediante una expresión delete. Esta propiedad puede dar lugar a fugas de memoria (memory leaks) indeseadas, como en el siguiente ejemplo:

   {       X* p = new X{};       // ...           } // el puntero sale fuera de ámbito      // pero el objeto referenciado por él permanece en el free store (memory leak)

Estas fugas deberán ser evitadas mediante técnicas modernas de programación, en particular haciéndose uso de los punteros inteligentes proporcionados por el estándar del lenguaje.

Una expresión de tipo delete como la siguiente

   X* p = new X{};    // ...    delete p;

es equivalente a:

   X* p = new X{};    // ...    if (p != nullptr) {       p->~X();            // llamada al destructor de X     operator delete(p); // desalojo del espacio en memoria    }

Es decir, si el puntero es no nulo, se invoca al destructor de la clase para el objeto referenciado y se libera la memoria ocupada por éste. Notemos en este caso (y también en ejemplos anteriores) la importancia de que el destructor de la clase X no emita excepciones de ningún tipo. En el ejemplo considerado, la razón es evidente: de lanzarse una excepción desde el destructor, el operador delete (responsable de la liberación del bloque de memoria ocupado por el objeto) no será invocado, produciéndose una laguna de memoria.

Análogamente,

   X* p = new X[n]{};    // ...    delete[] p;

equivale a:

   X* p = new X[n]{};    // ...    if (p != nullptr) {       for (auto i = std::size_t{}; i < n; ++i)          (p + n - i -1)->~X();       operator delete[](p);    }

Observemos que no es necesario especificar el tamaño de la matriz (en el código, n) para desalojarla a través del operador delete[]. Dicho tamaño es almacenado junto al propio objeto en memoria dinámica o bien mediante la gestión de una tabla asociativa entre punteros y tamaños en memoria (ver el enlace [5] para más información al respecto).


Referencias bibliográficas:
  1. Boehm-Demers-Weiser garbage collector - https://www.hboehm.info/gc/
  2. isocpp.org C++ FAQ - https://isocpp.org/wiki/faq/freestore-mgmt#new-doesnt-leak-if-ctor-throws
  3. Meyers S., 'Effective C++ Third Edition'. Addison Wesley, 2005.
  4. Dickheiser M. J., 'C++ for Game Programmers', Second Edition. Charles River Media (2006).
  5. isocpp.org C++ FAQ - https://isocpp.org/wiki/faq/freestore-mgmt#num-elems-in-new-array
  6. New expression - https://en.cppreference.com/w/cpp/language/new

No hay comentarios:

Publicar un comentario