c++ - todas - ¿Cómo pasar los parámetros correctamente?
tipos de funciones en c (5)
Soy un principiante de C ++ pero no un principiante de programación. Estoy tratando de aprender C ++ (c ++ 11) y para mí es lo más importante: pasar los parámetros.
Consideré estos ejemplos simples:
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, acostumbrado a las referencias, es un poco extraño para mí y creo que debería haber 2 sobrecargas para cada parámetro, pero sé que estoy equivocado.
¿Hay alguna mejor práctica de cómo enviar parámetros en C ++ porque realmente la encuentro, digamos, no trivial? ¿Cómo manejarías mis ejemplos presentados arriba?
Para mí, es algo poco claro: pasar parámetros.
- Si desea modificar la variable pasada dentro de la función / método
- lo pasas por referencia
- lo pasas como un puntero (*)
- Si quiere leer el valor / variable pasada 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, debería preferir referencias sobre punteros, incluso si las referencias son, en el final, generalmente implementadas 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 se le pasa una std::string
, por lo tanto, debe asegurarse de que exista.
Antes que nada, std::string
es un tipo de clase bastante fuerte como std::vector
. Ciertamente no es primitivo.
Si toma cualquier tipo de elemento móvil grande por valor en un constructor, lo std::move
forma std::move
al 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 el number
miembros y la creditCard
de creditCard
se muevan construidos, en lugar de copiarlos. Cuando use este constructor, habrá una copia (o movimiento, si es temporal) a medida que el objeto se pasa al constructor y luego se mueve al inicializar el miembro.
Ahora consideremos este constructor:
Account(std::string number, float amount, CreditCard& creditCard)
: number(number), amount(amount), creditCard(creditCard)
Tiene razón, esto implicará una copia de la creditCard
de creditCard
, porque primero se pasa al constructor por referencia. Pero ahora no puede pasar objetos const
al constructor (porque la referencia es no 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 con las referencias de rvalue y std::forward
. Solo debería usar std::forward
cuando el objeto que está reenviando se declare como T&&
para algún tipo deducido T
Aquí CreditCard
no se deduce (supongo), por lo que std::forward
se está utilizando por error. Busque referencias universales .
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 de valor real no es un movimiento. Solo hay un movimiento.
Además, dado que CreditCard
no es un parámetro de plantilla, std::forward<CreditCard>(creditCard)
es solo una forma std::move(creditCard)
de decir std::move(creditCard)
.
Ahora...
Si tus tipos tienen movimientos "baratos", es posible que desees simplificarte la vida y tomar todo por valor y " std::move
along".
Account(std::string number, float amount, CreditCard creditCard)
: number(std::move(number),
amount(amount),
creditCard(std::move(creditCard)) {}
Este enfoque te dará dos movimientos cuando podría dar solo uno, pero si los movimientos son baratos, pueden ser aceptables.
Mientras estamos en este asunto de "movimientos baratos", debo recordarle que std::string
menudo se implementa con la denominada optimización de cadenas pequeñas, por lo que sus movimientos pueden no ser tan baratos como copiar algunos punteros. Como es habitual con los problemas de optimización, ya sea que importe o no, es algo que debe preguntarle a su perfilador, no a mí.
¿Qué hacer si no quieres incurrir en esos movimientos extra? Tal vez resulten demasiado caros o, lo que es peor, quizás los tipos no se puedan mover realmente y podría incurrir en copias adicionales.
Si solo hay un parámetro problemático, puede proporcionar dos sobrecargas, con T const&
y T&&
. Eso vinculará las referencias todo el tiempo hasta la inicialización del miembro real, donde ocurre una copia o movimiento.
Sin embargo, si tiene más de un parámetro, esto genera una explosión exponencial en el número de sobrecargas.
Este es un problema que se puede resolver con un reenvío perfecto. Eso significa que escribe una plantilla en su lugar y usa std::forward
para 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)) {}
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 se hace 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 el puntero: casi nunca los utilicé. La única ventaja sobre & es que pueden ser nulas o reasignarse.
LA PREGUNTA MÁS IMPORTANTE PRIMERO:
¿Hay alguna mejor práctica de cómo enviar parámetros en C ++ porque realmente la encuentro, digamos, no trivial?
Si su función necesita modificar el objeto original que se está transfiriendo, de modo que después de que regrese la llamada, las modificaciones a ese objeto serán visibles para la persona que llama, luego deberá 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 la referencia lvalue a const
:
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 se moverá como resultado de llamar a std::move()
).
También se podría argumentar que para tipos o tipos fundamentales para los que la copia es rápida , como int
, bool
o char
, no hay necesidad de pasar por referencia si la función simplemente necesita observar el valor, y se debe favorecer el paso por el valor. . Eso es correcto si no se necesita semántica de referencia , pero ¿qué ocurre si la función quiere almacenar un puntero a ese mismo objeto de entrada en alguna parte, de modo que las lecturas futuras a través de ese puntero verán las modificaciones de valores 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...
}
Invocar la función anterior siempre dará como resultado una copia al pasar lvalues, y en una se mueve al pasar rvalues. Si su función necesita almacenar este objeto en alguna parte, podría realizar un movimiento adicional (por ejemplo, en el caso de que foo()
sea una función miembro que necesite almacenar el valor en un miembro de datos ).
En caso de que los movimientos sean costosos para objetos de tipo my_class
, entonces puede considerar sobrecargar foo()
y proporcionar una versión para lvalues (aceptando una referencia lvalue a const
) y una versión para rvalues (aceptando una referencia 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 sola función: foo()
podría convertirse en una plantilla de función y podría usar un reenvío perfecto para determinar si un movimiento o una copia del objeto que se pasa será generado 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 al ver esta charla de Scott Meyers (simplemente tenga en cuenta el hecho de que el término " referencias universales " que está utilizando no es estándar).
Una cosa a tener en cuenta es que std::forward
normalmente terminará en un movimiento para valores r, por lo que aunque parezca relativamente inocente, reenviar el mismo objeto varias veces puede ser una fuente de problemas, por ejemplo, pasar del mismo objetar 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, debes enfocarte en la claridad y la simplicidad .
Lo anterior son solo pautas simples, pero la mayoría de las veces lo guiarán hacia buenas decisiones de diseño.
CONCERNIENTE AL RESTO DE TU POST:
Si lo reescribo, [...] habrá 2 movimientos y no habrá copia.
Esto no es correcto. Para empezar, una referencia de valor real no puede vincularse a un valor l, por lo que solo se compilará cuando pases un valor r de tipo CreditCard
a tu 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 intentas hacer esto:
CreditCard cc("12345",2,2015,1001);
Account acc("asdasd",345, cc); // ERROR! cc is an lvalue
Debido a que cc
es un lvalue y rvalue, las referencias no pueden vincularse a lvalues. Además, al vincular una referencia a un objeto, no se realiza ningún movimiento : es solo un enlace de referencia. Por lo tanto, solo habrá un movimiento.
Por lo tanto, según las pautas proporcionadas en la primera parte de esta respuesta, si le preocupa el número de movimientos que se generan cuando toma una CreditCard
por valor, puede definir dos sobrecargas de constructor, una tomando como referencia CreditCard const&
a const
( CreditCard const&
) y uno tomando una referencia CreditCard&&
( CreditCard&&
).
La resolución de sobrecarga seleccionará la primera al pasar un lvalue (en este caso, se realizará una copia) y la segunda al pasar un valor r (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
{ }
Su uso de std::forward<>
se ve normalmente cuando quiere 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: C
se deducirá que es 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 causará una copia de la construcción de la creditCard
de creditCard
, como desearía. Por otro lado, cuando se pasa un rvalue, se deducirá que C
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 causará un movimiento de construcción de la creditCard
de creditCard
, que es lo que desea (porque el valor que se pasa es un valor r, y eso significa que estamos autorizados a abandonarlo).