Disponible la nueva versión "donationware" 7.3 de OrganiZATOR
Descubre un nuevo concepto en el manejo de la información.
La mejor ayuda para sobrevivir en la moderna jungla de datos la tienes aquí.

Curso C++

[Home]  [Inicio]  [Índice]


4.1.5a  Control de recursos

Advertencia didáctica: la lectura de este capítulo exige un conocimiento previo de las clases ( 4.11), del mecanismo de excepciones ( 1.6) y de los operadores new y delete ( 4.9.20).

§1 Sinopsis

La duración de las entidades creadas en un programa, no solo está relacionada con la creación/destrucción de variables y la correspondiente asignación y liberación de memoria. También se relaciona con el manejo y control de determinados recursos externos que son utilizados en runtime.

Para situarnos en el asunto desde un punto de vista general, podemos decir que cuando el programa entra en un ámbito léxico (bloque o función), se crean determinadas entidades (que hemos denominado objetos-valor 4.1.1). Estas entidades deben ser iniciadas correctamente antes de su uso y dependiendo de su duración, serán posteriormente destruidas o no al salir del bloque. También es frecuente que se asignen determinados recursos para uso del programa durante su estancia en el ámbito. Estos recursos, que deben ser correctamente iniciados antes de su utilización y desasignados cuando ya no son necesarios, pueden consistir en el establecimiento de una línea de comunicación; la apertura de un fichero; la asignación de un dispositivo determinado, Etc. Por ejemplo: un fichero abierto de forma exclusiva; un registro bloqueado para escritura, o la asignación de una unidad de almacenamiento para backup.

Nota: como puede verse, en este contexto, el significado de "recurso" es muy amplio. Parafraseando a Bartosz Milewski [2] podríamos decir que recurso es cualquier cosa que el programa deba acopiar/adquirir y que deba ser posteriormente desechada.

Lo normal es que tales recursos estén controlados y representados por un objeto. Por ejemplo, el manejador ("handle") de un fichero abierto, de forma que existe una íntima relación entre los recursos y las entidades que los representan. Es fundamental que antes que esta entidad sea destruida, se realice la correcta liberación del recurso. Por ejemplo, el fichero debe ser cerrado antes que su handle sea destruido, y el objeto creado con new debe ser borrado con delete antes que el puntero desaparezca. Como regla general se acepta que los objetos y recursos deben ser destruidos/desasignados exactamente en orden inverso al que se utilizó para asignarlos y crearlos.

El asunto es que, por ejemplo, los objetos creados con new, o los ficheros abiertos con fopen, no se destruyen o cierran, automáticamente al salir del ámbito. El programador debe ser especialmente cuidadoso, y recordar destruir (con delete), o cerrar (con fclose), el objeto/fichero correspondiente antes que los respectivos manejadores dejen de ser accesibles.

La situación puede ser esquematizada como sigue:

// Ejemplo-1
func f1 (char* file_name, char* mode, int size) {
  char* cbuff = new char[size];      // asignar recursos
  FILE* fptr = fopen(file_name, mode);
 
  ...                                // usar recursos
 
  fclose(fptr);                      // liberar recursos
  delete[] cbuff;
}


Las precauciones anteriores son tediosas para el programador y propensas a errores. Además, como pone de manifiesto el siguiente ejemplo, en ocasiones pueden resultar insuficientes.

// Ejemplo-2
func foo () {
  int size = 1000
  char* filenam = "miFichero.txt";
  char* mode = "w+t";
  try {
    f1(filenam, mode, size);
  }
  catch(...) {
    cout << "Ha ocurrido un error!!" << endl;
  }
}


El problema aquí es que cualquier error en la zona de uso de la función f1 (Ejemplo-1), podría lanzar una excepción que sería recogida en el catch de foo, con lo que la memoria de cbuff se perdería, y miFichero.txt quedaría abierto.

§2 Adquirir un recurso es inicializarlo

La propiedad del compilador ya señalada ( 4.1.5), de invocar automáticamente los destructores de los objetos automáticos cuando estos salen de ámbito, puede ser utilizada para la desasignación de recursos de forma cómoda y segura. El funcionamiento consiste en que los recursos se asocian a instancias de clases diseñadas al efecto, conocidas "RAII classes", en cuyos destructores se han incluido los mecanismos de destrucción/desasignación pertinentes. Esta técnica, conocida como adquirir un recurso es inicializarlo, RAII [1], es eficaz incluso en presencia del mecanismo de excepciones, ya que en este caso, el proceso de limpieza de la pila ("stack unwinding" 1.6) garantiza la destrucción de los objetos creados desde el comienzo del bloque try hasta el punto de lanzamiento de la excepción.

  Así pues, la regla de oro para el control de recursos consiste en encapsularlos en objetos de ciertas clases diseñadas al efecto; asignándolos en los constructores y desasignándolos en el destructor de la clase correspondiente.

El sentido de la frase "adquirir un recurso es inicializarlo" se explica porque el recurso está representado por un objeto, y para adquirirlo basta iniciar el objeto correspondiente (instanciarlo). Para ilustrar la aplicación de esta técnica, completaremos los ejemplos anteriores suponiendo que el objeto representado por cbuff y el fichero abierto se asocian a sendos objetos. La operación de abrir y cerrar el fichero del ejemplo-1 lo vamos a encomendar a un objeto de la clase Fichero:

class Fichero {
  public:
  FILE* fptr;
  Fichero(char* file_name, char* mode) { // constructor
    fptr = fopen(file_name, mode);
  }
  ~Fichero() {            // destructor
    fclose(fptr);
  }
};

A su vez, la operación de recabar memoria para un buffer de caracteres la asociamos a un objeto de la clase CBuffer:

class CBuffer {
  public:
  char* cbuff;
  CBuffer(size_t size) {  // constructor
    cbuff = new char[size];
  }
  ~CBuffer() {            // destructor
    delete[] cbuff;
  }
};


Las nuevas definiciones permiten reducir la adquisición de memoria para un buffer, o la apertura de un fichero, a la operación de crear un objeto de la clase adecuada. A su vez, la desasignación de los respectivos recursos se reduce a la destrucción de dichos objetos (lo que ocurrirá generalmente a su salida de ámbito). Bajo estas premisas, la función f1 del ejemplo-1 puede ser modificada en la forma siguiente:

// Ejemplo-1a
func f1 (char* file_name, char* mode, int size) {
  CBuffer cb1(size);             // asignar recursos
  Fichero file1(file_name, mode);
  ...                            // usar recursos
  cout << cb1.cbuff << endl;
 
}    // Ok. liberar recursos (esto lo hace el compilador)


Al contrario de lo que ocurre con la primera versión, la utilización de este nuevo diseño de f1 en foo , resulta inmune al posible lanzamiento de una excepción en su zona de uso. El proceso de desmontaje de la pila implicaría la llamada a los destructores de los objetos cb1 y file1.

§3 Precauciones adicionales

Naturalmente el sistema anterior no está totalmente exento de riesgos (nada lo está realmente). Recordemos que el proceso de destrucción relacionado con el "Stack unwinding" solo se realiza con aquellos objetos que hayan sido total y completamente construidos. En nuestro caso podría presentarse un problema de asignación de memoria durante la construcción de un objeto Cbuffer o en la apertura de un fichero en un objeto Fichero. En consecuencia, deberían adoptarse precauciones adicionales en el diseño de las clases respectivas.

class Fichero {
  public:
  FILE* fptr;
  Fichero(const char* file_name, const char* mode) { // constructor
    fptr = fopen(file_name, mode);
    if (!fptr) {
      cout << "Error en apertura de fichero " << file_name;
      throw 1;
    }
  }
  ~Fichero() {         // destructor
    if (fptr) fclose(fptr);
  }
};

Para la clase CBuffer el diseño podría ser el siguiente:

class CBuffer {
  public:
  char* cbuff;
  enum {MAX = 64000}
  CBuffer(size_t size = 32000) {    // constructor
    if (size == 0 || size > MAX) {
      cout << "Tamaño no válido << endl;
      throw 2;
    }
    try { cbuff = new char[size]; }
    catch (const bad_alloc& e) {
      cout << "Memoria agotada " << e.what() << endl;
      throw;             // relanzar excepción
    }
  }
  ~CBuffer() {           // destructor
    if (cbuff) delete[] cbuff;
  }
};


Como puede verse, es posible, e incluso recomendable, lanzar excepciones en los constructores. En nuestro caso la excepción lanzada por el constructor de Fichero en caso de fallo en la apertura, sería capturada en por el "handler" de foo . Por su parte, la excepción lanzada por CBuffer en caso de error en la asignación de memoria, es manejada por el propio constructor para posteriormente relanzarla ( 1.6.1); finalmente la nueva excepción es capturada por el manejador de foo.

  Otra precaución adicional es no encapsular distintos recursos en un solo objeto, de forma que si una clase debe encapsular varios recursos, es necesario colocarlos separadamente en sub-objetos de la clase (miembros que son instancias de otras clases).  La razón es que generalmente, los recursos son entidades finitas. Por ejemplo, memoria o ancho de banda en una línea de comunicación, por lo que su adquisición responde a operaciones con cierta propensión a fallar.  Como hemos visto en los ejemplos anteriores, el diseño se realiza de forma que el fallo en la adquisición de un recurso se traduce en el lanzamiento de una excepción, y como señala Milewski, "si está intentando matar dos pájaros con la misma piedra o adquirir dos recursos con el mismo constructor, puede encontrarse con problemas".  Es justamente la situación que se presentaría si dos recursos deben ser adquiridos en el mismo constructor, y una vez adquirido el primero, se produce un error en la adquisición del segundo, lo que origina el lanzamiento de una excepción. Como en este caso la ejecución del constructor no ha acabado, no es invocado el destructor, por lo que el primer recurso quedaría sin desasignar.

§4  Clases centinela

Las habilidades de los constructores y destructores para realizar determinadas tareas previas a la creación de los objetos o en el momento de su destrucción, no solo pueden ser utilizadas para la obtención y liberación de recursos, sino en muchas otras circunstancias, lo que ha motivado la aparición de las denominadas clases centinela ("sentry classes").  Estas clases suponen una generalización de las RAII, y realizan determinadas comprobaciones o tareas auxiliares durante la construcción y destrucción de los objetos correspondientes (de ahí su nombre).

Los flujos ("streams") de la Librería Estándar de plantillas (STL), utilizan estas clases centinela para garantizar que antes o después de la construcción de determinados flujos se cumplen ciertas condiciones.  Ver ejemplos en   5.3.2c y 5.3.2d.

Tema relacionado: punteros inteligentes ( 4.12.2b1)

  Inicio.


[1]   RAII "Resource Acquisition Is Initialization"  [TC++PL-00] §14.4.1.

[2]   The Official Resource Management Page    www.relisoft.com