miércoles, 27 de abril de 2016

Entendiendo la sintaxis move

Una de las novedades de C++11 es la sintaxis move. Esta nueva característica de C++ pretende acelerar el trasvase de información entre elementos. Como toda herramienta conviene aprender a utilizarla para ser capaces de exprimir todos su potencial.

Para comprender lo que se comenta en esta entrada es necesario entender los conceptos de Rvalue y Lvalue, lo cuales se explican en esta otra entrada.

¿Qué es la sintaxis move?

La sintaxis move vino para suplir una de las grandes carencias de C++ en cuanto al rendimiento en tiempo de ejecución. Antes de la llegada de C++11 los elementos de tipo Rvalue se consideraban objetos no modificables, lo que impide realizar cambios sobre ellos.

La llegada del nuevo estándar trajo bajo el brazo una forma de cambiar este comportamiento. C++11 añadió un nuevo constructor y un nuevo operador de asignación a la lista de los ya existentes:

class POO
{
  // Constructor move
  POO(POO&&);

  // Operador de asignación move
  POO& operator=(POO&&);
};

El argumento de estas funciones, como se puede ver, se caracteriza por el doble ampersand. Además, dicho parámetro no debe ser constante. Ya que nuestro objetivo es poder realizar cambios en el objeto Rvalue no parece buena idea etiquetar dicho objeto como const en nuestra función.

Estas dos funciones, si están definidas, se llamarán automáticamente cuando le pasemos como parámetro un objeto de tipo Rvalue y el motivo que da sentido a su existencia es que, dado que los Rvalue dejan de existir tras la ejecución de la sentencia, estas funciones pueden apropiarse los recursos adquiridos por la clase original como suyos en vez de realizar una costosa copia:

class ObjetoPesado
{
  private:

    const int NumDatos = 1000000;
    int* _datos;

  public:

    ObjetoPesado()
      : _datos(new int[NumDatos])
    {
    }

    ObjetoPesado(ObjetoPesado&& origen)
      : _datos(nullptr)
    {
      std::swap(_datos,origen._datos);
    }

    ObjetoPesado(const ObjetoPesado& origen)
      : ObjetoPesado() // Llamada al constructor por defecto,
                       // soportado desde C++11
    {
      std::copy(origen._datos,
                origen._datos+NumDatos,
                _datos);
    }

    ~ObjetoPesado()
    {
      delete[] _datos;
    }
};

int main()
{
  clock_t start = clock();
  for( auto i=0; i<10000; i++ )
  {
    ObjetoPesado origen;

    // Llamada al constructor copia
    ObjetoPesado* copia = new ObjetoPesado(origen);
    delete copia;
  }
  std::cout << "copia: " << static_cast<double>(clock()-start) / CLOCKS_PER_SEC << std::endl;

  start = clock();
  for( auto i=0; i<10000; i++ )
  {
    ObjetoPesado origen;

    // Llamada al constructor move
    ObjetoPesado* copia = new ObjetoPesado(std::move(origen)); 
    delete copia;
  }
  std::cout << "move: " << static_cast<double>(clock()-start) / CLOCKS_PER_SEC << std::endl;
}

En mi caso, este programa, compilado sin optimizaciones para evitar que que se borren instrucciones, arroja el siguiente resultado:

copia: 22.929
move: 0.034

En este caso la nueva sintaxis es unas 670 veces más rápida que la versión tradicional, lo cual no está nada mal.

Vamos a analizar más de cerca el constructor move:

ObjetoPesado(ObjetoPesado&& origen)
  : _datos(nullptr)
{
  std::swap(_datos,origen._datos);
}

Lo primero que hace es inicializar su puntero interno a nullptr y después llama a swap(). Esta última función intercambia los valores de dos variables, de tal forma que _datos acaba apuntando a la memoria que hasta ese momento gestionaba origen._datos, dejando este último puntero apuntando a nullptr (aquí el por qué de la inicialización de _datos).

Como se aprecia, después de usar la sintaxis move el objeto original pierde su estado (al menos todo lo relacionado con su memoria dinámica), quedándo vacío. Esto se permite, repito, porque se asume que los Rvalue dejan de existir después de la expresión.

Del ejemplo propuesto también hay que destacar una función de nueva aparición en C++11 y cuya aparición se debe precisamente al nacimiento de toda esta operativa. Me refiero a move(), función que aparece en la línea 55. Esta función recibe un objeto y lo convierte en Rvalue. ¿Cómo obra esta magia? bueno, simplificando un poco podríamos decir que move() es un alias de static_cast. Una posible implementación de esta función:

template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{
  return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
}

Simplificando un poco la explicación de esta función, tenemos dos partes:

  • remove_reference: un template que elimina el modificador '&' del tipo para evitar problemas. Si nos fijamos veremos que de dicho template únicamente le interesa su atributo type. Para más información sobre esta función recomiendo consultar la documentación online sobre este template.
  • static_cast, como vimos en la entrada de Rvalue y Lvalue, convierte un lValue en un rValue.

¿Cuándo se debe usar move()?

Ignorando el hecho de que, aunque su uso sea prácticamente gratis en este caso, static_cast() siempre va a tener un cierto coste para nuestro programa, conviene saber cuándo conviene y cuando no usar move(), al menos para mantener nuestro código lo más legible posible.

Desde C++11 los compiladores deberían realizar una serie de optimizaciones desconocidas hasta entonces. Una de ellas tiene que ver con los return. Cuando el compilador se topa con un return automáticamente va a convertir el objeto de retorno en un Rvalue, lo que va a permitir que se llame implícitamente al constructor o al operador de asignación move, según corresponda. Esto quiere decir que el siguiente uso de move() no es necesario:

ObjetoPesado func()
{
  ObjetoPesado obj;
  // ...
  return std::move(obj);
}

Lo correcto sería:

ObjetoPesado func()
{
  ObjetoPesado obj;
  // ...
  return obj;
}

De la misma forma, tampoco tiene sentido usar move() con el resultado devuelto por las funciones, ya que dicho resultado ya es un rValue:

// Incorrecto
ObjetoPesado obj = std::move(func());

// Correcto
ObjetoPesado obj = func();

El uso de move() cobra sentido cuando estamos trabajando con Lvalue. Un ejemplo:

ObjetoPesado obj;

if( something )
{
  ObjetoPesado temp;

  // ...

  if( something )
  {
    obj = std::move(temp);
  }
}

En este caso necesitamos trabajar con un objeto temporal y únicamente si se cumplen ciertas condiciones los datos de ese objeto temporal pasan a ser definitivos.

Otra situación en la que move() es de vital importancia la encontramos al trabajar con smart pointers, concretamente con unique_ptr, sobretodo si van integrados en contenedores:

std::vector<std::unique_ptr<POO>> items;

std::unique_ptr<POO> item = new POO;

// Error, unique_ptr tiene capado el consructor copia
items.push_back(item);

// Ok
items.push_back(std::move(item));

¿Es necesario implementar siempre el nuevo constructor y operador de asignación?

La respuesta es no. El compilador no nos va a obligar en ningún momento a implementar estas funciones.

Si el compilador necesita llamar al constructor move y no lo encuentra intentará llamar al constructor copia y únicamente si esta última llamada falla se provocará un error en tiempo de compilación. Esto es aplicable también para el operador de asignación.

Tambien hay que tener en cuenta que si nuestro objeto no hace uso de memoria dinámica (ni directa ni indirectamente) no vamos a obtener ningun beneficio implementando esta nueva sintaxis.


No hay comentarios:

Publicar un comentario