Datentypen mit Concept prüfen – Die Motivation

Mit static_assert lässt sich zur Compiletime testen, ob ein Datentyp T das Concept erfüllt: static_assert(Concept<T>).

Lesezeit: 8 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen 3 Beiträge
Von
  • Rainer Grimm

Bevor ich in meinem nächsten Beitrag auf Concepts eingehe, möchte ich die Motivation zu deren Anwendung in diesem Artikel darlegen.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Wenn ich in meinen Kursen die Move-Semantik bespreche, stelle ich die Idee der Big Six vor. Die Big Six steuern den vierstufigen Lebenszyklus von Objekten: Erzeugen, Kopieren, Verschieben und Löschen. Hier sind die sechs speziellen Mitgliedsfunktionen für einen Typ X

      • Default-Konstruktor: X()
      • Copy-Konstruktor: X(const X&)
      • Copy-Zuweisungsoperator: operator=(const X&)
      • Move-Konstruktor: X(X&&)
      • Move-Zuweisungsoperator: operator=(X&&)
      • Destruktor: ~X()

Per Default kann der Compiler die Big Six bei Bedarf erzeugen. Du kannst die sechs speziellen Mitgliedsfunktionen definieren, aber du kannst den Compiler auch explizit bitten, sie mit =default bereitzustellen oder sie mit =delete zu löschen. Wenn möglich, sollten die speziellen Mitgliedsfunktionen nicht definiert werden. Diese Regel ist auch bekannt als die Nullerregel. Das bedeutet, falls eine Klasse keine Default-Operatoren benötigt, da bereits alle Mitglieder diese besitzen, muss man auch keine definieren. Das gilt für die Built-in-Datentypen bool oder double, aber auch für die Container der Standard Template Library wie std::vector oder std::string.

class Named_map {
 public:
    // ... no default operations declared ...
 private:
    std::string name;
    std::map<int, int> rep;
};

Named_map nm;       // default construct
Named_map nm2 {nm}; // copy construct

Der Aufruf des Default- und des Copy-Konstruktors ist in diesem Fall möglich, da beide bereits für std::string und std::map vorhanden sind. Wenn der Compiler für das Beispiel den Copy-Konstruktor für eine Klasse automatisch generiert, ruft er den Copy-Konstruktor für alle Mitglieder und alle Basisklassen der Klasse auf.

Der Spaß beginnt, wenn du eine der speziellen Memberfunktionen definierst oder auf=delete setzt, denn die Big Six sind eng miteinander verbunden. Aufgrund dieser Beziehung musst du alle sechs definieren oder auf =delete setzen. Daher wird diese Regel auch als Sechserregel bezeichnet. Manchmal hört man auch von der Fünferregel, weil der Default Konstruktor besonders betrachtet und aus der Sechserregel ausgeschlossen wird. Lasse mich diese Regel ein wenig abschwächen: Wenn du eine spezielle Memberfunktion definierst oder auf =delete setzt, musst du an alle sechs denken. Eine spezielle Memberfunktion zu definieren, kann beides bedeuten: Du implementierst die spezielle Memberfunktion oder du forderst sie mit =default vom Compiler an.

Ich habe geschrieben, dass der Compiler bei Bedarf die Big Six erzeugen kann. Jetzt muss ich klarstellen, was ich damit meine: Das stimmt nur, wenn keine spezielle Mitgliedsfunktion definiert oder auf =delete gesetzt ist, denn es gibt ziemlich ausgeklügelte Abhängigkeiten zwischen den sechs speziellen Mitgliedsfunktionen.

Das ist genau der Punkt, an dem die Diskussionen in meinen Schulungen beginnen: Wie kann ich sicher sein, dass mein Typ X Move-Semantik unterstützt? Natürlich soll dein Typ X Move-Semantik unterstützen.

Die Move-Semantik hat zwei große Vorteile:

  • Billige Move-Operationen werden anstelle von teuren Copy-Operationen verwendet
  • Move-Operationen erfordern keine Speicherallokation und es ist daher keine std::bad_alloc Exception möglich

Es gibt im Wesentlichen zwei Möglichkeiten zu prüfen, ob ein Datentyp die Big Six unterstützt: analysiere die Abhängigkeiten der speziellen Memberfunktionen des Datentyps oder definiere und verwende ein Concept BigSix.

Zunächst wollen wir die Abhängigkeiten zwischen den speziellen Memberfunktionen analysieren.

Howard Hinnant hatte in seinem Vortrag auf der ACCU-Konferenz 2014 einen Überblick über die Abhängigkeiten der automatisch generierten speziellen Mitgliedsfunktionen entwickelt. Hier ist ein Screenshot seiner vollständigen Tabelle:

Howards Tabelle verlangt eine ausführliche Erklärung.

Zunächst einmal bedeutet user declared für eine dieser sechs speziellen Mitgliedsfunktionen, dass du sie explizit definierst oder sie vom Compiler mit =default anforderst. Das Löschen der speziellen Memberfunktion mit =delete wird ebenfalls als user declared angesehen. Wenn du nur den Namen verwendest, gilt somit die spezielle Memberfunktion als deklariert.

Wenn du einen beliebigen Konstruktor definierst, erhältst du keinen Default-Konstruktor. Ein Default-Konstruktor ist ein Konstruktor, der ohne ein Argument aufgerufen werden kann.

Wenn du einen Default-Konstruktor mit =default oder =delete definierst oder ihn implementierst, ist keine andere der sechs speziellen Mitgliedsfunktionen davon betroffen.

Ist der Destruktor, der Copy-Konstruktor oder der Copy-Zuweisungsoperator user declared, erhältst du keinen vom Compiler generierten Move-Konstruktor und Move-Zuweisungsoperator. Das bedeutet, dass Move-Operation wie move-Konstruktion oder Move-Zuweisung auf Copy-Operationen wie copy-Konstruktion oder copy-Zuweisung zurückfallen. Dieser Fallback-Automatismus ist in der Tabelle rot markiert. Außerdem sind die rot markierten Copy-Operationen deprecated.

Wenn du einen Move-Konstruktor oder einen Move-Zuweisungsoperator deklarierst, erhältst du nur diesen Move-Konstruktor oder Move-Zuweisungsoperator. Folglich werden der copy-Konstruktor und der copy-Zuweisungsoperator auf =delete gesetzt. Das Aufrufen einer Copy-Operation führt daher zum Fehler zur Compilezeit.

Aufgrund dieser Abhängigkeitshölle gebe ich in meinen Kursen die folgende allgemeine Regel an: Mache deine benutzerdefinierten Datentypen so einfach wie möglich und setze auf Abstraktion. Überlasse die schwierigen Aufgaben dem Compiler.

Hier sind ein paar Konsequenzen dieser Regel:

  • Deklariere keine spezielle Mitgliedsfunktion, wenn dies möglich ist.
  • Verwende ein std::array anstelle eines C-Arrays in deiner Klasse. Ein std::array unterstützt die Big Six.
  • Verwende einen std::unique_ptr oder einen std::shared_ptr in deiner Klasse, aber keinen nackten Zeiger. Der vom Compiler erzeugte Copy-Konstruktor und der Copy-Zuweisungsoperator für einen nackten Zeiger erstellen eine flache Kopie, aber keine tiefe Kopie. Das bedeutet, dass nur der Zeiger kopiert wird, nicht aber sein Inhalt. Die Verwendung eines std::unique_ptr oder std::shared_ptr in einem benutzerdefinierten Datentyp drückt deine Absicht direkt aus. Ein std::unique_ptr kann nicht kopiert werden, und deshalb kann auch die Klasse nicht kopiert werden. Eine std::shared_ptr und damit auch die Klasse kann kopiert werden.
  • Wenn du einen benutzerdefinierten Datentyp in deiner Klasse hast, der die automatische Erzeugung der Big Six unterdrückt, hast du zwei Möglichkeiten: Implementiere die speziellen Mitgliedsfunktionen für diesen benutzerdefinierten Datentyp, oder zerlege deine Klasse in zwei Klassen. Lass nicht zu, dass ein benutzerdefinierter Datentyp dein Klassendesign infiziert.

Meine allgemeine Empfehlung möchte ich mit einer Anekdote beenden: Ich habe einen Code-Review für einen Freund durchgeführt. Er bat mich, seinen Code zu analysieren, bevor er in Produktion geht. Er verwendete in seiner zentralen Klasse eine Union. Ich nenne die Klasse, die die Union enthält, der Einfachheit halber WrapperClass. Die verwendete Union war eine sogenannte tagged union. Das bedeutet, dass die WrapperClass den aktuell verwendeten Typ der Union verwaltet. Wenn du mehr über Unions wissen willst, lies meinen Artikel "C++ Core Guidelines: Regeln für Unions". Letztendlich bestand die WrapperClass aus etwa 800 Zeilen Code, um die Big Six zu unterstützen. Im Wesentlichen musste er die sechs speziellen Mitgliedsfunktionen in acht Varianten implementieren, da die Union acht verschiedene Typen haben konnte.

Außerdem implementierte mein Freund ein paar zusätzlichen Operationen, um Instanzen der WrapperClass zu vergleichen. Als ich die Klasse analysierte, war mir sofort klar: Das ist ein Geschmäckle (Code Smell) und ein Grund für ein Refactoring. Ich fragte ihn, ob er C++17 verwenden kann. Die Antwort war ja, und ich ersetzte die Union durch eine std::variant. Zudem fügte ich einen generischen Konstruktor hinzu. Das Ergebnis war, dass die WrapperClass von 800 Zeilen Code auf 40 Zeilen Code reduziert wurde. std::variant unterstützt die sechs speziellen Mitgliedsfunktionen und die sechs Vergleichsoperatoren per Design.

Vielleicht möchtest du dich nicht mit den Abhängigkeiten zwischen den sechs speziellen Mitgliedsfunktionen beschäftigen? In meinem nächsten Beitrag setze ich diese Geschichte fort und definiere und verwende das Concept BigSix, um zur Compilezeit zu entscheiden, ob ein bestimmter Typ alle sechs speziellen Mitgliedsfunktionen unterstützt. ()