Tabla de contenidos
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:
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.
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++.
La creación de un objeto en C++ tiene lugar en dos pasos:
Asignación de memoria para el objeto.
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:
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.
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.
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.
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.
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.
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.
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.
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.