Ventajas del paso por valor y std :: moverse sobre el paso por referencia

105

Estoy aprendiendo C ++ en este momento y trato de evitar adquirir malos hábitos. Por lo que tengo entendido, clang-tidy contiene muchas "mejores prácticas" y trato de ceñirme a ellas lo mejor posible (aunque no necesariamente entiendo por qué se consideran buenas todavía), pero no estoy seguro de si entienda lo que se recomienda aquí.

Usé esta clase del tutorial:

class Creature
{
private:
    std::string m_name;

public:
    Creature(const std::string &name)
            :  m_name{name}
    {
    }
};

Esto lleva a una sugerencia de clang-tidy de que debería pasar por valor en lugar de referencia y uso std::move. Si lo hago, recibo la sugerencia de hacer nameuna referencia (para asegurarme de que no se copien todas las veces) y la advertencia que std::moveno tendrá ningún efecto porque namees constasí que debería eliminarla.

La única forma en que no recibo una advertencia es eliminando por constcompleto:

Creature(std::string name)
        :  m_name{std::move(name)}
{
}

Lo que parece lógico, ya que el único beneficio de constera evitar jugar con la cadena original (lo que no sucede porque pasé por valor). Pero leí en CPlusPlus.com :

Aunque tenga en cuenta que, en la biblioteca estándar, mover implica que el objeto movido desde se deja en un estado válido pero no especificado. Lo que significa que, después de una operación de este tipo, el valor del objeto desde el que se movió solo debe destruirse o asignarse un nuevo valor; acceder a él de otro modo produce un valor no especificado.

Ahora imagina este código:

std::string nameString("Alex");
Creature c(nameString);

Debido a que nameStringse pasa por valor, std::movesolo se invalidará namedentro del constructor y no tocará la cadena original. Pero, ¿cuáles son las ventajas de esto? Parece que el contenido se copia solo una vez de todos modos: si paso por referencia cuando llamo m_name{name}, si paso por valor cuando lo paso (y luego se mueve). Entiendo que esto es mejor que pasar por valor y no usar std::move(porque se copia dos veces).

Entonces dos preguntas:

  1. ¿Entendí correctamente lo que está pasando aquí?
  2. ¿Hay alguna ventaja de usar std::movesobrepasar por referencia y simplemente llamar m_name{name}?
Blackbot
fuente
3
Con pase por referencia, Creature c("John");hace una copia adicional
user253751
1
Este enlace puede ser una lectura valiosa, también cubre la aprobación std::string_viewy el SSO.
lubgr
He descubierto que clang-tidyes una excelente manera de obsesionarme con microoptimizaciones innecesarias a expensas de la legibilidad. La pregunta que debemos hacer aquí, antes que nada, es cuántas veces llamamos realmente al Creatureconstructor.
cz

Respuestas:

36
  1. ¿Entendí correctamente lo que está pasando aquí?

Si.

  1. ¿Hay alguna ventaja de usar std::movesobrepasar por referencia y simplemente llamar m_name{name}?

Una firma de función fácil de entender sin sobrecargas adicionales. La firma revela inmediatamente que se copiará el argumento; esto evita que las personas que llaman se pregunten si una const std::string&referencia podría almacenarse como un miembro de datos, lo que posiblemente se convierta en una referencia pendiente más adelante. Y no hay necesidad de sobrecargar argumentos std::string&& namey const std::string&para evitar copias innecesarias cuando se pasan rvalues ​​a la función. Pasando un lvalue

std::string nameString("Alex");
Creature c(nameString);

a la función que toma su argumento por valor causa una copia y una construcción de movimiento. Pasando un rvalue a la misma función

std::string nameString("Alex");
Creature c(std::move(nameString));

provoca dos construcciones de movimiento. Por el contrario, cuando el parámetro de la función es const std::string&, siempre habrá una copia, incluso al pasar un argumento rvalue. Esto es claramente una ventaja siempre que el tipo de argumento sea barato de mover-construir (este es el caso std::string).

Pero hay una desventaja a considerar: el razonamiento no funciona para funciones que asignan el argumento de la función a otra variable (en lugar de inicializarlo):

void setName(std::string name)
{
    m_name = std::move(name);
}

provocará una desasignación del recurso al que se m_namerefiere antes de que se reasigne. Recomiendo leer el ítem 41 en Effective Modern C ++ y también esta pregunta .

lubgr
fuente
Eso tiene sentido, especialmente porque hace que la declaración sea más intuitiva de leer. No estoy seguro de comprender completamente la parte de desasignación de su respuesta (y comprender el hilo vinculado), así que solo para verificar Si uso move, el espacio se desasignará. Si no lo uso move, solo se desasignará si el espacio asignado es demasiado pequeño para contener la nueva cadena, lo que mejora el rendimiento. ¿Es eso correcto?
Blackbot
1
Sí, eso es exactamente. Cuando se asigna m_namedesde un const std::string&parámetro, la memoria interna se reutiliza siempre que m_nameencaje. Al asignar un movimiento m_name, la memoria debe desasignarse de antemano. De lo contrario, era imposible "robar" los recursos del lado derecho de la tarea.
lubgr
¿Cuándo se convierte en una referencia pendiente? Creo que la lista de inicialización usa una copia profunda.
Li Taiji
103
/* (0) */ 
Creature(const std::string &name) : m_name{name} { }
  • Un lvalue pasado se une a namey luego se copia en m_name.

  • Un rvalue pasado se une a namey luego se copia en m_name.


/* (1) */ 
Creature(std::string name) : m_name{std::move(name)} { }
  • Un pasado lvalue se copian en name, a continuación, se trasladó a m_name.

  • Un rvalue pasado se mueve a name, luego se mueve a m_name.


/* (2) */ 
Creature(const std::string &name) : m_name{name} { }
Creature(std::string &&rname) : m_name{std::move(rname)} { }
  • Un lvalue pasado se une a namey luego se copia en m_name.

  • Un rvalue pasado se une a rnamey luego se mueve a m_name.


Como las operaciones de movimiento suelen ser más rápidas que las copias, (1) es mejor que (0) si pasa muchos temporales. (2) es óptimo en términos de copias / movimientos, pero requiere repetición de código.

La repetición del código se puede evitar con un reenvío perfecto :

/* (3) */
template <typename T,
          std::enable_if_t<
              std::is_convertible_v<std::remove_cvref_t<T>, std::string>, 
          int> = 0
         >
Creature(T&& name) : m_name{std::forward<T>(name)} { }

Opcionalmente, puede querer restringir Tpara restringir el dominio de tipos con los que se puede crear una instancia de este constructor (como se muestra arriba). C ++ 20 tiene como objetivo simplificar esto con Concepts .


En C ++ 17, los valores prvalues se ven afectados por la elisión de copia garantizada , que, cuando sea aplicable, reducirá el número de copias / movimientos al pasar argumentos a funciones.

Vittorio Romeo
fuente
Para (1) el caso de valor pr y valor x no son idénticos desde c ++ 17 ¿no?
Oliv
1
Tenga en cuenta que no necesita el SFINAE para perfeccionar el avance en este caso. Solo es necesario desambiguar. Es plausiblemente útil para los posibles mensajes de error al pasar malos argumentos
Caleth
@Oliv Sí. xvalues ​​deben moverse, mientras que prvalues ​​se pueden eliminar :)
Rakete1111
1
¿Podemos escribir: Creature(const std::string &name) : m_name{std::move(name)} { }en el (2) ?
skytree
4
@skytree: no se puede mover de un objeto constante, ya que mover muta la fuente. Eso se compilará, pero hará una copia.
Vittorio Romeo
1

La forma en que aprueba no es la única variable aquí, lo que aprueba marca la gran diferencia entre los dos.

En C ++, tenemos todo tipo de categorías de valor y exista este "lenguaje" para los casos en que se pasa en un valor de lado derecho (como "Alex-string-literal-that-constructs-temporary-std::string"o std::move(nameString)), lo que da como resultado 0 copias de std::stringhaber sido hecho (el tipo no siquiera tiene que ser copia Urbanizable para argumentos rvalue), y solo usa std::stringel constructor move.

Preguntas y respuestas algo relacionadas .

LogicStuff
fuente
1

Hay varias desventajas del enfoque de paso por valor y movimiento sobre la referencia de paso por (rv):

  • hace que se generen 3 objetos en lugar de 2;
  • pasar un objeto por valor puede llevar a una sobrecarga de pila adicional, porque incluso la clase de cadena regular es típicamente al menos 3 o 4 veces más grande que un puntero;
  • la construcción de los objetos de argumento se realizará en el lado del llamante, lo que provocará un exceso de código;
usuario7860670
fuente
¿Podrías aclarar por qué haría que aparecieran 3 objetos? Por lo que tengo entendido, puedo pasar "Peter" como una cadena. Esto se generaría, se copiaría y luego se movería, ¿no es así? ¿Y no se usaría la pila en algún momento independientemente? ¿No en el punto de la llamada al constructor, sino en la m_name{name}parte donde se copia?
Blackbot
@Blackbot Me estaba refiriendo a su ejemplo, std::string nameString("Alex"); Creature c(nameString);un objeto es nameString, otro es un argumento de función y el tercero es un campo de clase.
user7860670