Buscar..


Mover la semantica

Mover la semántica es una forma de mover un objeto a otro en C ++. Para ello, vaciamos el objeto antiguo y colocamos todo lo que tenía en el nuevo objeto.

Para esto, debemos entender qué es una referencia rvalue. Una referencia rvalue ( T&& donde T es el tipo de objeto) no es muy diferente de una referencia normal ( T& , ahora se llama referencias lvalue). Pero actúan como 2 tipos diferentes, y así, podemos hacer constructores o funciones que toman un tipo u otro, lo que será necesario cuando se trata de la semántica de movimientos.

La razón por la que necesitamos dos tipos diferentes es para especificar dos comportamientos diferentes. Los constructores de referencia de valores están relacionados con la copia, mientras que los constructores de referencia de valores están relacionados con el movimiento.

Para mover un objeto, usaremos std::move(obj) . Esta función devuelve una referencia de valor al objeto, por lo que podemos robar los datos de ese objeto en uno nuevo. Hay varias maneras de hacer esto que se discuten a continuación.

Es importante tener en cuenta que el uso de std::move crea solo una referencia rvalue. En otras palabras, la declaración std::move(obj) no cambia el contenido de obj, mientras que auto obj2 = std::move(obj) (posiblemente) sí lo hace.

Mover constructor

Digamos que tenemos este fragmento de código.

class A {
public:
    int a;
    int b;
       
    A(const A &other) {
        this->a = other.a;
        this->b = other.b;
    }
};

Para crear un constructor de copia, es decir, para hacer una función que copie un objeto y cree uno nuevo, normalmente elegiríamos la sintaxis que se muestra arriba, tendríamos un constructor para A que toma una referencia a otro objeto de tipo A, y copiaríamos el objeto manualmente dentro del método.

Alternativamente, podríamos haber escrito A(const A &) = default; que copia automáticamente sobre todos los miembros, haciendo uso de su copia constructor.

Para crear un constructor de movimientos, sin embargo, tomaremos una referencia rvalue en lugar de una referencia lvalue, como aquí.

class Wallet {
public:
    int nrOfDollars;
    
    Wallet() = default; //default ctor

    Wallet(Wallet &&other) {
        this->nrOfDollars = other.nrOfDollars;
        other.nrOfDollars = 0;
    }
};

Tenga en cuenta que establecemos los valores antiguos en zero . El constructor de movimiento predeterminado ( Wallet(Wallet&&) = default; ) copia el valor de nrOfDollars , ya que es un POD.

Como las semánticas de movimiento están diseñadas para permitir el estado de "robo" de la instancia original, es importante considerar cómo debería verse la instancia original después de este robo. En este caso, si no cambiáramos el valor a cero, habríamos duplicado la cantidad de dólares en juego.

Wallet a;
a.nrOfDollars = 1;
Wallet b (std::move(a)); //calling B(B&& other);
std::cout << a.nrOfDollars << std::endl; //0
std::cout << b.nrOfDollars << std::endl; //1

Así hemos movido un objeto construido a partir de uno viejo.


Si bien lo anterior es un ejemplo simple, muestra lo que se pretende que haga el constructor de movimientos. Se vuelve más útil en casos más complejos, como cuando se trata de la gestión de recursos.

    // Manages operations involving a specified type.
    // Owns a helper on the heap, and one in its memory (presumably on the stack).
    // Both helpers are DefaultConstructible, CopyConstructible, and MoveConstructible.
    template<typename T,
             template<typename> typename HeapHelper,
             template<typename> typename StackHelper>
    class OperationsManager {
        using MyType = OperationsManager<T, HeapHelper, StackHelper>;

        HeapHelper<T>* h_helper;
        StackHelper<T> s_helper;
        // ...

      public:
        // Default constructor & Rule of Five.
        OperationsManager() : h_helper(new HeapHelper<T>) {}
        OperationsManager(const MyType& other)
          : h_helper(new HeapHelper<T>(*other.h_helper)), s_helper(other.s_helper) {}
        MyType& operator=(MyType copy) {
            swap(*this, copy);
            return *this;
        }
        ~OperationsManager() {
            if (h_helper) { delete h_helper; }
        }

        // Move constructor (without swap()).
        // Takes other's HeapHelper<T>*.
        // Takes other's StackHelper<T>, by forcing the use of StackHelper<T>'s move constructor.
        // Replaces other's HeapHelper<T>* with nullptr, to keep other from deleting our shiny
        //  new helper when it's destroyed.
        OperationsManager(MyType&& other) noexcept
          : h_helper(other.h_helper),
            s_helper(std::move(other.s_helper)) {
            other.h_helper = nullptr;
        }

        // Move constructor (with swap()).
        // Places our members in the condition we want other's to be in, then switches members
        //  with other.
        // OperationsManager(MyType&& other) noexcept : h_helper(nullptr) {
        //     swap(*this, other);
        // }

        // Copy/move helper.
        friend void swap(MyType& left, MyType& right) noexcept {
            std::swap(left.h_helper, right.h_helper);
            std::swap(left.s_helper, right.s_helper);
        }
    };

Mover la tarea

De manera similar a cómo podemos asignar un valor a un objeto con una referencia de valor l, al copiarlo, también podemos mover los valores de un objeto a otro sin construir uno nuevo. Llamamos a esta asignación de movimiento. Movemos los valores de un objeto a otro objeto existente.

Para esto, tendremos que sobrecargar el operator = , no para que tome una referencia de lvalor, como en la asignación de copia, sino para que tome una referencia de rvalor.

class A {
    int a;
    A& operator= (A&& other) {
        this->a = other.a;
        other.a = 0;
        return *this;
    }
};

Esta es la sintaxis típica para definir la asignación de movimiento. Sobrecargamos el operator = para que podamos darle una referencia de valor y asignarlo a otro objeto.

A a;
a.a = 1;
A b;
b = std::move(a); //calling A& operator= (A&& other)
std::cout << a.a << std::endl; //0
std::cout << b.a << std::endl; //1

Por lo tanto, podemos mover asignar un objeto a otro.

Usando std :: move para reducir la complejidad de O (n²) a O (n)

C ++ 11 introdujo el lenguaje central y el soporte de biblioteca estándar para mover un objeto. La idea es que cuando un objeto o es un temporal y quiere una copia lógica, entonces es seguro que sólo los recursos de hurto o 's, como un buffer asignado dinámicamente, dejando o lógicamente vacíos, pero aún destructible y copiable.

El soporte de lenguaje principal es principalmente

  • el generador de tipos de referencia rvalue && , por ejemplo, std::string&& es una referencia rvalue a una std::string , lo que indica que el objeto referido es un temporal cuyos recursos solo pueden ser robados (es decir, movidos)

  • soporte especial para un constructor de movimientos T( T&& ) , que se supone que mueve recursos de manera eficiente del otro objeto especificado, en lugar de copiar esos recursos, y

  • soporte especial para un operador de auto operator=(T&&) -> T& asignación operador de movimiento auto operator=(T&&) -> T& , que también se supone que se mueve desde la fuente.

El soporte de biblioteca estándar es principalmente la plantilla de función std::move del encabezado <utility> . Esta función produce una referencia de valor al objeto especificado, lo que indica que se puede mover desde, como si fuera un temporal.


Para un contenedor, la copia real suele ser de complejidad O ( n ), donde n es el número de elementos en el contenedor, mientras que mover es O (1), tiempo constante. Y para un algoritmo que copia ese contenedor lógicamente n veces, esto puede reducir la complejidad del O ( n ²) generalmente impráctico a solo O ( n ) lineal.

En su artículo "Contenedores que nunca cambian" en el Dr. Dobbs Journal del 19 de septiembre de 2013 , Andrew Koenig presentó un ejemplo interesante de ineficiencia algorítmica al usar un estilo de programación donde las variables son inmutables después de la inicialización. Con este estilo los bucles se expresan generalmente mediante recursión. Y para algunos algoritmos como la generación de una secuencia de Collatz, la recursión requiere copiar lógicamente un contenedor:

// Based on an example by Andrew Koenig in his Dr. Dobbs Journal article
// “Containers That Never Change” September 19, 2013, available at
// <url: http://www.drdobbs.com/cpp/containters-that-never-change/240161543>

// Includes here, e.g. <vector>

namespace my {
    template< class Item >
    using Vector_ = /* E.g. std::vector<Item> */;

    auto concat( Vector_<int> const& v, int const x )
        -> Vector_<int>
    {
        auto result{ v };
        result.push_back( x );
        return result;
    }

    auto collatz_aux( int const n, Vector_<int> const& result )
        -> Vector_<int>
    {
        if( n == 1 )
        {
            return result;
        }
        auto const new_result = concat( result, n );
        if( n % 2 == 0 )
        {
            return collatz_aux( n/2, new_result );
        }
        else
        {
            return collatz_aux( 3*n + 1, new_result );
        }
    }

    auto collatz( int const n )
        -> Vector_<int>
    {
        assert( n != 0 );
        return collatz_aux( n, Vector_<int>() );
    }
}  // namespace my

#include <iostream>
using namespace std;
auto main() -> int
{
    for( int const x : my::collatz( 42 ) )
    {
        cout << x << ' ';
    }
    cout << '\n';
}

Salida:

42 21 64 32 16 8 4 2

El número de operaciones de copia de elementos debidas a la copia de los vectores es aproximadamente O ( ), ya que es la suma 1 + 2 + 3 + ... n .

En números concretos, con los compiladores g ++ y Visual C ++, la invocación anterior de collatz(42) dio como resultado una secuencia de Collatz de 8 elementos y 36 operaciones de copia de elementos (8 * collatz(42) = 28, más algunas) en llamadas de constructor de copia vectorial.

Todas estas operaciones de copia de elementos pueden eliminarse simplemente moviendo vectores cuyos valores ya no son necesarios. Para hacer esto es necesario eliminar const y referencia para los argumentos de tipo vector, pasando los vectores por valor . La función de devoluciones ya está optimizada automáticamente. Para las llamadas donde se pasan los vectores, y no se usan más adelante en la función, simplemente aplique std::move para mover esos buffers en lugar de copiarlos:

using std::move;

auto concat( Vector_<int> v, int const x )
    -> Vector_<int>
{
    v.push_back( x );
    // warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
    // See https://stackoverflow.com/documentation/c%2b%2b/2489/copy-elision
    // return move( v );
    return v;
}

auto collatz_aux( int const n, Vector_<int> result )
    -> Vector_<int>
{
    if( n == 1 )
    {
        return result;
    }
    auto new_result = concat( move( result ), n );
    struct result;      // Make absolutely sure no use of `result` after this.
    if( n % 2 == 0 )
    {
        return collatz_aux( n/2, move( new_result ) );
    }
    else
    {
        return collatz_aux( 3*n + 1, move( new_result ) );
    }
}

auto collatz( int const n )
    -> Vector_<int>
{
    assert( n != 0 );
    return collatz_aux( n, Vector_<int>() );
}

Aquí, con los compiladores g ++ y Visual C ++, el número de operaciones de copia de elementos debidas a invocaciones del constructor de copia vectorial fue exactamente 0.

El algoritmo sigue siendo necesariamente O ( n ) en la longitud de la secuencia de Collatz producida, pero esta es una mejora bastante dramática: O ( ) → O ( n ).


Con algún soporte de lenguaje, quizás se pueda usar mover y aún expresar y aplicar la inmutabilidad de una variable entre su inicialización y su movimiento final , después de lo cual cualquier uso de esa variable debería ser un error. Por desgracia, a partir de C ++ 14 C ++ no es compatible con eso. Para el código sin bucle, el movimiento sin uso después de la mudanza se puede aplicar mediante una nueva declaración del nombre relevante como una struct incompleta, como con el struct result; arriba, pero esto es feo y no es probable que sea entendido por otros programadores; También los diagnósticos pueden ser bastante engañosos.

En resumen, el lenguaje de C ++ y el soporte de la biblioteca para el movimiento permiten mejoras drásticas en la complejidad del algoritmo, pero debido a su carácter incompleto, al costo de renunciar a las garantías de corrección de códigos y la claridad de código que puede proporcionar const .


Para completar, la clase vectorial instrumentada utilizada para medir el número de operaciones de copia de elementos debidas a invocaciones de constructor de copia:
template< class Item >
class Copy_tracking_vector
{
private:
    static auto n_copy_ops()
        -> int&
    {
        static int value;
        return value;
    }
    
    vector<Item>    items_;
    
public:
    static auto n() -> int { return n_copy_ops(); }

    void push_back( Item const& o ) { items_.push_back( o ); }
    auto begin() const { return items_.begin(); }
    auto end() const { return items_.end(); }

    Copy_tracking_vector(){}
    
    Copy_tracking_vector( Copy_tracking_vector const& other )
        : items_( other.items_ )
    { n_copy_ops() += items_.size(); }

    Copy_tracking_vector( Copy_tracking_vector&& other )
        : items_( move( other.items_ ) )
    {}
};

Uso de semántica de movimiento en contenedores

Puedes mover un contenedor en lugar de copiarlo:

void print(const std::vector<int>& vec) {
    for (auto&& val : vec) {
        std::cout << val << ", ";
    }
    std::cout << std::endl;
}

int main() {
    // initialize vec1 with 1, 2, 3, 4 and vec2 as an empty vector
    std::vector<int> vec1{1, 2, 3, 4};
    std::vector<int> vec2;

    // The following line will print 1, 2, 3, 4
    print(vec1);

    // The following line will print a new line
    print(vec2);

    // The vector vec2 is assigned with move assingment.
    // This will "steal" the value of vec1 without copying it.
    vec2 = std::move(vec1);

    // Here the vec1 object is in an indeterminate state, but still valid.
    // The object vec1 is not destroyed,
    // but there's is no guarantees about what it contains.

    // The following line will print 1, 2, 3, 4
    print(vec2);
}

Reutilizar un objeto movido

Puedes reutilizar un objeto movido:

void consumingFunction(std::vector<int> vec) {
    // Some operations
}

int main() {
    // initialize vec with 1, 2, 3, 4
    std::vector<int> vec{1, 2, 3, 4};

    // Send the vector by move
    consumingFunction(std::move(vec));

    // Here the vec object is in an indeterminate state.
    // Since the object is not destroyed, we can assign it a new content.
    // We will, in this case, assign an empty value to the vector,
    // making it effectively empty
    vec = {};

    // Since the vector as gained a determinate value, we can use it normally.
    vec.push_back(42);

    // Send the vector by move again.
    consumingFunction(std::move(vec));
}


Modified text is an extract of the original Stack Overflow Documentation
Licenciado bajo CC BY-SA 3.0
No afiliado a Stack Overflow