13.3.3. Sobrecarga de los operadores new y delete

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 el operador operator delete(). Las llamadas al constructor y destructor no están bajo el control del programador, pero se pueden cambiar las funciones opertator new() y operatator 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. Frecuentemente la razón para cambiar el asignador es la eficiencia; puede que se necesite crear y destruir tantos objetos de la misma clase que lo haga ineficaz en términos de velocidad: un cuello de botella. 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 se vuelva inútil. Es decir, el espacio puede estar disponible, pero debido al nivel de fragmentación alcanzado, no exista 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 integrados, suele ser necesario que los programas funcionen por largo 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 en el área dinámica. La solución a este problema consiste en utilizar un asignador «personalizado»; de otro modo, los programadores evitarían usar new y delete es 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 al 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é acciones va a realizar en tal caso: devolver cero, un bucle de reintento con llamada al new-handler, o lo que es más frecuente, disparar una excepción bad_alloc (tema que se trata en el Volumen 2).

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.

Sobrecarga global de new y delete

Este es el modo más drástico de abordar el asunto, resulta útil cuando el comportamiento de new y delete no es satisfactorio para la mayor parte del sistema. Al sobrecargar la versión global, quedan inaccesibles las originales, y ya no es posible llamarlas desde dentro de las funciones sobrecargadas.

El new sobrecargado debe tomar un argumento del tipo size_t (el estándar de C) para tamaños. Este argumento es generado y pasado por el compilador, y se refiere al tamaño del objeto para el que ahora tenemos la responsabilidad de la asignación de memoria. 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 no se encontrara un bloque adecuado. Si eso sucede, no se producirá la llamada al constructor. Por supuesto, hay que hacer algo más informativo que sólo devolver cero, por ejemplo llamar al «new-handler» o disparar 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 que hace es obtener un bloque de memoria, no un objeto definido, no hasta que que sea llamado el constructor, un acto que el compilador garantiza y que está fuera del control de este operador.

El operador operator delete() toma como argumento un puntero void* a un bloque obtenido con el operator new(). Es un void* ya que el delete obtiene el puntero sólo después de que haya sido llamado el destructor, lo que efectivamente elimina su caracter 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.8. 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 biblioteca 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 [73]. Usar printf() evita el fatal bloqueo, ya que no hace llamadas a new.

En main(), se crean algunos objetos de tipos básicos 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 vector, ambos de tipo S. En el caso del vector 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.

Sobrecarga de new y delete específica para una clase

Aunque no es necesario poner el modificador static, cuando se sobrecarga 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 usa un primitivo sistema de asignación de almacenamiento para la clase Framis. Se reserva un bloque de memoria en el área de datos estática FIXME , 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 vector 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.9. C13/Framis.cpp


El espacio de almacenamiento para el montículo Framis se crea sobre el bloque obtenido al declarar un vector de tamaño suficiente para contener psize objetos de clase Framis. Se ha declarado también una variable lógica para cada uno de los psize bloques en el vector. Todas estas variables lógicas son inicializadas a false usando el truco consistente en inicializar el primer elemento para que el compilador lo haga automáticamente con los restantes iniciándolos a su valor por defecto, false, en el caso de variables lógicas.

El operador 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 localizació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 dispara una excepción de tipo bad_alloc.

Este es el primer ejemplo con excepción que aparece en este libro. En el Volumen 2 se verá una discusión detallada del tratamiento de excepciones, por lo que en este ejemplo se hace un uso muy simple del mismo. En el operador new hay dos expresiones relacionadas con el tratamiento de excepciones. Primero, a la lista de argumentos de función le sigue la expresión throw(bad_alloc), esto informa al compilador que la función puede disparar 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 la rutina de tratamiento de excepción que se ha definido en una cláusula catch(bad_alloc).

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 sigue una o varias cláusulas catch, especificando en cada una la excepción a la que se destina. En este caso, catch(bad_alloc) indica que en ese bloque se tratarán las excepciones de tipo bad_alloc. El código de este bloque sólo se ejecutará si se dispara la excepción, continuando la ejecución del programa justo después de la última del grupo de cláusulas catch que existan. Aquí sólo hay una, pero podría haber más.

En este ejemplo, el uso de iostream es correcto ya que el operator new() global no ha sido modificado.

El operator delete() asume que la dirección de Framis ha sido obtenida de nuestro almacén particular. Una asunción justa, ya que cada vez que se crea un objeto Framis simple se llama al operator new() local; pero cuando se crea un vector de tales objetos se llama al new global. Esto causaría problemas si el usuario llamara accidentalmente al operador delete sin usar la sintaxis para destrucción de vectores. Podría ser que incluso estuviera tratando de borrar un puntero a un objeto de la pila. Si cree que estas cosas puedan suceder, conviene pensar en añadir una línea que asegurare que la dirección está en el intervalo correcto (aquí se demuestra el potencial que tiene la sobrecarga de los operadores new y delete para la localización de fugas de memoria).

operador delete() calcula el bloque al que el puntero representa y después pone a false la bandera correspondiente en el mapa de localizació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.

Este esquema específico de asignación de memoria es probablemente mucho más rápido que el esquema de propósito general que usan los operadores new y delete originales. Se debe advertir, no obstante, que este enfoque no es automáticamente utilizable cuando se usa herencia, un tema que verá en el Capítulo 14 (FIXME).

Sobrecarga de new y delete para vectores

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 vector de tales objetos se llama al operator new() global para obtener el espacio necesario para el vector, y al operator delete() global para liberarlo. Es posible controlar también la asignación de memoria para vectores sobrecargando los métodos operator new[] y operator delete[]; se trata de versiones especiales para vectores. A continuación se expone un ejemplo que muestra el uso de ambas 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.10. C13/ArrayOperatorNew.cpp


Si exceptuamos la información de rastreo 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. Como se ha visto anteriormente, 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 vectores es la misma que la usada para objetos simples añadiéndoles el operador subíndice []. En ambos casos se le pasa a new como argumento el tamaño del bloque de memoria solicitado. A la versión para vectores 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() es que devuelva un puntero a un bloque de memoria suficientemente grande. Aunque es posible inicializar el bloque referido, eso es trabajo del constructor, que se llamará automáticamente por el compilador.

El constructor y el destructor simplemente imprimen mensajes para que pueda verse que han sido llamados. A continuación se muestran dichos mensajes:

	    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 vector de objetos Widget, se observa el uso de la versión de operator new() para vectores, de acuerdo con lo dicho anteriormente. Se observa 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 compilador guarda la información sobre el tamaño del vector. De ese modo, la expresión

delete []Widget;

informa al compilador que se trata de un vector, 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 solo una vez a operator new() y operator delete() para el vector, se llama al constructor y al destructor una vez para cada uno de los objetos del vector.

Llamadas al constructor

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 habrá llamada al constructor al que se le tendría que pasar un puntero this nulo, para un objeto que no se ha creado . 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.11. C13/NoMemory.cpp


Cuando se ejecuta, el programa imprime los mensajes del operator new() y del manejador de excepción, pero no el del constructor. Como new nunca retorna, no se llama al constructor y por tanto no se imprime su mensaje.

Para asegurar que no se usa indebidamente, Es importante inicializar nm a cero, debido a que new no se completa. El código de manejo de excepciones debe 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 podía impedir que se llamara al constructor. Si se intenta hacer esto con un compilador que sea conforme al estándar actual, le informará de que en lugar de devolver un valor nulo, debe disparar una excepción de tipo bad_alloc.

Operadores new y delete de [FIXME emplazamiento (situación)]

He aquí otros dos usos, menos comunes, para la sobrecarga de operador new():

  1. Puede ocurrir que necesite emplazar un objeto en un lugar específico de la memoria. Esto puede ser importante en programas en los que algunos de los objetos se refieren o son sinónimos de componentes hardware mapeados sobre una zona de la memoria.

  2. Si se quiere permitir la elección entre varios asignadores de memoria (allocators) en la llamada a new.

Ambas situaciones se resuelven mediante el mismo mecanismo: la función operator new() 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 queremos emplazar el objeto, una referencia a una función de asignación de memoria, o cualquiera 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 clave 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 al operador 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.12. C13/PlacementOperatorNew.cpp


Observe que lo único que hace el operador new es retornar el puntero que se pasa. Por tanto, es posible especificar la dirección en la que se quiere construir el objeto.

Aunque este ejemplo muestra sólo un argumento adicional, nada impide añadir otros, si se considera conveniente para sus propósitos.

Al tratar de destruir estos objetos surge un problema. Sólo hay una versión del operador delete, de modo que no hay forma de decir: "Usa mi función de liberación de memoria para este objeto". Se requiere llamar al destructor, pero sin utilizar el mecanismo de memoria dinámica, ya que el objeto no está alojado en el montículo.

La solución tiene una sintaxis muy especial. Se debe 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. Esto es un error, que puede provocar problemas si se 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 dispare 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 previamente a que el constructor lanzase la excepción. Este asunto se tratará en el Volumen 2, en un capítulo dedicado al tratamiento de excepciones.



[73] Provocaría una serie continua de llamadas a new hasta agotar la pila y abortaría el programa.