Le C++98 a permis la mise en place des concepts Return Value Optimization (RVO), Named RVO (NRVO) et plus globalement Copy-Elision (élision du constructeur par copie). Dans cet article, nous allons voir ce que sont ces concepts et comment en tirer partie pour produire du code plus performant.

Je vais prendre pour exemple la classe Widget suivante (les constructeur et affectation par déplacement doivent être commentés pour compiler le code en C++98):

Copy-Elision

L’élision du constructeur par copie (copy-elision, copy-omission) est une technique d’optimisation des compilateurs visant à éliminer des copies non nécessaires d’objets. Comme nous le verrons plus bas, la RVO et la NRVO sont des formes de cette élision.

Voici un petit exemple d’application de la Copy Ellision :

Avant la mise en place de l’optimisation, l’instanciation de w  nécessitait la création de deux objets, ici pour le C++98 (l’option -fno-elide-constructors  permet de désactiver l’optimisation d’élision du constructeur sur gcc) :

g++ --std=c++98 -fno-elide-constructors main.cpp && ./a.out
Widget()
Widget(const Widget &)
~Widget()
~Widget()

Widget()  est appelé à la création de l’objet temporaire ligne (1) et Widget(const Widget &) est appelé pour construire par copie le paramètre de la fonction en ligne (2).

Avec la mise en place de l’optimisation dans nos compilateurs, la construction de l’objet temporaire peut maintenant être court-circuitée à la compilation et le paramètre de fonction peut être construit directement « en place » à partir de l’argument de la fonction.

g++ --std=c++98 main.cpp && ./a.out
Widget()
~Widget()

Dorénavant, seul le constructeur de w  (ligne (2)) est appelé.

Aujourd’hui, la plupart des compilateurs implémentent cette optimisation. Cependant, en fonction du compilateur et des circonstances, il peut y avoir des cas où le compilateur n’est pas capable d’effectuer cette optimisation. Le cas le plus courant est lorsqu’une fonction peut potentiellement retourner plusieurs objets nommés selon le chemin d’exécution (voir Cas problématiques).

Fort heureusement, le C++11 a introduit la sémantique de déplacement dans le standard ce qui permet de réduire considérablement le coût des opérations même lorsque le compilateur n’est pas en mesure d’appliquer la copy-elision. De plus, à partir du C++17 la RVO devient requise par le standard dans certaines situations mais ceci dépasse le cadre de cet article.

Return Value Optimization

La RVO vise à autoriser le compilateur à ne pas créer d’objet temporaire pour les valeurs retournées, quand bien même cela aurait des effets secondaires !

Voici un exemple pratique de la RVO:

Sur une version ancienne du C++, on pourrait s’attendre à ce que l’objet retourné par la fonction getWidgetRVO()  soit d’abord créé dans la fonction (en (1)) puis copié dans la variable a  (en (2)). Ce n’est pas le cas avec la RVO qui évite l’emploie d’un objet temporaire :

g++ --std=c++11 main.cpp && ./a.out
Widget()
~Widget()

On observe effectivement bien qu’un unique Widget est créé. De plus, notons que cette optimisation se fait malgré l’effet secondaire de ne pas afficher dans la console toutes les étapes de créations d’objets temporaires qui auraient été créés sans la RVO.

En désactivant la RVO sur le compilateur :

g++ --std=c++11 -fno-elide-constructors main.cpp && ./a.out
Widget()
Widget(Widget &&)
~Widget()
Widget(Widget &&)
~Widget()
~Widget()

Cette fois on voit bien que trois objets sont créés:

  1. Un objet temporaire dans  getWidgetRVO() (constructeur par défaut)
  2. Un second objet temporaire retourné par cette fonction dans main() (constructeur par déplacement)
  3. Et enfin l’objet a  lui-même initialisé par déplacement.

Named RVO

La Named RVO est une optimisation liée à la RVO, c’est-à-dire qu’elle permet de retourner un objet créé sans le coût de la copie, à ceci près que cette fois l’objet est nommé dans la fonction.

Ainsi, dans le code suivant :

De façon identique à l’exemple de la RVO, la construction de a  bénéficie de l’optimisation visant à ne pas créer d’objet intermédiaire :

g++ --std=c++11 main.cpp && ./a.out
Widget()
~Widget()

Consommer des objets en bénéficiant de la copy elision

Un idiom est basé sur la capacité du compilateur à appliquer l’élision du constructeur par copie : pass-by-value constructor. En effet, on peut tirer partie de l’élision lorsque la copie de l’argument est inévitable, c’est notamment le cas lors de la construction d’un objet dont les attributs sont copiés à partir des valeurs données en paramètres.

L’ancienne approche (avant le C++11) suggère de toujours passer les valeurs en const &  au constructeur afin d’éviter toute copie inutile. Cela reste évidemment vrai lorsque le constructeur ne consomme pas le paramètre, lorsqu’il ne fait que s’en servir. Cependant, qu’en est-il lorsque le paramètre est consommé ? C’est-à-dire lorsque sa valeur doit être stockée dans l’objet ?

Dans ce cas précis, le C++ moderne suggère de ne plus passer le paramètre par référence mais plutôt de combiner un passage par copie suivi d’un déplacement ! Aucun avantage ni aucune pénalité lorsque l’argument est une lvalue car la copie reste inévitable. Là où l’élision intervient c’est lorsque l’argument est une rvalue. Et dans ce dernier cas, le compilateur est en mesure d’appliquer l’élision du constructeur par copie (pour le paramètre du constructeur) et donc d’offrir une initialisation des membres de la classes au simple coût d’un déplacement.

g++ --std=c++11 main.cpp && ./a.out
Widget()
Widget(Widget &&)
~Widget()
~Widget()

Cas problématiques

Par commodité, les acronymes RVO et NRVO seront confondus dans cette section.

Bien que la RVO soit la plupart du temps appliquée et même requise pour certaines situations en C++17, encore une fois elle n’est pas tout le temps garantie.

Choix de l’instance à retourner à l’exécution

L’exemple classique de non-application de la RVO est lorsque l’objet à retourner dépend du chemin d’exécution. Étudions par exemple les deux cas suivants:

Dont voici l’exécution :

g++ --std=c++11 main.cpp && ./a.out
Widget()
 
Widget()
Widget(Widget &&)
~Widget()
~Widget()
~Widget()

Dans la fonction main()  on appelle deux fonctions retournant toutes les deux un Widget nouvellement créé. Dans le cas ligne (1), le widget est directement retourné ligne (2) et le compilateur applique la RVO (un seul constructeur appelé). Dans le cas ligne (3), l’objet retourné (lignes (5) ou (6)) dépend directement du test ligne (4) évalué à l’exécution. La conséquence ici est que le compilateur n’est pas en mesure d’optimiser le code à la compilation (deux constructeurs sont appelés alors qu’un seul objet est créé puis retourné).

Retourner une valeur non construite dans le scope de la fonction

Lorsque la fonction retourne un objet qui n’a pas été construit dans le scope de cette fonction, le compilateur ne sera pas en mesure d’appliquer la RVO. Voyons l’exemple dans le cas où l’on souhaite retourner l’objet initialisé en argument de la fonction :

g++ --std=c++11 main.cpp && ./a.out
Widget()
Widget(Widget &&)
~Widget()
~Widget()

Deux appels de constructeurs sont nécessaires là où naïvement on pourrait s’attendre à ce que le compilateur parvienne à optimiser. Le passage de la valeur temporaire en argument bénéficie bien de la RVO, cependant le compilateur n’assure pas de transitivité de l’optimisation jusqu’à la valeur retournée.

Utiliser std::move au return

L’utilisation de std::move pour retourner une valeur est à bannir dans le cas général pour plusieurs raisons. La première est qu’elle va forcer l’utilisation du constructeur par déplacement (évidemment) mais ceci aura pour effet de désactiver la RVO qui peut s’en passer comme nous avons pu le voir plus haut. La seconde raison est que cela rajoute une redondance car le déplacement aura lieu même sans l’emploi de std::move.

g++ --std=c++11 main.cpp && ./a.out
Widget()
Widget(Widget &&)
~Widget()
~Widget()

Conclusion

L’élision du constructeur par copie est une optimisation que font tous nos compilateurs modernes, elle permet d’économiser la création d’objets temporaires lors du passage d’objet en paramètre ou lorsqu’on retourne un objet. Nous avons vu que nous pouvons tirer partie de cette optimisation pour définir des constructeurs plus performants dans le cas de la consommation d’objets temporaires. Cependant, comme nous l’avons vu dans les cas problématiques, certaines situations empêchent nécessairement le compilateur d’appliquer l’optimisation. Il faudra par exemple rester vigilant à la manière de retourner une valeur nommée afin d’augmenter les chances d’économiser la création d’objets temporaires.

 

Plus d’informations ici : http://en.cppreference.com/w/cpp/language/copy_elision

 

LAISSER UN COMMENTAIRE

Please enter your comment!
Please enter your name here