Parameterübergabe in C++11

von Hubert Schmid vom 2013-08-11

Andrew Koenig hat diese Woche auf Dr. Dobb's Webseite den Artikel Some Optimizations Are More Important Than Others veröffentlicht. In diesem Artikel beschreibt er unter anderem die folgenden beiden Funktionen, die sich gegenseitig überladen und zusammengenommen für alle übergebenen Argumente optimal sein sollen – bezüglich der ausgeführten Copy- und Move-Operationen.

string rev(string&& s) { reverse(s.begin(), s.end()); return s; }string rev(const string& s) { string t = s; reverse(t.begin(), t.end()); return t; }

Das Beispiel hat jedoch zwei Probleme: Erstens impliziert es eine hohe Komplexität und Redundanz aufgrund der Parameterübergabe. Und zweitens ist es unnötig ineffizient sowie insbesondere nicht optimal bezüglich dem genannten Kriterium.

Move

Zunächst zu Letzterem: Die erste Funktion erzeugt bei jedem Aufruf eine unnötige Kopie der Zeichenkette. Dabei handelt es sich um das temporäre Objekt, das die Funktion zurückgibt, und das aus dem Ausdruck der return-Anweisung erzeugt wird. In diesem Fall ist dafür eine Kopie notwendig, die sich nicht mittels Return-Value-Optimization eliminieren lässt.

Das Problem lässt sich einfach lösen. Die return-Anweisung der ersten Funktion muss lediglich durch return move(s); ersetzt werden. Somit wird das temporäre Rückgabe-Objekt mittels Move-Konstruktor erzeugt, was wesentlich effizienter als eine Kopie ist.

By-Value

Nun zum ersten Problem: Der Komplexität und Redundanz. Dazu vergleiche ich die Kombination der beiden obigen Funktionen mit der folgenden Funktion. Der wesentliche Unterschied liegt in der Art der Parameterübergabe. Statt mittels Referenzen werden die Argumente in meiner Variante By-Value übergeben. Darüber hinaus habe ich lediglich den Stil so angepasst, wie ich ihn für produktiven Code empfehle.

auto rev(std::string s) -> std::string { std::reverse(s.begin(), s.end()); return s; }

Diese Implementierung ist funktional offensichtlich identisch zu den beiden obigen. Die interessante Frage ist: Wie effizient ist sie gegenüber den beiden anderen? Um dieser Frage nachzugehen betrachte ich die Copy- und Move-Operationen, die bei den folgenden vier Anweisungen jeweils durchgeführt werden. Aufgrund potentieller Optimierungen hängen sie von der Implementierung ab. Doch zumindest GCC-4.8.1 und Clang-3.4 verhalten sich bei aktivierter Optimierung in dieser Hinsicht identisch.

  1. auto s = rev("Hello World!");
  2. s = rev("Hello World!");
  3. auto t = rev(s);
  4. t = rev(s);

Vergleich

Für die erste Anweisung wird bei beiden Varianten ein std::string aus dem char[] erzeugt und direkt in die Variable s beim Aufrufer verschoben. Bei der zweiten Anweisung wird wiederum ein std::string aus dem char[] erzeugt. Dieses Mal wird das Objekt jedoch in das temporäre Rückgabe-Objekt verschoben, das mittels Move-Zuweisung an s zugewiesen wird. Auch in diesem Fall verhalten sich beide Varianten identisch.

Einen Unterschied gibt es bei der dritten Anweisung: Im Fall der überladenen Funktionen wird diejenige mit dem Parameter const string& s verwendet. Dabei wird lediglich einmal der Copy-Konstruktor aufgerufen. Besser geht es nicht. Die Variante mit der Parameterübergabe By-Value benötigt hingegen zusätzlich noch einen Aufruf des Move-Konstruktor. Denn leider funktioniert die Return-Value-Optimization nicht für Parameter.

Ganz analog verhält es sich bei der vierten Anweisung: Von den Überladenen wird wiederum die gleiche Funktion ausgewählt. Innerhalb der Funktion wird nur einmal der Copy-Konstruktor aufgerufen, denn die lokale Variable t und das temporäre Rückgabe-Objekt sind in diesem Fall identisch. Auf Seiten des Aufrufers wird schließlich noch die Move-Zuweisung verwendet.

Mit der Übergabe bei By-Value ist der Ablauf wie folgt: Der Copy-Konstruktor wird verwendet, um den Parameter zu initialisieren. Das temporäre Rückgabe-Objekt wird mittels Move-Konstruktor initialisiert, da die Return-Value-Optimization für Parameter nicht greift. Und schließlich wird wird wiederum auf Seiten des Aufrufers die Move-Zuweisung verwendet. Diese Variante benötigt also ebenfalls einen zusätzlichen Aufruf des Move-Konstruktors.

Fazit

Es gibt zwei grundsätzliche Varianten für die Realisierung einer Funktionalität, die eine Kopie des übergebenen Objekts erfordert. Entweder man implementiert zwei sich überladene Funktionen mit Referenz-Übergabe, oder man schreibt nur eine Funktion, die das Argument By-Value übernimmt. Ersteres ist in einigen Fällen während der Ausführung effizienter. Letzteres reduziert hingegen die Komplexität und Redundanz während der Entwicklung und Wartung.

Der Unterschied in der Effizienz beschränkt sich auf jeweils einen Aufruf des Move-Konstruktors in zwei von vier Fällen. Da der Move-Konstruktor für std::string um eine Größenordnung effizienter ist als der Copy-Konstruktor, ist der Unterschied für das diskutierte Beispiel vernachlässigbar. Für den allgemeinen Fall bedeutet das: Benötigt eine Funktion eine Kopie des Arguments, dann sollte die Parameterübergabe By-Value erfolgen – außer der Move-Konstruktor ist im Vergleich zum Copy-Konstruktor teuer und es kommt wirklich auf Performance an.