Version courte: il est courant de renvoyer des objets volumineux, tels que des vecteurs / tableaux, dans de nombreux langages de programmation. Ce style est-il maintenant acceptable en C ++ 0x si la classe a un constructeur de mouvement, ou les programmeurs C ++ le considèrent-ils bizarre / moche / abomination?
Version longue: en C ++ 0x est-ce toujours considéré comme une mauvaise forme?
std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();
La version traditionnelle ressemblerait à ceci:
void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);
Dans la version la plus récente, la valeur renvoyée par BuildLargeVector
est une rvalue, donc v serait construit en utilisant le constructeur de déplacement de std::vector
, en supposant que (N) RVO n'a pas lieu.
Même avant C ++ 0x, la première forme était souvent "efficace" à cause de (N) RVO. Cependant, (N) RVO est à la discrétion du compilateur. Maintenant que nous avons des références rvalue, il est garanti qu'aucune copie profonde n'aura lieu.
Edit : La question n'est vraiment pas une question d'optimisation. Les deux formes présentées ont des performances presque identiques dans les programmes du monde réel. Alors que, dans le passé, la première forme aurait pu avoir des performances plus mauvaises d'un ordre de grandeur. En conséquence, la première forme était pendant longtemps une odeur de code majeure dans la programmation C ++. Plus maintenant, j'espère?
Réponses:
Dave Abrahams a une analyse assez complète de la vitesse de passage / retour des valeurs .
Réponse courte, si vous devez renvoyer une valeur, renvoyez une valeur. N'utilisez pas de références de sortie car le compilateur le fait quand même. Bien sûr, il y a des mises en garde, vous devriez donc lire cet article.
la source
x / 2
àx >> 1
forint
s, mais vous supposez que ce sera le cas. La norme ne dit pas non plus comment les compilateurs sont tenus d'implémenter des références, mais vous supposez qu'elles sont gérées efficacement à l'aide de pointeurs. La norme ne dit pas non plus sur les v-tables, vous ne pouvez donc pas être sûr que les appels de fonction virtuelle sont efficaces non plus. Essentiellement, vous devez parfois faire confiance au compilateur.Au moins IMO, c'est généralement une mauvaise idée, mais pas pour des raisons d'efficacité. C'est une mauvaise idée car la fonction en question doit généralement être écrite comme un algorithme générique qui produit sa sortie via un itérateur. Presque tout code qui accepte ou renvoie un conteneur au lieu d'opérer sur des itérateurs doit être considéré comme suspect.
Ne vous méprenez pas: il y a des moments où il est logique de faire circuler des objets de type collection (par exemple, des chaînes) mais pour l'exemple cité, je considérerais que passer ou renvoyer le vecteur est une mauvaise idée.
la source
L'essentiel est:
Copy Elision et RVO peuvent éviter les "copies effrayantes" (le compilateur n'est pas obligé de mettre en œuvre ces optimisations, et dans certaines situations, il ne peut pas être appliqué)
Les références C ++ 0x RValue permettent une implémentation de chaîne / vecteur qui garantit cela.
Si vous pouvez abandonner les anciennes implémentations de compilateurs / STL, renvoyez les vecteurs librement (et assurez-vous que vos propres objets le prennent également en charge). Si votre base de code doit prendre en charge des compilateurs «inférieurs», respectez l'ancien style.
Malheureusement, cela a une influence majeure sur vos interfaces. Si C ++ 0x n'est pas une option et que vous avez besoin de garanties, vous pouvez utiliser à la place des objets comptés par référence ou copie à l'écriture dans certains scénarios. Cependant, ils ont des inconvénients avec le multithreading.
(Je souhaite qu'une seule réponse en C ++ soit simple et directe et sans conditions).
la source
En effet, depuis C ++ 11, le coût de la copie du
std::vector
disparaît dans la plupart des cas.Cependant, il faut garder à l'esprit que le coût de construction du nouveau vecteur (puis de sa destruction ) existe toujours, et l'utilisation de paramètres de sortie au lieu de renvoyer par valeur est toujours utile lorsque vous souhaitez réutiliser la capacité du vecteur. Ceci est documenté comme une exception dans F.20 des directives de base C ++.
Comparons:
avec:
Maintenant, supposons que nous devions appeler ces méthodes
numIter
fois dans une boucle serrée et effectuer une action. Par exemple, calculons la somme de tous les éléments.En utilisant
BuildLargeVector1
, vous feriez:En utilisant
BuildLargeVector2
, vous feriez:Dans le premier exemple, de nombreuses allocations / désallocations dynamiques inutiles se produisent, qui sont évitées dans le deuxième exemple en utilisant un paramètre de sortie à l'ancienne, en réutilisant la mémoire déjà allouée. La valeur de cette optimisation dépend du coût relatif de l'allocation / désallocation par rapport au coût du calcul / de la mutation des valeurs.
Référence
Jouons avec les valeurs de
vecSize
etnumIter
. Nous garderons vecSize * numIter constant pour que "en théorie", cela prenne le même temps (= il y a le même nombre d'affectations et d'ajouts, avec exactement les mêmes valeurs), et le décalage horaire ne peut provenir que du coût de allocations, désallocations et meilleure utilisation du cache.Plus précisément, utilisons vecSize * numIter = 2 ^ 31 = 2147483648, car j'ai 16 Go de RAM et ce nombre garantit que pas plus de 8 Go sont alloués (sizeof (int) = 4), en veillant à ne pas échanger sur le disque ( tous les autres programmes étaient fermés, j'avais ~ 15 Go disponibles lors de l'exécution du test).
Voici le code:
Et voici le résultat:
(Intel i7-7700K à 4,20 GHz; 16 Go de DDR4 à 2400 MHz; Kubuntu 18.04)
Notation: mem (v) = v.size () * sizeof (int) = v.size () * 4 sur ma plateforme.
Sans surprise, lorsque
numIter = 1
(c'est-à-dire, mem (v) = 8 Go), les heures sont parfaitement identiques. En effet, dans les deux cas, nous n'allouons qu'une seule fois un énorme vecteur de 8 Go en mémoire. Cela prouve également qu'aucune copie ne s'est produite lors de l'utilisation de BuildLargeVector1 (): je n'aurais pas assez de RAM pour faire la copie!Lorsque
numIter = 2
, réutiliser la capacité vectorielle au lieu de réallouer un deuxième vecteur est 1,37x plus rapide.Quand
numIter = 256
, réutiliser la capacité vectorielle (au lieu d'allouer / désallouer un vecteur encore et encore 256 fois ...) est 2,45x plus rapide :)Nous pouvons remarquer que time1 est à peu près constant de
numIter = 1
ànumIter = 256
, ce qui signifie qu'allouer un énorme vecteur de 8 Go est à peu près aussi coûteux que d'allouer 256 vecteurs de 32 Mo. Cependant, allouer un vecteur énorme de 8 Go est certainement plus coûteux que d'allouer un vecteur de 32 Mo, donc la réutilisation de la capacité du vecteur offre des gains de performances.De
numIter = 512
(mem (v) = 16MB) ànumIter = 8M
(mem (v) = 1kB) est le point idéal: les deux méthodes sont exactement aussi rapides et plus rapides que toutes les autres combinaisons de numIter et vecSize. Cela a probablement à voir avec le fait que la taille du cache L3 de mon processeur est de 8 Mo, de sorte que le vecteur tient à peu près complètement dans le cache. Je n'explique pas vraiment pourquoi le saut soudain detime1
est pour mem (v) = 16 Mo, il semblerait plus logique de se produire juste après, quand mem (v) = 8 Mo. Notez que étonnamment, dans ce sweet spot, ne pas réutiliser la capacité est en fait légèrement plus rapide! Je n'explique pas vraiment cela.Quand les
numIter > 8M
choses commencent à devenir moche. Les deux méthodes sont plus lentes, mais le retour du vecteur par valeur est encore plus lent. Dans le pire des cas, avec un vecteur ne contenant qu'une seuleint
capacité, la réutilisation de la capacité au lieu de renvoyer par valeur est 3,3 fois plus rapide. Cela est probablement dû aux coûts fixes de malloc () qui commencent à dominer.Notez que la courbe pour le temps2 est plus douce que la courbe pour le temps1: non seulement la réutilisation de la capacité vectorielle est généralement plus rapide, mais peut-être plus important encore, elle est plus prévisible .
Notez également que dans le sweet spot, nous avons pu effectuer 2 milliards d'ajouts d'entiers 64 bits en ~ 0,5 s, ce qui est tout à fait optimal sur un processeur 4,2 GHz 64 bits. Nous pourrions faire mieux en parallélisant le calcul afin d'utiliser les 8 cœurs (le test ci-dessus n'utilise qu'un seul cœur à la fois, ce que j'ai vérifié en relançant le test tout en surveillant l'utilisation du processeur). Les meilleures performances sont obtenues lorsque mem (v) = 16 ko, qui est l'ordre de grandeur du cache L1 (le cache de données L1 pour le i7-7700K est 4x32 ko).
Bien sûr, les différences deviennent de moins en moins pertinentes au fur et à mesure que vous devez effectuer des calculs sur les données. Voici les résultats si nous remplaçons
sum = std::accumulate(v.begin(), v.end(), sum);
parfor (int k : v) sum += std::sqrt(2.0*k);
:Conclusions
Les résultats peuvent différer sur d'autres plates-formes. Comme d'habitude, si les performances comptent, écrivez des benchmarks pour votre cas d'utilisation spécifique.
la source
Je pense toujours que c'est une mauvaise pratique, mais il convient de noter que mon équipe utilise MSVC 2008 et GCC 4.1, donc nous n'utilisons pas les derniers compilateurs.
Auparavant, la plupart des hotspots affichés dans vtune avec MSVC 2008 se résumaient à la copie de chaînes. Nous avions un code comme celui-ci:
... notez que nous avons utilisé notre propre type String (cela était nécessaire car nous fournissons un kit de développement logiciel dans lequel les auteurs de plugins pourraient utiliser différents compilateurs et donc des implémentations différentes et incompatibles de std :: string / std :: wstring).
J'ai fait un simple changement en réponse à la session de profilage d'échantillonnage de graphe d'appel montrant que String :: String (const String &) prend beaucoup de temps. Les méthodes comme dans l'exemple ci-dessus ont été les plus grands contributeurs (en fait, la session de profilage a montré que l'allocation de mémoire et la désallocation étaient l'un des plus gros hotspots, le constructeur de copie String étant le principal contributeur pour les allocations).
Le changement que j'ai effectué était simple:
Pourtant, cela a fait toute la différence! Le hotspot a disparu lors des sessions suivantes du profileur, et en plus de cela, nous effectuons de nombreux tests unitaires approfondis pour suivre les performances de nos applications. Toutes sortes de temps de test de performance ont chuté de manière significative après ces simples changements.
Conclusion: nous n'utilisons pas les derniers compilateurs absolus, mais nous ne pouvons toujours pas sembler dépendre du compilateur qui optimise la copie pour renvoyer par valeur de manière fiable (du moins pas dans tous les cas). Ce n'est peut-être pas le cas pour ceux qui utilisent des compilateurs plus récents comme MSVC 2010. J'attends avec impatience le moment où nous pourrons utiliser C ++ 0x et utiliser simplement des références rvalue et ne jamais avoir à s'inquiéter du fait que nous pessimisons notre code en retournant des complexes classes par valeur.
[Edit] Comme Nate l'a souligné, RVO s'applique au retour des temporaires créés à l'intérieur d'une fonction. Dans mon cas, il n'y avait pas de tels temporaires (sauf pour la branche invalide où nous construisons une chaîne vide) et donc RVO n'aurait pas été applicable.
la source
<::
ou??!
avec l' opérateur conditionnel?:
(parfois appelé l' opérateur ternaire ).Juste pour pinailler un peu: il n'est pas courant dans de nombreux langages de programmation de renvoyer des tableaux à partir de fonctions. Dans la plupart d'entre eux, une référence au tableau est renvoyée. En C ++, l'analogie la plus proche serait de retourner
boost::shared_array
la source
shared_ptr
et appelez-le un jour.Si les performances sont un réel problème, vous devez comprendre que la sémantique de déplacement n'est pas toujours plus rapide que la copie. Par exemple, si vous avez une chaîne qui utilise l' optimisation des petites chaînes, alors pour les petites chaînes, un constructeur de déplacement doit effectuer exactement la même quantité de travail qu'un constructeur de copie ordinaire.
la source