c++ - ¿Por qué algunas personas usan swap para asignaciones de movimiento?
c++11 rvalue-reference (4)
Otra cosa a considerar con respecto a la compensación:
La implementación default-construct + swap puede parecer más lenta, pero -a veces- el análisis de flujo de datos en el compilador puede eliminar algunas asignaciones inútiles y terminar muy similar al código manuscrito. Esto funciona solo para tipos sin semántica de valores "inteligentes". Como ejemplo,
struct Dummy
{
Dummy(): x(0), y(0) {} // suppose we require default 0 on these
Dummy(Dummy&& other): x(0), y(0)
{
swap(other);
}
void swap(Dummy& other)
{
std::swap(x, other.x);
std::swap(y, other.y);
text.swap(other.text);
}
int x, y;
std::string text;
}
código generado en movimiento ctor sin optimización:
<inline std::string() default ctor>
x = 0;
y = 0;
temp = x;
x = other.x;
other.x = temp;
temp = y;
y = other.y;
other.y = temp;
<inline impl of text.swap(other.text)>
Esto se ve horrible, pero el análisis de flujo de datos puede determinar que es equivalente al código:
x = other.x;
other.x = 0;
y = other.y;
other.y = 0;
<overwrite this->text with other.text, set other.text to default>
Tal vez en la práctica los compiladores no siempre produzcan la versión óptima. Es posible que desee experimentar con él y echar un vistazo a la asamblea.
También hay casos en los que el intercambio es mejor que la asignación debido a la semántica de los valores "inteligentes", por ejemplo, si uno de los miembros de la clase es std :: shared_ptr. No hay razón para que un constructor de movimiento se meta con el refcounter atómico.
Por ejemplo, stdlibc ++ tiene lo siguiente:
unique_lock& operator=(unique_lock&& __u)
{
if(_M_owns)
unlock();
unique_lock(std::move(__u)).swap(*this);
__u._M_device = 0;
__u._M_owns = false;
return *this;
}
¿Por qué no simplemente asignar los dos miembros __u a * esto directamente? El intercambio no implica que a __u se le asignen * estos miembros, solo para que luego se les asigne 0 y falso ... en cuyo caso el intercambio está haciendo un trabajo innecesario. ¿Qué me estoy perdiendo? (unique_lock :: swap solo hace un std :: swap en cada miembro)
Que es mi culpa. (medio bromeando, medio no).
Cuando primero mostré ejemplos de implementaciones de operadores de asignación de movimiento, acabo de usar swap. Luego, un tipo inteligente (no recuerdo quién) me señaló que los efectos secundarios de la destrucción de lhs antes de la asignación podrían ser importantes (como el desbloqueo () en su ejemplo). Así que dejé de usar el intercambio para la asignación de movimiento. Pero la historia de usar swap sigue ahí y persiste.
No hay ninguna razón para usar swap en este ejemplo. Es menos eficiente que lo que sugieres. De hecho, en libc++ , hago exactamente lo que sugiere:
unique_lock& operator=(unique_lock&& __u)
{
if (__owns_)
__m_->unlock();
__m_ = __u.__m_;
__owns_ = __u.__owns_;
__u.__m_ = nullptr;
__u.__owns_ = false;
return *this;
}
En general, un operador de asignación de movimiento debe:
- Destruya los recursos visibles (aunque tal vez guarde los recursos detallados de implementación).
- Mover asignar todas las bases y miembros.
- Si la asignación de movimiento de bases y miembros no hizo que el rhs carezca de recursos, hágalo.
Al igual que:
unique_lock& operator=(unique_lock&& __u)
{
// 1. Destroy visible resources
if (__owns_)
__m_->unlock();
// 2. Move assign all bases and members.
__m_ = __u.__m_;
__owns_ = __u.__owns_;
// 3. If the move assignment of bases and members didn''t,
// make the rhs resource-less, then make it so.
__u.__m_ = nullptr;
__u.__owns_ = false;
return *this;
}
Actualizar
En los comentarios hay una pregunta complementaria sobre cómo manejar los constructores de movimientos. Empecé a responder allí (en comentarios), pero las restricciones de formato y longitud hacen que sea difícil crear una respuesta clara. Por lo tanto, estoy poniendo mi respuesta aquí.
La pregunta es: ¿Cuál es el mejor patrón para crear un constructor de movimientos? Delegar al constructor predeterminado y luego intercambiar? Esto tiene la ventaja de reducir la duplicación de código.
Mi respuesta es: creo que la conclusión más importante es que los programadores deben desconfiar de seguir patrones sin pensar. Puede haber algunas clases donde la implementación de un constructor de movimiento como predeterminado + intercambio es exactamente la respuesta correcta. La clase puede ser grande y complicada. A(A&&) = default;
puede hacer lo incorrecto. Creo que es importante considerar todas tus opciones para cada clase.
Echemos un vistazo al ejemplo de OP en detalle: std::unique_lock(unique_lock&&)
.
Observaciones:
A. Esta clase es bastante simple. Tiene dos miembros de datos:
mutex_type* __m_; bool __owns_;
B. Esta clase está en una biblioteca de propósito general, para ser utilizada por un número desconocido de clientes. En tal situación, las preocupaciones sobre el rendimiento son una alta prioridad. No sabemos si nuestros clientes van a utilizar esta clase en el código de rendimiento crítico o no. Entonces debemos asumir que lo son.
C. El constructor de movimientos para esta clase va a consistir en un pequeño número de cargas y tiendas, no importa qué. Entonces, una buena forma de ver el rendimiento es contar cargas y tiendas. Por ejemplo, si haces algo con 4 tiendas y alguien más hace lo mismo con solo 2 tiendas, ambas implementaciones son muy rápidas. ¡Pero el de ellos es dos veces más rápido que el tuyo! Esa diferencia podría ser crítica en el círculo cerrado de algunos clientes.
Primero permite contar cargas y tiendas en el constructor predeterminado, y en la función de intercambio de miembros:
// 2 stores
unique_lock()
: __m_(nullptr),
__owns_(false)
{
}
// 4 stores, 4 loads
void swap(unique_lock& __u)
{
std::swap(__m_, __u.__m_);
std::swap(__owns_, __u.__owns_);
}
Ahora permitamos implementar el constructor de movimientos de dos maneras:
// 4 stores, 2 loads
unique_lock(unique_lock&& __u)
: __m_(__u.__m_),
__owns_(__u.__owns_)
{
__u.__m_ = nullptr;
__u.__owns_ = false;
}
// 6 stores, 4 loads
unique_lock(unique_lock&& __u)
: unique_lock()
{
swap(__u);
}
La primera forma parece mucho más complicada que la segunda. Y el código fuente es más grande, y un código de duplicación que ya podríamos haber escrito en otro lugar (por ejemplo, en el operador de asignación de movimiento). Eso significa que hay más oportunidades para los errores.
La segunda forma es más simple y reutiliza el código que ya hemos escrito. Por lo tanto, menos posibilidades de errores.
La primera forma es más rápida. Si el costo de las cargas y las tiendas es aproximadamente el mismo, quizás un 66% más rápido.
Esta es una compensación de ingeniería clásica. No hay almuerzo gratis. Y a los ingenieros nunca se les exime de la carga de tener que tomar decisiones sobre las compensaciones. En el momento en que lo hace, los aviones comienzan a caerse del aire y las plantas nucleares comienzan a derretirse.
Para libc++ , elegí la solución más rápida. Mi razonamiento es que para esta clase, será mejor que lo haga bien sin importar nada; la clase es lo suficientemente simple como para que mis posibilidades de hacerlo bien sean altas; y mis clientes van a valorar el rendimiento. Bien podría llegar a otra conclusión para una clase diferente en un contexto diferente.
Responderé la pregunta desde el encabezado: "¿Por qué algunas personas usan el intercambio para las asignaciones de movimiento?".
La razón principal para usar swap
es proporcionar una asignación de movimiento sin excepción .
Del comentario de Howard Hinnant:
En general, un operador de asignación de movimiento debe:
1. Destruya los recursos visibles (aunque tal vez guarde los recursos detallados de implementación).
¡Pero en general la función de destrucción / liberación puede fallar y arrojar excepciones !
Aquí hay un ejemplo:
class unix_fd
{
int fd;
public:
explicit unix_fd(int f = -1) : fd(f) {}
~unix_fd()
{
if(fd == -1) return;
if(::close(fd)) /* !!! call is failed! But we can''t throw from destructor so just silently ignore....*/;
}
void close() // Our release-function
{
if(::close(fd)) throw system_error_with_errno_code;
}
};
Ahora comparemos dos implementaciones de movimiento-asignación:
// #1
void unix_fd::operator=(unix_fd &&o) // Can''t be noexcept
{
if(&o != this)
{
close(); // !!! Can throw here
fd = o.fd;
o.fd = -1;
}
return *this;
}
y
// #2
void unix_fd::operator=(unix_fd &&o) noexcept
{
std::swap(fd, o.fd);
return *this;
}
#2
es perfectamente noexcept!
Sí, la llamada close()
puede ser "demorada" en el caso #2
. ¡Pero! Si queremos una estricta comprobación de errores, debemos usar la llamada explícita close()
, no el destructor. Destructor libera recursos solo en situaciones de "emergencia", donde la excepción no se puede lanzar de todos modos.
PD Vea también la discusión here en comentarios
Se trata de seguridad de excepción. Como __u
ya está construido cuando se llama al operador, sabemos que no hay excepción, y que el swap
no se produce.
Si hicieras las asignaciones de miembros manualmente, te arriesgarías a que cada una de ellas arrojara una excepción, y luego tendrías que lidiar con tener algo asignado a un movimiento parcial pero tener que rescatar.
Quizás en este ejemplo trivial esto no se muestra, pero es un principio de diseño general:
- Copiar-asignar por copy-construir y swap.
- Mover-asignar por mover-construir y cambiar.
- Escribe
+
en términos de constructo y+=
, etc.
Básicamente, intenta minimizar la cantidad de código "real" e intenta expresar tantas otras características en términos de las características principales como pueda.
(El unique_ptr
toma una referencia rvalue explícita en la asignación porque no permite la construcción / asignación de copias, por lo que no es el mejor ejemplo de este principio de diseño).