Ich habe etwas C ++ gelernt und muss häufig große Objekte von Funktionen zurückgeben, die innerhalb der Funktion erstellt wurden. Ich weiß, dass es die Referenzübergabe, die Rückgabe eines Zeigers und die Rückgabe einer Referenztyp-Lösung gibt, aber ich habe auch gelesen, dass C ++ - Compiler (und der C ++ - Standard) die Rückgabewertoptimierung ermöglichen, wodurch das Kopieren dieser großen Objekte durch den Speicher vermieden wird Das spart Zeit und Gedächtnis.
Ich bin der Meinung, dass die Syntax viel klarer ist, wenn das Objekt explizit als Wert zurückgegeben wird und der Compiler im Allgemeinen das RVO verwendet, um den Prozess effizienter zu gestalten. Ist es eine schlechte Praxis, sich auf diese Optimierung zu verlassen? Dies macht den Code für den Benutzer klarer und lesbarer, was äußerst wichtig ist. Aber sollte ich vorsichtig sein, wenn ich annehme, dass der Compiler die RVO-Gelegenheit ergreifen wird?
Handelt es sich um eine Mikrooptimierung, oder etwas, das ich beim Entwerfen meines Codes beachten sollte?
quelle
Antworten:
Setze das Prinzip des geringsten Erstaunens ein .
Sind Sie und immer nur Sie es, die diesen Code verwenden, und sind Sie sicher, dass das, was Sie in 3 Jahren tun, Sie nicht überraschen wird?
Fahre fort.
Verwenden Sie in allen anderen Fällen die Standardmethode. Andernfalls werden Sie und Ihre Kollegen auf schwer zu findende Fehler stoßen.
Zum Beispiel beschwerte sich mein Kollege über meinen Code, der Fehler verursachte. Es stellte sich heraus, dass er die Kurzschluss-Boolesche Auswertung in seinen Compilereinstellungen deaktiviert hatte. Ich hätte ihn fast geschlagen.
quelle
Geben Sie in diesem speziellen Fall auf jeden Fall nur nach Wert zurück.
RVO und NRVO sind bekannte und robuste Optimierungen, die von jedem anständigen Compiler auch im C ++ 03-Modus durchgeführt werden sollten.
Die Verschiebungssemantik stellt sicher, dass Objekte aus Funktionen verschoben werden, wenn (N) RVO nicht stattgefunden hat. Das ist nur dann sinnvoll , wenn Ihr Objekt dynamische Daten intern verwendet (wie der
std::vector
Fall ist), aber das sollte wirklich der Fall sein , wenn es , dass große - überfüllt den Stapel ein Risiko mit großen automatischen Objekten.C ++ 17 erzwingt RVO. Also keine Sorge, es verschwindet nicht auf Ihnen und wird sich erst dann vollständig etablieren, wenn die Compiler auf dem neuesten Stand sind.
Letztendlich sind das Erzwingen einer zusätzlichen dynamischen Zuordnung, um einen Zeiger zurückzugeben, oder das Erzwingen, dass Ihr Ergebnistyp standardmäßig konstruierbar ist, nur damit Sie ihn als Ausgabeparameter übergeben können, hässliche und nicht idiomatische Lösungen für ein Problem, das Sie wahrscheinlich nie finden werden haben.
Schreiben Sie einfach sinnvollen Code und danken Sie den Compiler-Autoren für die korrekte Optimierung des sinnvollen Codes.
quelle
Dies ist keine wenig bekannte, niedliche Mikrooptimierung, über die Sie in einem kleinen Blog gelesen haben und über die Sie sich dann schlau und überlegen fühlen.
Nach C ++ 11 ist RVO die Standardmethode , um diesen Code zu schreiben. Es ist üblich, zu erwarten, zu unterrichten, in Vorträgen zu erwähnen, in Blogs zu erwähnen, und im Standard zu erwähnen. Wird dies nicht implementiert, wird dies als Compiler-Fehler gemeldet. In C ++ 17 geht die Sprache noch einen Schritt weiter und schreibt in bestimmten Szenarien die Kopierentscheidung vor.
Auf diese Optimierung sollten Sie sich unbedingt verlassen.
Darüber hinaus führt die Rückgabe nach Wert dazu, dass Code wesentlich einfacher gelesen und verwaltet werden kann als Code, der als Referenz zurückgegeben wird. Wertesemantik ist eine mächtige Sache, die selbst zu mehr Optimierungsmöglichkeiten führen könnte.
quelle
Die Richtigkeit des von Ihnen geschriebenen Codes sollte niemals von einer Optimierung abhängen. Es sollte das richtige Ergebnis ausgeben, wenn es auf der C ++ - "virtuellen Maschine" ausgeführt wird, die sie in der Spezifikation verwenden.
Worüber Sie jedoch sprechen, ist eher eine Frage der Effizienz. Ihr Code läuft besser, wenn er mit einem RVO-Optimierungscompiler optimiert wird. Aus all den Gründen, auf die in den anderen Antworten hingewiesen wird, ist das in Ordnung.
Wenn Sie jedoch diese Optimierung benötigen (z. B. wenn der Kopierkonstruktor tatsächlich zum Fehlschlagen Ihres Codes führen würde), sind Sie jetzt bei den Launen des Compilers.
Ich denke, das beste Beispiel dafür in meiner eigenen Praxis ist die Tail-Call-Optimierung:
Es ist ein albernes Beispiel, aber es zeigt einen Tail-Aufruf, bei dem eine Funktion direkt am Ende einer Funktion rekursiv aufgerufen wird. Die virtuelle C ++ - Maschine wird zeigen, dass dieser Code ordnungsgemäß funktioniert, obwohl ich möglicherweise ein wenig verwirrt darüber bin , warum ich mir überhaupt die Mühe gemacht habe, eine solche Additionsroutine zu schreiben. In praktischen Implementierungen von C ++ verfügen wir jedoch über einen Stapel, und der Speicherplatz ist begrenzt. Bei umständlicher Ausführung müsste diese Funktion beim
b + 1
Hinzufügen mindestens Stapelrahmen auf den Stapel schieben . Wenn ich rechnen willsillyAdd(5, 7)
, ist das keine große Sache. Wenn ich rechnen möchtesillyAdd(0, 1000000000)
, könnte ich wirklich Probleme haben, einen StackOverflow zu verursachen (und nicht die gute Art ).Wir können jedoch sehen, dass wir, sobald wir diese letzte Rücksprunglinie erreicht haben, wirklich mit allem im aktuellen Stapelrahmen fertig sind. Wir müssen es nicht wirklich behalten. Mit der Tail-Call-Optimierung können Sie den vorhandenen Stack-Frame für die nächste Funktion "wiederverwenden". Auf diese Weise benötigen wir stattdessen nur 1 Stapelrahmen
b+1
. (Wir müssen immer noch all diese dummen Additionen und Subtraktionen machen, aber sie nehmen nicht mehr Platz in Anspruch.) In der Tat verwandelt die Optimierung den Code in:In einigen Sprachen ist die Tail-Call-Optimierung in der Spezifikation explizit erforderlich. C ++ gehört nicht dazu . Ich kann mich nicht darauf verlassen, dass C ++ - Compiler diese Möglichkeit zur Optimierung von Tail Calls erkennen, es sei denn, ich gehe von Fall zu Fall. Bei meiner Version von Visual Studio führt die Release-Version die Tail-Call-Optimierung durch, die Debug-Version jedoch nicht (von Entwurf).
Daher wäre es schlecht für mich, wenn ich darauf angewiesen wäre, rechnen zu können
sillyAdd(0, 1000000000)
.quelle
#ifdef
Blöcke und bieten eine standardkonforme Problemumgehung an.b = b + 1
?In der Praxis erwarten C ++ - Programme einige Compileroptimierungen.
Schauen Sie insbesondere in den Standard - Header Ihrer Standard - Container - Implementierungen. Mit GCC können Sie die vorverarbeitete Form (
g++ -C -E
) und die GIMPLE-interne Darstellung (g++ -fdump-tree-gimple
oder Gimple SSA mit-fdump-tree-ssa
) der meisten Quelldateien (technische Übersetzungseinheiten) mithilfe von Containern anfordern . Sie werden überrascht sein, wie viel Optimierung (mitg++ -O2
) durchgeführt wird. Daher verlassen sich die Implementierer von Containern auf die Optimierungen (und meistens weiß der Implementierer einer C ++ - Standardbibliothek, welche Optimierung stattfinden würde, und schreibt die Containerimplementierung unter Berücksichtigung dieser Aspekte. Manchmal schreibt er auch den Optimierungspass im Compiler an mit den von der Standard-C ++ - Bibliothek benötigten Funktionen umgehen).In der Praxis sind es die Compiler-Optimierungen, die C ++ und seine Standard-Container effizient genug machen. Darauf können Sie sich verlassen.
Und ebenso für den in Ihrer Frage genannten RVO-Fall.
Der C ++ - Standard wurde mitentwickelt (insbesondere durch Experimentieren mit ausreichend guten Optimierungen, während neue Funktionen vorgeschlagen wurden), um mit den möglichen Optimierungen gut zusammenzuarbeiten.
Betrachten Sie zum Beispiel das folgende Programm:
kompiliere es mit
g++ -O3 -fverbose-asm -S
. Sie werden feststellen, dass die generierte Funktion keineCALL
Maschinenanweisungen ausführt . Daher wurden die meisten C ++ - Schritte (Konstruktion eines Lambda-Verschlusses, wiederholte Anwendung, Abrufen vonbegin
undend
Iteratoren usw.) optimiert. Der Maschinencode enthält nur eine Schleife (die im Quellcode nicht explizit vorkommt). Ohne solche Optimierungen wird C ++ 11 nicht erfolgreich sein.Nachträge
(hinzugefügt december 31 st 2017)
Siehe CppCon 2017: Matt Godbolt „Was hat mein Compiler in letzter Zeit für mich getan? Öffnen des Compiler-Deckels “ .
quelle
Wann immer Sie einen Compiler verwenden, müssen Sie wissen, dass er Maschinen- oder Byte-Code für Sie erzeugt. Es gibt keine Garantie dafür, wie der generierte Code aussieht, außer dass der Quellcode gemäß der Spezifikation der Sprache implementiert wird. Beachten Sie, dass diese Garantie unabhängig von der verwendeten Optimierungsstufe gleich ist. Daher gibt es im Allgemeinen keinen Grund, eine Ausgabe als „richtiger“ als die andere zu betrachten.
In Fällen wie RVO, in denen es in der Sprache angegeben ist, erscheint es außerdem sinnlos, sich Mühe zu geben, es nicht zu verwenden, insbesondere wenn dadurch der Quellcode einfacher wird.
Es wird viel Mühe darauf verwendet, dass Compiler effiziente Ergebnisse erzielen, und es ist klar, dass diese Funktionen genutzt werden sollen.
Es kann Gründe dafür geben, nicht optimierten Code zu verwenden (zum Beispiel für das Debuggen), aber der in dieser Frage erwähnte Fall scheint kein solcher zu sein (und wenn Ihr Code nur im optimierten Zustand fehlschlägt und keine Konsequenz einer Besonderheit des ist) Gerät, auf dem Sie es ausführen, dann ist irgendwo ein Fehler aufgetreten, und es ist unwahrscheinlich, dass es sich im Compiler befindet.)
quelle
Ich denke, andere haben den spezifischen Aspekt von C ++ und RVO gut abgedeckt. Hier ist eine allgemeinere Antwort:
Wenn es um Korrektheit geht, sollten Sie sich nicht auf Compileroptimierungen oder compilerspezifisches Verhalten im Allgemeinen verlassen. Zum Glück scheinen Sie das nicht zu tun.
Wenn es um Leistung geht, Sie müssen auf Compiler-spezifische Verhalten im Allgemeinen verlassen, und Compiler - Optimierungen im Besonderen. Ein standardkonformer Compiler kann Ihren Code nach Belieben kompilieren, solange sich der kompilierte Code gemäß der Sprachspezifikation verhält. Und mir ist keine Spezifikation für eine Standardsprache bekannt, die angibt, wie schnell jeder Vorgang sein muss.
quelle
Compiler-Optimierungen sollten sich nur auf die Leistung auswirken, nicht auf die Ergebnisse. Es ist nicht nur vernünftig, sich auf Compiler-Optimierungen zu verlassen, um nichtfunktionalen Anforderungen gerecht zu werden. Dies ist häufig der Grund, warum ein Compiler über einen anderen gestellt wird.
Flags, die bestimmen, wie bestimmte Vorgänge ausgeführt werden (z. B. Index- oder Überlaufbedingungen), werden häufig mit Compiler-Optimierungen in Verbindung gebracht, sollten dies jedoch nicht. Sie wirken sich explizit auf die Ergebnisse von Berechnungen aus.
Wenn eine Compiler-Optimierung zu unterschiedlichen Ergebnissen führt, ist dies ein Fehler - ein Fehler im Compiler. Auf einen Fehler im Compiler zu vertrauen, ist auf lange Sicht ein Fehler - was passiert, wenn er behoben wird?
Die Verwendung von Compiler-Flags, die die Funktionsweise von Berechnungen ändern, sollte gut dokumentiert sein, aber bei Bedarf verwendet werden.
quelle
x*y>z
im Falle eines Überlaufs willkürlich 0 oder 1 ergibt, vorausgesetzt, es hat keine anderen Nebenwirkungen , und ein Programmierer muss entweder um jeden Preis Überläufe verhindern oder den Compiler zwingen, den Ausdruck auf eine bestimmte Weise auszuwerten unnötigex*y
seine Operanden auf einen willkürlich längeren Typ heraufstufen (wodurch Formen des Hebens und der Festigkeitsreduzierung möglich werden, die das Verhalten einiger Überlauffälle ändern würden). Viele Compiler verlangen jedoch, dass Programmierer entweder um jeden Preis einen Überlauf verhindern oder Compiler zwingen, im Falle eines Überlaufs alle Zwischenwerte abzuschneiden.Nein.
Das mache ich die ganze Zeit. Wenn ich auf einen beliebigen 16-Bit-Block im Speicher zugreifen muss, tue ich dies
... und sich darauf verlassen, dass der Compiler alles tut, um diesen Code zu optimieren. Der Code funktioniert auf ARM, i386, AMD64 und praktisch auf jeder einzelnen Architektur. Theoretisch könnte ein nicht optimierender Compiler tatsächlich aufrufen
memcpy
, was zu einer völlig schlechten Leistung führt, aber das ist für mich kein Problem, da ich Compileroptimierungen verwende.Betrachten Sie die Alternative:
Dieser alternative Code funktioniert nicht auf Computern, die eine ordnungsgemäße Ausrichtung erfordern, wenn
get_pointer()
ein nicht ausgerichteter Zeiger zurückgegeben wird. Alternativ können auch Aliasing-Probleme auftreten.Der Unterschied zwischen -O2 und -O0 bei der Verwendung des
memcpy
Tricks ist groß: 3,2 Gbit / s IP-Prüfsummenleistung im Vergleich zu 67 Gbit / s IP-Prüfsummenleistung. Über eine Größenordnung Unterschied!Manchmal müssen Sie möglicherweise dem Compiler helfen. Anstatt sich beispielsweise darauf zu verlassen, dass der Compiler Schleifen auflöst, können Sie dies selbst tun. Entweder durch Implementierung des berühmten Duff-Geräts oder auf eine sauberere Art und Weise.
Der Nachteil, sich auf die Compiler-Optimierungen zu verlassen, besteht darin, dass Sie, wenn Sie gdb ausführen, um Ihren Code zu debuggen, möglicherweise feststellen, dass viel weg optimiert wurde. Daher müssen Sie möglicherweise mit -O0 neu kompilieren, was bedeutet, dass die Leistung beim Debuggen völlig sinkt. Ich halte dies für einen Nachteil, wenn man die Vorteile der Optimierung von Compilern berücksichtigt.
Was auch immer Sie tun, stellen Sie bitte sicher, dass Ihr Weg tatsächlich nicht undefiniertes Verhalten ist. Der Zugriff auf einen zufälligen Speicherblock als 16-Bit-Ganzzahl ist aufgrund von Aliasing- und Ausrichtungsproblemen undefiniert.
quelle
Alle Versuche, effizienten Code in einer anderen Form als Assembly zu schreiben, beruhen sehr, sehr stark auf Compiler-Optimierungen, angefangen bei der einfachsten wie effizienten Registerzuweisung, um überflüssige Stapelüberläufe zu vermeiden, und einer zumindest einigermaßen guten, wenn nicht ausgezeichneten Anweisungsauswahl. Andernfalls wären wir in die 80er Jahre zurückgekehrt, wo wir
register
überall Hinweise setzen und die minimale Anzahl von Variablen in einer Funktion verwenden müssten , um archaischen C-Compilern zu helfen, oder sogar früher, als diesgoto
eine nützliche Verzweigungsoptimierung war.Wenn wir uns nicht darauf verlassen könnten, dass unser Optimierer unseren Code optimiert, würden wir alle weiterhin leistungskritische Ausführungspfade in der Assembly codieren.
Es ist wirklich eine Frage der Zuverlässigkeit, mit der Sie die Optimierung durchführen können, die am besten aussortiert werden kann, indem Sie die Fähigkeiten Ihrer Compiler profilieren und untersuchen und möglicherweise sogar disassemblieren, wenn es einen Hotspot gibt, an dem Sie nicht herausfinden können, wo der Compiler sich befindet haben es versäumt, eine offensichtliche Optimierung vorzunehmen.
RVO gibt es schon seit Ewigkeiten, und Compiler wenden es seit Ewigkeiten zuverlässig an, zumindest sehr komplexe Fälle auszuschließen. Es lohnt sich definitiv nicht, ein Problem zu umgehen, das es nicht gibt.
Err auf der Seite, sich auf den Optimierer zu verlassen und ihn nicht zu fürchten
Im Gegenteil, ich würde sagen, dass man sich eher auf Compiler-Optimierungen als auf zu wenig verlässt. Dieser Vorschlag kommt von einem Mann, der in sehr leistungskritischen Bereichen arbeitet, in denen Effizienz, Wartbarkeit und wahrgenommene Qualität bei Kunden sind alles eine riesige Unschärfe. Ich würde es vorziehen, wenn Sie sich zu selbstbewusst auf Ihren Optimierer verlassen und obskure Randfälle finden, in denen Sie sich zu sehr verlassen, als sich auf zu wenig zu verlassen und für den Rest Ihres Lebens nur aus abergläubischen Ängsten zu codieren. Zumindest müssen Sie dann nach einem Profiler greifen und ordnungsgemäß nachforschen, wenn die Dinge nicht so schnell ablaufen, wie sie sollten, und dabei wertvolles Wissen und nicht Aberglauben erwerben.
Es ist gut, sich auf den Optimierer zu stützen. Mach weiter. Werden Sie nicht zu dem Typ, der explizit verlangt, jede in einer Schleife aufgerufene Funktion inline zu setzen, bevor Sie aus Angst vor den Mängeln des Optimierers ein Profil erstellen.
Profiling
Profiling ist wirklich der Kreisverkehr, aber die ultimative Antwort auf Ihre Frage. Das Problem, mit dem Anfänger, die gerne effizienten Code schreiben, häufig zu kämpfen haben, ist, nicht zu optimieren, sondern nicht zu optimieren, da sie alle möglichen fehlgeleiteten Vermutungen über Ineffizienzen entwickeln, die zwar menschlich intuitiv sind, aber rechnerisch falsch. Wenn Sie Erfahrungen mit einem Profiler sammeln, können Sie nicht nur die Optimierungsfunktionen Ihrer Compiler richtig einschätzen, auf die Sie sich sicher verlassen können, sondern auch die Funktionen (sowie die Einschränkungen) Ihrer Hardware. Es ist wohl noch wertvoller, ein Profil zu erstellen, wenn man lernt, was sich nicht zu optimieren lohnt, als zu lernen, was es war.
quelle
Software kann in C ++ auf sehr unterschiedlichen Plattformen und für viele verschiedene Zwecke geschrieben werden.
Dies hängt vollständig vom Zweck der Software ab. Sollte es einfach zu warten, zu erweitern, zu patchen, umzugestalten usw. sein. oder sind andere Dinge wichtiger, wie die Leistung, die Kosten oder die Kompatibilität mit einer bestimmten Hardware oder die Zeit, die für die Entwicklung benötigt wird.
quelle
Ich denke, die langweilige Antwort darauf lautet: "Es kommt darauf an".
Ist es eine schlechte Praxis, Code zu schreiben, der sich auf eine Compiler-Optimierung stützt, die wahrscheinlich deaktiviert ist und bei der die Sicherheitsanfälligkeit nicht dokumentiert ist und bei der der fragliche Code nicht einheitlich getestet wird, sodass Sie ihn wissen, wenn er kaputt geht ? Wahrscheinlich.
Ist es eine schlechte Praxis, Code zu schreiben, der auf einer Compileroptimierung beruht, die wahrscheinlich nicht deaktiviert wird , die dokumentiert und Unit-getestet ist ? Vielleicht nicht.
quelle
Wenn Sie uns nicht mehr mitteilen, ist dies eine schlechte Praxis, jedoch nicht aus dem von Ihnen vorgeschlagenen Grund.
Möglicherweise wird bei der Rückgabe des Werts eines Objekts in C ++ im Gegensatz zu anderen Sprachen, die Sie zuvor verwendet haben, eine Kopie des Objekts erstellt. Wenn Sie dann das Objekt ändern, ändern Sie ein anderes Objekt . Das heißt, wenn ich habe
Obj a; a.x=1;
undObj b = a;
dann tue ichb.x += 2; b.f();
, dann ist esa.x
immer noch 1, nicht 3.Nein, die Verwendung eines Objekts als Wert anstelle eines Verweises oder Zeigers bietet nicht die gleiche Funktionalität, und es kann zu Fehlern in Ihrer Software kommen.
Vielleicht wissen Sie das und es wirkt sich nicht negativ auf Ihren speziellen Anwendungsfall aus. Anhand des Wortlauts in Ihrer Frage scheint Ihnen der Unterschied jedoch möglicherweise nicht bewusst zu sein. Formulierung wie "Objekt in der Funktion erstellen"
"Objekt in der Funktion erstellen" klingt wie
new Obj;
"Objekt nach Wert zurückgeben"Obj a; return a;
Obj a;
undObj* a = new Obj;
sind sehr, sehr verschiedene Dinge; Ersteres kann zu Speicherbeschädigung führen, wenn es nicht richtig verwendet und verstanden wird, und Letzteres kann zu Speicherlecks führen, wenn es nicht richtig verwendet und verstanden wird.quelle
return
Anweisung erstellt wurde, die für RVO erforderlich ist. Außerdem sprechen Sie dann über Schlüsselwörternew
und Zeiger, worum es bei RVO nicht geht. Ich glaube, Sie verstehen die Frage entweder nicht oder RVO oder möglicherweise beides.Pieter B ist absolut richtig darin, das geringste Erstaunen zu empfehlen.
Um Ihre spezifische Frage zu beantworten, bedeutet dies (höchstwahrscheinlich) in C ++, dass Sie a
std::unique_ptr
an das konstruierte Objekt zurückgeben sollten.Der Grund ist, dass dies für einen C ++ - Entwickler klarer ist , was los ist.
Obwohl Ihr Ansatz höchstwahrscheinlich funktionieren würde, signalisieren Sie effektiv, dass das Objekt ein kleiner Werttyp ist, obwohl dies nicht der Fall ist. Darüber hinaus werfen Sie alle Möglichkeiten der Schnittstellenabstraktion weg. Dies mag für Ihre derzeitigen Zwecke in Ordnung sein, ist jedoch häufig sehr nützlich, wenn Sie sich mit Matrizen befassen.
Ich schätze, dass, wenn Sie aus anderen Sprachen kommen, alle Siegel anfangs verwirrend sein können. Achten Sie jedoch darauf, nicht anzunehmen, dass Sie Ihren Code klarer machen, wenn Sie ihn nicht verwenden. In der Praxis dürfte das Gegenteil der Fall sein.
quelle
std::make_unique
, nicht einstd::unique_ptr
direktes. Zweitens handelt es sich bei RVO nicht um eine esoterische, herstellerspezifische Optimierung, sondern um eine Integration in den Standard. Auch damals, als es nicht war, war es weithin unterstützt und erwartetes Verhalten. Es gibt keinen Punkt, an dem ein zurückgegeben wird,std::unique_ptr
wenn ein Zeiger überhaupt nicht benötigt wird.