13: Creación dinámica de objetos

Tabla de contenidos

13.1. Creación de objetos
13.2. Rediseño de los ejemplos anteriores
13.3. new y delete para vectores
13.4. Resumen
13.5. Ejercicios

A veces se conoce la cantidad exacta exacta, el tipo y duración de la vida de los objetos en un programa, pero no siempre es así.

¿Cuántos aviones tendrá que supervisar un sistema de control de tráfico aéreo? ¿Cuántas formas o figuras se usarán en un sistema CAD? ¿Cuántos nodos habrá en una red?

Para resolver un problema general de programación, es esencial poder crear y destruir objetos en tiempo de ejecución. Por supuesto, C proporciona las funciones de asignación dinámica de memoria malloc() y sus variantes, y free(), que permiten obtener y liberar bloques en el espacio de memoria del montículo (también llamado espacio libre[71] mientras se ejecuta el programa.

Este método sin embargo, no funcionará en C++. El constructor no le permite manipular la dirección de memoria a inicializar, y con motivo. De permitirse, sería posible:

  1. Olvidar la llamada al constructor. Con lo cual no sería posible garantizar la inicialización de los objetos en C++.

  2. Usar accidentalmente un objeto que aún no ha sido inicializado, esperando que todo vaya bien.

  3. Manipular un objeto de tamaño incorrecto.

Y por supuesto, incluso si se hizo todo correctamente, cualquiera que modifique el programa estaría expuesto a cometer esos mismos errores. Una gran parte de los problemas de programación tienen su origen en la inicialización incorrecta de objetos, lo que hace especialmente importante garantizar la llamada a los constructores para los objetos que han de ser creados en el montículo.

¿Cómo se garantiza en C++ la correcta inicialización y limpieza, permitiendo la creación dinámica de objetos?

La respuesta está en integrar en el lenguaje mismo la creación dinámica de objetos. malloc() y free() son funciones de biblioteca y por tanto, están fuera del control del compilador. Si se dispone de un operador que lleve a cabo el acto combinado de la asignación dinámica de memoria y la inicialización, y de otro operador que realice el acto combinado de la limpieza y de liberación de memoria, el compilador podrá garantizar la llamada a los constructores y destructores de los objetos.

En este capítulo verá cómo se resuelve de modo elegante este problema con los operadores new y delete de C++.

13.1. Creación de objetos

La creación de un objeto en C++ tiene lugar en dos pasos:

  1. Asignación de memoria para el objeto.

  2. Llamada al constructor.

Aceptemos por ahora que este segundo paso ocurre siempre. C++ lo fuerza, debido a que el uso de objetos no inicializados es una de las causas más frecuentes de errores de programación. Siempre se invoca al constructor, sin importar cómo ni dónde se crea el objeto.

El primero de estos pasos puede ocurrir de varios modos y en diferente momento:

  1. Asignación de memoria en la zona de almacenamiento estático, que tiene lugar durante la carga del programa. El espacio de memoria asignado al objeto existe hasta que el programa termina.

  2. Asignación de memoria en la pila, cuando se alcanza algún punto determinado durante la ejecución del programa (la llave de apertura de un bloque). La memoria asignada se vuelve a liberar de forma automática en cuanto se alcanza el punto de ejecución complementario (la llave de cierre de un bloque). Las operaciones de manipulación de la pila forman parte del conjunto de instrucciones del procesador y son muy eficientes. Por otra parte, es necesario saber cuantas variables se necesitan mientras se escribe el programa de modo que el copilador pueda generar el código correspondiente.

  3. Asignación dinámica, en una zona de memoria libre llamada montículo (heap o free store). Se reserva espacio para un objeto en esta zona mediante la llamada a una función durante la ejecución del programa; esto significa que se puede decidir en cualquier momento que se necesita cierta cantidad de memoria. Esto conlleva la responsabilidad de determinar el momento en que ha de liberarse la memoria, lo que implica determinar el tiempo de vida de la misma que, por tanto, ya no está bajo control de las reglas de ámbito.

A menudo, las tres regiones de memoria referidas se disponen en una zona contigua de la memoria física: área estática, la pila, y el montículo, en un orden determinado por el escritor del compilador. No hay reglas fijas. La pila puede estar en una zona especial, y puede que las asignaciones en el montículo se obtengan mediante petición de bloques de la memoria del sistema operativo. Estos detalles quedan normalmente ocultos al programador puesto que todo lo que se necesita conocer al respecto es que esa memoria estará disponible cuando se necesite.

13.1.1. Asignación dinámica en C

C proporciona las funciones de su biblioteca estándar malloc() y sus variantes calloc() y realloc() para asignar, y free() para liberar bloques de memoria dinámicamente en tiempo de ejecución. Estas funciones son pragmáticas pero rudimentarias por lo que requieren comprensión y un cuidadoso manejo por parte del programador. El listado que sigue es un ejemplo que ilustra el modo de crear una instancia de una clase con estas funciones de C:

//: C13:MallocClass.cpp
// Malloc with class objects
// What you'd have to do if not for "new"
#include "../require.h"
#include <cstdlib> // malloc() & free()
#include <cstring> // memset()
#include <iostream>
using namespace std;

class Obj {
  int i, j, k;
  enum { sz = 100 };
  char buf[sz];
public:
  void initialize() { // Can't use constructor
    cout << "initializing Obj" << endl;
    i = j = k = 0;
    memset(buf, 0, sz);
  }
  void destroy() const { // Can't use destructor
    cout << "destroying Obj" << endl;
  }
};

int main() {
  Obj* obj = (Obj*)malloc(sizeof(Obj));
  require(obj != 0);
  obj->initialize();
  // ... sometime later:
  obj->destroy();
  free(obj);
} ///:~

Listado 13.1. C13/MallocClass.cpp


Observe el uso de malloc() para la obtención de espacio para el objeto:

Obj* obj = (Obj*)malloc(sizeof(Obj));

Se debe pasar como parámetro a malloc() el tamaño del objeto. El tipo de retorno de malloc() es void*, pues es sólo un puntero a un bloque de memoria, no un objeto. En C++ no se permite la asignación directa de un void* a ningún otro tipo de puntero, de ahí la necesidad de la conversión explícita de tipo (molde).

Puede ocurrir que malloc() no encuentre un bloque adecuado, en cuyo caso devolverá un puntero nulo, de ahí la necesidad de comprobar la validez del puntero devuelto.

El principal escollo está en la línea:

obj->initialize();

El usuario deberá asegurarse de inicializar el objeto antes de su uso. Obsérvese que no se ha usado el constructor debido a que éste no puede ser llamado de modo explícito [72]; es llamado por el compilador cuando se crea un objeto. El problema es que el usuario puede olvidar inicializar el objeto antes de usarlo, introduciendo así una importante fuente de problemas.

Como consecuencia, muchos programadores encuentran muy confusas y complicadas las funciones de asignación dinámica de la memoria en C. No es muy difícil encontrar programadores que, usando máquinas con memoria virtual, usan vectores enormes en el área de almacenamiento estático para evitar tener que tratar con la asignación dinámica. Dado que C++ intenta facilitar el uso de la biblioteca a los programadores ocasionales, no es aceptable la forma de abordar la asignación dinámica en C.

13.1.2. El operador new

La solución que ofrece C++ consiste en combinar la serie de acciones necesarias para la creación de un objeto en un único operador llamado >new. Cuando se crea un objeto mediante el operador >new, éste se encarga de obtener el espacio necesario para el objeto y de llamar a su constructor. Cuando se ejecuta el código:

MyType *fp = new MyType(1,2);

se asigna espacio mediante alguna llamada equivalente a >malloc(sizeof(MyType)) --con frecuencia es así, literalmente--, y usando la dirección obtenida como puntero >this, y (1, 2) como argumentos, se llama al constructor de la clase MyType. Para cuando está disponible, el valor de retorno de new es ya un puntero válido a un objeto inicializado. Además es del tipo correcto, lo que hace innecesaria la conversión.

El operador new por defecto, comprueba el éxito o fracaso de la asignación de memoria como paso previo a la llamada al constructor, haciendo innecesaria y redundante la posterior comprobación. Más adelante en este capítulo se verá qué sucede si se produce este fallo.

En las expresiones con new se puede usar cualquiera de los constructores disponibles para una clase. Si éste no tiene argumentos, se escribe la expresión sin lista de argumentos

MyType *fp = new MyType;

Es notable la simpleza alcanzada en la creación dinámica de objetos: una única expresión realiza todo el trabajo de cálculo de tamaño, asignación, comprobaciones de seguridad y conversión de tipo. Esto hace que la creación dinámica de objetos sea tan sencilla como la creación en la pila.

13.1.3. El operador delete

El complemento a la expresión new es la expresión delete, que primero llama al destructor y después libera la memoria (a menudo mediante una llamada a free()). El argumento para una expresión con delete debe ser una dirección: un puntero a objeto creado mediante new.

delete fp;

Esta expresión destruye el objeto y después libera el espacio dinámicamente asignado al objeto MyType

El uso del operador delete debe limitarse a los objetos que hayan sido creados mediante new. Las consecuencias de aplicar el operador delete a los objetos creados con malloc(), calloc() o realloc() no están definidas. Dado que la mayoría de las implementaciones por defecto de new y delete usan malloc() y free(), el resultado será probablemente la liberación de la memoria sin la llamada al destructor.

No ocurre nada si el puntero que se le pasa a delete es nulo. Por esa razón, a menudo se recomienda asignar cero al puntero inmediatamente después de usar delete; se evita así que pueda ser usado de nuevo como argumento para delete. Tratar de destruir un objeto más de una vez es un error de consecuencias imprevisibles.

13.1.4. Un ejemplo sencillo

El siguiente ejemplo demuestra que la inicialización tiene lugar:

//: C13:Tree.h
#ifndef TREE_H
#define TREE_H
#include <iostream>

class Tree {
  int height;
public:
  Tree(int treeHeight) : height(treeHeight) {}
  ~Tree() { std::cout << "*"; }
  friend std::ostream&
  operator<<(std::ostream& os, const Tree* t) {
    return os << "Tree height is: "
              << t->height << std::endl;
  }
}; 
#endif // TREE_H ///:~

Listado 13.2. C13/Tree.h


Se puede probar que el constructor es invocado imprimiendo el valor de Tree. Aquí se hace sobrecargando el operator << para usarlo con un ostream y un Tree*. Note, sin embargo, que aunque la función está declarada como friend, está definida como una inline!. Esto es así por conveniencia --definir una función amiga como inline a una clase no cambia su condición de amiga o el hecho de que es una función global y no un método. También resaltar que el valor de retorno es el resultado de una expresión completa (el ostream&), y así debe ser, para satisfacer el tipo del valor de retorno de la función.

13.1.5. Trabajo extra para el gestor de memoria

Cuando se crean objetos automáticos en la pila, el tamaño de los objetos y su tiempo de vida queda fijado en el código generado, porque el compilador conoce su tipo, cantidad y alcance. Crear objetos en el montículo implica una sobrecarga adicional, tanto en tiempo como en espacio. Veamos el escenario típico (Puede reemplazar malloc() con calloc() o realloc()).

Se invoca malloc(), que pide un bloque de memoria. (Este código realmente puede ser parte de malloc()).

Ahora tiene lugar la búsqueda de un bloque de tamaño adecuado de entre los bloques libres. Esto requiere la comprobación de un mapa o directorio de algún tipo que lleve el registro de los bloques disponibles y de los que están en uso. Es un proceso rápido, pero puede que necesite varias pruebas, es pues un proceso no determinista. Dicho de otro modo, no se puede contar con que malloc() tarde siempre exactamente el mismo tiempo en cada búsqueda.

Antes de entregar el puntero del bloque obtenido, hay que registrar en alguna parte su tamaño y localización para que malloc() no lo vuelva a usar y para que cuando se produzca la llamada a free(), el sistema sepa cuánto espacio ha de liberar.

El modo en que se implementan todas estas operaciones puede variar mucho. No hay nada que impida que puedan implementarse las primitivas de asignación de memoria en el conjunto de instrucciones del procesador. Si es suficientemente curioso, pueden escribir programas que permitan averiguar cómo está implementada malloc(). Si dispone de él, puede leer el código fuente de la biblioteca de funciones de C, si no, siempre está disponible el de GNU C.



[71] N.T. espacio de almacenamiento libre (free store)

[72] Existe una sintaxis especial llamada placement-new que permite llamar al constructor para un bloque de memoria preasignando. Se verá más adelante, en este mismo capítulo.