Con “guardianes de alcance” me refiero a objetos locales en una función que tienen algún efecto sólo por existir. Sólo tienen un constructor y un destructor, que suelen reservar y liberar algún recurso.

El mutex locker

Una tarea típica en la programación multi-hilo es usar un mutex. El hilo que reserva el mutex puede seguir; los demás hilos que quieren reservarlo deben esperar. En el código, esto suele aparacer así:

// Definición del mutex
Mutex mi_mutex;

// Una función que usa el mutex
void mi_funcion
{
    // Reserva el mutex o espera hasta que pueda reservarlo
    mi_mutex.lock();

    // Haz algo

    // Libera el mutex
    mi_mutex.unlock();
}

Este emparejamiento de lock y unlock aparece frecuentemente en un programa multi-hilo. No obstante, el unlock puede ser necesario en varios puntos, cuando una función tiene varias salidas – por ejemplo por un return en medio o por un lanzamiento de una excepción. Poner un unlock en cada rama engorda la función y es fácil olvidarse de algúno. Pero olvidarse de liberar algún recursos puede dar a errores difíciles de localizar.

Por suerte existe una manera de no olvidarse de un unlock. Esto se puede conseguir mediante una clase MutexLocker. El constructor de esta clase reserva el mutex y el destructor lo libera.

class MutexLocker
{
    // El constructor reserva el mutex
    MutexLocker(Mutex& mutex_a_reservar)
    : _mutex_reservado(mutex_a_reservar)
    {
        _mutex_reservado.lock();
    }
    
    // El destructor libera el mutex
    ~MutexLocker(void)
    {
        _mutex_reservado.unlock();
    }
    
    // La REFERNCIA al mutex que se quiere reservar
    Mutex& _mutex_reservado;
};

El código arriba se modifica así

// Definición del mutex - esto no se modifica
Mutex mi_mutex;

// El uso, sí, se modifica
void mi_funcion
{
    const MutexLocker mi_mutex_reservado(mi_mutex)
    // Haz algo
}

Como vemos, sólo hace falta una línea en la función. Cuando la variable mi_mutex_reservado sale fuera del alcance, se llama al destructor que libera el mutex. Esto es así, no importa por qué salida la función termina. De esta forma tenemos un “guardián de alcance”. Nos limpia todo al salir del alcance de la función.

En general, se puede declarar const a un guardián, ya que no lo modificamos durante su vida. Ni siquiera podríamos, ya que un guardián de alcance no tiene métodos. Sólo tiene un constructor y un destructor.

Por hacerlo académicamente aún más correcto podríamos impedir hacer copias de la clase MutexLocker para evitar, que una instancia afecte al mutex controlado por otra instancia. Esto podemos conseguir declarando el constructor de copia y el operador de asignación como métodos privados y sin implementarlos. De esta forma conseguiríamos un error de compilación si alguien intentara copiar una instancia de la clase MutexLocker. No obstante, es un problema académico. Normalmente nadie intenta copiar un guardián de alcance.

Trazas de entrada y salida en una función

Los guardianes de alcance se puede utilizar para más tareas. Por ejemplo, un objeto que escribe una traza cuando entramos en la función y otra cuando cuando salimos.

class TraceWriter
{
    // El constructor escribe la traza de entrada
    TraceWriter(const char* nombre_de_funcion)
    : _funcion(nombre_de_funcion)
    {
        std::cout << "Entro en " << _funcion << std::endl;
    }
    
    // El destructor escribe la traza de salida
    ~MutexLocker(void)
    {
        std::cout << "Salgo en " << _funcion << std::endl;
    }
    
    // El nombre de la función. Puede ser const, ya que no
    // se modifica durante la vida del objeto.
    const std::string _funcion;
};

// La macro que crea un objeto de la clase TraceWriter
// (si el compilador soporta la macro __FUNCTION__)
#define LOG_FUNCION  \
    TraceWriter _trace_writer(__FUNCTION__)

En una función, se llama a la macro en la primera línea

void mi_funcion
{
    LOG_FUNCION();
    // Haz algo
}

Así obtenemos una traza en stdout cuando entramos en la función y otra cuando salimos por no importe qué salida. Como usamos una macro, la podemos definir como nada en una versión de release.

Con un miembro estático en la clase TraceWriter es posible crear anidamientos. El constructor aumenta el nivel de anidamiento y el desctructor lo disminuye. Para cada nivel de anidamiento se imprime alguna cantidad de espacios delante la traza.

Memoria reservada

No olvidarse de liberar la memoria reservada es importante. Se puede utilizar la técnica descrita para reservar y liberar memoria dinámica en una función. En la biblioteca estándar de C++ ya hay clases que implementan este comportamiento:

  • unique_ptr para el estándar C++11 y
  • auto_ptr para el estándar C++0x

Presentamos aquí el uso de unique_ptr. No obstante, el código sería igual sustituyendo unique_ptr por auto_ptr.

void mi_funcion
{
    // Reserva memoria con new
    std::unique_ptr<MiClase> puntero_a_mi_objeto(new MiClase);
    
    // Haz algo con puntero_a_mi_objeto
    
    // La memoria se libera al salir de la función
}

Cuando se sale de la función, el destructor de unique_ptr llama delete puntero_a_mi_objeto. Con esto libera la memoria cuando se sale de la función.

Conclusión

Hemos visto que los “guardianes de alcance” sirven para asegurar la liberación de recursos. Además, pueden reservar y liberar estos recursos con una sóla línea en la función que los utiliza. La técnica consiste en crear una clase, cuyo constructor reserva el recurso y cuyo destructor lo libera. Esta clase suele guardar una referencia a este recurso.

Este concepto sólo se puede utilizar con los lenguajes que llaman al destructor inmediatamente después que un objeto sale del alcance. Por eso no se puede usar con lenguajes que usan un colector de basura como Java, ya que en estos lenguajes no está definido, cuando se llama al destructor. Por lo tanto no está asegurado que se libera un recurso a tiempo.

Lectura adicional