c++ - smart - ¿Cómo implementar realmente la regla de cinco?
smart contracts ethereum (6)
Como no he visto a nadie más señalar esto explícitamente ...
Su operador de asignación de copias tomando su argumento por valor es una oportunidad de optimización importante si (y solo si) se le pasa un valor r, debido a la elisión de copia. Pero en una clase con un operador de asignación que explícitamente solo toma valores r (es decir, uno con un operador de asignación de movimiento), este es un escenario sin sentido. Por lo tanto, a pesar de las pérdidas de memoria que ya se han señalado en otras respuestas, diría que su clase ya es ideal si simplemente cambia el operador de asignación de copias para tomar su argumento por referencia constante.
ACTUALIZAR en la parte inferior
q1: ¿Cómo implementaría la regla de cinco para una clase que maneja recursos bastante pesados, pero de los cuales quiere que se transmita por valor porque eso simplifica y embellece su uso? ¿O no son necesarios los cinco elementos de la regla?
En la práctica, estoy comenzando algo con imágenes tridimensionales donde una imagen suele ser de 128 * 128 * 128 dobles. Ser capaz de escribir cosas como esta haría las matemáticas mucho más fáciles:
Data a = MakeData();
Data c = 5 * a + ( 1 + MakeMoreData() ) / 3;
q2: Usando una combinación de copia elisión / semántica de movimiento / RVO, el compilador debería poder hacer esto con un mínimo de copiado, ¿no?
Traté de descubrir cómo hacer esto, así que comencé con lo básico; supongamos que un objeto implementa la forma tradicional de implementar la copia y la asignación:
class AnObject
{
public:
AnObject( size_t n = 0 ) :
n( n ),
a( new int[ n ] )
{}
AnObject( const AnObject& rh ) :
n( rh.n ),
a( new int[ rh.n ] )
{
std::copy( rh.a, rh.a + n, a );
}
AnObject& operator = ( AnObject rh )
{
swap( *this, rh );
return *this;
}
friend void swap( AnObject& first, AnObject& second )
{
std::swap( first.n, second.n );
std::swap( first.a, second.a );
}
~AnObject()
{
delete [] a;
}
private:
size_t n;
int* a;
};
Ahora ingrese los valores y mueva la semántica. Por lo que puedo decir, esto sería una implementación de trabajo:
AnObject( AnObject&& rh ) :
n( rh.n ),
a( rh.a )
{
rh.n = 0;
rh.a = nullptr;
}
AnObject& operator = ( AnObject&& rh )
{
n = rh.n;
a = rh.a;
rh.n = 0;
rh.a = nullptr;
return *this;
}
Sin embargo, el compilador (VC ++ 2010 SP1) no está muy satisfecho con esto, y los compiladores suelen ser correctos:
AnObject make()
{
return AnObject();
}
int main()
{
AnObject a;
a = make(); //error C2593: ''operator ='' is ambiguous
}
q3: ¿Cómo resolver esto? Volviendo a AnObject & operator = (const AnObject & rh) ciertamente lo soluciona, pero ¿no perdemos una oportunidad de optimización bastante importante?
Aparte de eso, está claro que el código para el constructor y la asignación de movimiento está lleno de duplicaciones. Así que por ahora nos olvidamos de la ambigüedad y tratamos de resolver esto usando copy y swap pero ahora para rvalues. Como se explica here , ni siquiera necesitaríamos un intercambio personalizado, sino que tendremos que hacer todo el trabajo, lo cual suena muy prometedor. Así que escribí lo siguiente, con la esperanza de que std :: swap copie construir un temporal usando el constructor de movimiento, luego cambiarlo por * this:
AnObject& operator = ( AnObject&& rh )
{
std::swap( *this, rh );
return *this;
}
Pero eso no funciona y en su lugar lleva a un desbordamiento de la pila debido a la recursión infinita, ya que std :: swap llama a nuestro operador = (AnObject && rh) nuevamente. q4: ¿Puede alguien dar un ejemplo de lo que se entiende en el ejemplo, entonces?
Podemos resolver esto proporcionando una segunda función de intercambio:
AnObject( AnObject&& rh )
{
swap( *this, std::move( rh ) );
}
AnObject& operator = ( AnObject&& rh )
{
swap( *this, std::move( rh ) );
return *this;
}
friend void swap( AnObject& first, AnObject&& second )
{
first.n = second.n;
first.a = second.a;
second.n = 0;
second.a = nullptr;
}
Ahora hay casi el doble del código de la cantidad, sin embargo, la parte del movimiento paga al permitir un movimiento bastante barato; pero, por otro lado, la asignación normal ya no puede beneficiarse de la elisión de copia. En este punto, estoy realmente confundido, y no veo más lo que está bien y lo que está mal, así que espero obtener algo de información aquí.
ACTUALIZACIÓN Entonces parece que hay dos campos:
- uno que dice omitir el operador de asignación de movimiento y continúa haciendo lo que C ++ 03 nos enseñó, es decir, escribir un único operador de asignación que pasa el argumento por valor.
- el otro dice que debe implementar el operador de asignación de movimiento (después de todo, ahora es C ++ 11) y hacer que el operador de asignación de copia tome su argumento por referencia.
(Ok, está el 3er campamento diciéndome que use un vector, pero está fuera del alcance de esta clase hipotética. Ok, en la vida real usaría un vector, y también habría otros miembros, pero desde el constructor de movimientos / asignación no se generan automáticamente (¿todavía?) la pregunta aún se mantendría)
Lamentablemente, no puedo probar ambas implementaciones en un escenario del mundo real, ya que este proyecto acaba de comenzar y todavía no se conoce la forma en que realmente fluirán los datos. Así que simplemente implementé ambos, agregué contadores para la asignación, etc. y ejecuté un par de iteraciones de aprox. este código, donde T es una de las implementaciones:
template< class T >
T make() { return T( narraySize ); }
template< class T >
void assign( T& r ) { r = make< T >(); }
template< class T >
void Test()
{
T a;
T b;
for( size_t i = 0 ; i < numIter ; ++i )
{
assign( a );
assign( b );
T d( a );
T e( b );
T f( make< T >() );
T g( make< T >() + make< T >() );
}
}
O este código no es lo suficientemente bueno para probar lo que busco, o el compilador es demasiado inteligente: no importa lo que uso para arraySize y numIter, los resultados para ambos campos son prácticamente idénticos: el mismo número de asignaciones, variaciones muy leves en el tiempo pero ninguna diferencia significativa reproducible.
Entonces, a menos que alguien pueda señalar una mejor manera de probar esto (dado que los scnearios de uso reales aún no se conocen), tendré que llegar a la conclusión de que no importa y, por lo tanto, queda al gusto del desarrollador. En cuyo caso elegiría el n. ° 2.
Con la ayuda de delegar el constructor, solo necesita implementar cada concepto una vez;
- init predeterminado
- eliminar recursos
- intercambiar
- dupdo
el resto simplemente usa esos.
Además, no olvides hacer move-assignment (y swap) no noexcept
, si ayuda mucho al rendimiento si, por ejemplo, pones tu clase en un vector
#include <utility>
// header
class T
{
public:
T();
T(const T&);
T(T&&);
T& operator=(const T&);
T& operator=(T&&) noexcept;
~T();
void swap(T&) noexcept;
private:
void copy_from(const T&);
};
// implementation
T::T()
{
// here (and only here) you implement default init
}
T::~T()
{
// here (and only here) you implement resource delete
}
void T::swap(T&) noexcept
{
using std::swap; // enable ADL
// here (and only here) you implement swap, typically memberwise swap
}
void T::copy_from(const T& t)
{
if( this == &t ) return; // don''t forget to protect against self assign
// here (and only here) you implement copy
}
// the rest is generic:
T::T(const T& t)
: T()
{
copy_from(t);
}
T::T(T&& t)
: T()
{
swap(t);
}
auto T::operator=(const T& t) -> T&
{
copy_from(t);
return *this;
}
auto T::operator=(T&& t) noexcept -> T&
{
swap(t);
return *this;
}
Deja que te ayude:
#include <vector>
class AnObject
{
public:
AnObject( size_t n = 0 ) : data(n) {}
private:
std::vector<int> data;
};
Desde C ++ 0x FDIS, [class.copy] nota 9:
Si la definición de una clase X no declara explícitamente un constructor de movimiento, se declarará implícitamente como predeterminado si y solo si
X no tiene un constructor de copia declarado por el usuario,
X no tiene un operador de asignación de copia declarado por el usuario,
X no tiene un operador de asignación de movimiento declarado por el usuario,
X no tiene un destructor declarado por el usuario, y
el constructor de movimiento no se definiría implícitamente como eliminado.
[Nota: cuando el constructor de movimientos no se declara implícitamente o no se proporciona explícitamente, las expresiones que de otro modo habrían invocado al constructor de movimientos pueden invocar en su lugar un constructor de copias. -finalizar nota]
Personalmente, tengo mucha más confianza en que std::vector
administre correctamente sus recursos y optimice las copias / movimientos que en cualquier código podría escribir.
Si su objeto es rico en recursos, es posible que desee evitar la copia por completo, y solo proporcione el constructor de movimiento y el operador de asignación de movimiento. Sin embargo, si realmente quiere copiar también, es fácil proporcionar todas las operaciones.
Sus operaciones de copia parecen sensatas, pero las operaciones de su movimiento no. En primer lugar, aunque un parámetro de referencia rvalue se vinculará a un valor r, dentro de la función es un valor l , por lo que el constructor de movimiento debe ser:
AnObject( AnObject&& rh ) :
n( std::move(rh.n) ),
a( std::move(rh.a) )
{
rh.n = 0;
rh.a = nullptr;
}
Por supuesto, para los tipos fundamentales como los que tienes aquí, en realidad no hace una diferencia, pero es mejor que te acostumbres.
Si proporciona un constructor de movimiento, entonces no necesita un operador de asignación de movimiento cuando define la asignación de copia como lo hizo --- porque acepta el parámetro por valor , un valor r se moverá al parámetro en lugar de copiarse .
Como descubrió, no puede usar std::swap()
en todo el objeto dentro de un operador de asignación de movimiento, ya que eso volverá a aparecer en el operador de asignación de movimiento. El punto del comentario en la publicación a la que se vinculó es que no necesita implementar un swap
personalizado si proporciona operaciones de movimiento, ya que std::swap
utilizará sus operaciones de movimiento. Desafortunadamente, si no define un operador de asignación de movimiento por separado, esto no funciona, y aún recurrirá. Por supuesto, puede usar std::swap
para intercambiar los miembros:
AnObject& operator=(AnObject other)
{
std::swap(n,other.n);
std::swap(a,other.a);
return *this;
}
Tu clase final es así:
class AnObject
{
public:
AnObject( size_t n = 0 ) :
n( n ),
a( new int[ n ] )
{}
AnObject( const AnObject& rh ) :
n( rh.n ),
a( new int[ rh.n ] )
{
std::copy( rh.a, rh.a + n, a );
}
AnObject( AnObject&& rh ) :
n( std::move(rh.n) ),
a( std::move(rh.a) )
{
rh.n = 0;
rh.a = nullptr;
}
AnObject& operator = ( AnObject rh )
{
std::swap(n,rh.n);
std::swap(a,rh.a);
return *this;
}
~AnObject()
{
delete [] a;
}
private:
size_t n;
int* a;
};
Te has perdido una optimización significativa en tu operador de asignación de copias. Y posteriormente la situación se ha confundido.
AnObject& operator = ( const AnObject& rh )
{
if (this != &rh)
{
if (n != rh.n)
{
delete [] a;
n = 0;
a = new int [ rh.n ];
n = rh.n;
}
std::copy(rh.a, rh.a+n, a);
}
return *this;
}
A menos que nunca pienses que va a asignar AnObject
s del mismo tamaño, esto es mucho mejor. Nunca tires recursos si puedes reciclarlos.
Algunos podrían quejarse de que el operador de asignación de copias de AnObject
ahora solo tiene seguridad de excepción básica en lugar de una fuerte excepción de seguridad. Sin embargo considere esto:
Sus clientes siempre pueden tomar un operador de asignación rápida y darle una fuerte seguridad de excepción. Pero no pueden tomar un operador de asignación lenta y hacerlo más rápido.
template <class T> T& strong_assign(T& x, T y) { swap(x, y); return x; }
Tu constructor de movimientos está bien, pero tu operador de asignación de movimiento tiene una pérdida de memoria. Debería ser:
AnObject& operator = ( AnObject&& rh )
{
delete [] a;
n = rh.n;
a = rh.a;
rh.n = 0;
rh.a = nullptr;
return *this;
}
...
Data a = MakeData(); Data c = 5 * a + ( 1 + MakeMoreData() ) / 3;
q2: Usando una combinación de copia elisión / semántica de movimiento / RVO, el compilador debería poder hacer esto con un mínimo de copiado, ¿no?
Es posible que deba sobrecargar a sus operadores para aprovechar los recursos en valores:
Data operator+(Data&& x, const Data& y)
{
// recycle resources in x!
x += y;
return std::move(x);
}
En última instancia, los recursos deben crearse exactamente una vez por cada Data
le interese. No debería haber innecesario new/delete
solo con el propósito de mover las cosas.
q3 del póster original
Creo que usted (y algunos de los otros que respondieron) no entendieron lo que significaba el error del compilador, y llegaron a conclusiones erróneas debido a eso. El compilador cree que la llamada de asignación (movimiento) es ambigua, ¡y está bien! Usted tiene múltiples métodos que están igualmente calificados.
En su versión original de la clase AnObject
, su constructor de copia toma el objeto antiguo por referencia const
(lvalue), mientras que el operador de asignación toma su argumento por valor (no calificado). El argumento de valor es inicializado por el constructor de transferencia apropiado de lo que haya en el lado derecho del operador. Como solo tiene un constructor de transferencia, ese constructor de copia siempre se usa, sin importar si la expresión original del lado derecho fue un valor l o un valor r. Esto hace que el operador de asignación actúe como la función de miembro especial de asignación de copia.
La situación cambia una vez que se agrega un constructor de movimiento. Cada vez que se llama al operador de asignación, hay dos opciones para el constructor de transferencia. El constructor de copia se seguirá utilizando para las expresiones lvalue, pero el constructor de movimientos se usará cada vez que se proporcione una expresión rvalue. Esto hace que el operador de asignación actúe simultáneamente como la función de miembro especial de asignación de movimiento.
Cuando agregó un operador de asignación de movimiento tradicional, le dio a la clase dos versiones de la misma función de miembro especial, que es un error. Ya tiene lo que quería, así que simplemente deshágase del operador de asignación de movimiento tradicional, y no se necesitan otros cambios.
En los dos campamentos que figuran en su actualización, creo que estoy técnicamente en el primer campamento, pero por razones completamente diferentes. (No omita el operador de asignación de movimiento (tradicional) porque está "roto" para su clase, sino porque es superfluo).
Por cierto, soy nuevo en leer sobre C ++ 11 y . Se me ocurrió esta respuesta al explorar otra pregunta SO antes de ver esta. ( Actualización : en realidad, todavía tenía la page abierta. El enlace va a la respuesta específica de FredOverflow que muestra la técnica).
Acerca de la respuesta 2011-mayo-12 por Howard Hinnant
(Soy demasiado novato para comentar directamente las respuestas).
No es necesario que compruebe explícitamente la autoasignación si una prueba posterior ya la hubiera eliminado. En este caso, n != rh.n
ya se ocuparía de la mayor parte. Sin embargo, la llamada std::copy
está fuera de eso (actualmente) inner if
, por lo que obtendría n
auto-asignaciones a nivel de componente. Depende de usted decidir si esas asignaciones serían demasiado anti-óptimas, aunque la autoasignación sea rara.