Comme c'est le mois du C++ sur LinuxFR, je vous propose ce petit quiz.
Voici un petit morceau de programme, avec une hiérarchie de classe A et sa fille B, deux fonctions qui prennent un pointeur à poil ou un pointeur partagé sur A, et des appels sur ces fonctions avec des A et des B.
class A {};
class B : public A {};
void f(A*);
void g(const std::shared_ptr<A> &);
int main()
{
auto ptr_a = new A;
auto ptr_b = new B;
auto shr_a = std::make_shared<A>();
auto shr_b = std::make_shared<B>();
f(ptr_a);
f(ptr_b);
g(shr_a);
g(shr_b);
return 0;
}
La question est, sur ces 4 appels à la fin du main, est-ce que certains sont plus lents que d'autres, et pourquoi ?
Remarque: Évidemment que dans la grande majorité des programmes, les coûts comparés d'un pointeur partagé et d'un pointeur à poil n'ont aucune importance, les problématiques d'architecture et de qualité du code étant largement prioritaires. Mais ce quiz soulève quelques points intéressants pour qui veut regarder sous le capot, et c'est pour cela que je le soumet.
# Ça compile pas
Posté par Christie Poutrelle (site web personnel) . Évalué à 3. Dernière modification le 23 août 2018 à 15:23.
Je ne fais pas de C++, par contre je sais comment regarder l'output du code assembleur généré, mais ne connaissant pas le C++, je n'arrive pas à compiler ce bout de code correctement, il manque les includes qui vont bien je pense.
Voici ce qui m'arrive:
Pour info j'ai rajouté en haut du fichier (suggéré par g++ lors de mon premier essai):
[^] # Re: Ça compile pas
Posté par Christie Poutrelle (site web personnel) . Évalué à 3.
Ah mais je viens de comprendre un truc, f et g sont des signatures de fonction mais n'ont pas d'implémentation, forcément ça ne peut pas marcher.
[^] # Re: Ça compile pas
Posté par small_duck (site web personnel) . Évalué à 3.
Coucou ! Alors en effet, je n'ai pas mis le code complet afin de ne mettre que les morceaux importants. Voici un exemple compilable. Attention cependant, avec certains niveaux d'optimisations, les appels à f et g pourraient se retrouver inlinés. Pour être sûr que ce n'est pas le cas, il faut les définir dans une unité de compilation séparée (un autre .cpp).
[^] # Re: Ça compile pas
Posté par Christie Poutrelle (site web personnel) . Évalué à 5. Dernière modification le 23 août 2018 à 15:36.
Même au niveau d'optimisation -Og les appels sont éliminés, en désactivant les optimisations, j'ai bien tout. À première vue et sans trop être sûr, je dirais que f(ptr_a), f(ptr_b) sont les plus rapides et g(shr_a) un peu plus lent, et g(shr_b) encore un peu plus lent, mais encore une fois, sans aller plus loin que juste regarder à quoi ressemble l'ASM non optimisé généré.
EDIT: Attention j'ai modifié ma réponse. Dans tous les cas le code généré est quasi identique, juste un mov est remplacé par un lea entre f(a) et g(a), et un lea est ajouté en plus pour g(b).
[^] # Re: Ça compile pas
Posté par Christie Poutrelle (site web personnel) . Évalué à 3.
Je m'auto répond, mais en fait j'avais pas tenu compte du fait que shr_a et shr_b sont déjà boxées, donc l'appel à aux fonctions, en tout cas dans le code ASM, ne montre finalement pas de différence avec les deux appels précédents, mais j'imagine qu'après lors de l'utilisation de la variable shared il y a un unboxing couteux. J'en sais vraiment rien en fait, je suis très nul en c++.
# Un temporaire en plus
Posté par Clément V . Évalué à 6.
Les trois premiers appels se contentent de passer un pointeur (une référence c'est pointeur caché) et sont donc aussi rapides l'un que l'autre.
g(shr_b)
a besoin de créer un temporaire de typeshared_ptr<A>
et une copie deshared_ptr
est coûteuse à cause de l'état partagé.En pratique, si
g
n'a pas besoin de posséder la ressource, il faut mieux utiliser pointeur nu ou unweak_ptr
. Sig
a besoin de la posséder, il faut mieux passer leshared_ptr
par valeur: il y a une copie au moment de l'appel dans tous les cas, mais on peut le déplacer par le suite si besoin.[^] # Re: Un temporaire en plus
Posté par Christie Poutrelle (site web personnel) . Évalué à 1.
Au vu du code ASM généré, j'aurais tendance à dire que finalement le shared_ptr à l'air d'être une zero-cost-abstraction, je me trompe ? C'est gcc qui compile ça comme ça ?
[^] # Re: Un temporaire en plus
Posté par Christie Poutrelle (site web personnel) . Évalué à 4.
Si je pouvais m'auto supprimer le message précédent je le ferais, j'ai dit des conneries.
[^] # Re: Un temporaire en plus
Posté par fearan . Évalué à 3. Dernière modification le 23 août 2018 à 16:44.
Je dirais plutôt :
si g n'a pas besoin de posséder la variable ET qu'elle peut être nulle, alors un pointeur nu fait l'affaire
si g n'a pas besoin de posséder la variable ET qu'elle est forcément définie alors un passage par référence est largement préférable
Ne pas oublier de mettre des const lorsque c'est possible.
Il ne faut pas décorner les boeufs avant d'avoir semé le vent
[^] # Re: Un temporaire en plus
Posté par Clément V . Évalué à 1.
Mais une référence de type
A
dans ce cas, la référence vers unshared_ptr
ne garantie pas que le pointeur lui-même est déréférençable.[^] # Re: Un temporaire en plus
Posté par fearan . Évalué à 2.
Oui évidemment, c'était clair dans mon esprit, mais peut être mal retranscrit. Le passage d'un shared_ptr ne doit se faire que lorsqu'il y a un partage sur la possession d'un pointeur et uniquement dans ce cas, ce qui normalement est assez rare.
Il ne faut pas décorner les boeufs avant d'avoir semé le vent
# arg! du c++ !
Posté par gaaaaaAab . Évalué à 5.
Je suis assez néophyte en C++ (c'est un langage qui m'a fait fuir assez vite): est-ce que c'est une vraiment question sur le langage C++ ou plutôt sur ce que va faire un compilateur C++ ?
D'ailleurs, pour ce que j'ai lu sur le C++, j'ai l'impression que la séparation entre la spécification du langage est son implémentation n'est pas toujours simple à faire. Est-ce que c'est une impression très fausse ou il y a un peu de ça ?
(Et du coup, ça répondrait à ma première question)
[^] # Re: arg! du c++ !
Posté par small_duck (site web personnel) . Évalué à 8.
Il y a du mieux. Autant pendant un temps, les compilateurs étaient vraiment derrière le standard, autant depuis C++11, on a un standard beaucoup plus clair, et des implémentations qui ont vraiment convergé.
Par exemple, le standard exclut clairement maintenant l'optimisation COW (copie sur écriture) pour std::string, et tous les compilos sont passés à l'optimisation SSO (optimisation de chaînes courtes. Je n'ai pas trouvé d'article Wikipedia, mais j'avais écrit quelque chose sur le sujet).
La question porte sur le standard, et sauf grosse surprise je m'attends à ce que tous les compilos fassent la même chose (mais je prends tous les benchmarks que vous avez pour confirmer !).
# Godbolt
Posté par rewind (Mastodon) . Évalué à 8.
On peut voir le résultat de la compilation sur godbolt. J'ai mis GCC et Clang, il y a quelques différences. Mais à vue de nez, je ne sais pas s'il y a vraiment beaucoup de différences.
Ceci dit, je trouve bizarre de passer un
shared_ptr
par référence. Soit on a besoin d'un partage et dans ce cas là, on passer par valeur. Soit on n'a pas besoin d'un partage et là, on déréférence le pointeur et on envoie une référence directe sur l'objet. Une référence sur unshared_ptr
, ça nécessite deux déréférencement pour atteindre l'objet, c'est un de trop.[^] # Re: Godbolt
Posté par fearan . Évalué à 5.
Justement imagine que tu passe ton shared_ptr par copie, puis que tu le stock dans ton objet, tu as 2 copie (avec comptage de référence); certes avec les opérateurs de déplacement y a des chances qu'on ait pas la deuxième copie, mais en pre-c++11 (version boost) on a pas cette mécanique.
Si tu le passes par références pour le stocker tu n'as qu'une seule copie.
Cependant il faut bien faire attention lorsque l'on joue avec du multithread
https://stackoverflow.com/questions/3310737/should-we-pass-a-shared-ptr-by-reference-or-by-value
Il ne faut pas décorner les boeufs avant d'avoir semé le vent
[^] # Re: Godbolt
Posté par groumly . Évalué à 10.
J’adore le c++, ou le moindre bout de code trivial à des effets papillon et des conséquences délirantes, saupoudré de “si t’as de la chance (ou malchance, c’est selon), le compilo va te sauver (ou te la faire à l’envers) selon les optimizations)”, tout ça pour ce qui est essentiellement des micros optimizations impossible à mesurer pour la majorité des cas d’usages.
Changez rien, les gars.
Linuxfr, le portail francais du logiciel libre et du neo nazisme.
[^] # Re: Godbolt
Posté par serge_sans_paille (site web personnel) . Évalué à 7.
Derrière la petite pique trollesque, tu pointes du doigt un vrai problème, qui est cependant partagé par d'autres couples (langage , compilateur) : le langage donne très peu de garantie d'optimisation ou non de certaines constructions, et le développeur en est réduit à faire confiance au compilateur pour apporter les propriétés de performance, abstraction à coût zéro etc. Autrement dit la phrase « C++ donne du code rapide » est bien moins pertinente que « GCC arrive généralement a compiler efficacement un code C++ ». Ce n'est pas du tout une propriété du langage, et du coup il est difficile de raisonner dessus…
[^] # Re: Godbolt
Posté par YBoy360 (site web personnel) . Évalué à 1.
En C, le comportement des compilateurs est bien plus homogène, ces micro optimisations sont explicites.
[^] # Re: Godbolt
Posté par gorbal . Évalué à 5.
Les autres languages également.
Sauf que tu ne le sais pas. Tente de déterminer à l'avance le temps d'execution d'une routine en Java ..
Même pour les microprocesseurs c'est la cas: le temp nécessaire à l'exécution d'une même instruction peut être multiplié par 2 à la génération suivante (à fréquence égale).
[^] # Re: Godbolt
Posté par rewind (Mastodon) . Évalué à 3.
Effectivement, il n'y a rien à changer. C++ permet une gestion très fine de la mémoire et des passages de paramètres (par valeur ou par référence, on a le choix, contrairement à quasiment tous les autres langages), et donc il y a des implications à choisir l'un ou l'autre. Mais ces implications sont très loin des «effets papillons». C'est juste histoire de savoir ce qu'on fait et comment ça se passe. Il y a des gens qui ont besoin de ce genre de subtilités pour avoir des performances, c'est bien qu'il existe un langage qui leur permette de le faire, il s'appelle C++.
[^] # Re: Godbolt
Posté par fearan . Évalué à 5. Dernière modification le 24 août 2018 à 09:58.
Ici y a rien de sorcier, dans le cas de la copie du paramètre pour le stocker ensuite, en C++11, on va avoir (copie + déplacement), en pré c++11 on aura 2 copie; dans les deux cas le résultat est strictement identique.
Le cas du passage de référence et plus taquin mais on aurait exactement le même problème en pointeur nu, ou en référence sans shared_ptr; si la ressource est désallouée par un autre thread on a une référence sur nulle part (ou un pointeur sur nulle part)
c'est ce qui permet de retourner un conteneur de plusieurs dizaines de milliers d'éléments (potentiellement complexe) sans passer par des pointeurs (du point de vue codeur).
Presque tous les autres langages passent par référence, mais pas toujours; si tu veux qu'on parle des truc marrant de java ou python, justement sur le passage de paramètre
Toujours de ce qui en découle, hormis quelques objets, il est impossible de filer un objet en paramètre ou retour d'une fonction (ou d'un objet) et d'être certain qu'il ne va pas être modifié; Alors tu as bien les ImmutableList, mais c'est du runtime. La seule solution c'est de passer par des copies… Là où une const& fait parfaitement l'affaire.
On en arrive à ce genre de truc http://www.javapractices.com/topic/TopicAction.do?Id=15 ; devoir faire des copies inutiles, des constructions d'objets des allocations…
Le c++ n'est pas parfait, loin de là, il permet de faire des grosses boulettes, mais je ne connais aucun langage permettant facilement le choix entre copie/référence, const/mutable (y a pas que ça mais c'est le fil du thread)
Il ne faut pas décorner les boeufs avant d'avoir semé le vent
[^] # Re: Godbolt
Posté par Blackknight (site web personnel, Mastodon) . Évalué à 10.
Tu ne fais pas d'effort, il suffit de se documenter sur Ada :)
Je n'ai pas mis d'exemple mais lorsque l'on passe un objet issu d'un type taggué (en gros, un objet qui autorise la POO), celui-ci est forcément passé par référence.
Pour les quelques curieux, voici les deux pages intéressantes sur le Wikibook, celle sur les passage de paramètres et celle des pointeurs.
[^] # Re: Godbolt
Posté par jyes . Évalué à 7. Dernière modification le 24 août 2018 à 13:30.
… et Fortran (et probablement plein d’autres langages de cette génération) !
Bon, ce n’est pas tout à fait comme en C++ car les “intent” déclarent les intentions d’utilisation d’une variable et laissent le compilateur libre de choisir le passage par référence ou par valeur, mais en terme de liberté pour le programmeur et de capacité d’optimisation, ça offre les mêmes possibilités que les “*”, “&” du C++ et leurs homologues précédés de “const”.
[^] # Re: Godbolt
Posté par jyes . Évalué à 4.
Arg, j’ai oublié “E”
pour le cas où l’on souhaite un type “nullable”.
[^] # Re: Godbolt
Posté par Blackknight (site web personnel, Mastodon) . Évalué à 3.
Au final, c'est pareil en Ada et c'est ce qui est dit dans le wikibook
C'est au final au compilateur de choisir sauf si le développeur veut forcer une manière de passer les arguments et dans ce cas, on peut passer par des pointeurs.
D'ailleurs pour faciliter le masquage de la sémantique des pointeurs, Ada 2012 a introduit l'aspect Implicit_Dereference qui permet de ne pas avoir à utiliser un .all.
Ainsi, une variable de type Accessor construite sur un pointeur d'Element donne directement le membre Data du type en question.
[^] # Re: Godbolt
Posté par Boiethios (site web personnel) . Évalué à 2. Dernière modification le 28 août 2018 à 11:28.
Je ne veux pas être le fanboy de Rust de service, mais c'est ce qu'il fait, en encore mieux, puisqu'il déplace les objets par défaut et ne fait une copie que de façon explicite.
[^] # Re: Godbolt
Posté par moi1392 . Évalué à 3.
tu n'as pas l'impression que ta remarque est auto contradictoire ?
[^] # Re: Godbolt
Posté par rewind (Mastodon) . Évalué à 2.
Le lien que tu donnes dit explicitement «Shortly, there is no reason to pass by value, unless the goal is to share ownership of an object», ce qui est exactement ce que j'ai dit plus haut : «Soit on a besoin d'un partage et dans ce cas là, on passer par valeur.»
Et après, de manière générale, si tu veux stocker un objet, tu as intérêt à le demander par valeur (ça ne concerne pas que les
shared_ptr
pour le coup) pour éviter des copies justement. Dans le cas du passage par référence, l'objet va être créé puis copié dans la fonction. Dans le cas du passage par valeur, il va y avoir zéro copie, l'objet va être créé puis déplacé directement.Dans le lien que tu donnes, il y a d'ailleurs une réponse qui pointent vers un post de Herb Sutter qui est accessoirement le président du comité de normalisation et qui donne une réponse très détaillée pour tous les cas de figure.
[^] # Re: Godbolt
Posté par barmic . Évalué à 4. Dernière modification le 24 août 2018 à 09:58.
Hum ? En quoi il s'agit d'une référence du coup ?
Si on a un constructeur adéquate, c'est ça ?
Ça se passe comment pour une grappe d'objets ? Si mon objet a référence un objet b et que je fais un déplacement de l'objet a, qu'est-ce qui va définir si on fait une copie ou un déplacement de l'objet b ? C'est transparent à l'utilisation, donc ça dépend juste de si b possède un constructeur de déplacement ?
[^] # Re: Godbolt
Posté par aiolos . Évalué à 2.
Ce ne serait pas juste une inversion des termes par erreur ?
[^] # Re: Godbolt
Posté par rewind (Mastodon) . Évalué à 6.
Non, ce n'est pas inversé. Je vais prendre un exemple avec
std::string
pour que ce soit plus clair. L'hypothèse de départ, c'est qu'on veut conserver la chaîne de caractères passé en paramètre (sinon, pas besoin de se poser la question, voir plus bas). Le cas le plus fréquent, c'est un constructeur, mais ça pourrait être n'importe quelle fonction.En (a), il y a une copie qui est faite au moment de l'appel, mais dans le constructeur, il n'y a pas de copie, juste un déplacement (ce qui revient à déplacer le pointeur). En (b), il n'y a pas de copie, la chaîne est construite dans le paramètre qui est ensuite déplacé dans la variable membre. En (c), il y a un passage de référence au moment de l'appel, mais il y a une copie dans le constructeur. En (d), il y a une création d'une chaîne sur la pile, puis il y a une copie dans le constructeur. Au final, en passant par valeur, on s'évite une copie si on passe un littéral, alors qu'en passant par référence, on a une copie superflue. C'est pour ça que depuis C++11 (depuis qu'on a la sémantique de déplacement), il est conseillé de passer ce genre de paramètre par valeur, et plus par référence.
Si on n'a pas besoin de conserver le paramètre, alors il faut passer le paramètre par référence constante. Mais ce cas était déjà géré avant C++11.
Dans le cas d'un
std::shared_ptr<T>
, on peut tenir le même genre de raisonnement, mais il y a une subtilité en plus qui est qu'on a en fait deux objets : le pointeur intelligent en lui-même (std::shared_ptr
), et l'objet pointé (de typeT
). Comme le précise Herb Sutter dans l'article que j'ai lié un peu plus haut, si on n'a besoin que de l'objet pointé, on doit passer soit une référence sur unT
, soit un pointeur sur unT
parce que ça rend la fonction plus générique en ce sens qu'on ne la restreint pas à une seule politique de gestion mémoire. Si on a besoin de partager le pointeur, alors on passe unshared_ptr<T>
par copie (pour la même raison que pour lestd::string
précédemment). Si on passe unshared_ptr<T>
par référence non-constante, c'est qu'on veut modifier le pointeur lui-même et pas l'objet pointé. Et si on le passe par référence constante, c'est qu'on ne sait pas si on va faire une copie ou pas, mais ce cas est extrêmement rare et c'est précisément celui qui est donné en exemple dans le post initial.[^] # Re: Godbolt
Posté par moi1392 . Évalué à 2.
Ce point n'est pas aussi clair. Prends l'exemple suivant :
et ailleurs dans le code :
si la variable some_b est égal à a::b, qui est un cas pas délirant du tout dans un contexte classique d'exécution de code, alors tu as une copie de B qui a lieu alors qu'elle n'aurait pas eu si B était passé en référence constante.
[^] # Re: Godbolt
Posté par rewind (Mastodon) . Évalué à 3.
Dans ce cas, tu es comme dans le dernier cas que j'ai cité du
shared_ptr
, tu ne sais pas si tu vas conserver le paramètre ou pas, donc tu passes une référence constante et tu fais la copie seulement si besoin. Donc on est bien d'accord et c'est très logique en fait.[^] # Re: Godbolt
Posté par fearan . Évalué à 2.
Vi et faut aussi faire gaffe à ce que la comparaison ne soit pas plus longue que la copie; sinon on perd un peu l'intérêt du schmilblick.
Il ne faut pas décorner les boeufs avant d'avoir semé le vent
[^] # Re: Godbolt
Posté par moi1392 . Évalué à 2.
Je suis d'accord, c'est juste que je n'ai pas l'impression qu'il y a un consensus clair pour les setter de membre de classe.
Personnellement (je ne suis pas une référence, mais j'aime bien donner mon avis quand même :p) je conserve la version "const ref" dans la majorité des cas, et quand j'ai des cas particuliers ou cela peut faire une vraie différence, je me pose la question et je choisi au cas pas cas.
[^] # Re: Godbolt
Posté par barmic . Évalué à 1.
Oui ok tu ne remet pas en cause ma compréhension des références (ouf ! :) ).
En fait si tu veux garder une copie, soit elle doit être fait via le passage par valeur soit dans ton code. Le fait de passer une référence ne crée pas une copie en soit. C'est le contenu de la méthode qui la copie.
[^] # Re: Godbolt
Posté par Clément V . Évalué à 2.
Pour garder un exemple dans le style du journal, mais où on sait ce que font les fonctions : https://gcc.godbolt.org/z/2llMlg
C::f
etC::g
garde toutes les deux une copie du pointeur, mais f prend par référence et g par valeur.C::h
prend possession du pointeur passé par référence de rvalue.c.f(ptr_A)
: le pointeur passé par référence puis copié dans la fonction -> 1 copie.c.f(ptr_B)
: le pointeur est converti, puis le temporaire est passé par référence, copié et détruit -> 2 copies, 1 destruction.c.g(ptr_A)
: le paramètre est crée par le constructeur de copie puis est déplacé par la fonction -> 1 copie, 1 déplacement, 1 destruction d'un pointeur nul (moins cher).c.g(ptr_B)
: pareil que le précédent sauf que le constructeur de copie est remplacé par la conversion.c.h(std::move(ptr_A))
: passage par référence, puis affectation -> 1 déplacement.c.h(std::move(ptr_B))
: conversion, passage par référence, puis affectation -> 2 déplacement, 1 destruction d'un pointeur nul.J'espère que c'est plus clair. Le passage par référence nécessite une copie supplémentaire pour la conversion, alors que le passage par valeur permet de limiter à une seule copie dans tous les cas.
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.