Ich schaue mir Chandler Carruths Vortrag auf der CppCon 2019 an:
Es gibt keine kostengünstigen Abstraktionen
Darin gibt er das Beispiel, wie er überrascht war, wie viel Aufwand Ihnen durch die Verwendung eines std::unique_ptr<int>
Over-An entsteht int*
. Dieses Segment beginnt ungefähr zum Zeitpunkt 17:25.
Sie können sich die Kompilierungsergebnisse seines Beispiel-Snippet-Paares (godbolt.org) ansehen - um zu sehen, dass der Compiler tatsächlich nicht bereit zu sein scheint, den Wert unique_ptr zu übergeben - was in der Tat das Endergebnis ist Nur eine Adresse - in einem Register, nur im direkten Speicher.
Einer der Punkte, die Herr Carruth gegen 27:00 Uhr hervorhebt, ist, dass für den C ++ - ABI By-Value-Parameter (einige, aber nicht alle; vielleicht - nicht primitive Typen? Nicht trivial konstruierbare Typen?) Erforderlich sind, um im Speicher übergeben zu werden anstatt innerhalb eines Registers.
Meine Fragen:
- Ist dies auf einigen Plattformen tatsächlich eine ABI-Anforderung? (welche?) Oder ist es in bestimmten Szenarien nur eine Pessimierung?
- Warum ist der ABI so? Das heißt, wenn die Felder einer Struktur / Klasse in Register oder sogar in ein einzelnes Register passen - warum sollten wir es nicht in diesem Register übergeben können?
- Hat das C ++ - Standardkomitee diesen Punkt in den letzten Jahren oder jemals diskutiert?
PS - Um diese Frage nicht ohne Code zu hinterlassen:
Einfacher Zeiger:
void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;
void foo(int* ptr) noexcept {
if (*ptr > 42) {
bar(ptr);
*ptr = 42;
}
baz(ptr);
}
Eindeutiger Zeiger:
using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;
void foo(unique_ptr<int> ptr) noexcept {
if (*ptr > 42) {
bar(ptr.get());
*ptr = 42;
}
baz(std::move(ptr));
}
quelle
this
Zeiger benötigen , der auf eine gültige Position zeigt.unique_ptr
hat die. Das Verschütten des Registers zu diesem Zweck würde die gesamte Optimierung "Durchlaufen eines Registers" irgendwie negieren.Antworten:
Ein Beispiel ist AMD64 Architecture Processor Supplement für die binäre System V-Anwendungsschnittstelle . Dieser ABI ist für 64-Bit-x86-kompatible CPUs (Linux x86_64-Architektur) vorgesehen. Es folgt unter Solaris, Linux, FreeBSD, macOS, Windows Subsystem für Linux:
Beachten Sie, dass nur 2 Allzweckregister zum Übergeben von 1 Objekt mit einem Trivial-Copy-Konstruktor und einem Trivial-Destruktor verwendet werden können, dh, nur Werte von Objekten mit
sizeof
nicht mehr als 16 können in Registern übergeben werden. Siehe Aufrufkonventionen von Agner Fog für eine ausführliche Behandlung der Aufrufkonventionen, insbesondere §7.1 Passing und Objekte zurück. Es gibt separate Aufrufkonventionen zum Übergeben von SIMD-Typen in Registern.Für andere CPU-Architekturen gibt es unterschiedliche ABIs.
Es ist ein Implementierungsdetail, aber wenn eine Ausnahme behandelt wird, müssen während des Abwickelns des Stapels die Objekte mit der automatischen Speicherdauer, die zerstört werden, relativ zum Funktionsstapelrahmen adressierbar sein, da die Register zu diesem Zeitpunkt überlastet waren. Der Stapelabwicklungscode benötigt die Adressen von Objekten, um ihre Destruktoren aufzurufen, aber Objekte in Registern haben keine Adresse.
Pedantisch wirken Destruktoren auf Objekte :
und ein Objekt kann in C ++ nicht existieren, wenn ihm kein adressierbarer Speicher zugewiesen ist, da die Identität des Objekts seine Adresse ist .
Wenn eine Adresse eines Objekts mit einem trivialen Kopierkonstruktor in Registern benötigt wird, kann der Compiler das Objekt einfach im Speicher speichern und die Adresse abrufen. Wenn der Kopierkonstruktor nicht trivial ist, kann der Compiler ihn andererseits nicht einfach im Speicher speichern, sondern muss den Kopierkonstruktor aufrufen, der eine Referenz verwendet und daher die Adresse des Objekts in den Registern benötigt. Die aufrufende Konvention kann wahrscheinlich nicht davon abhängen, ob der Kopierkonstruktor im Angerufenen eingefügt wurde oder nicht.
Eine andere Möglichkeit, darüber nachzudenken, besteht darin, dass der Compiler für trivial kopierbare Typen den Wert eines Objekts in Registern überträgt , aus denen ein Objekt bei Bedarf durch einfache Speicher wiederhergestellt werden kann. Z.B:
auf x86_64 mit System V ABI kompiliert in:
In seinem zum Nachdenken anregenden Vortrag erwähnt Chandler Carruth , dass eine brechende ABI-Änderung (unter anderem) notwendig sein könnte, um den destruktiven Schritt umzusetzen, der die Dinge verbessern könnte. IMO, die ABI-Änderung kann nicht unterbrochen werden, wenn sich die Funktionen, die das neue ABI verwenden, explizit für eine neue, andere Verknüpfung entscheiden, z. B. sie im
extern "C++20" {}
Block deklarieren (möglicherweise in einem neuen Inline-Namespace für die Migration vorhandener APIs). Damit nur der Code, der mit der neuen Verknüpfung gegen die neuen Funktionsdeklarationen kompiliert wurde, den neuen ABI verwenden kann.Beachten Sie, dass ABI nicht angewendet wird, wenn die aufgerufene Funktion eingebunden wurde. Neben der Generierung von Link-Time-Code kann der Compiler Funktionen einbinden, die in anderen Übersetzungseinheiten definiert sind, oder benutzerdefinierte Aufrufkonventionen verwenden.
quelle
Bei gängigen ABIs kann ein nicht trivialer Destruktor -> keine Register übergeben
(Eine Illustration eines Punktes in der Antwort von @ MaximEgorushkin am Beispiel von @ harold in einem Kommentar; korrigiert gemäß dem Kommentar von @ Yakk.)
Wenn Sie kompilieren:
du erhältst:
dh das
Foo
Objekt wirdtest
in einem register (edi
) übergeben und auch in einem register (eax
) zurückgegeben.Wenn der Destruktor nicht trivial ist (wie im
std::unique_ptr
Beispiel von OPs) - Häufige ABIs müssen auf dem Stapel platziert werden. Dies gilt auch dann, wenn der Destruktor die Adresse des Objekts überhaupt nicht verwendet.So können Sie auch im Extremfall eines Nicht-Zerstörers kompilieren:
du erhältst:
mit nutzlosem Laden und Lagern.
quelle
std::unique_ptr
in einem Register nicht konform weiterzuleiten.register
Schlüsselwort sollte es für die physische Maschine trivial machen, etwas in einem Register zu speichern, indem Dinge blockiert werden, die es praktisch schwieriger machen, "keine Adresse" in der physischen Maschine zu haben.Wenn etwas an der Grenze der Komplikationseinheit sichtbar ist, wird es Teil des ABI, unabhängig davon, ob es implizit oder explizit definiert ist.
Das grundlegende Problem besteht darin, dass Register ständig gespeichert und wiederhergestellt werden, wenn Sie den Aufrufstapel nach unten und oben bewegen. Es ist also nicht praktisch, einen Verweis oder Zeiger auf sie zu haben.
In-Lining und die daraus resultierenden Optimierungen sind nett, wenn es passiert, aber ein ABI-Designer kann sich nicht darauf verlassen, dass es passiert. Sie müssen den ABI im schlimmsten Fall entwerfen. Ich glaube nicht, dass Programmierer mit einem Compiler sehr zufrieden wären, bei dem sich der ABI je nach Optimierungsstufe geändert hat.
Ein trivial kopierbarer Typ kann in Registern übergeben werden, da die logische Kopieroperation in zwei Teile aufgeteilt werden kann. Die Parameter werden in die Register kopiert, die vom Aufrufer zum Übergeben von Parametern verwendet werden, und dann vom Angerufenen in die lokale Variable kopiert. Ob die lokale Variable einen Speicherort hat oder nicht, ist daher nur Sache des Angerufenen.
Bei einem Typ, bei dem ein Kopier- oder Verschiebungskonstruktor verwendet werden muss, kann der Kopiervorgang nicht auf diese Weise aufgeteilt werden, sodass er im Speicher übergeben werden muss.
Ich habe keine Ahnung, ob die Normungsgremien dies berücksichtigt haben.
Die naheliegende Lösung für mich wäre, der Sprache korrekte destruktive Bewegungen hinzuzufügen (anstelle des aktuellen halben Hauses eines "gültigen, aber ansonsten nicht spezifizierten Zustands") und dann eine Möglichkeit einzuführen, einen Typ als "triviale destruktive Bewegungen" zu kennzeichnen "Auch wenn es keine trivialen Kopien zulässt.
Eine solche Lösung würde jedoch erfordern, dass der ABI des vorhandenen Codes gebrochen wird, um ihn für vorhandene Typen zu implementieren, was einiges an Widerstand mit sich bringen kann (obwohl ABI-Brüche aufgrund neuer C ++ - Standardversionen nicht beispiellos sind, zum Beispiel die Änderungen von std :: string in C ++ 11 führte zu einer ABI-Unterbrechung.
quelle
unique_ptr
undshared_ptr
semantisch:shared_ptr<T>
Sie können dem ctor 1) ein ptr x für das abgeleitete Objekt U bereitstellen, das mit dem statischen Typ U w / dem Ausdruck gelöscht werden solldelete x;
(Sie benötigen hier also keinen virtuellen dtor) 2) oder sogar eine benutzerdefinierte Bereinigungsfunktion. Das bedeutet, dass der Laufzeitstatus innerhalb desshared_ptr
Steuerblocks verwendet wird, um diese Informationen zu codieren. OTOHunique_ptr
verfügt über keine solche Funktionalität und codiert das Löschverhalten im Status nicht. Die einzige Möglichkeit, die Bereinigung anzupassen, besteht darin, eine andere Vorlageninstanz (einen anderen Klassentyp) zu erstellen.Zuerst müssen wir zu dem zurückkehren, was es bedeutet, nach Wert und Referenz zu gehen.
Für Sprachen wie Java und SML ist die Übergabe von Werten unkompliziert (und es gibt keine Übergabe als Referenz), genau wie das Kopieren eines Variablenwerts, da alle Variablen nur Skalare sind und eine integrierte Kopiersemantik haben: Sie zählen entweder als Arithmetik Geben Sie C ++ oder "Referenzen" ein (Zeiger mit unterschiedlichem Namen und Syntax).
In C haben wir skalare und benutzerdefinierte Typen:
In C ++ können benutzerdefinierte Typen eine benutzerdefinierte Kopiersemantik haben, die eine wirklich "objektorientierte" Programmierung mit Objekten ermöglicht, die Eigentümer ihrer Ressourcen sind, und "Deep Copy" -Operationen. In einem solchen Fall ist eine Kopieroperation wirklich ein Aufruf einer Funktion, die fast beliebige Operationen ausführen kann.
Für C-Strukturen, die als C ++ kompiliert wurden, wird "Kopieren" weiterhin als Aufruf der benutzerdefinierten Kopieroperation (entweder Konstruktor oder Zuweisungsoperator) definiert, die implizit vom Compiler generiert werden. Dies bedeutet, dass die Semantik eines gemeinsamen C / C ++ - Teilmengenprogramms in C und C ++ unterschiedlich ist: In C wird ein ganzer Aggregattyp kopiert, in C ++ wird eine implizit generierte Kopierfunktion aufgerufen, um jedes Mitglied zu kopieren. Das Endergebnis ist, dass in jedem Fall jedes Mitglied kopiert wird.
(Ich denke, es gibt eine Ausnahme, wenn eine Struktur innerhalb einer Union kopiert wird.)
Für einen Klassentyp ist die einzige Möglichkeit (außerhalb von Union-Kopien), eine neue Instanz zu erstellen, die Verwendung eines Konstruktors (selbst für solche mit trivial vom Compiler generierten Konstruktoren).
Sie können die Adresse eines r-Werts nicht über einen unären Operator übernehmen
&
, dies bedeutet jedoch nicht, dass kein r-Wert-Objekt vorhanden ist. und ein Objekt hat per Definition eine Adresse ; und diese Adresse wird sogar durch ein Syntaxkonstrukt dargestellt: Ein Objekt vom Klassentyp kann nur von einem Konstruktor erstellt werden und hat einenthis
Zeiger; Für triviale Typen gibt es jedoch keinen vom Benutzer geschriebenen Konstruktor, sodass kein Platz zum Platzieren vorhanden istthis
erst nach dem Erstellen und Benennen der Kopie ein ist.Für den Skalartyp ist der Wert eines Objekts der Wert des Objekts, der reine mathematische Wert, der im Objekt gespeichert ist.
Für einen Klassentyp ist der einzige Begriff für einen Wert des Objekts eine andere Kopie des Objekts, die nur von einem Kopierkonstruktor erstellt werden kann, eine echte Funktion (obwohl diese Funktion für triviale Typen so speziell trivial ist, kann dies manchmal sein erstellt, ohne den Konstruktor aufzurufen). Dies bedeutet, dass der Wert des Objekts das Ergebnis einer Änderung des globalen Programmstatus durch eine Ausführung ist . Es greift nicht mathematisch zu.
Das Übergeben von Werten ist also wirklich keine Sache: Es ist das Übergeben eines Kopierkonstruktoraufrufs , was weniger hübsch ist. Es wird erwartet, dass der Kopierkonstruktor eine sinnvolle "Kopier" -Operation gemäß der richtigen Semantik des Objekttyps ausführt, wobei seine internen Invarianten (abstrakte Benutzereigenschaften, keine intrinsischen C ++ - Eigenschaften) berücksichtigt werden.
Wertübergabe eines Klassenobjekts bedeutet:
Beachten Sie, dass das Problem nichts damit zu tun hat, ob die Kopie selbst ein Objekt mit einer Adresse ist: Alle Funktionsparameter sind Objekte und haben eine Adresse (auf sprachensemantischer Ebene).
Die Frage ist, ob:
Im Fall eines trivialen Klassentyps können Sie weiterhin das Mitglied der Mitgliedskopie des Originals definieren, sodass Sie aufgrund der Trivialität der Kopiervorgänge (Kopierkonstruktor und Zuweisung) den reinen Wert des Originals definieren können. Nicht so bei beliebigen speziellen Benutzerfunktionen: Ein Wert des Originals muss eine konstruierte Kopie sein.
Klassenobjekte müssen vom Aufrufer erstellt werden. Ein Konstruktor hat formal einen
this
Zeiger, aber der Formalismus ist hier nicht relevant: Alle Objekte haben formal eine Adresse, aber nur diejenigen, deren Adresse tatsächlich nicht rein lokal verwendet wird (im Gegensatz zu einer*&i = 1;
rein lokalen Verwendung der Adresse), müssen genau definiert sein Adresse.Ein Objekt muss unbedingt an Adresse übergeben werden, wenn es in beiden separat kompilierten Funktionen eine Adresse zu haben scheint:
Selbst wenn
something(address)
es sich um eine reine Funktion oder ein Makro handelt oder was auch immer (wieprintf("%p",arg)
), das die Adresse nicht speichern oder nicht mit einer anderen Entität kommunizieren kann, müssen wir die Adresse übergeben, da die Adresse für ein eindeutiges Objektint
mit einer eindeutigen Adresse genau definiert sein muss Identität.Wir wissen nicht, ob eine externe Funktion in Bezug auf die an sie übergebenen Adressen "rein" ist.
Hier ist das Potenzial für eine echte Verwendung der Adresse in einem nicht trivialen Konstruktor oder Destruktor auf der Anruferseite wahrscheinlich der Grund, den sicheren, vereinfachten Weg zu gehen und dem Objekt eine Identität im Anrufer zu geben und seine Adresse so zu übergeben, wie sie es macht Stellen Sie sicher, dass jede nicht triviale Verwendung der Adresse im Konstruktor nach der Konstruktion und im Destruktor konsistent ist : Sie
this
muss über die Objektexistenz hinweg gleich erscheinen.Ein nicht trivialer Konstruktor oder Destruktor wie jede andere Funktion kann den
this
Zeiger auf eine Weise verwenden, die Konsistenz über seinen Wert erfordert, obwohl einige Objekte mit nicht trivialem Material möglicherweise nicht:Beachten Sie, dass in diesem Fall
this->
die Objektidentität trotz expliziter Verwendung eines Zeigers (explizite Syntax ) irrelevant ist: Der Compiler könnte das Objekt durchaus bitweise kopieren, um es zu verschieben und "Elision kopieren". Dies basiert auf dem Grad der "Reinheit" der Verwendungthis
in speziellen Mitgliedsfunktionen (Adresse entweicht nicht).Aber Reinheit ist nicht verfügbar Attribut auf der Standard - Erklärung Ebene (Compiler - Erweiterungen vorhanden ist, dass die Add Reinheit Beschreibung auf nicht Inline - Funktionsdeklaration), so Sie kein ABI basierend auf Reinheit des Codes definieren, die nicht zur Verfügung stehen (Code kann oder möglicherweise nicht inline und für die Analyse verfügbar).
Reinheit wird als "sicherlich rein" oder "unrein oder unbekannt" gemessen. Die Gemeinsamkeit oder Obergrenze der Semantik (tatsächlich maximal) oder LCM (Least Common Multiple) ist "unbekannt". Der ABI entscheidet sich also für Unbekanntes.
Zusammenfassung:
Mögliche zukünftige Arbeit:
Ist die Reinheitsanmerkung nützlich genug, um verallgemeinert und standardisiert zu werden?
quelle
void foo(unique_ptr<int> ptr)
aber das Klassenobjekt nach Wert . Dieses Objekt hat ein Zeigerelement, aber wir sprechen über das Klassenobjekt selbst, das als Referenz übergeben wird. (Da es nicht trivial kopierbar ist, benötigt sein Konstruktor / Destruktor eine konsistentethis
.) Dies ist das eigentliche Argument und nicht mit dem ersten Beispiel einer expliziten Referenzübergabe verbunden . In diesem Fall wird der Zeiger in einem Register übergeben.int
: Ich habe ein "Smart Fileno" -Beispiel geschrieben, das zeigt, dass "Besitz" nichts mit "Tragen eines ptr" zu tun hat.unique_ptr<T*>
, das ist die gleiche Größe und Layout wieT*
und paßt in einem Register. Trivial kopierbare Klassenobjekte können wie die meisten Aufrufkonventionen in Registern in x86-64 System V als Wert übergeben werden . Dies macht eine Kopie desunique_ptr
Objekts, anders als in Ihremint
Beispiel , wo die Angerufenen&i
ist die Adresse des Anrufers ist ,i
weil Sie als Referenz übergaben an der C ++ Ebene , nicht nur als asm Implementierungsdetail.unique_ptr
Objekts zu erstellen. Es wird verwendet,std::move
so dass es sicher ist, es zu kopieren, da dies nicht zu 2 Kopien desselben führtunique_ptr
. Bei einem trivial kopierbaren Typ wird jedoch das gesamte Aggregatobjekt kopiert. Wenn dies ein einzelnes Mitglied ist, behandeln gute Aufrufkonventionen es genauso wie einen Skalar dieses Typs.struct{}
ist eine C ++ - Struktur. Vielleicht sollten Sie "einfache Strukturen" oder "anders als C" sagen. Denn ja, da gibt es einen Unterschied. Wenn Sieatomic_int
als Strukturelement verwenden, kopiert C es nicht atomar, C ++ - Fehler im gelöschten Kopierkonstruktor. Ich habe vergessen, was C ++ bei Strukturen mitvolatile
Mitgliedern macht. Mit C können Siestruct tmp = volatile_struct;
das Ganze kopieren (nützlich für ein SeqLock). C ++ wird nicht.