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.
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 en cuyos destructores se han incluido los mecanismos de destrucción/desasignación
pertinentes. Esta técnica, conocida como RAII, adquirir un recurso es inicializarlo [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.
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.
Tema relacionado: punteros inteligentes
(
4.12.2b1)
[1] RAII "Resource acquisition is initialization"
TC++PL §14.4.1.