La regla de los cuatro miembros y el principio RAII

febrero 23, 2010

La regla de los cuatro miembros no se refiere a alguna misteriosa deidad tántrica, sino al hecho de que en C++ , una clase o estructura sin ninguna función miembro declarada expresamente posee cuatro funciones miembro implícitas: el constructor por defecto, el constructor de copia, el operador de asignación y el destructor. Esto es así para mantener la compatibilidad con C

Es cierto que en C no hay funciones miembro ni sobrecarga de operadores, pero dada una estructura siempre se pueden hacer esas cuatro operaciones básicas: declaración, inicialización, asignación y destrucción. Y puesto que en C++ de estas operaciones se encargan las citadas funciones miembro, estas se definen implícitamente.
Las tres últimas, el constructor de copia, el operador de asignación y el destructor, existen siempre para toda clase o estructura, si no las declara el programador las generará el compilador implícitamente. El constructor por defecto, sin embargo, solamente se declara automáticamente si no se define a mano ningún otro constructor.
Estos cuatro miembros, cuando los genera el compilador, son funciones públicas, inline, tienen siempre el mismo prototipo y se definen (es decir, se crea el cuerpo de la función) cuando se usan por primera vez.
La definición implícita de estas funciones es la que cabría esperar en cada uno de los casos. En el siguiente ejemplo los cuatro miembros se definen de la misma forma que haría el compilador:

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<const Base1&>(o)), Base2(static_cast<const Base2&>(o)), 
            m1(o.m1), m2(o.m2)
    {
    }
    //operador de asignación
    Clase &operator=(const Clase &o)
    {
        Base1::operator=(static_cast<const Base1&>(o));
        Base2::operator=(static_cast<const Base2&>(o));
        m1 = o.m1;
        m2 = o.m2;
        return *this;
    }
    //destructor
    ~Clase()
    {//Los constructores de clases bases y variables miembro
     //se llaman implícitamente
        //m2.~Tipo();
        //m1.~Tipo();
        //~Base2();
        //~Base1();
    }
};

El constructor por defecto inicializa las clases base y las variables miembro con sus constructores por defecto. De forma análoga, el constructor de copia copia las clases base y las variables miembro con sus constructores de copia correspondientes. Lo mismo para el operador de asignación. El destructor destruye las variables miembro y las clases base en orden inverso al de creación.
Si alguna de estas cuatro funciones no fuera válida (porque usa una función no accesible, privada, o que no existe, por ejemplo) y se intenta utilizar, entonces el programa es erróneo.

Es importante tener en mente la regla de los cuatro miembros cuando se escribe una clase que implemente el principio RAII (Resource Adquisition Is Initialización).

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.
Pero ¿por qué no más de un recurso por clase? 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 es segura frente a excepciones, porque si el constructor de Doble falla, no se ejecuta su destructor, así que habría que añadir 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
        }
    }

Y eso que este es un caso bastante sencillo. En el caso de que existan otros tipos de recurso distintos de la memoria (ficheros abiertos, sockets, etc.) y que se construyan de forma condicional, el código puede complicarse considerablemente.

Sin embargo utilizando el principio RAII:

template<typename T> 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<A> m_a;
    Puntero<B> m_b;
};

Ahora el código es a prueba de bomba, incluso volviendo a asignar el puntero, con la función miembro Reset(), garantiza que el objeto anterior se destruye.
¿O no lo es?
¡No! Recuerda los cuatro miembros. La clase template Puntero tiene un constructor de copia y un operador de asignación generados implícitamente que son fuente de desastres. De hecho la siguiente línea, en apariencia inofensiva oculta un grave error:

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

Esto equivale a:

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

Es decir, se crea un temporal con el constructor de conversión, luego se copia el objeto temporal en 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. Una, al constructor de conversión, que en una clase RAII como esta, en general, debe declararse explicit, para evitar que se llame de forma inadvertida. Pero sobre todo, a la existencia del operador de asignación (o del constructor de copia, que causa problemas similares).
No podemos evitar que la clase tenga un constructor de copia y un operador de asignación, pero sí podemos evitar que se usen: los declaramos privados y los dejamos intencionadamente sin definir. Así cualquier intento de uso dará un error de compilación (intento de acceso a miembro privado) o de linker (función no definida):

template<typename T> 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 &); //nocopy
    Puntero &operator=(const Puntero &); //nocopy
};

Suele ser una buena idea colocar un comentario //nocopy para indicar cuál es nuestra intención. Ahora sí que es una clase segura.

Si tienes muchas clases RAII en tu proyecto puede ser más cómodo hacer una clase base no copiable y heredar de ella:

class nocopy
{
private:
    nocopy(const nocopy &); //nocopy
    nocopy &operator=(const nocopy &); //nocopy
};
 
template<typename T> 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;
};

Así, el constructor de copia y el operador de asignación generados implícitamente en la clase Puntero no son válidos y no pueden usarse. Esta es la aproximación utilizada por ejemplo en la librería boost.

Novedades en C++0x

El problema de hacer una clase no copiable de esa manera es el mensaje de error que emite el compilador cuando se intenta hacer la copia (función miembro no es accesible, o algo así). Por eso y otras razones, la nueva versión del lenguaje, C++0x, proporciona una nueva sintaxis para evitar la creación de las funciones implícitas. Es tan sencilla como lo siguiente:

template<typename T> 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;
};

Es decir, se escribe el prototipo de la función que queremos evitar seguido de =delete (¡viva el reciclaje de palabras reservadas!). Ahora la intención del programador está perfectamente clara.

Y ya que estamos redefiniendo el capítulo sobre funciones implícitas, también se añade una sintaxis para decirle al compilador que genere él la función, se añade =default. ¿Para qué sirve esto? Pues para cambiar la visibilidad, por ejemplo, sin tener que escribir el cuerpo de la función, o para hacer una función implícita no inline.

//En el .h
class Foo
{
public:
    Foo();
protected:
    ~Foo() =default;
};
 
//En el .cpp
Foo::Foo() = default; // no es inline

Naturalmente, si estamos usando C++0x, resulta útil añadirle a nuestra clase RAII las operaciones de movimiento, para hacerla más flexible, sin perder ni un ápice de seguridad:

template<typename T> 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;
};

Artículos relacionados:

  1. std::unique_ptr Si alguna vez has utilizado (o intentado utilizar) la clase...
  2. Referencias a R-valor (1ª parte) En un post anterior comentaba que en C++ no se...
  3. Referencias a R-valor (2ª parte) En el post anterior describía cómo las referencias a R-valor...
  4. Funciones lambda en C++0x Una de las funcionalidades menos esperadas, pero en mi opinión...
  5. Referencias a referencias ¿Es posible en C++ declarar una referencia a referencia? Y...

Deja un comentario