Repaso a C++11: El principio RAII y los cuatro miembros implícitos

Repaso a C++11: El principio RAII y los cuatro miembros implícitos
Sin comentarios Facebook Twitter Flipboard E-mail

Seguimos con el repaso a C++11, en esta ocasión hablaremos de el principio RAII y de los cuatro miembros implícitos en toda clase de C++. Con esto en mente y una buena aplicación de los conceptos que aquí veremos haremos nuestro código a prueba de balas, y lo que es más importantes, a prueba de fugas de memoria.

Uno de los temas más importantes de C++ es tener claro la regla de los cuatro miembros implícitos. Toda clase escrita en C++ tiene por defecto de forma implícita cuatro métodos.

  • El constructor por defecto.

  • El constructor de copia.

  • El operador de asignación.

  • El destructor.

Esto es así para mantener la compatibilidad con C donde es necesario poder realizar la siguientes operaciones básicas: declaración, inicialización, asignación y destrucción. Como en C++ de estas operaciones se encargan los métodos citados anteriormente el compilador se encargaría de crearlos automáticamente si no los definimos nosotros. De los cuatro, los tres últimos siempre deben existir, sin embargo, si definimos nosotros cualquier otro constructor el compilador ya no generaría el constructor por defecto.

Estos cuatro miembros, cuando son generados por el compilador, son métodos públicos e inline, tienen siempre el mismo prototipo y se definen (es decir, se crea el cuerpo de la función) cuando son usados por primera vez.

La definición implícita de estas funciones es la que cabría esperar. Veamos un ejemplo de como definiría el compilador los métodos de la siguiente clase.

class Clase : public Base1, Base2
{
private:
    Tipo m1, m2;
public:
    //constructor por defecto
    Clase()
    //La siguiente línea es opcional
        :Base1(), Base2(), m1(), m2()
    {
    }
    //constructor de copia
    Clase(const Clase &o)
        :Base1(static_cast(o)), Base2(static_cast(o)), 
            m1(o.m1), m2(o.m2)
    {
    }
    //operador de asignación
    Clase &operator=(const Clase &o)
    {
        Base1::operator=(static_cast(o));
        Base2::operator=(static_cast(o));
        m1 = o.m1;
        m2 = o.m2;
        return *this;
    }
    //destructor
    ~Clase()
    {
    }
};

Como podemos ver el constructor por defecto llama a los constructores por defecto de las clases bases y a los constructores por defecto de las variables miembro, como podíamos esperar. Lo mismo ocurre con el constructor copia que llama a los respectivos constructores copia de las clases bases y los objetos miembros. Lo mismo hace el operador de asignación.

El destructor sabemos que llama en orden inverso a los destructores. Así pues primero llamaría a los destructores de los objetos miembros y luego a los de las clases Bases subiendo por el árbol de herencia.

Si alguna de estas cuatro funciones no fuera válida (porque usa un método no accesible, privado, o que no existe) y se intenta utilizar, entonces el compilador nos daría un error.

El principio RAII

Adquirir Recursos es Inicializar, a menudo referido por sus siglas en inglés RAII (de "Resource Acquisition Is Initialization"). Es un patrón de diseño inventado por Bjarne Stroustrup (El padre de C++).

Este principio de diseño dice que cada recurso del programa que requiere ser liberado debe ser gestionado por una clase, y que cada objeto simple no debe gestionar más de un único recurso. Hacerlo así simplifica considerablemente la tarea de escribir código tolerante a excepciones.

Esto es así porque en el caso de que ocurra una excepción en un constructor solamente se ejecutan los destructores de los objetos o subobjetos que se hayan construido completamente (es decir, cuyo constructor haya terminado). Así, por ejemplo:

class Doble
{
public:
    Doble()
    {
        //código que puede lanzar #1
        m_a = new A;
        //código que puede lanzar #2
        m_b = new B;
        //código que puede lanzar #3
    }
    ~Doble()
    {
        delete m_a;
        delete m_b;
    }
private:
    A *m_a;
    B *m_b;
};

Esta clase no sería segura ya que si el constructor de Doble falla no se ejecutaría su destructor y por tanto la memoria asignada a las variables miembros nunca sería liberada habiendo una fuga de memoria.

Podríamos solucionarlo con varios bloques try/catch.

    Doble()
    {
        //código que puede lanzar #1
        m_a = new A;
        try
        {
            //código que puede lanzar #2
            m_b = new B;
            try
            {
                //código que puede lanzar #3
            }
            catch (...)
            {
                delete m_b;
                throw;
            }
        }
        catch (...)
        {
            delete m_a;
            throw; //se relanza
        }
    }

Esto es solo un caso sencillo si tuviéramos otros recursos como ficheros, sockets, etc. El código necesario para hacer seguro podría complicarse considerablemente. Sin embargo, si usamos el principio RAII:

template class Puntero
{
public:
    Puntero(T *t = NULL)
    :m_t(t)
    {
    }
    void Reset(T *t = NULL)
    {
        delete m_t;
        m_t = t;
    }
    A *Get() const
    {
        return m_t;
    }
    ~Puntero()
    {
        delete m_t;
    }
private:
    A *m_t;
};
class Doble
{
public:
    Doble()
    {
        //código que puede lanzar #1
        m_a.Reset(new A);
        //código que puede lanzar #2
        m_b.Reset(new B);
        //código que puede lanzar #3
    }
    //El destructor implícito nos vale
private:
    Puntero m_a;
    Puntero m_b;
};

Ahora sí nuestro código parece a prueba de bombas de una manera sencilla. Pero ¡Ojo! no lo es debido a la regla de las cuatro miembros implícitos. Recuerda que aunque no los hayamos declarado nuestro template Puntero tiene de forma implícita un constructor copia y un operador de asignación. Así que este código que parece de lo más normal:

void Fun()
{
    Puntero pa;
    pa = new A; //ops! Olvidé usar Reset
}

Equivale a lo siguiente:

void Fun()
{
    Puntero pa;
    pa = Puntero(new A); //hala!
}

Es decir, se crea un temporal con el constructor de conversión, luego se copia el objeto temporal en la variable pa con el operador de asignación, y finalmente se destruye el temporal donde se hace delete del objeto dinámico. ¡El puntero de pa se queda apuntando a un objeto que ya no existe!

Aquí el problema se debe a dos causas principalmente. Una, al constructor de conversión, que en una clase RAII como esta debe declararse explicit, para evitar que se llame de forma inadvertida. Pero sobre todo por la existencia del operador de asignación (o del constructor de copia, que causa el mismo problema).

No se puede evitar que la clase tenga un constructor copia y un operador de asignación, pero si podemos declarar ambos como privados y dejarlos sin definir para así prevenir que sean llamados. Así cualquier intento de usarlos nos dará un error de compilación.


template class Puntero
{
public:
    explicit Puntero(T *t = NULL)
    :m_t(t)
    {
    }
    void Reset(T *t = NULL)
    {
        delete m_t;
        m_t = t;
    }
    A *Get() const
    {
        return m_t;
    }
    ~Puntero()
    {
        delete m_t;
    }
private:
    A *m_t;
    Puntero(const Puntero &); //no copiable
    Puntero &operator=(const Puntero &); //no copiable
};

Si tienes muchas clases RAII en tu código lo que se suele hacer es crear una clase base no copiable y hacer que tadas las clases que queremos que sean RAII hereden de ella. Las bibliotecas Boost usan este método, así como lo podemos ver en el proyecto SFML.

Podría ser algo como lo siguiente.

class nocopy
{
private:
    nocopy(const nocopy &); //nocopy
    nocopy &operator=(const nocopy &); //nocopy
};
 
template class Puntero : nocopy
{
public:
    explicit Puntero(T *t = NULL)
    :m_t(t)
    {
    }
    void Reset(T *t = NULL)
    {
        delete m_t;
        m_t = t;
    }
    A *Get() const
    {
        return m_t;
    }
    ~Puntero()
    {
        delete m_t;
    }
private:
    A *m_t;
};

Ahora sí, nuestro código es a prueba de bombas, o por lo menos, de fugas de memoria.

Novedades en C++11

Hasta ahora no hemos visto ninguna novedad de C++11, todo este tema del principio RAII ya viene desde las primeras versiones de C++. Lo que aporta nuevo C++11 es una nueva manera de evitar la definición de las funciones implícitas por medio del operador delete (A los de C++ nos encanta reutilizar operadores):

template class Puntero
{
public:
    Puntero(const Puntero &) = delete;
    Puntero &operator=(const Puntero &) = delete;
 
    explicit Puntero(T *t = NULL)
    :m_t(t)
    {
    }
 
    void Reset(T *t = NULL)
    {
        delete m_t;
        m_t = t;
    }
    A *Get() const
    {
        return m_t;
    }
    ~Puntero()
    {
        delete m_t;
    }
private:
    A *m_t;
};

Como vemos solo tenemos que escribir el prototipo de la función y añadir = delete. Con esto queda el código bastante claro porque se ve inmediatamente la intención del programador.

Por último si estamos usando C++11 podemos usar operaciones de movimiento como ya vimos anteriormente para añadir funcionalidad a nuestra clase sin perder nada de seguridad.

template class Puntero
{
public:
    Puntero(const Puntero &) = delete;
    Puntero &operator=(const Puntero &) = delete;
 
    explicit Puntero(T *t = NULL)
    :m_t(t)
    {
    }
    void Reset(T *t = NULL)
    {
        delete m_t;
        m_t = t;
    }
    A *Get() const
    {
        return m_t;
    }
    ~Puntero()
    {
        delete m_t;
    }
    //constructor de movimiento
    Puntero(Puntero &&o)
    {
        m_t = o.m_t;
        o.m_t = NULL;
    }
    //operador de movimiento
    Puntero &operator=(Puntero &&o)
    {
        Reset(o.m_t);
        o.m_t = NULL;        
    }
private:
    A *m_t;
};

Así que ya sabes, piensa en RAII y tus programas serán seguros sin tener que perderte en el mundo de las excepciones.

En Gebeta Dev | Repaso a C++11

Comentarios cerrados
Inicio