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
Bevor ich in meinem nächsten Beitrag auf Concepts eingehe, möchte ich die Motivation zu deren Anwendung in diesem Artikel darlegen.

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 [1]. 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 doubl
e, 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 [2] 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.
Abhängigkeiten zwischen den speziellen Memberfunktionen
Howard Hinnant hatte in seinem Vortrag auf der ACCU-Konferenz [3] 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.
Entscheide dich für Abstraktion
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. Einstd::array
unterstützt die Big Six. - Verwende einen
std::unique_ptr
oder einenstd::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 [4]. Das bedeutet, dass nur der Zeiger kopiert wird, nicht aber sein Inhalt. Die Verwendung einesstd::unique_ptr
oderstd::shared_ptr
in einem benutzerdefinierten Datentyp drückt deine Absicht direkt aus. Einstd::unique_ptr
kann nicht kopiert werden, und deshalb kann auch die Klasse nicht kopiert werden. Einestd::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 [5]". 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 [6]) 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.
Wie geht es weiter?
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.
( [7])
URL dieses Artikels:
https://www.heise.de/-7077131
Links in diesem Artikel:
[1] https://www.heise.de/blog/C-Core-Guidelines-Die-Nuller-Fuenfer-oder-Sechserregel-3813435.html
[2] https://www.heise.de/blog/C-Core-Guidelines-Die-Nuller-Fuenfer-oder-Sechserregel-3813435.html
[3] https://members.accu.org/index.php/conferences/accu_conference_2014
[4] https://stackoverflow.com/questions/184710/what-is-the-difference-between-a-deep-copy-and-a-shallow-copy
[5] https://heise.de/-3893493
[6] https://en.wikipedia.org/wiki/Code_smell
[7] mailto:rainer@grimm-jaud.de
Copyright © 2022 Heise Medien