Passer des arguments en tant que références const est-il une optimisation prématurée?

20

"L'optimisation prématurée est la racine de tout Mal"

Je pense que nous pouvons tous nous mettre d'accord. Et j'essaie très fort d'éviter de faire ça.

Mais récemment, je me suis interrogé sur la pratique de passer des paramètres par référence const plutôt que par valeur . J'ai appris / appris que les arguments de fonction non triviaux (c'est-à-dire la plupart des types non primitifs) devraient de préférence être passés par référence const - un certain nombre de livres que j'ai lus recommandent cela comme une "meilleure pratique".

Je ne peux pas m'empêcher de me demander: les compilateurs modernes et les nouvelles fonctionnalités de langage peuvent faire des merveilles, donc les connaissances que j'ai apprises peuvent très bien être obsolètes, et je n'ai jamais vraiment pris la peine de profiler s'il y a des différences de performances entre

void fooByValue(SomeDataStruct data);   

et

void fooByReference(const SomeDataStruct& data);

La pratique que j'ai apprise - passer des références const (par défaut pour les types non triviaux) - est-elle une optimisation prématurée?

CharonX
la source
1
Voir aussi: F.call dans les directives de base C ++ pour une discussion des différentes stratégies de passage de paramètres.
amon
1
@DocBrown La réponse acceptée à cette question se réfère au principe du moindre étonnement , qui peut également s'appliquer ici (c'est-à-dire que l'utilisation de références const est la norme de l'industrie, etc., etc.). Cela dit, je ne suis pas d'accord pour dire que la question est en double: la question à laquelle vous vous référez demande si c'est une mauvaise pratique de s'appuyer (généralement) sur l'optimisation du compilateur. Cette question pose l'inverse: le passage des références const est-il une optimisation (prématurée)?
CharonX
@CharonX: si l'on peut s'appuyer ici sur l'optimisation du compilateur, la réponse à votre question est clairement "oui, l'optimisation manuelle n'est pas nécessaire, elle est prématurée". Si l'on ne peut pas s'y fier (peut-être parce que vous ne savez pas au préalable quels compilateurs seront jamais utilisés pour le code), la réponse est "pour les objets plus grands, ce n'est probablement pas prématuré". Donc, même si ces deux questions ne sont pas littéralement égales, à mon humble avis, elles semblent suffisamment semblables pour les relier en tant que doublons.
Doc Brown
1
@DocBrown: Donc, avant de pouvoir le déclarer dupe, indiquez où, dans la question, il est dit que le compilateur sera autorisé et capable "d'optimiser" cela.
Déduplicateur

Réponses:

49

"Optimisation prématurée" ne consiste pas à utiliser les optimisations au début . Il s'agit d'optimiser avant que le problème soit compris, avant que le runtime soit compris, et de rendre souvent le code moins lisible et moins maintenable pour des résultats douteux.

Utiliser "const &" au lieu de passer un objet par valeur est une optimisation bien comprise, avec des effets bien compris sur l'exécution, sans pratiquement aucun effort, et sans aucun mauvais effet sur la lisibilité et la maintenabilité. Il améliore réellement les deux, car il me dit qu'un appel ne modifiera pas l'objet transmis. Donc, ajouter "const &" à droite lorsque vous écrivez le code n'est PAS PRÉMATURÉ.

gnasher729
la source
2
Je suis d'accord sur la partie "pratiquement sans effort" de votre réponse. Mais l'optimisation prématurée est avant tout une optimisation avant que leur impact sur les performances ne soit mesuré et notable. Et je ne pense pas que la plupart des programmeurs C ++ (y compris moi-même) prennent des mesures avant d'utiliser const&, donc je pense que la question est assez sensible.
Doc Brown
1
Vous mesurez avant d'optimiser pour savoir si des compromis en valent la peine. Avec const & l'effort total est de taper sept caractères, ce qui présente d'autres avantages. Lorsque vous n'avez pas l'intention de modifier la variable transmise, c'est avantageux même s'il n'y a pas d'amélioration de la vitesse.
gnasher729
3
Je ne suis pas un expert C, donc une question:. const& foodit que la fonction ne modifiera pas foo, donc l' appelant est en sécurité. Mais une valeur copiée indique qu'aucun autre thread ne peut changer foo, donc l' appelé est en sécurité. Droite? Ainsi, dans une application multi-thread, la réponse dépend de l'exactitude et non de l'optimisation.
user949300
1
@DocBrown vous pouvez éventuellement supprimer la motivation du développeur qui a mis la const &? S'il ne le faisait que pour la performance sans tenir compte du reste, cela pourrait être considéré comme une optimisation prématurée. Maintenant, s'il le met parce qu'il sait que ce sera un paramètre const, il ne fait qu'auto-documenter son code et donne l'opportunité au compilateur d'optimiser, ce qui est mieux.
Walfrat
1
@ user949300: Peu de fonctions permettent de modifier leurs arguments simultanément ou par des rappels utilisés, et ils le disent explicitement.
Déduplicateur
16

TL; DR: passer par référence const est toujours une bonne idée en C ++, tout bien considéré. Pas une optimisation prématurée.

TL; DR2: La plupart des adages n'ont pas de sens, jusqu'à ce qu'ils le fassent.


Objectif

Cette réponse essaie juste d'étendre un peu l'élément lié sur les directives de base C ++ (mentionné pour la première fois dans le commentaire d'Amon).

Cette réponse n'essaie pas d'aborder la question de savoir comment penser et appliquer correctement les divers adages qui ont été largement diffusés dans les cercles des programmeurs, en particulier la question de la réconciliation entre des conclusions ou des preuves contradictoires.


Applicabilité

Cette réponse s'applique uniquement aux appels de fonction (étendues imbriquées non détachables sur le même thread).

(Note latérale.) Lorsque des choses passables peuvent échapper à la portée (c'est-à-dire avoir une durée de vie qui dépasse potentiellement la portée externe), il devient plus important de satisfaire le besoin de l'application de gestion de la durée de vie des objets avant toute autre chose. Habituellement, cela nécessite l'utilisation de références qui sont également capables de gérer la durée de vie, telles que les pointeurs intelligents. Une alternative pourrait être d'utiliser un gestionnaire. Notez que, lambda est une sorte de portée détachable; les captures lambda se comportent comme ayant une portée d'objet. Par conséquent, soyez prudent avec les captures lambda. Faites également attention à la façon dont le lambda lui-même est transmis - par copie ou par référence.


Quand passer par valeur

Pour les valeurs scalaires (primitives standard qui s'inscrivent dans un registre de machine et ont une valeur sémantique) pour lesquelles il n'y a pas besoin de communication par mutabilité (référence partagée), passez par valeur.

Pour les situations où l'appelé nécessite le clonage d'un objet ou d'un agrégat, passez par valeur, dans lequel la copie de l'appelé répond au besoin d'un objet cloné.


Quand passer par référence, etc.

pour toutes les autres situations, passez par des pointeurs, des références, des pointeurs intelligents, des poignées (voir: idiome poignée-corps), etc. Chaque fois que ce conseil est suivi, appliquez le principe de const-correctness comme d'habitude.

Les objets (agrégats, objets, tableaux, structures de données) dont l'empreinte mémoire est suffisamment importante doivent toujours être conçus pour faciliter le passage par référence, pour des raisons de performances. Ce conseil s'applique définitivement lorsqu'il s'agit de centaines d'octets ou plus. Ce conseil est limite quand il fait des dizaines d'octets.


Paradigmes inhabituels

Il existe des paradigmes de programmation à usage spécial qui sont lourds de copies par intention. Par exemple, traitement de chaînes, sérialisation, communication réseau, isolation, habillage de bibliothèques tierces, communication interprocessus en mémoire partagée, etc. Dans ces domaines d'application ou paradigmes de programmation, les données sont copiées de structures en structures, ou parfois reconditionnées dans tableaux d'octets.


Comment la spécification du langage affecte cette réponse, avant que l' optimisation ne soit considérée.

Sub-TL; DR La propagation d'une référence ne doit invoquer aucun code; le passage par const-reference satisfait ce critère. Cependant, toutes les autres langues satisfont sans effort à ce critère.

(Les programmeurs C ++ novices sont invités à ignorer entièrement cette section.)

(Le début de cette section est en partie inspiré par la réponse de gnasher729. Cependant, une conclusion différente est atteinte.)

C ++ autorise les constructeurs de copie définis par l'utilisateur et les opérateurs d'affectation.

(C'est (était) un choix audacieux qui est (était) à la fois étonnant et regrettable. Il s'agit certainement d'une divergence par rapport à la norme acceptable d'aujourd'hui dans la conception de la langue.)

Même si le programmeur C ++ n'en définit pas une, le compilateur C ++ doit générer ces méthodes en fonction des principes du langage, puis déterminer si du code supplémentaire doit être exécuté autrement que memcpy. Par exemple, un class/ structqui contient unstd::vector membre doit avoir un constructeur de copie et un opérateur d'affectation non trivial.

Dans d'autres langages, les constructeurs de copie et le clonage d'objets sont déconseillés (sauf lorsque cela est absolument nécessaire et / ou significatif pour la sémantique de l'application), car les objets ont une sémantique de référence, par conception de langage. Ces langages auront généralement un mécanisme de récupération de place basé sur l'accessibilité plutôt que sur la propriété basée sur la portée ou le comptage de références.

Lorsqu'une référence ou un pointeur (y compris la référence const) est transmis en C ++ (ou C), le programmeur est assuré qu'aucun code spécial (fonctions définies par l'utilisateur ou générées par le compilateur) ne sera exécuté, autre que la propagation de la valeur d'adresse (référence ou pointeur). Il s'agit d'une clarté de comportement avec laquelle les programmeurs C ++ sont à l'aise.

Cependant, la toile de fond est que le langage C ++ est inutilement compliqué, de sorte que cette clarté de comportement est comme une oasis (un habitat qui peut survivre) quelque part autour d'une zone de retombées nucléaires.

Pour ajouter plus de bénédictions (ou insultes), C ++ introduit des références universelles (valeurs r) afin de faciliter les opérateurs de déplacement définis par l'utilisateur (constructeurs de mouvement et opérateurs d'affectation de mouvement) avec de bonnes performances. Cela profite à un cas d'utilisation très pertinent (le déplacement (transfert) d'objets d'une instance à une autre), en réduisant le besoin de copie et de clonage en profondeur. Cependant, dans d'autres langues, il est illogique de parler d'un tel déplacement d'objets.


(Section hors sujet) Une section dédiée à un article, "Vous voulez de la vitesse? Passez par valeur!" écrit vers 2009.

Cet article a été écrit en 2009 et explique la justification de la conception de la valeur r en C ++. Cet article présente un contre-argument valable à ma conclusion dans la section précédente. Cependant, l'exemple de code et la déclaration de performance de l'article ont longtemps été réfutés.

Sub-TL; DR La conception de la sémantique de valeur r en C ++ permet une sémantique côté utilisateur étonnamment élégante sur unSort fonction, par exemple. Cet élégant est impossible à modéliser (imiter) dans d'autres langues.

Une fonction de tri est appliquée à toute une structure de données. Comme mentionné ci-dessus, ce serait lent si beaucoup de copies sont impliquées. En tant qu'optimisation des performances (qui est pratiquement pertinente), une fonction de tri est conçue pour être destructrice dans plusieurs langages autres que C ++. Destructif signifie que la structure de données cible est modifiée pour atteindre l'objectif de tri.

En C ++, l'utilisateur peut choisir d'appeler l'une des deux implémentations: une destructrice avec de meilleures performances, ou une normale qui ne modifie pas l'entrée. (Le modèle est omis par souci de concision.)

/*caller specifically passes in input argument destructively*/
std::vector<T> my_sort(std::vector<T>&& input)
{
    std::vector<T> result(std::move(input)); /* destructive move */
    std::sort(result.begin(), result.end()); /* in-place sorting */
    return result; /* return-value optimization (RVO) */
}

/*caller specifically passes in read-only argument*/ 
std::vector<T> my_sort(const std::vector<T>& input)
{
    /* reuse destructive implementation by letting it work on a clone. */
    /* Several things involved; e.g. expiring temporaries as r-value */
    /* return-value optimization, etc. */
    return my_sort(std::vector<T>(input));  
}

/*caller can select which to call, by selecting r-value*/
std::vector<T> v1 = {...};
std::vector<T> v2 = my_sort(v1); /*non-destructive*/
std::vector<T> v3 = my_sort(std::move(v1)); /*v1 is gutted*/    

Outre le tri, cette élégance est également utile dans la mise en œuvre d'un algorithme destructeur de recherche médiane dans un tableau (initialement non trié), par partitionnement récursif.

Cependant, notez que la plupart des langues appliqueraient une approche d'arbre de recherche binaire équilibrée au tri, au lieu d'appliquer un algorithme de tri destructif aux tableaux. Par conséquent, la pertinence pratique de cette technique n'est pas aussi élevée qu'il n'y paraît.


Comment l'optimisation du compilateur affecte cette réponse

Lorsque l'inlining (et également l'optimisation de l'ensemble du programme / l'optimisation du temps de liaison) est appliquée à plusieurs niveaux d'appels de fonction, le compilateur est capable de voir (parfois de manière exhaustive) le flux de données. Lorsque cela se produit, le compilateur peut appliquer de nombreuses optimisations, dont certaines peuvent éliminer la création d'objets entiers en mémoire. Généralement, lorsque cette situation s'applique, peu importe si les paramètres sont passés par valeur ou par const-référence, car le compilateur peut analyser de manière exhaustive.

Cependant, si la fonction de niveau inférieur appelle quelque chose qui est au-delà de l'analyse (par exemple quelque chose dans une bibliothèque différente en dehors de la compilation, ou un graphe d'appel qui est tout simplement trop compliqué), alors le compilateur doit optimiser de manière défensive.

Les objets plus grands qu'une valeur de registre d'ordinateur peuvent être copiés par des instructions explicites de chargement / stockage de mémoire ou par un appel à la memcpyfonction vénérable . Sur certaines plates-formes, le compilateur génère des instructions SIMD afin de se déplacer entre deux emplacements de mémoire, chaque instruction déplaçant des dizaines d'octets (16 ou 32).


Discussion sur la question de la verbosité ou du désordre visuel

Les programmeurs C ++ sont habitués à cela, c'est-à-dire que tant qu'un programmeur ne déteste pas C ++, la surcharge d'écriture ou de lecture de const-reference dans le code source n'est pas horrible.

Les analyses coûts-avantages auraient pu être effectuées à plusieurs reprises auparavant. Je ne sais pas s'il y en a des scientifiques qui devraient être cités. Je suppose que la plupart des analyses seraient non scientifiques ou non reproductibles.

Voici ce que j'imagine (sans preuve ni références crédibles) ...

  • Oui, cela affecte les performances des logiciels écrits dans cette langue.
  • Si les compilateurs peuvent comprendre le but du code, il pourrait être suffisamment intelligent pour automatiser
  • Malheureusement, dans les langages qui favorisent la mutabilité (par opposition à la pureté fonctionnelle), le compilateur classerait la plupart des choses comme étant mutées, donc la déduction automatisée de la constance rejetterait la plupart des choses comme non const.
  • Les frais généraux mentaux dépendent des gens; les gens qui trouveraient cela comme une surcharge mentale élevée auraient rejeté le C ++ comme langage de programmation viable.
rwong
la source
C'est l'une des situations où j'aimerais pouvoir accepter deux réponses au lieu d'avoir à n'en choisir qu'une seule ... soupir
CharonX
8

Dans l'article "StructuredProgrammingWithGoToStatements" de DonaldKnuth, il écrit: "Les programmeurs perdent énormément de temps à penser ou à s'inquiéter de la vitesse des parties non critiques de leurs programmes, et ces tentatives d'efficacité ont en fait un fort impact négatif lorsque le débogage et la maintenance sont envisagés . Nous devons oublier les petites efficacités, disons environ 97% du temps: l'optimisation prématurée est la racine de tout mal. Pourtant, nous ne devons pas laisser passer nos opportunités dans ces 3% critiques. " - Optimisation prématurée

Cela ne conseille pas aux programmeurs d'utiliser les techniques les plus lentes disponibles. Il s'agit de se concentrer sur la clarté lors de l'écriture de programmes. Souvent, la clarté et l'efficacité sont un compromis: si vous ne devez en choisir qu'une seule, choisissez la clarté. Mais si vous pouvez atteindre les deux facilement, il n'est pas nécessaire de paralyser la clarté (comme signaler que quelque chose est une constante) juste pour éviter l'efficacité.

Lawrence
la source
3
"si vous ne devez en choisir qu'un, choisissez la clarté." La seconde devrait être préférable à la place, car vous pourriez être obligé de choisir l'autre.
Déduplicateur
@Deduplicator Merci. Dans le contexte de l'OP, cependant, le programmeur a la liberté de choisir.
Lawrence
Cependant, votre réponse est un peu plus générale que cela ...
Déduplicateur
@Deduplicator Ah, mais le contexte de ma réponse est (aussi) celui que le programmeur choisit. Si le choix était imposé au programmeur, ce ne serait pas "vous" qui fera le picking :). J'ai considéré le changement que vous avez suggéré et je ne m'opposerais pas à ce que vous modifiiez ma réponse en conséquence, mais je préfère le libellé existant pour sa clarté.
Lawrence
7

Le passage par (référence [const] [rvalue]) | (valeur) devrait concerner l'intention et les promesses faites par l'interface. Cela n'a rien à voir avec les performances.

Règle générale de Richy:

void foo(X x);          // I intend to own the x you gave me, whether by copy, move or direct initialisation on the call stack.     

void foo(X&& x);        // I intend to steal x from you. Do not use it other than to re-assign to it after calling me.

void foo(X const& x);   // I guarantee not to change your x

void foo(X& x);         // I may modify your x and I will leave it in a defined state
Richard Hodges
la source
3

Théoriquement, la réponse devrait être oui. Et, en fait, c'est oui parfois - en fait, passer par référence const au lieu de simplement passer une valeur peut être une pessimisation, même dans les cas où la valeur transmise est trop grande pour tenir dans un seul registre (ou la plupart des autres heuristiques que les gens essaient d'utiliser pour déterminer quand passer par valeur ou non). Il y a des années, David Abrahams a écrit un article intitulé "Vous voulez de la vitesse? Passez par valeur!" couvrant certains de ces cas. Ce n'est plus facile à trouver, mais si vous pouvez déterrer une copie, cela vaut la peine d'être lu (IMO).

Dans le cas spécifique du passage par référence const, cependant, je dirais que l'idiome est si bien établi que la situation est plus ou moins inversée: à moins que vous ne sachiez que le type sera char/ short/ int/ long, les gens s'attendent à le voir passer par const référence par défaut, il est donc préférable d'y aller à moins que vous n'ayez une raison assez spécifique de faire autrement.

Jerry Coffin
la source