Tabla de contenidos
A veces se conoce exactamente la cantidad, el tipo y el ciclo de vida de los objetos de un programa, pero no siempre es así.
¿Cuántos aviones tendrá que supervisar un sistema de control de tráfico aéreo? ¿Cuántas figuras usará 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 siempre ha proporcionado las funciones de asignación dinámica de memoria malloc() y free() (y sus variantes), que permiten obtener y liberar bloques de espacio de memoria del montículo (también llamado almacenamiento libre[72]) en tiempo de ejecución.
Sin embargo, este método no funcionará en C++. El constructor no permite que se le pase la dirección de la memoria que debe inicializar, y por una buena razón. De permitirlo, sería posible:
Olvidar la llamada al constructor, con lo cual no sería posible garantizar la inicialización de los objetos en C++.
Usar accidentalmente un objeto que aún no ha sido inicializado, esperando que todo vaya bien.
Pasarle 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 se crean en el montículo.
¿Cómo garantiza C++ la correcta inicialización y limpieza y permite, a la vez, crear objetos dinámicamente en el montículo?
La respuesta pasa por integrar en el lenguaje mismo la creación dinámica de objetos. malloc() y free() son funciones de librería 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 la liberación de memoria, el compilador podrá garantizar la llamada a los constructores y destructores de los objetos.
En este capítulo aprenderá cómo se resuelve de modo elegante este problema con los operadores new y delete de C++, que crean objetos en el montículo de forma segura.
La creación de un objeto en C++ tiene lugar en dos pasos:
Asignación de memoria para el objeto.
Llamada al constructor para inicializar esa memoria.
A estas alturas ya debería estar convencido de que el segundo paso ocurre siempre. C++ lo impone, 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.
Sin embargo, el primero de estos pasos puede ocurrir de varios modos y en distintos momentos:
Asignación de memoria en la zona de almacenamiento estático, que tiene lugar antes de que comience el programa. El espacio de memoria asignado al objeto existe hasta que el programa termine.
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. Sin embargo, es necesario saber exactamente cuántas variables se necesitan mientras se escribe el programa de modo que el compilador pueda generar el código correspondiente.
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 memoria y cuánta se necesita. 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: el área estática, la pila y el montículo, en un orden determinado por el autor del compilador. Sin embargo, 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, de modo que todo lo que necesita saber al respecto es que esa memoria estará disponible cuando la pida.
C proporciona las funciones de su librería 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 en el montículo 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));
Aquí el usuario debe calcular el tamaño del objeto (una posible fuente de errores). 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();
Si el usuario ha llegado hasta aquí sin errores, deberá acordarse de inicializar el objeto antes de usarlo. Obsérvese que no se ha usado el constructor debido a que éste no puede ser llamado de modo explícito[73]; lo llama 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.
Además, parece que muchos programadores encuentran muy confusas y complicadas las funciones de asignación dinámica de la memoria en C. No es raro encontrar programadores que, usando máquinas con memoria virtual, usan arrays enormes en el área de almacenamiento estático para evitar tener que tratar con la asignación dinámica. Dado que C++ intenta que el uso de la librería sea seguro y sencillo para los programadores ocasionales, no es aceptable la forma de abordar la asignación dinámica en C.
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 en el montículo 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 el puntero se asigna a fp, ya apunta a un objeto vivo e inicializado; ni siquiera es posible acceder a él antes. Además es automáticamente del tipo MyType adecuado, lo que hace innecesario el molde.
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 el constructor no tiene argumentos, se escribe la expresión sin lista de argumentos:
MyType *fp = new MyType;
Es notable la sencillez 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.
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()). Del mismo modo que una expresión new devuelve un puntero al objeto, una expresión delete necesita la dirección de un objeto.
delete fp;
Esta expresión destruye el objeto y después libera el espacio dinámicamente asignado al objeto MyType creado anteriormente.
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.
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
//: C13:NewAndDelete.cpp // Simple demo of new & delete #include "Tree.h" using namespace std; int main() { Tree* t = new Tree(40); cout << t; delete t; } ///:~
Listado 13.3. C13/NewAndDelete.cpp
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 inline! Es una mera cuestión de conveniencia --definir una función amiga como inline en una clase no cambia su condición de amiga ni el hecho de que es una función global y no un método. Observe también que el valor de retorno es el resultado de toda la expresión de salida, que es un ostream& (y así debe ser, para satisfacer el tipo del valor de retorno de la función).
Cuando se crean objetos automáticos en la pila, el tamaño de los objetos y su tiempo de vida quedan fijados 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 un escenario típico (puede reemplazar malloc() por 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 varios intentos, de modo que podría no ser 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 al 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, puede escribir programas que permitan averiguar cómo está implementada malloc(). Si dispone de él, puede leer el código fuente de la librería de funciones de C, si no, siempre está disponible el de GNU C.