c++ - Implementando Move Constructor llamando al operador de asignación de movimientos
c++11 move-semantics (6)
[...] ¿El compilador podrá optimizar las inicializaciones adicionales?
En casi todos los casos: sí.
¿Debo escribir siempre mis constructores de movimientos llamando al operador de asignación de movimientos?
Sí, solo implementarlo a través del operador de asignación de movimiento, excepto en los casos en los que usted midió que conduce a un rendimiento subóptimo.
El optimizador de hoy hace un trabajo increíble en la optimización de código. Su código de ejemplo es especialmente fácil de optimizar. En primer lugar: el constructor de movimientos estará en línea en casi todos los casos. Si lo implementa a través del operador de asignación de movimiento, ese también estará en línea.
¡Y veamos alguna asamblea! This muestra el código exacto del sitio web de Microsoft con ambas versiones del constructor de movimientos: manual y mediante asignación de movimientos. Aquí está la salida de ensamblaje para GCC con -O
( -O1
tiene la misma salida; la salida de clang lleva a la misma conclusión):
; ===== manual version ===== | ; ===== via move-assig =====
MemoryBlock(MemoryBlock&&): | MemoryBlock(MemoryBlock&&):
mov QWORD PTR [rdi], 0 | mov QWORD PTR [rdi], 0
mov QWORD PTR [rdi+8], 0 | mov QWORD PTR [rdi+8], 0
| cmp rdi, rsi
| je .L1
mov rax, QWORD PTR [rsi+8] | mov rax, QWORD PTR [rsi+8]
mov QWORD PTR [rdi+8], rax | mov QWORD PTR [rdi+8], rax
mov rax, QWORD PTR [rsi] | mov rax, QWORD PTR [rsi]
mov QWORD PTR [rdi], rax | mov QWORD PTR [rdi], rax
mov QWORD PTR [rsi+8], 0 | mov QWORD PTR [rsi+8], 0
mov QWORD PTR [rsi], 0 | mov QWORD PTR [rsi], 0
| .L1:
ret | rep ret
Aparte de la rama adicional para la versión correcta, el código es exactamente el mismo. Significado: se han eliminado las tareas duplicadas .
¿Por qué la rama adicional? El operador de asignación de movimiento, tal como lo define la página de Microsoft, realiza más trabajo que el constructor de movimiento: está protegido contra la autoasignación. El constructor de movimientos no está protegido contra eso. Pero : como ya dije, el constructor estará en línea en casi todos los casos. Y en estos casos, el optimizador puede ver que no es una autoasignación, por lo que esta rama también se optimizará.
Esto se repite mucho, pero es importante: ¡no realice una microoptimización prematura!
No me malinterprete, también odio el software que desperdicia muchos recursos debido a desarrolladores perezosos o descuidados o decisiones de gestión. Y ahorrar energía no se trata solo de baterías, sino también de un tema medioambiental, que me apasiona mucho. Pero , ¡hacer micro-optimizaciones prematuramente no ayuda en ese sentido! Claro, mantenga la complejidad algorítmica y la facilidad de almacenamiento en caché de sus grandes datos en la parte posterior de su cabeza. ¡Pero antes de hacer cualquier optimización específica, mida!
En este caso específico, incluso supongo que nunca tendrá que optimizar manualmente, porque el compilador siempre podrá generar código óptimo alrededor de su constructor de movimientos. Hacer la microoptimización inútil ahora le costará tiempo de desarrollo más tarde cuando necesite cambiar el código en dos lugares o cuando necesite depurar un error extraño que solo ocurre porque cambió el código en un solo lugar. Y eso es una pérdida de tiempo de desarrollo que podría gastarse en hacer optimizaciones útiles.
El artículo de MSDN, Cómo escribir un agente de movimiento , tiene la siguiente recomendación.
Si proporciona un constructor de movimientos y un operador de asignación de movimientos para su clase, puede eliminar el código redundante escribiendo el constructor de movimientos para llamar al operador de asignación de movimientos. El siguiente ejemplo muestra una versión revisada del constructor de movimientos que llama al operador de asignación de movimientos:
// Move constructor.
MemoryBlock(MemoryBlock&& other)
: _data(NULL)
, _length(0)
{
*this = std::move(other);
}
¿Es este código ineficiente al inicializar doblemente los valores de MemoryBlock
, o el compilador podrá optimizar las inicializaciones adicionales? ¿Debo escribir siempre mis constructores de movimientos llamando al operador de asignación de movimientos?
Depende de lo que haga su operador de asignación de movimiento. Si observa el artículo en el que está vinculado, verá en parte:
// Free the existing resource.
delete[] _data;
Entonces, en este contexto, si llama al operador de asignación de movimiento desde el constructor de movimiento sin inicializar primero los datos, terminará intentando eliminar un puntero sin inicializar. Entonces, en este ejemplo, ineficiente o no, en realidad es crucial que inicialice los valores.
Mi versión C ++ 11 de la clase MemoryBlock
.
#include <algorithm>
#include <vector>
// #include <stdio.h>
class MemoryBlock
{
public:
explicit MemoryBlock(size_t length)
: length_(length),
data_(new int[length])
{
// printf("allocating %zd/n", length);
}
~MemoryBlock() noexcept
{
delete[] data_;
}
// copy constructor
MemoryBlock(const MemoryBlock& rhs)
: MemoryBlock(rhs.length_) // delegating to another ctor
{
std::copy(rhs.data_, rhs.data_ + length_, data_);
}
// move constructor
MemoryBlock(MemoryBlock&& rhs) noexcept
: length_(rhs.length_),
data_(rhs.data_)
{
rhs.length_ = 0;
rhs.data_ = nullptr;
}
// unifying assignment operator.
// move assignment is not needed.
MemoryBlock& operator=(MemoryBlock rhs) // yes, pass-by-value
{
swap(rhs);
return *this;
}
size_t Length() const
{
return length_;
}
void swap(MemoryBlock& rhs)
{
std::swap(length_, rhs.length_);
std::swap(data_, rhs.data_);
}
private:
size_t length_; // note, the prefix underscore is reserved.
int* data_;
};
int main()
{
std::vector<MemoryBlock> v;
// v.reserve(10);
v.push_back(MemoryBlock(25));
v.push_back(MemoryBlock(75));
v.insert(v.begin() + 1, MemoryBlock(50));
}
Con un compilador C ++ 11 correcto, MemoryBlock::MemoryBlock(size_t)
solo debe llamarse 3 veces en el programa de prueba.
No creo que vayas a notar una diferencia significativa en el rendimiento. Considero una buena práctica usar el operador de asignación de movimientos del constructor de movimientos.
Sin embargo, prefiero usar std :: forward en lugar de std :: move porque es más lógico:
*this = std::forward<MemoryBlock>(other);
No lo haría de esta manera. La razón por la cual los miembros del movimiento existen en primer lugar es el rendimiento . Hacer esto por su constructor de mudanzas es como despedir a los megabucks de un súper auto y luego tratar de ahorrar dinero al comprar gasolina regular.
Si desea reducir la cantidad de código que escribe, simplemente no escriba los miembros de movimiento. Tu clase se copiará bien en un contexto de movimiento.
Si desea que su código sea de alto rendimiento, entonces adapte su constructor de movimientos y la asignación de movimientos sea lo más rápido posible. Los miembros de Good Move serán increíblemente rápidos, y usted debería estimar su velocidad contando las cargas, las tiendas y las sucursales. Si puedes escribir algo con 4 cargas / tiendas en lugar de 8, ¡hazlo! Si puedes escribir algo sin ramas en lugar de 1, ¡hazlo!
Cuando usted (o su cliente) pone su clase en un std::vector
, se pueden generar muchos movimientos en su tipo. Incluso si su movimiento es veloz en 8 cargas / tiendas, si puede hacerlo dos veces más rápido, o incluso 50% más rápido con solo 4 o 6 cargas / tiendas, imho es un tiempo bien empleado.
Personalmente, estoy harto de ver cursores en espera y estoy dispuesto a donar 5 minutos adicionales para escribir mi código y saber que es lo más rápido posible.
Si aún no está convencido de que valga la pena, escríbalo en ambas direcciones y luego examine el ensamblaje generado con la máxima optimización. Quién sabe, su compilador podría ser lo suficientemente inteligente como para optimizar las cargas y almacenes adicionales para usted. Pero para este momento ya ha invertido más tiempo que si hubiera escrito un constructor de movimientos optimizado en primer lugar.
Simplemente eliminaría la inicialización de miembros y escribiría,
MemoryBlock(MemoryBlock&& other)
{
*this = std::move(other);
}
Esto siempre funcionará a menos que la asignación de movimiento arroje excepciones, ¡y normalmente no lo hace!
Ventajas de estos estilos:
- No debe preocuparse por si el compilador inicializará por duplicado los miembros, ya que esto puede variar en diferentes entornos.
- Usted escribe menos código.
- No necesita actualizarlo incluso si agrega miembros adicionales a la clase en el futuro.
- Los compiladores a menudo pueden alinear la asignación de movimiento, por lo que la sobrecarga del constructor de copia sería mínima.
Creo que la publicación de @ Howard no responde a esta pregunta. En la práctica, a las clases a menudo no les gusta copiar, muchas clases simplemente deshabilitan el constructor de copias y la asignación de copias. Pero la mayoría de las clases pueden ser movibles incluso si no son copiables.