Comment passer correctement les paramètres?

108

Je suis un débutant en C ++ mais pas un débutant en programmation. J'essaie d'apprendre le C ++ (c ++ 11) et ce n'est pas clair pour moi la chose la plus importante: passer des paramètres.

J'ai considéré ces exemples simples:

  • Une classe qui a tous ses membres types primitifs:
    CreditCard(std::string number, int expMonth, int expYear,int pin):number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • Une classe qui a comme membres des types primitifs + 1 type complexe:
    Account(std::string number, float amount, CreditCard creditCard) : number(number), amount(amount), creditCard(creditCard)

  • Une classe qui a comme membres des types primitifs + 1 collection d'un type complexe: Client(std::string firstName, std::string lastName, std::vector<Account> accounts):firstName(firstName), lastName(lastName), accounts(accounts)

Lorsque je crée un compte, je fais ceci:

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

Évidemment, la carte de crédit sera copiée deux fois dans ce scénario. Si je réécris ce constructeur comme

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

il y en aura une copie. Si je le réécris comme

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

Il y aura 2 coups et aucune copie.

Je pense que parfois vous voudrez peut-être copier un paramètre, parfois vous ne voulez pas copier lorsque vous créez cet objet.
Je viens de C # et, étant habitué aux références, c'est un peu étrange pour moi et je pense qu'il devrait y avoir 2 surcharges pour chaque paramètre mais je sais que je me trompe.
Existe-t-il des meilleures pratiques pour envoyer des paramètres en C ++ parce que je le trouve vraiment, disons, pas trivial. Comment géreriez-vous mes exemples présentés ci-dessus?

Jack Willson
la source
9
Meta: Je ne peux pas croire que quelqu'un vient de poser une bonne question C ++. +1.
23
FYI, std::stringest une classe, tout comme CreditCard, pas un type primitif.
chris
7
En raison de la confusion des chaînes, bien que non liée, vous devez savoir qu'une chaîne littérale,, "abc"n'est pas de type std::string, pas de type char */const char *, mais de type const char[N](avec N = 4 dans ce cas en raison de trois caractères et d'un nul). C'est une bonne idée fausse courante de se soustraire.
chris
10
@chuex: Jon Skeet est partout sur les questions C #, beaucoup plus rarement C ++.
Steve Jessop

Réponses:

158

LA QUESTION LA PLUS IMPORTANTE D'ABORD:

Existe-t-il des meilleures pratiques pour envoyer des paramètres en C ++ parce que je le trouve vraiment, disons, pas trivial

Si votre fonction a besoin de modifier l'objet d'origine passé, de sorte qu'après le retour de l'appel, les modifications apportées à cet objet soient visibles pour l'appelant, alors vous devez passer par la référence lvalue :

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

Si votre fonction n'a pas besoin de modifier l'objet d'origine, et n'a pas besoin d'en créer une copie (en d'autres termes, il suffit d'observer son état), alors vous devez passer par référence lvalue àconst :

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

Cela vous permettra d'appeler la fonction à la fois avec des lvalues ​​(les lvalues ​​sont des objets avec une identité stable) et avec des rvalues ​​(les rvalues ​​sont, par exemple, des temporaires ou des objets dont vous êtes sur le point de vous déplacer suite à un appel std::move()).

On pourrait également soutenir que pour les types fondamentaux ou les types pour lesquels la copie est rapide , tels que int,, boolou char, il n'est pas nécessaire de passer par référence si la fonction a simplement besoin d'observer la valeur, et le passage par valeur doit être privilégié . C'est correct si la sémantique de référence n'est pas nécessaire, mais que se passerait-il si la fonction voulait stocker un pointeur vers ce même objet d'entrée quelque part, de sorte que les futures lectures de ce pointeur voient les modifications de valeur qui ont été effectuées dans une autre partie du code? Dans ce cas, le passage par référence est la bonne solution.

Si votre fonction n'a pas besoin de modifier l'objet d'origine, mais doit stocker une copie de cet objet ( éventuellement pour renvoyer le résultat d'une transformation de l'entrée sans modifier l'entrée ), alors vous pouvez envisager de prendre par valeur :

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...
}

L'appel de la fonction ci-dessus entraînera toujours une copie lors du passage de lvalues, et un déplacement lors du passage de rvalues. Si votre fonction a besoin de stocker cet objet quelque part, vous pouvez effectuer un déplacement supplémentaire à partir de celui-ci (par exemple, dans le cas où il foo()s'agit d' une fonction membre qui doit stocker la valeur dans un membre de données ).

Dans le cas où les déplacements sont coûteux pour les objets de type my_class, alors vous pouvez envisager de surcharger foo()et fournir une version pour lvalues ​​(acceptant une référence lvalue à const) et une version pour rvalues ​​(acceptant une référence 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...
}

Les fonctions ci-dessus sont si similaires, en fait, que vous pourriez en faire une seule fonction: foo()pourrait devenir un modèle de fonction et vous pourriez utiliser un transfert parfait pour déterminer si un déplacement ou une copie de l'objet passé sera généré en interne:

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...
}

Vous voudrez peut-être en savoir plus sur cette conception en regardant cette conférence de Scott Meyers (faites attention au fait que le terme « références universelles » qu'il utilise n'est pas standard).

Une chose à garder à l'esprit est que std::forwardcela se terminera généralement par un mouvement pour les valeurs, donc même si cela semble relativement innocent, transmettre le même objet plusieurs fois peut être une source de problèmes - par exemple, passer deux fois du même objet! Veillez donc à ne pas mettre cela en boucle, et à ne pas transmettre le même argument plusieurs fois dans un appel de fonction:

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

Notez également que vous ne recourez normalement pas à la solution basée sur un modèle, sauf si vous avez une bonne raison, car cela rend votre code plus difficile à lire. Normalement, vous devez vous concentrer sur la clarté et la simplicité .

Ce ne sont que de simples directives, mais la plupart du temps, elles vous orienteront vers de bonnes décisions de conception.


CONCERNANT LE RESTE DE VOTRE POSTE:

Si je le réécris comme [...] il y aura 2 coups et aucune copie.

Ce n'est pas correct. Pour commencer, une référence rvalue ne peut pas se lier à une lvalue, donc cela ne sera compilé que lorsque vous passerez une rvalue de type CreditCardà votre constructeur. Par exemple:

// 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));

Mais cela ne fonctionnera pas si vous essayez de faire ceci:

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

Car ccis an lvalue et les références rvalue ne peuvent pas se lier à lvalues. De plus, lors de la liaison d'une référence à un objet, aucun déplacement n'est effectué : c'est juste une liaison de référence. Ainsi, il n'y aura qu'un seul mouvement.


Donc, sur la base des instructions fournies dans la première partie de cette réponse, si vous êtes préoccupé par le nombre de mouvements générés lorsque vous prenez une CreditCardvaleur par, vous pouvez définir deux surcharges de constructeur, l'une prenant une référence lvalue à const( CreditCard const&) et l'autre prenant une référence rvalue ( CreditCard&&).

La résolution de surcharge sélectionnera le premier lors du passage d'une lvalue (dans ce cas, une copie sera effectuée) et le dernier lors du passage d'une rvalue (dans ce cas, un déplacement sera effectué).

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
{ }

Votre utilisation de std::forward<>est normalement visible lorsque vous souhaitez obtenir une transmission parfaite . Dans ce cas, votre constructeur serait en fait un modèle de constructeur , et ressemblerait plus ou moins comme suit

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

Dans un sens, cela combine les deux surcharges que j'ai montrées précédemment en une seule fonction: Csera déduit au CreditCard&cas où vous passeriez une lvalue, et en raison des règles de réduction de référence, cela provoquera l'instanciation de cette fonction:

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

Cela entraînera une copie-construction de creditCard, comme vous le souhaiteriez. D'autre part, lorsqu'une rvalue est passée, Celle sera déduite comme étant CreditCard, et cette fonction sera instanciée à la place:

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

Cela entraînera une construction de déplacement de creditCard, ce que vous voulez (car la valeur passée est une rvalue, et cela signifie que nous sommes autorisés à en sortir).

Andy Prowl
la source
Est-il faux de dire que pour les types de paramètres non primitifs, il est correct de toujours utiliser la version du modèle avec forward?
Jack Willson
1
@JackWillson: Je dirais que vous ne devriez recourir à la version du modèle que lorsque vous avez vraiment des inquiétudes sur les performances des mouvements. Voir ma question , qui a une bonne réponse, pour plus d'informations. En général, si vous n'avez pas besoin de faire une copie et que vous n'avez qu'à observer, prenez par référence à const. Si vous avez besoin de modifier l'objet d'origine, prenez par ref à non-`const. Si vous avez besoin de faire une copie et que les déplacements sont bon marché, prenez par valeur puis déplacez-vous.
Andy Prowl
2
Je pense qu'il n'est pas nécessaire de se déplacer dans le troisième exemple de code. Vous pouvez simplement utiliser objau lieu de faire une copie locale dans laquelle vous déplacer, non?
juanchopanza
3
@AndyProwl: Vous avez obtenu ma réponse il y a +1 heure, mais en relisant votre (excellente) réponse, j'aimerais souligner deux choses: a) la valeur par valeur ne crée pas toujours une copie (si elle n'est pas déplacée). L'objet pourrait être construit sur place sur le site des appelants, en particulier avec RVO / NRVO, cela fonctionne même plus souvent qu'on ne le pense. b) Veuillez noter que dans le dernier exemple, ne std::forwardpeut être appelé qu'une seule fois . J'ai vu des gens le mettre en boucle, etc. et puisque cette réponse sera vue par beaucoup de débutants, il devrait à mon humble avis y avoir une grosse étiquette «Attention!» Pour les aider à éviter ce piège.
Daniel Frey
1
@SteveJessop: Et à part cela, il y a des problèmes techniques (pas nécessairement des problèmes) avec les fonctions de transfert (en particulier les constructeurs prenant un argument ), principalement le fait qu'ils acceptent des arguments de tout type et peuvent vaincre le std::is_constructible<>trait de type à moins qu'ils ne soient correctement SFINAE- contraint - ce qui n'est peut-être pas anodin pour certains.
Andy Prowl
11

Tout d'abord, permettez-moi de corriger certains détails. Quand vous dites ce qui suit:

il y aura 2 coups et aucune copie.

C'est faux. La liaison à une référence rvalue n'est pas un déplacement. Il n'y a qu'un seul mouvement.

De plus, puisque CreditCardn'est pas un paramètre de modèle, std::forward<CreditCard>(creditCard)c'est juste une façon verbeuse de dire std::move(creditCard).

Maintenant...

Si vos types ont des mouvements "bon marché", vous voudrez peut-être simplement vous simplifier la vie et prendre tout par valeur et " std::moveavec".

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

Cette approche vous donnera deux coups alors qu'elle ne pourrait en donner qu'un, mais si les mouvements sont bon marché, ils peuvent être acceptables.

Pendant que nous sommes sur cette question de "coups bon marché", je dois vous rappeler que cela std::stringest souvent implémenté avec l'optimisation dite des petites chaînes, donc ses mouvements peuvent ne pas être aussi bon marché que la copie de certains pointeurs. Comme d'habitude avec les problèmes d'optimisation, que ce soit important ou non, c'est quelque chose à demander à votre profileur, pas à moi.

Que faire si vous ne voulez pas subir ces mouvements supplémentaires? Peut-être qu'ils s'avèrent trop chers, ou pire, peut-être que les types ne peuvent pas être déplacés et que vous risquez d'encourir des copies supplémentaires.

S'il n'y a qu'un seul paramètre problématique, vous pouvez fournir deux surcharges, avec T const&et T&&. Cela liera les références tout le temps jusqu'à l'initialisation réelle du membre, où une copie ou un déplacement se produit.

Cependant, si vous avez plus d'un paramètre, cela conduit à une explosion exponentielle du nombre de surcharges.

C'est un problème qui peut être résolu avec une transmission parfaite. Cela signifie que vous écrivez un modèle à la place et que vous utilisez std::forwardpour transporter la catégorie de valeur des arguments jusqu'à leur destination finale en tant que membres.

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
la source
Il y a un problème avec la version du modèle: l'utilisateur ne peut plus écrire Account("",0,{brace, initialisation}).
ipc
@ipc ah, c'est vrai. C'est vraiment ennuyeux et je ne pense pas qu'il existe une solution de contournement simple et évolutive.
R. Martinho Fernandes
6

Tout d'abord, std::stringc'est un type de classe assez lourd, tout comme std::vector. Ce n'est certainement pas primitif.

Si vous prenez des types mobiles volumineux par valeur dans un constructeur, je les ferais std::movedans le membre:

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

C'est exactement ainsi que je recommanderais d'implémenter le constructeur. Cela provoque la construction des membres numberet creditCarddu déplacement, plutôt que la construction par copie. Lorsque vous utilisez ce constructeur, il y aura une copie (ou un déplacement, s'il est temporaire) lorsque l'objet est passé au constructeur, puis un déplacement lors de l'initialisation du membre.

Considérons maintenant ce constructeur:

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

Vous avez raison, cela impliquera une copie de creditCard, car il est d'abord passé au constructeur par référence. Mais maintenant, vous ne pouvez pas passer d' constobjets au constructeur (car la référence est non- const) et vous ne pouvez pas passer d'objets temporaires. Par exemple, vous ne pouvez pas faire ceci:

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

Considérons maintenant:

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

Ici, vous avez montré une incompréhension des références rvalue et std::forward. Vous ne devriez vraiment l'utiliser que std::forwardlorsque l'objet que vous transférez est déclaré comme T&&pour un type déduit T . Ici CreditCardn'est pas déduit (je suppose), et donc le std::forwardest utilisé par erreur. Recherchez des références universelles .

Joseph Mansfield
la source
1

J'utilise une règle assez simple pour le cas général: utilisez la copie pour POD (int, bool, double, ...) et const & pour tout le reste ...

Et vouloir copier ou pas, ne répond pas par la signature de la méthode mais plus par ce que vous faites avec les paramètres.

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

précision pour pointeur: je ne les ai presque jamais utilisés. Le seul avantage par rapport aux & est qu'ils peuvent être nuls ou réaffectés.

David Fleury
la source
2
"J'utilise une règle assez simple pour le cas général: utilisez la copie pour POD (int, bool, double, ...) et const & pour tout le reste." No. Just No.
Chaussure
Peut-être ajouter, si vous souhaitez modifier une valeur en utilisant un & (pas de const). Sinon, je ne vois pas ... la simplicité suffit. Mais si vous l'avez dit ...
David Fleury
Parfois, vous souhaitez modifier par référence des types primitifs, et parfois vous devez faire une copie d'objets et parfois vous devez déplacer des objets. Vous ne pouvez pas simplement tout réduire à POD> par valeur et UDT> par référence.
Chaussure du
Ok, c'est juste ce que j'ai ajouté après. Peut-être une réponse trop rapide.
David Fleury
1

Ce n'est pas clair pour moi la chose la plus importante: passer des paramètres.

  • Si vous souhaitez modifier la variable passée à l'intérieur de la fonction / méthode
    • vous le passez par référence
    • vous le passez comme pointeur (*)
  • Si vous voulez lire la valeur / variable passée à l'intérieur de la fonction / méthode
    • vous le passez par référence const
  • Si vous souhaitez modifier la valeur passée à l'intérieur de la fonction / méthode
    • vous le passez normalement en copiant l'objet (**)

(*) les pointeurs peuvent faire référence à de la mémoire allouée dynamiquement, donc lorsque cela est possible, vous devriez préférer les références aux pointeurs même si les références sont, en fin de compte, généralement implémentées comme des pointeurs.

(**) "normalement" signifie par constructeur de copie (si vous passez un objet du même type de paramètre) ou par constructeur normal (si vous passez un type compatible pour la classe). Lorsque vous passez un objet comme myMethod(std::string), par exemple, le constructeur de copie sera utilisé si un lui std::stringest passé, vous devez donc vous assurer qu'il en existe un.

Chaussure
la source