"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 const Reference au lieu de Value. On m'a 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 bon nombre de livres que j'ai lus recommandent ceci 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 références const (par défaut pour les types non triviaux) - est-elle une optimisation prématurée?
"L'optimisation prématurée" ne consiste pas à utiliser des optimisations tôt. 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É.
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 le C++ Core Guidelines (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.-à-d. Avoir une durée de vie qui dépasse potentiellement la portée extérieure), il devient plus important de satisfaire le besoin de la 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 un 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 l'optimisation est prise en compte.
Sub-TL; DR La propagation d'une référence ne devrait 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 cette section entièrement.)
(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 aucune, le compilateur C++ doit générer de telles méthodes en fonction des principes du langage, puis déterminer si du code supplémentaire doit être exécuté autre que memcpy
. Par exemple, un class
/struct
qui contient un std::vector
le membre doit avoir un constructeur de copie et un opérateur d'affectation qui n'est pas 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 une 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++ se sentent à 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 en circa 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 l'allégation de performance de l'article ont longtemps été réfutés.
Sub-TL; DR La conception de la sémantique des valeurs r en C++ permet une sémantique côté utilisateur étonnamment élégante sur une fonction Sort
, 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. En règle générale, 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 graphique d'appel tout simplement trop compliqué), le compilateur doit optimiser de manière défensive.
Les objets plus grands qu'une valeur de registre de machine peuvent être copiés par des instructions explicites de chargement/stockage de mémoire ou par un appel à la fonction vénérable memcpy
. 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) ...
Dans l'article de DonaldKnuth "StructuredProgrammingWithGoToStatements", 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é.
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.
La règle d'or 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
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 sachez le type soit char
/short
/int
/long
, les gens s'attendent à le voir passé par référence const par défaut, donc il est probablement préférable de suivre cela à moins que vous n'ayez un raison de faire autrement.