¿Cómo pasar los parámetros correctamente?

108

Soy un principiante en C ++ pero no un principiante en programación. Estoy tratando de aprender C ++ (c ++ 11) y no me queda claro lo más importante: pasar parámetros.

Consideré estos simples ejemplos:

  • Una clase que tiene todos sus miembros tipos primitivos:
    CreditCard(std::string number, int expMonth, int expYear,int pin):number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • Una clase que tiene como miembros tipos primitivos + 1 tipo complejo:
    Account(std::string number, float amount, CreditCard creditCard) : number(number), amount(amount), creditCard(creditCard)

  • Una clase que tiene como miembros tipos primitivos + 1 colección de algún tipo complejo: Client(std::string firstName, std::string lastName, std::vector<Account> accounts):firstName(firstName), lastName(lastName), accounts(accounts)

Cuando creo una cuenta, hago esto:

    CreditCard cc("12345",2,2015,1001);
    Account acc("asdasd",345, cc);

Obviamente, la tarjeta de crédito se copiará dos veces en este escenario. Si reescribo ese constructor como

Account(std::string number, float amount, CreditCard& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(creditCard)

habrá una copia. Si lo reescribo como

Account(std::string number, float amount, CreditCard&& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(std::forward<CreditCard>(creditCard))

Habrá 2 movimientos y ninguna copia.

Creo que a veces es posible que desee copiar algún parámetro, a veces no desea copiar cuando crea ese objeto.
Vengo de C # y, al estar acostumbrado a las referencias, me resulta un poco extraño y creo que debería haber 2 sobrecargas para cada parámetro, pero sé que estoy equivocado.
¿Existen mejores prácticas sobre cómo enviar parámetros en C ++ porque realmente lo encuentro, digamos, no trivial? ¿Cómo manejaría mis ejemplos presentados anteriormente?

Jack Willson
fuente
9
Meta: No puedo creer que alguien haya hecho una buena pregunta sobre C ++. +1.
23
Para su información, std::stringes una clase CreditCard, no un tipo primitivo.
chris
7
Debido a la confusión de cadenas, aunque no está relacionada, debe saber que un literal de cadena,, "abc"no es de tipo std::string, ni de tipo char */const char *, sino de tipo const char[N](con N = 4 en este caso debido a tres caracteres y un nulo). Esa es una idea errónea común y buena de apartarse del camino.
Chris
10
@chuex: Jon Skeet se ocupa de todas las preguntas de C #, mucho menos de C ++.
Steve Jessop
Altamente relacionado: ¿Cómo pasar objetos a funciones en C ++?
legends2k

Respuestas:

158

LA PREGUNTA MÁS IMPORTANTE PRIMERO:

¿Existen mejores prácticas sobre cómo enviar parámetros en C ++ porque realmente lo encuentro, digamos, no trivial?

Si su función necesita modificar el objeto original que se está pasando, de modo que después de que la llamada regrese, las modificaciones a ese objeto serán visibles para la persona que llama, entonces debe pasar por la referencia lvalue :

void foo(my_class& obj)
{
    // Modify obj here...
}

Si su función no necesita modificar el objeto original y no necesita crear una copia del mismo (en otras palabras, solo necesita observar su estado), entonces debe pasar por referencia lvalue aconst :

void foo(my_class const& obj)
{
    // Observe obj here
}

Esto le permitirá llamar a la función tanto con lvalues ​​(lvalues ​​son objetos con una identidad estable) como con rvalues ​​(rvalues ​​son, por ejemplo , temporales u objetos de los que está a punto de moverse como resultado de la llamada std::move()).

También se podría argumentar que para los tipos fundamentales o tipos para los que la copia es rápida , como int, boolo char, no hay necesidad de pasar por referencia si la función simplemente necesita observar el valor, y debería favorecerse el paso por valor . Eso es correcto si la semántica de referencia no es necesaria, pero ¿qué pasa si la función quisiera almacenar un puntero a ese mismo objeto de entrada en algún lugar, de modo que las lecturas futuras a través de ese puntero vean las modificaciones de valor que se han realizado en alguna otra parte del ¿código? En este caso, pasar por referencia es la solución correcta.

Si su función no necesita modificar el objeto original, pero necesita almacenar una copia de ese objeto ( posiblemente para devolver el resultado de una transformación de la entrada sin alterar la entrada ), entonces podría considerar tomar por valor :

void foo(my_class obj) // One copy or one move here, but not working on
                       // the original object...
{
    // Working on obj...

    // Possibly move from obj if the result has to be stored somewhere...
}

La invocación de la función anterior siempre resultará en una copia al pasar lvalues, y en una se mueve al pasar rvalues. Si su función necesita almacenar este objeto en algún lugar, puede realizar un movimiento adicional desde él (por ejemplo, en el caso de que foo()sea una función miembro que necesita almacenar el valor en un miembro de datos ).

En caso de que los movimientos sean costosos para los objetos de tipo my_class, entonces puede considerar la sobrecarga foo()y proporcionar una versión para lvalues ​​(aceptando una referencia de lvalue a const) y una versión para rvalues ​​(aceptando una referencia de rvalue):

// Overload for lvalues
void foo(my_class const& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = obj; // Copy!
    // Working on copyOfObj...
}

// Overload for rvalues
void foo(my_class&& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = std::move(obj); // Move! 
                                         // Notice, that invoking std::move() is 
                                         // necessary here, because obj is an
                                         // *lvalue*, even though its type is 
                                         // "rvalue reference to my_class".
    // Working on copyOfObj...
}

Las funciones anteriores son tan similares, de hecho, que podría hacer una función única: foo()podría convertirse en una plantilla de función y podría usar el reenvío perfecto para determinar si un movimiento o una copia del objeto que se está pasando se generará internamente:

template<typename C>
void foo(C&& obj) // No copy, no move (just reference binding)
//       ^^^
//       Beware, this is not always an rvalue reference! This will "magically"
//       resolve into my_class& if an lvalue is passed, and my_class&& if an
//       rvalue is passed
{
    my_class copyOfObj = std::forward<C>(obj); // Copy if lvalue, move if rvalue
    // Working on copyOfObj...
}

Es posible que desee obtener más información sobre este diseño viendo esta charla de Scott Meyers (solo tenga en cuenta el hecho de que el término " Referencias universales " que está usando no es estándar).

Una cosa a tener en cuenta es que std::forwardgeneralmente terminará en un movimiento para rvalues, por lo que aunque parezca relativamente inocente, reenviar el mismo objeto varias veces puede ser una fuente de problemas, por ejemplo, ¡moverse desde el mismo objeto dos veces! Así que tenga cuidado de no poner esto en un bucle y no reenviar el mismo argumento varias veces en una llamada de función:

template<typename C>
void foo(C&& obj)
{
    bar(std::forward<C>(obj), std::forward<C>(obj)); // Dangerous!
}

También tenga en cuenta que normalmente no recurre a la solución basada en plantillas a menos que tenga una buena razón para ello, ya que hace que su código sea más difícil de leer. Normalmente, debería centrarse en la claridad y la sencillez .

Las anteriores son solo pautas simples, pero la mayoría de las veces le indicarán buenas decisiones de diseño.


ACERCA DEL RESTO DE SU PUESTO:

Si lo reescribo como [...] habrá 2 movimientos y ninguna copia.

Esto no es correcto. Para empezar, una referencia rvalue no se puede vincular a un lvalue, por lo que esto solo se compilará cuando pase un rvalue de tipo CreditCarda su constructor. Por ejemplo:

// Here you are passing a temporary (OK! temporaries are rvalues)
Account acc("asdasd",345, CreditCard("12345",2,2015,1001));

CreditCard cc("12345",2,2015,1001);
// Here you are passing the result of std::move (OK! that's also an rvalue)
Account acc("asdasd",345, std::move(cc));

Pero no funcionará si intenta hacer esto:

CreditCard cc("12345",2,2015,1001);
Account acc("asdasd",345, cc); // ERROR! cc is an lvalue

Porque cces un lvalue y las referencias de rvalue no se pueden enlazar con lvalues. Además, cuando se vincula una referencia a un objeto, no se realiza ningún movimiento : es solo una vinculación de referencia. Por lo tanto, solo habrá un movimiento.


Entonces, según las pautas proporcionadas en la primera parte de esta respuesta, si le preocupa la cantidad de movimientos que se generan cuando toma un CreditCardvalor por valor, podría definir dos sobrecargas de constructor, una tomando una referencia de lvalue a const( CreditCard const&) y otra tomando una referencia rvalue ( CreditCard&&).

La resolución de sobrecarga seleccionará el primero al pasar un lvalue (en este caso, se realizará una copia) y el segundo al pasar un rvalue (en este caso, se realizará un movimiento).

Account(std::string number, float amount, CreditCard const& creditCard) 
: number(number), amount(amount), creditCard(creditCard) // copy here
{ }

Account(std::string number, float amount, CreditCard&& creditCard) 
: number(number), amount(amount), creditCard(std::move(creditCard)) // move here
{ }

El uso de std::forward<>normalmente se ve cuando desea lograr un reenvío perfecto . En ese caso, su constructor sería en realidad una plantilla de constructor y se vería más o menos de la siguiente manera

template<typename C>
Account(std::string number, float amount, C&& creditCard) 
: number(number), amount(amount), creditCard(std::forward<C>(creditCard)) { }

En cierto sentido, esto combina las sobrecargas que he mostrado anteriormente en una sola función: Cse deducirá CreditCard&en caso de que esté pasando un lvalue, y debido a las reglas de colapso de referencia, hará que esta función sea instanciada:

Account(std::string number, float amount, CreditCard& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard&>(creditCard)) 
{ }

Esto provocará una construcción de copia de creditCard, como desearía. Por otro lado, cuando se pasa un rvalue, Cse deducirá que es CreditCard, y esta función se instanciará en su lugar:

Account(std::string number, float amount, CreditCard&& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard>(creditCard)) 
{ }

Esto provocará una construcción de movimiento de creditCard, que es lo que desea (porque el valor que se pasa es un valor r, y eso significa que estamos autorizados a movernos de él).

Andy Prowl
fuente
¿Es incorrecto decir que para tipos de parámetros no primitivos está bien usar siempre la versión de plantilla con reenvío?
Jack Willson
1
@JackWillson: Yo diría que debería recurrir a la versión de plantilla solo cuando realmente tenga preocupaciones sobre el desempeño de los movimientos. Vea esta pregunta mía , que tiene una buena respuesta, para obtener más información. En general, si no tienes que hacer una copia y solo necesitas observar, lleva por referencia const. Si necesita modificar el objeto original, tome por referencia a non-`const. Si necesita hacer una copia y los movimientos son baratos, tome por valor y luego muévase.
Andy Prowl
2
Creo que no es necesario moverse en la tercera muestra de código. Podrías usar en objlugar de hacer una copia local para mudarte, ¿no?
juanchopanza
3
@AndyProwl: Obtuviste mi +1 hace una hora, pero releyendo tu (excelente) respuesta, me gustaría señalar dos cosas: a) el valor por valor no siempre crea una copia (si no se mueve). El objeto podría construirse in situ en el sitio de las personas que llaman, especialmente con RVO / NRVO, esto incluso funciona con más frecuencia de lo que uno pensaría en un principio. b) Tenga en cuenta que en el último ejemplo, solo std::forwardse puede llamar una vez . He visto a personas ponerlo en bucles, etc. y dado que esta respuesta será vista por muchos principiantes, en mi humilde opinión debería haber una gruesa etiqueta de "¡Advertencia!" Para ayudarlos a evitar esta trampa.
Daniel Frey
1
@SteveJessop: Y aparte de eso, hay problemas técnicos (no necesariamente problemas) con las funciones de reenvío ( especialmente los constructores que toman un argumento ), principalmente el hecho de que aceptan argumentos de cualquier tipo y pueden std::is_constructible<>anular el rasgo de tipo a menos que sean correctamente SFINAE- restringido, lo que puede no ser trivial para algunos.
Andy Prowl
11

Primero, déjame corregir algunos detalles. Cuando dices lo siguiente:

habrá 2 movimientos y ninguna copia.

Eso es falso. La vinculación a una referencia rvalue no es un movimiento. Solo hay un movimiento.

Además, dado CreditCardque no es un parámetro de plantilla, std::forward<CreditCard>(creditCard)es solo una forma detallada de decirlo std::move(creditCard).

Ahora...

Si sus tipos tienen movimientos "baratos", es posible que desee hacer su vida más fácil y tomar todo por valor y " std::movejunto".

Account(std::string number, float amount, CreditCard creditCard)
: number(std::move(number),
  amount(amount),
  creditCard(std::move(creditCard)) {}

Este enfoque le dará dos movimientos cuando solo podría producir uno, pero si los movimientos son baratos, pueden ser aceptables.

Mientras estamos en este asunto de "movimientos baratos", debo recordarles que a std::stringmenudo se implementa con la llamada optimización de cadena pequeña, por lo que sus movimientos pueden no ser tan baratos como copiar algunos punteros. Como es habitual con los problemas de optimización, si importa o no es algo para preguntarle a su generador de perfiles, no a mí.

¿Qué hacer si no quiere incurrir en esos movimientos adicionales? Tal vez resulten demasiado costosos o, lo que es peor, tal vez los tipos no se puedan mover y podría generar copias adicionales.

Si solo hay un parámetro problemático, puede proporcionar dos sobrecargas, con T const&yT&& . Eso vinculará las referencias todo el tiempo hasta la inicialización real del miembro, donde ocurre una copia o movimiento.

Sin embargo, si tiene más de un parámetro, esto conduce a una explosión exponencial en el número de sobrecargas.

Este es un problema que se puede solucionar con un reenvío perfecto. Eso significa que, en su lugar, escribe una plantilla y la usa std::forwardpara llevar la categoría de valor de los argumentos a su destino final como miembros.

template <typename TString, typename TCreditCard>
Account(TString&& number, float amount, TCreditCard&& creditCard)
: number(std::forward<TString>(number),
  amount(amount),
  creditCard(std::forward<TCreditCard>(creditCard)) {}
R. Martinho Fernandes
fuente
Hay un problema con la versión de la plantilla: el usuario ya no puede escribir Account("",0,{brace, initialisation}).
ipc
@ipc ah, cierto. Eso es realmente molesto y no creo que haya una solución fácil y escalable.
R. Martinho Fernandes
6

En primer lugar, std::stringes un tipo de clase bastante fuerte comostd::vector . Ciertamente no es primitivo.

Si está tomando tipos móviles grandes por valor en un constructor, los incluiría std::moveen el miembro:

CreditCard(std::string number, float amount, CreditCard creditCard)
  : number(std::move(number)), amount(amount), creditCard(std::move(creditCard))
{ }

Así es exactamente como recomendaría implementar el constructor. Hace que los miembros numberycreditCard se muevan construidos, en lugar de copiarse. Cuando use este constructor, habrá una copia (o movimiento, si es temporal) cuando el objeto se pasa al constructor y luego un movimiento al inicializar el miembro.

Ahora consideremos este constructor:

Account(std::string number, float amount, CreditCard& creditCard)
  : number(number), amount(amount), creditCard(creditCard)

Tienes razón, esto implicará una copia de creditCard, porque primero se pasa al constructor por referencia. Pero ahora no puede pasar constobjetos al constructor (porque la referencia no es const) y no puede pasar objetos temporales. Por ejemplo, no podrías hacer esto:

Account account("something", 10.0f, CreditCard("12345",2,2015,1001));

Ahora consideremos:

Account(std::string number, float amount, CreditCard&& creditCard)
  : number(number), amount(amount), creditCard(std::forward<CreditCard>(creditCard))

Aquí ha mostrado un malentendido de las referencias de rvalue y std::forward. Solo debería usarlo std::forwardcuando el objeto que está reenviando se declara como T&&para algún tipo deducido T . Aquí CreditCardno se deduce (supongo), por lo que std::forwardse está utilizando por error. Busque referencias universales .

Joseph Mansfield
fuente
1

Utilizo una regla bastante simple para el caso general: use copy para POD (int, bool, double, ...) y const & para todo lo demás ...

Y querer copiar o no, no se responde con la firma del método sino más bien con lo que haces con los parámetros.

struct A {
  A(const std::string& aValue, const std::string& another) 
    : copiedValue(aValue), justARef(another) {}
  std::string copiedValue;
  const std::string& justARef; 
};

precisión para puntero: casi nunca los usé. La única ventaja sobre & es que pueden ser nulos o reasignados.

David Fleury
fuente
2
"Utilizo una regla bastante simple para el caso general: use copy para POD (int, bool, double, ...) y const & para todo lo demás". No. Solo No.
Zapato
Puede estar agregando, si desea modificar un valor usando un & (sin constante). De lo contrario, no veo ... la simplicidad es suficiente. Pero si tú lo
dijeras
Algunas veces desea modificar por referencia tipos primitivos, otras veces necesita hacer una copia de objetos y otras veces debe mover objetos. No puede simplemente reducir todo a POD> por valor y UDT> por referencia.
Zapato
Ok, esto es solo lo que agregué después. Puede ser una respuesta demasiado rápida.
David Fleury
1

No me queda claro lo más importante: pasar parámetros.

  • Si desea modificar la variable pasada dentro de la función / método
    • lo pasas por referencia
    • lo pasas como puntero (*)
  • Si desea leer el valor / variable pasado dentro de la función / método
    • lo pasas por referencia constante
  • Si desea modificar el valor pasado dentro de la función / método
    • lo pasas normalmente copiando el objeto (**)

(*) los punteros pueden referirse a la memoria asignada dinámicamente, por lo tanto, cuando sea posible, debe preferir las referencias a los punteros incluso si, al final, las referencias se implementan generalmente como punteros.

(**) "normalmente" significa por constructor de copia (si pasa un objeto del mismo tipo del parámetro) o por constructor normal (si pasa un tipo compatible para la clase). Cuando pasa un objeto como myMethod(std::string), por ejemplo, el constructor de copia se usará si std::stringse le pasa un, por lo tanto, debe asegurarse de que exista uno.

Zapato
fuente