Verschiedene Template-Verbesserungen mit C++20

Modernes C++  –  11 Kommentare

Zugegeben, ich stelle in dem heutigen Artikel ein paar kleine Verbesserungen zu Templates und zu C++20 im Allgemeinen vor. Obwohl diese Verbesserungen nicht so beeindruckend wirken, wird durch sie C++20 konsistenter und damit auch weniger fehleranfällig, wenn es um das generische Programmieren geht.

Der heutige Artikel befasst sich mit dem bedingt expliziten Konstruktor und neuen Nichttyp (non-type) Template-Parameter.

Bedingt expliziter Konstruktor

Häufig soll eine Klasse mehrere Konstruktoren besitzen, die verschiedene Datentypen annehmen kann. Zum Beispiel gilt dies für die Klasse VariantWrapper, die eine std::variant enthält.

class VariantWrapper {

std::variant<bool, char, int, double, float, std::string> myVariant;

};

Um myVariant mit bool, char, int, double, float oder std::string zu initialisieren, benötigt die Klasse VariantWrapper Konstruktoren für jeden der aufgezählten Datentypen. Faulheit ist eine Tugend - zumindest für Programmierer. Daher bietet sich in diesem Fall ein generischer Konstruktor an.

Die Klasse Implicit stellt einen generischen Konstruktor vor.

// explicitBool.cpp

#include <iostream>
#include <string>
#include <type_traits>

struct Implicit {
template <typename T> // (1)
Implicit(T t) {
std::cout << t << std::endl;
}
};

struct Explicit {
template <typename T>
explicit Explicit(T t) { // (2)
std::cout << t << std::endl;
}
};

int main() {

std::cout << std::endl;

Implicit imp1 = "implicit";
Implicit imp2("explicit");
Implicit imp3 = 1998;
Implicit imp4(1998);

std::cout << std::endl;

// Explicit exp1 = "implicit"; // (3)
Explicit exp2{"explicit"}; // (4)
// Explicit exp3 = 2011; // (3)
Explicit exp4{2011}; // (4)

std::cout << std::endl;

}

Diese Umsetzung besitzt ein Problem. Ein generischer Konstruktor (1) ist ein Catch-all-Konstruktor, da er mit jedem beliebigen Datentyp verwendet werden kann. Der Konstruktor ist viel zu gierig. Durch die Verwendung des Schlüsselworts explicit (1) vor dem Konstruktor wird dieser explizit. Das heißt, das er keine impliziten Konvertierungen (3) ausführt. Lediglich explizite Aufrufe (4) sind gültig.

Dank dem Clang 10 Compiler ist hier die Ausgabe des Programms:

Dies ist aber noch nicht das Ende der Geschichte. Eventuell möchte man einen Datentyp MyBool implementieren, der lediglich die implizite Konvertierung von bool erlaubt. Alle anderen impliziten Konvertierung sollen aber nicht zulässig sein. In diesem Fall lässt sich explicit auch bedingt verwenden.

// myBool.cpp

#include <iostream>
#include <type_traits>
#include <typeinfo>

struct MyBool {
template <typename T>
explicit(!std::is_same<T, bool>::value) MyBool(T t) { // (1)
std::cout << typeid(t).name() << std::endl;
}
};

void needBool(MyBool b){ } // (2)

int main() {

MyBool myBool1(true);
MyBool myBool2 = false; // (3)

needBool(myBool1);
needBool(true); // (4)
// needBool(5); // (5)
// needBool("true"); // (5)

}

Der explicit(!std::is_same<T, bool>::value) Ausdruck sichert zu, dass MyBool nur implizit von einem bool-Wert erzeugt werden kann. Die Funktion std::is_same ist ein Compile-Zeit-Prädikat der Type-Traits Bibliothek. Compilezeit-Prädikat bedeutet, dass std::is_same beim Kompilieren ausgeführt wird und einen Wahrheitswert zurückgibt. Konsequenterweise sind die impliziten Konvertierung von bool in (3) und (4) zulässig. Diese implizite Konvertierung für einen int- und einen C-String-Wert ist aber nicht in Zeile (5) zulässig.

Nun könnte man einwenden, dass der bedingt explizite Konstruktor sich auch mit SFINAE umsetzen ließe. Ehrlich gesagt bin ich kein Freund des entsprechenden Konstruktors, der SFINAE verwendet. Zum einen benötige ich mehrere Sätze, um ihn zu erklären, zum anderen habe ich ihn erst nach dem dritten Versuch richtig implementiert.

template <typename T, 
std::enable_if_t<std::is_same_v<std::decay_t<T>,
bool>, bool> = true>
MyBool(T&& t) {
std::cout << typeid(t).name() << std::endl;
}

Ich denke, jetzt sind ein paar erklärende Worte notwendig. SFINAE steht für Substitution Failure Is Not An Error und wird während der Überladung von Funktions-Templates angewandt. Es bedeutet: Wenn bei der Ersetzung der Template-Parameter ein Fehler auftritt, ist dies kein Fehler. Diese Instantiierung der Template-Parameter wird aus der Menge aller Funktionsüberladungen entfernt. Genau das passiert in dem konkreten Fall. Die Spezialisierung wird entfernt, wenn std::is_same_v<std::decay_t<T>, bool> zu false evaluiert. std::decay<T> wendet Konvertierung auf T wie das Entfernen von const, volatile oder das Entfernen einer Referenz von T an. std::decay_t<T> ist eine angenehme Schreibeweise für std::decay<T>::type. Dasselbe gilt für std::is_same_v<T, bool> als Kurzform für std::is_same<T, bool>::value.

Pre alpha wies mich darauf hin, dass dieser SFINAE einsetzende Konstruktor viel zu gierig ist: Er unterbindet alle Konstruktoren, die nicht bool verwenden.

Neben meiner länglichen Erklärung gibt es ein weiteres Argument, das gegen SFINAE und für den bedingten expliziten Konstruktor spricht: Performanz. Simon Brand hat in seinem Artikel "C++20's Conditionally Explicit Constructors" dargestellt, dass Template Instanziierung mit explicit(bool) im Falle von Visual Studio 2019 um 15% schneller ist als die Template Instanziierung mit SFINAE.

Mit C++20 werden auch weitere Nichttyp (non-type) Template-Parameter unterstützt.

Neue Nichttyp Template-Parameter

Mit C++20 werden Fließkommazahlen und Klassen mit constexpr Konstruktor als Nichttypen unterstützt.

C++ unterstützt bereits Nichttypen als Template-Parameter. Im Wesentlichen sind Nichttypen

  • Ganzzahlen oder Aufzähler
  • Zeiger oder Referenzen auf Objekte, Funktionen oder Mitglieder einer Klasse
  • std::nullptr_t

Wenn ich die Teilnehmer meiner Schulung frage, ob sie bereits Nichttypen als Template-Parameter verwendet haben, ist die einheitliche Antwort: Nein! Natürlich beantworte ich direkt meine knifflige Frage und stelle ein oft verwendetes Beispiel für Nichttyp Template-Parameter vor.

std::array<int, 5> myVec;

5 ist ein Nichttyp und wird als Template-Argument verwendet. Seit dem ersten Standard C++98 gibt es die Diskussion in der C++-Community, dass Fließkommazahlen auch als Template-Parameter unterstützt werden sollten. Mit C++20 stehen sie zur Verfügung.

// nonTypeTemplateParameter.cpp

struct ClassType {
constexpr ClassType(int) {} // (1)
};

template <ClassType cl> // (2)
auto getClassType() {
return cl;
}

template <double d> // (3)
auto getDouble() {
return d;
}

int main() {

auto c1 = getClassType<ClassType(2020)>();

auto d1 = getDouble<5.5>(); // (4)
auto d2 = getDouble<6.5>(); // (4)

}

ClassType besitzt einen constexpr Konstruktor (1) und kann daher als Template-Argument verwendet werden (2). Dieselbe Beobachtung gilt für das Funktions-Template getDouble (3), das nur double-Werte annimmt. Ich möchte es explizt betonen, dass jeder Aufruf des Funktions-Templates getDouble (4) mit einem neuen Argument die Instanziierung einer Funktion getDouble bewirkt. Das bedeutet, dass zwei Instanziierung für die double-Werte 5.5 und 6.5 erzeugt werden.

Wenn der Clang-Compiler dieses Feature bereits unterstützen würde, könnte ich schön mit C++ Insights zeigen, dass jede Instanziierung für 5.5 und 6.5 ein vollständig spezialisiertes Funktions-Template erzeugen würde. Zumindest kann ich aber dank des GCC-Compilers und dem Compiler Explorer die entscheidenden Assemblerbefehle vorstellen.

Der Screenshot zeigt, dass der Compiler für jedes Template-Argument eine Funktion erzeugt.

Wie geht's weiter?

Ähnlich wie Templates erfahren Lambdas auch einige Verbesserungen in C++20. In meinem nächsten Artikel gehe ich auf diese Verbesserungen ein.