1 Punkte von GN⁺ 2024-07-06 | 1 Kommentare | Auf WhatsApp teilen
  • Die Objektinitialisierung in C++ kann selbst bei ähnlicher Syntax wie T t;, T t{};, T t{...} in default/value/list/aggregate initialization auseinanderfallen, wodurch sich der tatsächliche Zustand der Member unterscheiden kann
  • Je nachdem, wo ein Default-Konstruktor mit = default definiert wird, ändert sich, ob zero-initialization erfolgt; wird er außerhalb der Klasse definiert, können Member mit nicht initialisierten Werten verbleiben
  • Selbst ein Typ mit einem const int-Member, bei dem der Default-Konstruktor als gelöscht erscheinen könnte, kann bei einem aggregate mit A a{}; über aggregate initialization dennoch auf 0 initialisiert werden
  • Die klammerbasierte aggregate initialization in C++20 ist freizügiger als die Initialisierung mit geschweiften Klammern und führt zu Unterschieden wie narrowing conversion und dangling references
  • In der Praxis ist es oft sicherer, die Initialisierungsabsicht mit expliziten Konstruktoren sichtbar zu machen, statt sich auf die Kombination aus impliziten Konstruktoren und Initialisierungsregeln zu verlassen

Wo sich die C++-Initialisierungssyntax aufspaltet

  • T t; führt default-initialization aus
    • Ist T ein Klassentyp und besitzt einen Default-Konstruktor, wird dieser Konstruktor ausgeführt
    • Ist T ein Array, werden alle Elemente default-initialized
    • Andernfalls geschieht nichts
  • T t{}; wirkt wie value-initialization, wird wegen der Syntax mit geschweiften Klammern aber zunächst als list-initialization behandelt
  • Schon an einfachen Beispielen wird der Unterschied direkt sichtbar
    • int x{}; wird value-initialized und damit mit 0 initialisiert
    • int y; wird default-initialized und bleibt uninitialisiert
    • Pair p; und Pair q{}; mit einem Default-Konstruktor rufen beide den Konstruktor auf
    • SimplePair r; mit nur Membern wird default-initialized, sodass seine Member uninitialisiert bleiben können

Welche Auswirkungen der Deklarationsort des Default-Konstruktors hat

  • Deklariert der Benutzer keinen Konstruktor, deklariert der Compiler implizit einen implicitly-declared Default-Konstruktor
  • Wird T() = default; in der Klasse an der ersten Deklarationsstelle verwendet, wird dies laut Standard nahezu wie ein implizit deklarierter Konstruktor behandelt
  • Wenn ein implizit deklarierter oder explizit als default definierter Default-Konstruktor nicht gelöscht ist, stellt der Compiler einen implicitly-defined default constructor bereit
    • In der Implementierung muss dieser einem T() {} mit leerem Rumpf und leerer Member-Initialisierungsliste entsprechen
  • Im folgenden Code wird t.x zu 0
struct T {
    int x;
    T() = default;
};
T t{};
std::cout << t.x << std::endl;
  • Der Grund ist, dass t value-initialized wird und der Default-Konstruktor von T nicht user-provided ist, sodass zuerst zero-initialization erfolgt und anschließend der Default-Konstruktor aufgerufen wird

= default außerhalb der Klasse und user-provided Konstruktoren

  • Dasselbe = default wird zu einem user-provided Konstruktor, wenn es außerhalb der Klasse definiert wird
struct T {
    int x;
    T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
  • Hier ist T::T() = default; nicht an der ersten Deklarationsstelle defaulted, sondern ein außerhalb der Klasse als default definierter Konstruktor
  • Nach den Regeln der value-initialization wird bei einem user-provided oder gelöschten Default-Konstruktor default-initialization ausgeführt
  • Deshalb wird im obigen Beispiel nur der Default-Konstruktor ohne vorherige zero-initialization ausgeführt, und weil dieser nichts tut, enthält t.x einen Garbage-Wert

Wann ein Default-Konstruktor gelöscht wird

  • Kann der Compiler keinen sinnvollen Default-Konstruktor erzeugen, kann ein implizit deklarierter Default-Konstruktor als deleted definiert werden
  • Typische Bedingungen sind:
    • Es gibt ein nichtstatisches Referenz-Member
    • Es gibt nichtstatische Member oder nichtabstrakte Basisklassen, die nicht passend default-constructed oder destructed werden können
    • Es gibt ein const-nichtstatisches Member ohne Default-Member-Initializer, und dieses Member ist nicht const-default-constructible
  • Stellt der Benutzer selbst einen Konstruktor bereit, versucht der Compiler nicht, einen impliziten Default-Konstruktor zu definieren, und erzeugt auch keinen gelöschten Konstruktor
  • Selbst unter solchen Bedingungen arbeiten die C++-Initialisierungsregeln insgesamt sehr freizügig

Der Moment, in dem aggregate initialization ins Spiel kommt

  • struct A { const int x; }; A a{}; scheint so, als müsste der Default-Konstruktor gelöscht sein und der Code dürfe nicht kompilieren, tatsächlich kompiliert er aber und a.x wird zu 0
  • Entscheidend ist, dass A ein aggregate ist
  • Ein aggregate ist ein Array oder eine Klasse, die folgende Bedingungen erfüllt
    • Keine user-declared oder geerbten Konstruktoren
    • Keine direkten nichtstatischen Daten-Member mit private oder protected
    • Keine direkten Basisklassen mit private oder protected
    • Keine virtuellen Funktionen und keine virtuellen Basisklassen
  • Wenn auf ein aggregate list-initialization angewendet wird, greift außer in bestimmten Ausnahmen aggregate initialization
    • Jedes Element der Initialisierungsliste initialisiert der Reihe nach die jeweiligen Elemente des aggregate
    • Wenn die Liste zu wenige Elemente enthält, werden die restlichen Elemente mit ihrem Default-Member-Initializer initialisiert, falls vorhanden
    • Gibt es keinen Default-Member-Initializer und ist das Element keine Referenz, wird es mit einer leeren Initialisierungsliste copy-initialized
  • Deshalb ist A a{}; kein Konstruktoraufruf, sondern ein Fall, in dem a.x mit einer leeren Liste copy-list-initialized und darüber via value-initialization zero-initialized wird

Zusätzliche Regeln der Initialisierung mit geschweiften Klammern

  • Klammerbasierte Syntax mit geschweiften Klammern wie T t{...} und T t = {...} ist im Allgemeinen list-initialization
    • T t{...} ist direct-list-initialization
    • T t = {...} ist copy-list-initialization
  • Bei einem nicht-aggregate Klassentyp mit leerer Initialisierungsliste und vorhandenem Default-Konstruktor wird value-initialization ausgeführt
  • Andernfalls werden Konstruktoren per normaler overload resolution betrachtet, wobei Overloads mit std::initializer_list Vorrang haben
  • Initialisierung mit geschweiften Klammern erlaubt bei Initialisierung mit einem einzelnen Element keine narrowing conversion
struct A {
    const int x;
};

A b{4};      // möglich
A c{4.0f};   // Fehler durch narrowing conversion

Initialisierung mit runden Klammern ist freizügiger

  • Klammerinitialisierung wie T t(a, b, c); ruft direct-non-list-initialization auf und ähnelt direct-list-initialization, folgt aber anderen Regeln
  • T t(); wird nicht als Objekterzeugung, sondern als Funktionsdeklaration ohne Argumente und mit Rückgabetyp T interpretiert; das ist das Problem des most vexing parse
  • Seit C++20 kann auch bei aggregates eine Initialisierung mit runden Klammern verwendet werden
  • Klammerbasierte aggregate initialization verhält sich anders als die Variante mit geschweiften Klammern
    • Sie erlaubt narrowing conversion
    • Verbleibende Elemente werden nicht mit leerer Liste initialisiert, sondern direkt value-initialized
    • Sie verlängert nicht die Lebensdauer temporärer Objekte, die an Referenzen gebunden sind
struct T {
    const int& r;
};
T t(42);
  • Im obigen Code wird t.r zu einer dangling reference; ein Lesezugriff darauf ist undefined behaviour

std::initializer_list, Kopierkonstruktor und Elision

  • Initialisierung mit runden Klammern kann auch dann den Kopierkonstruktor von T aufrufen, wenn es einen Konstruktor-Overload std::initializer_list<T> gibt
  • Umgekehrt kann bei Initialisierung mit geschweiften Klammern der std::initializer_list-Konstruktor bevorzugt werden
struct T {
    T(std::initializer_list<T>) {
        std::cout << "list" << std::endl;
    }
    T(const T&) {
        std::cout << "copy" << std::endl;
    }
};

T t{};    // list
T s{t};   // list
T r(t);   // copy
T q(T{}); // list, kein copy
  • Dass bei T q(T{}); keine Kopie erfolgt, liegt an der Regel, dass bei direct- und copy-initialization ein Objekt direkt aus dem Ausdruck initialisiert wird, wenn der Initializer ein prvalue vom Typ T ist
  • Dieses Verhalten ist meist als copy elision bekannt, der Standard bezeichnet es jedoch nicht ausdrücklich als elision
  • Ob dieselbe Elision bei list-initialization möglich ist, wird im Standard nicht ausreichend beschrieben; relevant dazu ist CWG issue 2311
    • GCC und Clang führen in den meisten Fällen Elision durch
    • Wenn es einen Konstruktor-Overload std::initializer_list<T> gibt, verwendet GCC statt Elision diesen Konstruktor

Auswertungsreihenfolge und praktisches Fazit

  • Die Auswertungsreihenfolge der Elemente in einer Initialisierungsliste mit runden Klammern ist nicht garantiert
  • Eine Initialisierungsliste mit geschweiften Klammern wertet ihre Elemente strikt von links nach rechts aus
  • Es gibt auch Sonderregeln wie die Initialisierung statischer Variablen und constant initialization, aber schon die Initialisierung gewöhnlicher Objekte ist komplex genug
  • In der Praxis ist es sicherer, die Initialisierungsabsicht durch explizite Konstruktoren klarzumachen, statt sich auf implizite Default-Konstruktoren und Initialisierungsregeln zu verlassen

1 Kommentare

 
GN⁺ 2024-07-06
Hacker-News-Kommentare
  • Die Erklärung, dass bei Wertinitialisierung von T das Ergebnis 0 ist, war korrekt; ich hatte das zunächst übersehen, aber der Autor hat x tatsächlich wertinitialisiert.
    Insgesamt sind die C++-Initialisierungsregeln durch 40 Jahre Erweiterung und Pflege zwar schwer verständlich und teils wenig intuitiv geworden, aber zu etwa 99,9 % verhalten sie sich wie erwartet.
    Eine große Verbesserung wäre, Default-Initialisierung explizit zu machen und ansonsten immer Wertinitialisierung zu verwenden. Wenn man die Kosten vermeiden will, große Arrays mit 0 zu füllen, wäre es besser, das nur dann ausdrücklich mit einer Syntax wie std::array = void; anzugeben

    • Wenn man Instanzen tatsächlich initialisiert, hat man dieses Problem nicht, und wenn man einen Konstruktor aufrufen möchte, sollte man eben einen Konstruktor definieren.
      Wirkt wie ein Fall von zu viel Cleverness, gefolgt von Klagen darüber, dass es schwer beherrschbar wird
  • Der gesamte Ansatz ist falsch. Man sollte keine const-Referenzen in eine Struct packen; wenn es unbedingt nötig ist, ist std::reference_wrapper die passendere Wahl.
    Die Antwort ist zwar etwas schroff, aber der Kern ist, dass die Schlussfolgerung des Artikels komplett falsch ist. Statt direkte Konstruktoren zu vermeiden, sollte man die Rule of 5/3/0 befolgen, und wenn man eine const-Referenz halten muss, prüfen, ob man ein rvalue-Temporary übergibt. Das ist kein besonders beängstigendes Problem

    • Die Rule of 5/3/0 betrifft Destruktor, Move-Konstruktor und Copy-Konstruktor. Abgesehen vom letzten Beispiel geht es im Artikel hauptsächlich um merkwürdiges Verhalten dessen, was man als Standardkonstruktoren bezeichnen könnte
    • Sehe ich genauso. Es wirkt stark so, als hätte der Autor ein paar sehr grundlegende Prinzipien und Leitlinien absichtlich ignoriert, um etwas zum Beklagen zu finden.
      Selbst Leute, die nur ein Basistutorial durchgearbeitet haben, vermeiden solche künstlichen Szenarien einfach
    • Ich habe std::reference_wrapper noch nie verwendet und auch in mehreren C++-Codebasen, an denen ich gearbeitet habe, nie gesehen.
      Vielleicht taucht es irgendwo in tiefem, komplexem Template-Code auf, aber auch wenn die Aussage stimmen mag, ist das meiner Erfahrung nach kein Allgemeinwissen
  • Den Originaltext von I Have No Mouth, and I Must Scream (1967) kann man hier lesen: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...

    • Ich habe einmal mit Harlan Ellison telefoniert. Als ich 17 war, bekam ich um 2 Uhr morgens einen Anruf zu Hause, damals noch zu Zeiten, als man mit einem 30-Fuß-Telefonkabel ins Zimmer ging und die Tür schloss.
      Die Familie von Martin H Greenberg übernachtete bei uns, und er suchte Martin. Ich mochte Sci-Fi und hatte auch einige seiner Bücher gelesen, aber nachdem ich den Namen gehört hatte, erinnere ich mich an den Rest des Gesprächs nur noch verschwommen. Schließlich habe ich Martin geweckt und ihm den Hörer gegeben, und danach konnte ich nur schwer wieder einschlafen
  • Ich bin überrascht, dass es keinen spöttischen Kommentar zu dem sogar fett hervorgehobenen unless gab.
    Es gibt elegante Syntax wie T t{v0}; und T t{v0, v1};, aber eine Initialisierung mit genau 1 Element funktioniert nicht so zuverlässig wie bei 2 oder mehr Elementen.
    In einer Sprache, die es einfacher macht, Structs mit Parameter-Packs zu bauen, und die sogar so etwas wie variabel lange Arrays unterstützt, die sich auf diese Weise initialisieren lassen, ist dieser Unterschied seltsam. Der Typ kann natürlich ebenfalls ein Template sein.
    Man kann auch direkte Konstruktoren verwenden oder Tuple bzw. Array mit nur einem Element initialisieren, aber in Sonderfällen kann dabei der falsche Konstruktor aufgerufen werden. Als ich das kurz nach Einführung der C++11-Initialisierungslisten entdeckt habe, hielt ich es für völlig verrückt

    • Initialisierungslisten sind nicht besonders wichtig. Außer dass einige Standard-Container sie verwenden, nutzt sie fast niemand, daher kann man dieses seltsame Feature im Grunde einfach ignorieren
  • Wer noch mehr C++-Absonderlichkeiten sehen will, dem sei die C++ FQA empfohlen: https://yosefk.com/c++fqa/
    Inzwischen etwa 15 Jahre alt, aber C++ entfernt alte Features oder Verhaltensweisen fast nie, daher ist es zugleich nicht wirklich gealtert

  • Das Blog-Theme ist wirklich wunderschön. Es wirkt eindeutig von Computern aus der DEC-Ära inspiriert und ist zugleich so sauber und minimalistisch, dass es frisch wirkt

    • Mir gefällt, wie die Linien rechts vom Titel auf Seitenbreite und Zeilenzahl reagieren
  • Schrecklich. Das ist einer der beängstigenden Teile von C++. Es gibt tolle Features und hervorragende Leute, die daran arbeiten, deshalb ist es umso bedauerlicher.
    Ich hoffe, dass etwas wie Herbs C++ syntax 2 daraus eine Sprache macht, die auch normale Leute wie ich benutzen können

    • Es wirkt, als würde hier übertrieben über etwas geklagt, das für jeden, der auch nur ein wenig Softwareentwicklung gemacht hat, kein großes Problem ist.
      Die Beispiele im Artikel sind so gebaut, dass sie gezielt Randfälle bei den Special-Member-Funktionen treffen, die die Sprache ausnahmsweise automatisch erzeugt, obwohl der Programmierer sich entschieden hat, sie nicht selbst zu schreiben.
      C++ verfolgt das Designziel, dass man nur für das bezahlt, was man nutzt, und die Rule of 3/Rule of 5 gehört zum Grundlagenwissen auf Einstiegsebene in C++. Dass solche Special-Member-Funktionen vom Compiler nur unter bestimmten Bedingungen erzeugt werden und sonst eben nicht, gehört ebenfalls zu den Grundlagen.
      Ich verstehe nicht, warum es so abwegig sein soll zu wissen, dass man die verwendeten Konstruktoren selbst festlegen muss. Auch dass man beim Erzeugen einer Instanz initialisieren sollte, ist nichts Überraschendes. In der Praxis erledigen Leute auf diese Weise ganz ohne großes Drama echte Arbeit
  • Mir wird schon schwindelig, wenn ich das lese. Es erinnert mich an die Zeit, als ich versucht habe, Java-Konstruktoren und Objektinitialisierung zu verstehen.
    Zumindest nach meiner bisherigen Erfahrung macht die Entscheidung, keine besonderen Konstruktoren zu haben, wie in Go und Rust, vieles einfacher.
    Ich frage mich, ob jemand nach dem Wechsel zu einer Sprache ohne Konstruktoren diese jemals vermisst hat.

    • Es gibt einige etwas obskure, fast magische Fälle, die Konstruktoren ermöglichen, die direkt am Zielort arbeiten. Rust hat zum Beispiel noch kein garantiertes Verhalten in Richtung placement new.
      Natürlich kann man mit MaybeUninit und ptr::write die Felder einzeln direkt hineinschreiben, aber das ist nicht besonders ergonomisch, und die Struktur muss das explizit zulassen.
    • Ich kann nicht ganz nachvollziehen, dass der Compiler, der gar nichts für einen tut, die Dinge „wirklich einfacher macht“.
      Am Ende bedeutet das doch, dass man alle Konstruktoren explizit deklarieren und definieren muss, und C++ bietet diese Möglichkeit ebenfalls von Anfang an. Die automatische Erzeugung spezieller Memberfunktionen wurde nur für sehr bestimmte Fälle hinzugefügt, für Leute, die Boilerplate vermeiden wollen.
      Wenn man will, kann man einfach selbst Konstruktoren hinzufügen und ganz normal damit leben. Sich darüber zu beschweren, dass spezielle Memberfunktionen einfache Dinge kompliziert machen, ist ein bisschen so, als würde man sagen, Englisch sei nicht einfach, nur weil es im Wörterbuch schwierige Wörter gibt.
    • Java-Konstruktoren sind eher einfach, aber dieses C++-Thema hier ist wirklich schwarze Magie.
      Soweit ich es verstehe, sind Rust-Konstruktoren im Grunde nicht ziemlich ähnlich wie in Java?
    • Auch die Zero-Initialisierung in Go zwingt einen manchmal dazu, die Sprachregeln genauer anzuschauen.
      https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
  • Ich frage mich, ob es ein C++-Tool gibt, das alle impliziten Vorgänge im Hintergrund ergänzt oder sichtbar macht.
    Zum Beispiel ein Tool, das automatisch hinzugefügte Konstruktoren, implizite Copy-Konstruktoren und andere Überraschungen zeigt.

    • Realistisch gesehen ist eine Kombination aus Godbolt und cppinsights wohl das Beste.
  • Beim Fall T::T() = default; würde man erwarten, dass die Ausgabe 0 ist, aber das Beispiel, in dem Müllwerte herauskommen, ist eigentlich gar nicht so ungewöhnlich.
    Relevanter Link: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
    Denn wenn man es anders machen würde, könnte jeder Nutzer der Bibliothek das Verhalten der Bibliothek verändern.

    • Nur weil die Alternative keinen Sinn ergibt, heißt das nicht automatisch, dass diese Lösung sinnvoll ist. Im Gegenteil: Es zeigt, in wie viele Ecken und Winkel sich das C++-Design selbst manövriert hat.