Mover la semántica en C++

Abdul Mateen 12 octubre 2023
  1. Preliminares de creación de objetos de C++
  2. Mover la semántica en C++
Mover la semántica en C++

En este tutorial, discutiremos la semántica de movimiento en C++:

  • Discutiremos conceptos relacionados de copia profunda y copia superficial.
  • Discutiremos rápidamente la idea de lvalue y rvalue.
  • Intentaremos entender la semántica de los movimientos con ejemplos.

Nota: si tiene confianza con los conceptos de copia superficial y profunda, puede saltar directamente a la sección Move Semantics.

Preliminares de creación de objetos de C++

Entendamos rápidamente el mecanismo para la creación de la copia de un objeto usando el siguiente ejemplo trivial:

class T {
  int x;

 public:
  T(int x = 1) { this->x = x; }
  int getX() const { return x; }
  void setX(int x) { this->x = x; }
};
int main() {
  T o1;
  T o2(o1);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  o2.setX(5);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  return 0;
}

En este ejemplo simple, la clase T tiene solo un miembro de datos: x. Hemos escrito un constructor parametrizado con un valor predeterminado y dos métodos de fábrica para x.

En main(), hemos creado el objeto o1 usando el constructor disponible. En la segunda línea, hicimos otro objeto, o2, una copia de o1.

Este objeto se creará a través de un constructor de copia que existe por defecto en cada clase. Este constructor predeterminado hace una copia por miembro de los miembros de datos.

En la línea 11, estamos imprimiendo miembros de datos de ambos objetos, mientras que la línea 4 cambia el valor del miembro de datos x de o2. Finalmente, estamos mostrando miembros de datos nuevamente para ver el efecto de la modificación en ambos objetos.

Aquí está la salida de este código:

1	1
1	5

En la primera línea de salida, ambos miembros de datos tienen el valor 1 porque el primer objeto se crea utilizando el constructor parametrizado con el valor predeterminado 1. Como el segundo objeto es una copia de o1, ambos valores son iguales.

Sin embargo, más tarde, o2 llama a la función setter para modificar el valor de su miembro de datos. La segunda línea de salida muestra que cambiar el valor de los miembros de datos de o2 no afecta el valor de los miembros de datos de o1.

Copia superficial

Desafortunadamente, el mecanismo de copia anterior no funciona en caso de que los miembros de datos sean punteros que apunten a la memoria dinámica (creada en el montón). En este escenario, el objeto de la copiadora apunta a la misma ubicación de memoria dinámica creada por el objeto anterior; por lo tanto, se dice que el objeto copiador tiene una copia superficial de otro objeto.

La solución funciona bien si los objetos o miembros de datos son de solo lectura; de lo contrario, puede crear problemas graves. Primero comprendamos el concepto de copia superficial con el siguiente código de ejemplo:

class T {
  int *x, size;

 public:
  T(int s = 5) {
    size = s;
    x = new int[size];
    x[0] = x[1] = x[2] = x[3] = x[4] = 1;
  }
  void set(int index, int val) { this->x[index] = val; }
  void show() const {
    for (int i = 0; i < size; i++) cout << x[i] << ' ';
    cout << '\n';
  }
};
int main() {
  T o1;
  T o2(o1);
  o1.show();
  o2.show();
  o2.set(2, 5);
  o1.show();
  o2.show();
  return 0;
}

La clase T tiene dos miembros de datos en este ejemplo: puntero x y tamaño. Nuevamente, tenemos un constructor parametrizado con un valor predeterminado.

Hemos asignado el valor parámetro predeterminado al miembro de datos tamaño dentro del cuerpo del constructor.

Las líneas 7 y 8 del constructor declaran una matriz dinámica de longitud de “tamaño” y asignan 1 a todos los elementos de la matriz. El método set coloca el valor de val en la ubicación index de la matriz dinámica.

En main(), creamos o1 y o2, donde o2 es el objeto de copia. A continuación, estamos imprimiendo ambos objetos.

De nuevo, estamos modificando el valor del tercer elemento del arreglo en o2 e imprimiendo objetos.

Aquí está la salida de este código:

1 1 1 1 1
1 1 1 1 1
1 1 5 1 1
1 1 5 1 1

Ahora, la salida puede no ser según sus expectativas. Las dos primeras líneas muestran una copia exacta; sin embargo, en la tercera y cuarta líneas, tenemos los mismos elementos en ambos objetos, mientras que hemos modificado solo uno.

Esto se debe a que el constructor de copia predeterminado ha creado una copia superficial al asignar el valor del puntero en el primer objeto (que es una dirección de la matriz asignada dinámicamente) al puntero del segundo objeto.

Nuevamente, esta copia superficial está bien si tenemos valores de solo lectura; de lo contrario, la salida muestra claramente los riesgos de actualización.

Copia profunda

A diferencia de la copia superficial, en la copia profunda, asignamos memoria dinámica separada para cada objeto. Luego hacemos una copia por miembros para cada elemento dentro del montón.

Esto se logra esencialmente sobrecargando el constructor de copia y el operador de asignación. Vea el ejemplo de codificación, donde estamos sobrecargando el constructor de copias:

T(const T &t) {
  size = t.size;
  x = new int[size];
  for (int i = 0; i < size; i++) x[i] = t.x[i];
}

Tenga en cuenta que en la segunda línea, la dirección de la nueva asignación dinámica se asigna a x. En las líneas 3 y 4, hemos copiado todos los elementos de la matriz dinámica para crear una copia profunda.

Nota: El ejemplo anterior proporciona código solo para la sobrecarga del constructor de copias. El resto del código es el mismo que en el ejemplo anterior.

Aquí está el resultado del código principal escrito en el ejemplo anterior después de agregar el constructor de copia:

1 1 1 1 1
1 1 1 1 1
1 1 1 1 1
1 1 5 1 1

Las líneas 3 y 4 no muestran ningún efecto de modificación en el segundo objeto sobre el primer objeto. Esto significa que el cambio solo es inherente al segundo objeto porque ahora 2 es una copia profunda de o1.

Ahora, analicemos rápidamente el concepto de lvalue y rvalue.

Valores L y valores R

El lvalue o valor de la izquierda se refiere al operando a la izquierda del operador de asignación que recibe el valor. Por el contrario, el rvalue significa el operando en el lado derecho del operador de asignación que proporciona el valor.

Por lo tanto, las variables simples se pueden usar como lvalue y rvalue. Las constantes o las constantes variables solo se pueden usar como un valor r.

Las expresiones son rvalue. Por ejemplo, podemos escribir a = b + c, pero no podemos escribir b + c = a o no podemos escribir 2 = a.

Mover la semántica en C++

Finalmente, comencemos la discusión sobre la semántica de movimiento. Aquí, discutiremos este concepto relacionado con los constructores de copias.

En primer lugar, vea las siguientes llamadas al constructor de copias para la clase T:

T o2(o1);
T o3(o1 - o2);

Si analizamos profundamente el código anterior, solo la primera línea requiere una copia profunda, donde podemos inspeccionar o1 más tarde. Además, podemos tener una declaración en el constructor de copias para modificar los contenidos de o1 (por ejemplo, una declaración como t.set(2,5)).

Por lo tanto, decimos que o1 es un valor l.

Sin embargo, la expresión o1-o2 no es un valor l; en cambio, es un rvalue ya que es un objeto unánime (sin nombre), y no necesitamos acceder a o1-o2 nuevamente. Por lo tanto, podemos decir que los valores r representan objetos temporales destruidos poco después de ejecutar la instrucción en cuestión.

C++0x ha introducido un nuevo enfoque llamado RValue Reference para la referencia del tipo rvalue. Este nuevo enfoque permite hacer coincidir los argumentos de rvalue en la sobrecarga de funciones.

Tenemos que escribir otro constructor con un parámetro de referencia rvalue para esto.

T(T&& t) {
  x = t.data;
  t.data = nullptr;
}

Primero, tenga en cuenta el nuevo constructor (llamado constructor de movimiento en lugar de constructor de copia), donde hemos agregado un signo & más que es una referencia para un valor r.

En la línea 2, en lugar de hacer una copia profunda de la matriz dinámica, acabamos de asignar la dirección del objeto existente al nuevo objeto. Para mayor seguridad, hemos asignado nullptr al puntero del objeto existente.

La razón es que no necesitamos acceder al objeto fuente temporal. Por lo tanto, en el constructor de movimiento, movemos el recurso (es decir, la matriz dinámica) del objeto existente (temporal) al nuevo objeto de llamada.

Para entenderlo mejor, veamos otro código que sobrecarga el operador de asignación:

T& operator=(T t) {
  size = t.size;
  for (int i = 0; i < size; i++) x[i] = t.x[i];
  return *this;
}

No hemos utilizado la referencia rvalue aquí. En C++0x, el compilador verificará si el parámetro es rvalue o lvalue, y se llamará a los constructores de mover o copiar según corresponda.

Por tanto, si llamamos al operador de asignación o1=o2, el constructor de copias inicializará t. Sin embargo, si llamamos al operador de asignación o3 = o2-o1, el constructor de movimiento inicializará t.

La razón es que o2-o1 es un valor r en lugar de un valor l.

Finalmente, concluimos que el constructor de copia se puede usar para crear una copia profunda para guardar la opción de mantener seguro el objeto de origen para un acceso posterior. El constructor de movimiento asigna la memoria dinámica del objeto existente al puntero del objeto que llama porque no hay necesidad de acceder al objeto rvalue más adelante.