Regla de tres en C++

Jay Shaw 12 octubre 2023
  1. Entendiendo la regla de los tres grandes en C++
  2. Definición implícita de constructores en C++
  3. Definición explícita de constructores en C++
  4. Conclusión
Regla de tres en C++

La regla de los tres grandes es uno de los lenguajes de codificación más populares en todo el mundo. La ley establece que C++ tiene algunas funciones especiales que deben declararse juntas, incluso si se requiere una.

Estas funciones son el constructor de copias, el operador de asignación de copias y el destructor.

La ley establece que los otros dos también deben seguir si una de las tres funciones especiales se declara en un programa. De lo contrario, el programa sufre graves pérdidas de memoria.

Este artículo explicará en detalle la ley de los tres grandes y cómo manejarla.

Entendiendo la regla de los tres grandes en C++

Debe considerarse lo que ocurre cuando se agrega un recurso asignado dinámicamente a una clase para conocer los tres grandes.

En el siguiente ejemplo, se crea una clase de constructor que asigna algo de espacio de memoria al puntero.

class Big3 {
  int* x;

 public:
  Big3() : x(new int()) { std::cout << "Resource allocated \n"; }
};

Como se vio arriba, la memoria se asigna al puntero. Ahora, también debe liberarse usando un destructor.

~Big3() {
  std::cout << "Resource is released \n";
  delete x;
}

La percepción común es que el trabajo está hecho hasta este punto. Pero la realidad no está ni cerca.

Algunas pérdidas de memoria graves ocurren cuando se inicia un constructor de copia dentro de esta clase.

La razón detrás de este problema es la definición implícita de constructores. Cuando se copia un objeto sin definir un constructor, el compilador crea implícitamente un constructor de copia que no crea un clon sino solo una sombra del mismo objeto.

Los constructores de copias, por defecto, ejecutan copias superficiales en objetos. El programa funciona bien con este método de copia hasta que algún recurso se asigna dinámicamente a un objeto.

Por ejemplo, en el siguiente código, se crea un objeto p1. Otro objeto, p2, es un objeto copia de p1.

int main() {
  Big3 p1;
  Big3 p2(p1);
}

En este código, cuando el destructor destruye el objeto p1, p2 se convierte en un puntero colgante. Esto se debe a que el objeto p2 apunta a la referencia del objeto p1.

El código completo se proporciona a continuación para una mejor comprensión.

#include <iostream>

class Big3 {
  int* x;

 public:
  Big3() : x(new int()) { std::cout << "Resource allocated \n"; }
  ~Big3() {
    std::cout << "Resource is released \n";
    delete x;
  }
};

int main() {
  Big3 p1;
  Big3 p2(p1);
}

Para evitar problemas como un puntero colgante, los programadores deben declarar explícitamente todos los constructores necesarios, que es de lo que se trata la regla de los tres grandes.

Definición implícita de constructores en C++

Hay dos formas a través de las cuales se hace una copia de un objeto.

  1. Copia superficial: usar un constructor para copiar la dirección de un objeto y almacenarla en el nuevo objeto.
  2. Copia profunda: utilizando un constructor similar, que copia los valores almacenados dentro de esa dirección en la nueva.

Por lo general, cuando se asigna algo de memoria a un objeto, la versión implícita del constructor de copia copia la referencia del puntero x en lugar de crear un nuevo objeto con su propio conjunto de asignación de memoria.

A continuación se muestra una representación de cómo se definen implícitamente las funciones miembro especiales.

book(const book& that) : name(that.name), slno(that.slno) {}

book& operator=(const book& that) {
  name = that.name;
  slno = that.slno;
  return *this;
}

~book() {}

En este ejemplo, se definen las tres funciones miembro especiales. El primero es el constructor de copias, que asigna las variables miembro a través de una lista de inicializadores de miembros: name(that.name), slno(that.slno).

El segundo constructor es el constructor de asignación de copias que crea una copia de los objetos de la clase. Aquí, la sobrecarga de operadores se usa para crear copias del objeto.

Al final, el destructor se mantiene vacío ya que no se asigna ningún recurso. El código no arroja errores ya que los objetos no necesitan ninguna asignación de memoria.

Por qué fallan las definiciones implícitas en la gestión de recursos

Supongamos que un puntero miembro de la clase recibe una asignación de memoria. La referencia de este puntero de miembro se copiará en el nuevo objeto cuando el objeto de esta clase se copie utilizando el operador de asignación predeterminado y el constructor de función de copia.

Como resultado, cualquier cambio realizado en un objeto también afectará al otro porque los objetos nuevos y antiguos apuntarán a la misma ubicación de memoria. El segundo objeto seguirá intentando usarlo.

class person {
  char* name;
  int age;

 public:
  // constructor acquires a resource
  // dynamic memory obtained via new
  person(const char* the_name, int the_age) {
    name = new char[strlen(the_name) + 1];
    strcpy(name, the_name);
    age = the_age;
  }

  // destructor must release this resource via delete
  ~person() { delete[] name; }
};

El ejemplo anterior copia name, que copia el puntero, dejando los valores almacenados dentro.

Cuando se declara el destructor, simplemente elimina la instancia del objeto original. Pero el objeto copiado sigue apuntando a la misma referencia, que ahora está destruida.

Esta es la causa principal de las fugas de memoria. Surgen cuando el destructor elimina el objeto original y su referencia, mientras que el objeto creado por el constructor de copia sigue colgando y también se conoce como puntero colgante.

De manera similar, si un puntero colgante se mantiene sin marcar, esa referencia de memoria creará múltiples pérdidas de memoria en el futuro.

La única solución a este problema es declarar explícitamente los constructores o, en palabras simples, crear un modelo propio de constructor de copia y operador de asignación para solucionar este problema.

Los constructores personalizados copian los valores a los que apunta el puntero inicial en lugar de su dirección, asignando memoria separada a los nuevos objetos.

Definición explícita de constructores en C++

Se observó que los constructores definidos implícitamente dieron como resultado una copia superficial de los objetos. Para arreglar eso, los programadores definen explícitamente un constructor de copia, que ayuda en la copia profunda de objetos en C++.

La copia profunda es el método que asigna nuevos bloques de memoria para almacenar el valor de un puntero en lugar de solo guardar la referencia de memoria.

Este método requiere definir explícitamente los tres métodos de miembros especiales para que el compilador asigne nueva memoria mientras copia un objeto.

Papel de los destructores en constructores definidos explícitamente en C++

Se debe crear un destructor para borrar la memoria que se asigna a un objeto de función. Esto corre el riesgo de pérdida de memoria si no lo hace.

En constructores implícitos, incluso después de declarar un destructor, el problema persiste. El problema surge porque si se copia la memoria asignada al objeto, el objeto copiado apuntará a la misma memoria que el objeto original.

Cuando uno borra la memoria en su destructor, el otro tendrá un puntero a memoria inválida, y las cosas se complicarán cuando intente usarla.

Como resultado, se debe crear un constructor de copia definido explícitamente, dando a los nuevos objetos sus fragmentos de memoria para purgar.

El siguiente programa muestra una clase de demostración que se adhiere a la regla de los tres grandes.

class name {
 private:
  int* variable;  // pointer variable
 public:
  // Constructor
  name() { variable = new int; }

  void input(int var1)  // Parameterized method to take input
  {
    *variable = var1;
  }

  // Copy Constructor
  name(name& sample) {
    variable = new int;
    *variable = *(sample.variable);

    // destructor
    ~name() { delete variable; }
  };

Implementar constructores de copia en C++

El programa tiene una clase Libro con un constructor parametrizado por defecto y un destructor. Un constructor predeterminado devuelve valores nulos cuando no se proporciona ninguna entrada, mientras que un constructor parametrizado inicializa los valores y los copia.

Aquí se incluye un método de manejo de excepciones (try-catch), que arroja excepciones cuando la variable m_Name no puede asignar un recurso.

Después del destructor, se crea un constructor de copias que hace una copia del objeto original.

#include <cstring>
#include <exception>
#include <iostream>

using namespace std;

class Book {
  int m_Slno;
  char* m_Name;

 public:
  // Default Constructor
  Book() : m_Slno(0), m_Name(nullptr) {}

  // Parametarized Constructor
  Book(int slNumber, char* name) {
    m_Slno = slNumber;
    unsigned int len = strlen(name) + 1;
    try {
      m_Name = new char[len];
    } catch (std::bad_alloc e) {
      cout << "Exception received: " << e.what() << endl;
      return;
    }
    memset(m_Name, 0, len);
    strcpy(m_Name, name);
  }

  // Destructor
  ~Book() {
    if (m_Name) {
      delete[] m_Name;
      m_Name = nullptr;
    }
  }

  friend ostream& operator<<(ostream& os, const Book& s);
};

ostream& operator<<(ostream& os, const Book& s) {
  os << s.m_Slno << ", " << s.m_Name << endl;
  return os;
}

int main() {
  Book s1(124546, "Digital Marketing 101");
  Book s2(134645, "Fault in our stars");

  s2 = s1;

  cout << s1;
  cout << s2;

  s1.~Book();
  cout << s2;

  return 0;
}

En la función principal, cuando se destruye el objeto s1, s2 no pierde su objeto dinámico, que es la variable de cadena del objeto s.

Otro ejemplo a continuación demuestra cómo realizar una copia profunda de objetos usando un constructor de copias. En el siguiente código, se crea una clase de constructor diseño.

La clase tiene tres variables privadas: dos de las estáticas l y h, y un objeto dinámico w.

#include <iostream>
using namespace std;

// A class design
class design {
 private:
  int l;
  int* w;
  int h;

 public:
  // Constructor
  design() { w = new int; }

  // Method to take input
  void set_dimension(int len, int brea, int heig) {
    l = len;
    *w = brea;
    h = heig;
  }

  // Display Function
  void show_data() {
    cout << "The Length is = " << l << "\n"
         << "Breadth  of the design = " << *w << "\n"
         << "Height = " << h << "\n"
         << endl;
  }

  // Deep copy is initialized here
  design(design& sample) {
    l = sample.l;
    w = new int;
    *w = *(sample.w);
    h = sample.h;
  }

  // Destructor
  ~design() { delete w; }
};

// Driver Code
int main() {
  // Object of class first
  design first;

  // Passing Parameters
  first.set_dimension(13, 19, 26);

  // Calling display method
  first.show_data();

  // Copying the data of 'first' object to 'second'
  design second = first;

  // Calling display method again to show the 'second' object
  second.show_data();

  return 0;
}

Producción :

The Length is = 13
Breadth  of the design = 19
Height = 26

The Length is = 13
Breadth  of the design = 19
Height = 26
--------------------------------
Process exited after 0.2031 seconds with return value 0
Press any key to continue . . .

Conclusión

Este artículo presenta una explicación completa y detallada de la regla de los 3 grandes y cómo afecta la programación. Los lectores aprenden la necesidad y la importancia de la regla de los 3 grandes.

Junto con él, se explican algunos conceptos nuevos, como la copia profunda y superficial, que tiene varias implementaciones.