Cuando se ejecuta una expresión con new, ocurren dos cosas. Primero se asigna la memoria al ejecutar el código del operator new() y después se realiza la llamada al constructor. En el caso de una expresión con delete, se llama primero al destructor y después se libera la memoria con operator delete(). Las llamadas al constructor y destructor nunca están bajo el control del programador (de lo contrario, podría alterarlas accidentalmente), pero se pueden cambiar las funciones de asignación de memoria operator new() y operator delete().
El sistema de asignación de memoria usado por new y delete es un sistema de propósito general. En situaciones especiales, puede que no funcione como se requiere. La razón más habitual para cambiar el asignador es la eficiencia; puede que se necesite crear y destruir tantos objetos de una clase concreta que el asignador se convierta en un cuello de botella para la velocidad. En C++ es posible sobrecargar new y delete para implementar un esquema particular más adecuado que permita manejar situaciones como ésta.
Otra cuestión es la fragmentación del montículo. Cuando los objetos tienen tamaños diferentes es posible llegar a dividir de tal modo el área de memoria libre que, en la práctica, se agote la memoria. Es decir, el espacio puede estar disponible, pero debido al nivel de fragmentación alcanzado, no existe ningún bloque del tamaño requerido. Es posible asegurarse de que esto no llegue a ocurrir mediante la creación de un asignador para una clase específica.
En los sistemas de tiempo real y en los sistemas empotrados, puede que un programa tenga que funcionar durante mucho tiempo con recursos muy limitados. Tales sistemas pueden incluso requerir que cada asignación tome siempre la misma cantidad de tiempo, y que no esté permitida la fragmentación ni el agotamiento del montículo. La solución a este problema consiste en utilizar un asignador «personalizado»; de otro modo, los programadores evitarían usar new y delete en estos casos y desperdiciarían un recurso muy valioso de C++.
A la hora de sobrecargar operator new() y operator delete() es importante tener en cuenta que lo único que se está cambiando es la forma en que se realiza la asignación del espacio. El compilador llamará a la nueva versión de new en lugar de a la original, para asignar espacio, llamando después al constructor que actuará sobre él. Así que, aunque el compilador convierte una expresión new en código para asignar el espacio y para llamar al constructor, todo lo que se puede cambiar al sobrecargar new es la parte correspondiente a la asignación. delete tiene una limitación similar.
Cuando se sobrecarga operator new(), se está reemplazando también el modo de tratar los posibles fallos en la asignación de la memoria. Se debe decidir qué hará su operator new() en tal caso: devolver cero, un bucle de reintento con llamada al new-handler, o lo que es más frecuente, lanzar una excepción bad_alloc (tema que se trata en el Volumen 2, disponible en www.BruceEckel.com).
La sobrecarga de new y delete es como la de cualquier otro operador. Existe la posibilidad de elegir entre sobrecarga global y sobrecarga para una clase determinada.
Éste es el modo más drástico de abordar el asunto; resulta útil cuando las versiones globales de new y delete no son satisfactorias para todo el sistema. Al sobrecargar la versión global, las originales quedan completamente inaccesibles: ni siquiera es posible llamarlas desde dentro de las funciones sobrecargadas.
El new sobrecargado debe tomar un argumento del tipo size_t (el tipo estándar de C para tamaños). Este argumento es generado y pasado por el compilador, y se refiere al tamaño del objeto cuya asignación de memoria es ahora responsabilidad suya. Debe devolver un puntero a un bloque de ese tamaño (o mayor, si hubiera motivos para hacerlo así), o cero en el caso de que no se encontrara un bloque adecuado. Si eso sucede, no se producirá la llamada al constructor. Sin embargo, en ese caso probablemente debería hacer algo más informativo que sólo devolver cero, por ejemplo llamar al «new-handler» o lanzar una excepción, para indicar que hubo un problema.
El valor de retorno de operator new() es void*, no un puntero a un tipo particular. Lo único que hace es obtener un bloque de memoria, no un objeto terminado; eso no ocurre hasta que se llama al constructor, un acto que el compilador garantiza y que está fuera del control del programador.
El operator delete() toma como argumento un puntero void* a un bloque obtenido con el operator new(). Es un void* ya que operator delete() obtiene el puntero sólo después de que haya sido llamado el destructor, lo que efectivamente elimina su carácter de objeto convirtiéndolo en un simple bloque de memoria. El tipo de retorno para delete es void.
A continuación se expone un ejemplo del modo de sobrecargar globalmente new y delete:
//: C13:GlobalOperatorNew.cpp // Overload global new/delete #include <cstdio> #include <cstdlib> using namespace std; void* operator new(size_t sz) { printf("operator new: %d Bytes\n", sz); void* m = malloc(sz); if(!m) puts("out of memory"); return m; } void operator delete(void* m) { puts("operator delete"); free(m); } class S { int i[100]; public: S() { puts("S::S()"); } ~S() { puts("S::~S()"); } }; int main() { puts("creating & destroying an int"); int* p = new int(47); delete p; puts("creating & destroying an s"); S* s = new S; delete s; puts("creating & destroying S[3]"); S* sa = new S[3]; delete []sa; } ///:~
Listado 13.9. C13/GlobalOperatorNew.cpp
Aquí puede verse la forma general de sobrecarga de operadores new y delete. Estos operadores sustitutivos usan las funciones malloc() y free() de la librería estándar de C, que es probablemente lo que ocurre en los operadores originales. Imprimen también mensajes sobre lo que están haciendo. Nótese que no se han usado iostreams sino printf() y puts(). Esto se hace debido a que los objetos iostream como los globales cin, cout y cerr llaman a new para obtener memoria[74]. Usar printf() evita el fatal bloqueo, ya que no hace llamadas a new.
En main(), se crean algunos objetos de tipos predefinidos para demostrar que también en estos casos se llama a los operadores new y delete sobrecargados. Posteriormente, se crean un objeto simple y un array, ambos de tipo S. En el caso del array se puede ver, por el número de bytes pedidos, que se solicita algo de memoria extra para incluir información sobre el número de objetos que tendrá. En todos los casos se efectúa la llamada a las versiones globales sobrecargadas de new y delete.
Aunque no es necesario poner el modificador static, cuando se sobrecargan new y delete para una clase se están creando métodos estáticos (métodos de clase). La sintaxis es la misma que para cualquier otro operador. Cuando el compilador encuentra una expresión new para crear un objeto de una clase, elige, si existe, un método de la clase llamado operator new() en lugar del new global. Para el resto de tipos o clases se usan los operadores globales (a menos que tengan definidos los suyos propios).
En el siguiente ejemplo se crea un sistema primitivo de asignación de almacenamiento para la clase Framis. Se reserva un bloque de memoria en el área de datos estática al arrancar el programa, y se usa esa memoria para asignar alojamiento para los objetos de tipo Framis. Para determinar qué bloques se han asignado, se usa un sencillo array de bytes, un byte por bloque.
//: C13:Framis.cpp // Local overloaded new & delete #include <cstddef> // Size_t #include <fstream> #include <iostream> #include <new> using namespace std; ofstream out("Framis.out"); class Framis { enum { sz = 10 }; char c[sz]; // To take up space, not used static unsigned char pool[]; static bool alloc_map[]; public: enum { psize = 100 }; // frami allowed Framis() { out << "Framis()\n"; } ~Framis() { out << "~Framis() ... "; } void* operator new(size_t) throw(bad_alloc); void operator delete(void*); }; unsigned char Framis::pool[psize * sizeof(Framis)]; bool Framis::alloc_map[psize] = {false}; // Size is ignored -- assume a Framis object void* Framis::operator new(size_t) throw(bad_alloc) { for(int i = 0; i < psize; i++) if(!alloc_map[i]) { out << "using block " << i << " ... "; alloc_map[i] = true; // Mark it used return pool + (i * sizeof(Framis)); } out << "out of memory" << endl; throw bad_alloc(); } void Framis::operator delete(void* m) { if(!m) return; // Check for null pointer // Assume it was created in the pool // Calculate which block number it is: unsigned long block = (unsigned long)m - (unsigned long)pool; block /= sizeof(Framis); out << "freeing block " << block << endl; // Mark it free: alloc_map[block] = false; } int main() { Framis* f[Framis::psize]; try { for(int i = 0; i < Framis::psize; i++) f[i] = new Framis; new Framis; // Out of memory } catch(bad_alloc) { cerr << "Out of memory!" << endl; } delete f[10]; f[10] = 0; // Use released memory: Framis* x = new Framis; delete x; for(int j = 0; j < Framis::psize; j++) delete f[j]; // Delete f[10] OK } ///:~
Listado 13.10. C13/Framis.cpp
El espacio de almacenamiento para el montículo Framis se crea sobre el bloque obtenido al declarar un array de tamaño suficiente para contener psize objetos de clase Framis. El mapa de asignación tiene psize elementos, de modo que hay un bool para cada bloque. Todos los valores del mapa de asignación se inicializan a false usando el truco de la inicialización de agregados, que consiste en inicializar el primer elemento para que el compilador inicialice automáticamente los restantes a su valor por defecto normal (que es false en el caso de bool).
El operator new() local usa la misma sintaxis que el global. Lo único que hace es buscar una posición libre, es decir, un valor false en el mapa de asignación alloc_map. Si la encuentra, cambia su valor a true para marcarla como ocupada, y devuelve la dirección del bloque correspondiente. En caso de no encontrar ningún bloque libre, envía un mensaje al fichero de trazas y lanza una excepción de tipo bad_alloc.
Éste es el primer ejemplo de excepciones que aparece en este libro. En el Volumen 2 se verá una discusión detallada de la gestión de excepciones, por lo que en este ejemplo se hace un uso muy simple de ellas. En operator new() hay dos elementos relacionados con la gestión de excepciones. Primero, a la lista de argumentos de función le sigue la expresión throw(bad_alloc), que informa al compilador y al lector de que la función puede lanzar una excepción del tipo indicado. En segundo lugar, si efectivamente se agota la memoria, la función alcanzará la sentencia throw bad_alloc() lanzando la excepción. En el caso de que esto ocurra, la función deja de ejecutarse y se cede el control del programa a un manejador de excepciones, que se expresa como una cláusula catch.
En main() se puede ver la cláusula try-catch que es la otra parte del mecanismo. El código que puede lanzar la excepción queda dentro del bloque try; en este caso, llamadas a new para objetos Framis. Justo a continuación de dicho bloque siguen una o varias cláusulas catch, cada una de las cuales especifica el tipo de excepción que captura. En este caso, catch(bad_alloc) indica que en ese bloque se capturarán las excepciones de tipo bad_alloc. El código de este bloque sólo se ejecutará si se lanza una excepción bad_alloc, y la ejecución del programa continúa justo después de la última cláusula catch del grupo (aquí sólo hay una, pero podría haber más).
En este ejemplo, el uso de iostream es correcto ya que ni el operator new() ni el operator delete() globales han sido modificados.
El operator delete() asume que la dirección de Framis ha sido obtenida del almacén particular. Es una suposición razonable, ya que cada vez que se crea un objeto Framis simple en el montículo se llama al operator new() local; pero cuando se crea un array de tales objetos se llama al new global. Esto causaría problemas si el usuario llamara accidentalmente al operador delete sin usar la sintaxis de corchetes vacíos para destrucción de arrays. Podría ser que incluso estuviera tratando de borrar un puntero a un objeto de la pila. Si cree que estas cosas pueden suceder, conviene pensar en añadir una línea que asegure que la dirección está dentro del almacén y en un límite de bloque correcto (también empezará a ver el potencial que tiene la sobrecarga de los operadores new y delete para la localización de fugas de memoria).
operator delete() calcula el bloque del almacén que representa el puntero y después pone a false la bandera correspondiente en el mapa de asignación, para indicar que dicho bloque está libre.
En la función main(), se crean dinámicamente suficientes objetos Framis para agotar la memoria. Con esto se prueba el comportamiento del programa en este caso. A continuación, se libera uno de los objetos y se crea otro para mostrar la reutilización del bloque recién liberado.
Como este esquema de asignación de memoria es específico para objetos Framis, es probablemente mucho más rápido que el esquema de propósito general que usan los operadores new y delete por defecto. Se debe advertir, no obstante, que este enfoque no funciona automáticamente cuando se usa herencia, un tema que verá en el Capítulo 14.
Si se sobrecargan los operadores new y delete para una clase, esos operadores se llaman cada vez que se crea un objeto simple de esa clase. Sin embargo, al crear un array de tales objetos se llama al operator new() global para obtener el espacio necesario para el array, y al operator delete() global para liberarlo. Es posible controlar también la asignación de memoria para arrays sobrecargando los métodos operator new[] y operator delete[]; se trata de versiones especiales para arrays. A continuación se expone un ejemplo que muestra cuándo se llama a cada una de las dos versiones:
//: C13:ArrayOperatorNew.cpp // Operator new for arrays #include <new> // Size_t definition #include <fstream> using namespace std; ofstream trace("ArrayOperatorNew.out"); class Widget { enum { sz = 10 }; int i[sz]; public: Widget() { trace << "*"; } ~Widget() { trace << "~"; } void* operator new(size_t sz) { trace << "Widget::new: " << sz << " bytes" << endl; return ::new char[sz]; } void operator delete(void* p) { trace << "Widget::delete" << endl; ::delete []p; } void* operator new[](size_t sz) { trace << "Widget::new[]: " << sz << " bytes" << endl; return ::new char[sz]; } void operator delete[](void* p) { trace << "Widget::delete[]" << endl; ::delete []p; } }; int main() { trace << "new Widget" << endl; Widget* w = new Widget; trace << "\ndelete Widget" << endl; delete w; trace << "\nnew Widget[25]" << endl; Widget* wa = new Widget[25]; trace << "\ndelete []Widget" << endl; delete []wa; } ///:~
Listado 13.11. C13/ArrayOperatorNew.cpp
Salvo por la información de traza que se añade aquí, las llamadas a las versiones globales de new y delete causan el mismo efecto que si estos operadores no se hubieran sobrecargado. Por supuesto, es posible usar cualquier esquema conveniente de asignación de memoria en estos operadores modificados.
Se puede observar que la sintaxis de new y delete para arrays es la misma que la usada para objetos simples salvo por los corchetes [] añadidos. En ambos casos se le pasa a new como argumento el tamaño del bloque de memoria solicitado. A la versión para arrays se le pasa el tamaño necesario para albergar todos sus componentes. Conviene tener en cuenta que lo único que se requiere del operator new() sobrecargado es que devuelva un puntero a un bloque de memoria suficientemente grande. Aunque es posible inicializar el bloque referido, normalmente eso es trabajo del constructor, al que el compilador llamará automáticamente.
El constructor y el destructor simplemente imprimen caracteres para que pueda verse cuándo han sido llamados. A continuación se muestra el fichero de traza generado con un compilador concreto:
new Widget Widget::new: 40 bytes * delete Widget ~Widget::delete new Widget[25] Widget::new: 1004 bytes ************************* delete []Widget ~~~~~~~~~~~~~~~~~~~~~~~~~Widget::delete[]
La creación de un único objeto Widget requiere 40 bytes, tal y como se podría esperar para una máquina que usa 32 bits para un int. Se invoca al operator new() y luego al constructor, que se indica con la impresión del carácter «*». De forma complementaria, la llamada a delete provoca primero la invocación del destructor y sólo después, la de operator delete().
Cuando lo que se crea es un array de objetos Widget, se observa el uso de la versión de operator new() para arrays, de acuerdo con lo dicho anteriormente. Pero observe que el tamaño del bloque solicitado en este caso es cuatro bytes mayor que el esperado. Es en estos cuatro bytes extra donde el sistema guarda información sobre el array, en concreto, el número de objetos que contiene. De ese modo, en la expresión
delete []Widget;
los corchetes informan al compilador de que se trata de un array de objetos, con lo cual generará el código para extraer la información que indica el número de objetos y para llamar otras tantas veces al destructor. Obsérvese que aunque se llame sólo una vez a operator new() y operator delete() para todo el bloque del array, se llama al constructor y al destructor por defecto una vez para cada uno de los objetos del array.
Considerando que
MyType* f = new MyType;
llama a new para obtener un bloque del tamaño de MyType invocando después a su constructor, ¿qué pasaría si la asignación de memoria falla en new? En tal caso no se llama al constructor, de modo que, aunque el objeto no se haya creado con éxito, al menos no se ha invocado al constructor pasándole un puntero this nulo . He aquí un ejemplo que lo demuestra:
//: C13:NoMemory.cpp // Constructor isn't called if new fails #include <iostream> #include <new> // bad_alloc definition using namespace std; class NoMemory { public: NoMemory() { cout << "NoMemory::NoMemory()" << endl; } void* operator new(size_t sz) throw(bad_alloc){ cout << "NoMemory::operator new" << endl; throw bad_alloc(); // "Out of memory" } }; int main() { NoMemory* nm = 0; try { nm = new NoMemory; } catch(bad_alloc) { cerr << "Out of memory exception" << endl; } cout << "nm = " << nm << endl; } ///:~
Listado 13.12. C13/NoMemory.cpp
Cuando se ejecuta, el programa imprime los mensajes del operator new() y del manejador de excepciones, pero no el del constructor. Como new nunca retorna, no se llama al constructor y por tanto no se imprime su mensaje.
Es importante inicializar nm a cero, debido a que la expresión new nunca se completa, y el puntero debe ser cero para asegurar que no se usa indebidamente. Sin embargo, el código del manejador de excepciones debería hacer algo más que imprimir un mensaje y continuar como si el objeto hubiera sido creado con éxito. Idealmente, debería hacer algo que permitiera al programa recuperarse del fallo, o al menos, provocar la salida después de registrar un error.
En las primeras versiones de C++, el comportamiento estándar consistía en hacer que new retornara un puntero nulo si la asignación de memoria fallaba. Esto impedía que se llamara al constructor. Sin embargo, si se intenta hacer esto con un compilador que sea conforme al estándar actual, debería informarle de que en lugar de devolver un valor nulo, debe lanzar una excepción de tipo bad_alloc.
He aquí otros dos usos, menos comunes, para la sobrecarga de operator new():
Puede ocurrir que necesite emplazar un objeto en un lugar específico de la memoria. Esto es especialmente importante en sistemas empotrados orientados al hardware, en los que un objeto puede ser sinónimo de un componente hardware concreto.
Puede que quiera elegir entre varios asignadores de memoria (allocators) en la llamada a new.
Ambas situaciones se resuelven mediante el mismo mecanismo: la función operator new() sobrecargada puede tomar más de un argumento. Como se ha visto, el primer argumento de new es siempre el tamaño del objeto, calculado en secreto y pasado por el compilador. El resto de argumentos puede ser de cualquier otro tipo que se necesite: la dirección en la que se quiere emplazar el objeto, una referencia a una función u objeto de asignación de memoria, o cualquier otra cosa que se considere conveniente.
Al principio puede parecer curioso el modo en que se pasan los argumentos extra al operator new(). Después de la palabra reservada new y antes del nombre de clase del objeto que se pretende crear, se pone la lista de argumentos, sin contar con el correspondiente al size_t del objeto, que le pasa el compilador. Por ejemplo, la expresión:
X* xp = new(a) X;
pasará a como segundo argumento a operator new(). Por supuesto, sólo funcionará si ha sido declarado el operator new() adecuado.
He aquí un ejemplo demostrativo de cómo se usa esto para colocar un objeto en una posición particular:
//: C13:PlacementOperatorNew.cpp // Placement with operator new() #include <cstddef> // Size_t #include <iostream> using namespace std; class X { int i; public: X(int ii = 0) : i(ii) { cout << "this = " << this << endl; } ~X() { cout << "X::~X(): " << this << endl; } void* operator new(size_t, void* loc) { return loc; } }; int main() { int l[10]; cout << "l = " << l << endl; X* xp = new(l) X(47); // X at location l xp->X::~X(); // Explicit destructor call // ONLY use with placement! } ///:~
Listado 13.13. C13/PlacementOperatorNew.cpp
Observe que lo único que hace operator new() es retornar el puntero que se le pasa. Por tanto, es quien hace la llamada el que decide dónde se ubicará el objeto, y se llama al constructor para esa memoria como parte de la expresión new.
Aunque este ejemplo muestra sólo un argumento adicional, nada impide añadir otros si los necesita para otros propósitos.
Al tratar de destruir estos objetos surge un problema. Sólo hay una versión de operator delete(), de modo que no hay forma de decir: "Usa mi función de liberación de memoria para este objeto". Se quiere llamar al destructor, pero sin que el mecanismo de memoria dinámica libere la memoria, ya que el objeto no está alojado en el montículo.
La solución tiene una sintaxis muy especial. Se puede llamar explícitamente al destructor, tal como se muestra:
xp->X::~X(); // Llamada explícita al destructor
Hay que hacer una llamada de atención al respecto. Algunas personas ven esto como un modo de destruir objetos en algún momento anterior al determinado por las reglas de ámbito, en lugar de ajustar el ámbito, o más correctamente, en lugar de usar asignación dinámica como medio de determinar la duración del objeto en tiempo de ejecución. Tendrá serios problemas si trata de destruir de esta manera un objeto ordinario creado en la pila, ya que el destructor será llamado de nuevo cuando se produzca la salida del ámbito correspondiente. Si se llama de esta forma directa al destructor de un objeto creado dinámicamente, se llevará a cabo la destrucción, pero no la liberación del bloque de memoria, lo que probablemente no es lo que se desea. La única razón para este tipo de llamada explícita al destructor es permitir este uso especial del operador new, para emplazamiento en memoria.
Existe también una forma de operador delete de emplazamiento que sólo es llamada en caso de que el constructor de una expresión new de emplazamiento lance una excepción, con lo que la memoria se libera automáticamente durante la excepción. El operador delete de emplazamiento usa una lista de argumentos que se corresponde con la del operador new de emplazamiento que fue llamado antes de que el constructor lanzase la excepción. Este asunto se tratará en el Volumen 2, en el capítulo dedicado a la gestión de excepciones.
[74] (N. del T.) Provocaría una serie continua de llamadas a new hasta agotar la pila y abortaría el programa.