C++ Core Guidelines: Regeln für Variadic Templates

Variadic Templates sind ein typisches Feature von C++: Aus Sicht der Anwender sind sie einfach einzusetzen, aus Sicht der Implementierer wirken sie recht furchteinflößend. Im heutigen Artikel geht es um die Sicht der Implementierer.

In Pocket speichern vorlesen Druckansicht 4 Kommentare lesen
Lesezeit: 8 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Variadic Templates sind ein typisches Feature von C++: Aus Sicht der Anwender sind sie einfach einzusetzen, aus Sicht der Implementierer wirken sie recht furchteinflößend. Im heutigen Artikel geht es um die Sicht der Implementierer.

Bevor ich über Variadic Templates schreibe, möchte ich meine Einleitung um eine Anmerkung ergänzen. Ich trage oft zwei Hüte, wenn ich eine C++-Schulung gebe: einen für den Anwender und einen für den Implementierer. Features von C++ wie Templates sind einfach zu verwenden, aber anspruchsvoll zu implementieren. Dieser signifikante Graben ist typisch für C++ und wohl tiefer als in anderen Mainstream-Programmiersprachen wie Python, Java und auch C. Ehrlich gesagt, ich habe kein Problem mit diesem Graben. Ich nenne ihn Abstraktion, und er ist ein wesentlicher Bestandteil der Mächtigkeit von C++. Die Kunst des Implementierers einer Bibliothek oder eines Frameworks ist es, ein einfach zu verwendendes und stabiles Interface zu entwickeln. Falls dies zu vage war, warte auf den nächsten Abschnitt, wenn ich std::make_unique entwickle.

Der heutige Artikel beschäftigt sich mit drei Regeln der C++ Core Guidelines:

Du kannst es bereits erahnen. Die drei Regeln bestehen nur aus den Überschriften. Ich werde eine Geschichte daraus machen.

Wie versprochen, entwickle ich std::make_unique. Es ist ein Funktions-Template, das ein dynamisch allokiertes Objekt in einem Smart Pointer std::unique_ptr erzeugt und zurückgibt. Hier sind ein paar Anwendungsfälle:

// makeUnique.cpp

#include <memory>

struct MyType{
MyType(int, double, bool){};
};

int main(){

int lvalue{2020};

std::unique_ptr<int> uniqZero = std::make_unique<int>(); // (1)
auto uniqEleven = std::make_unique<int>(2011); // (2)
auto uniqTwenty = std::make_unique<int>(lvalue); // (3)
auto uniqType = std::make_unique<MyType>(lvalue, 3.14, true); // (4)

}

Basierend auf diesem Anwendungsfall stellt sich die Frage: Welche Anforderungen muss std::make_unique erfüllen?

  1. Es soll mit einer beliebigen Anzahl von Argumenten umgehen können. Die verwendeten std::make_unique-Aufrufe besitzen 0, 1 und 3 Argumente.
  2. Es soll mit Rvalues und Lvalues umgehen können. Der std::make_unique-Aufruf in Zeile (2) erhält einen Rvalue und der in Zeile (3) einen Lvalue. Der letzte Aufruf in Zeile (4) bekommt einen Rvalue und einen Lvalue.
  3. Es soll seine Argumente unverändert an den zugrunde liegenden Konstruktor weiterreichen. Das heißt, der Konstruktor von std::unique_ptr soll einen Lvalue/Rvalue erhalten, wenn std::make_unique einen Lvalue/Rvalue erhält.

Diese Anforderungen sind typisch für Fabrikfunktionen wie std::make_unique, std::make_shared, std::make_tuple, aber auch für std::thread. Alle nützen zwei mächtige Features von C++11:

  1. Perfect Forwarding
  2. Variadic Templates

Jetzt entwickle ich eine Fabrikfunktion createT. Los geht es mit Perfect Forwarding.

Perfect Forwarding erlaubt es, die Wert-Kategorien (Lvalue/Rvalue) und die const/volatile-Qualifier des Arguments beizubehalten. Es tritt typischerweise in einem Pattern auf, das aus einer Universal-Referenz und std::forward besteht:

template<typename T>        // (1)
void create(T&& t){ // (2)
std::forward<T>(t); // (3)
}

Die drei Komponenten, um Perfect Forwarding zu erhalten, sind:

  1. Verwende einen Template-Parameter T: typename T
  2. Binde T mit einer Universal-Referenz, die auch unter den Namen Perfect-Forwarding-Referenz bekannt ist: T&& t
  3. Rufe std::forward auf dem Argument auf: std::forward<T>(t)

Die entscheidende Beobachtung ist, dass T&& (Zeile 2) einen Lvalue und einen Rvalue binden kann und dass std::forward in Zeile (3) perfekt weiterleitet.

Nun ist es an der Zeit, einen Prototyp für die Fabrikfunktion createT zu implementieren. Am Ende soll sich createT wie der std::make_unique-Aufruf in dem Programm in makeUnique.cpp verhalten. Ich habe lediglich std::make_unique in dem Programm mit createT Aufruf ersetzt, die createT-Fabrikfunktion hinzugefügt und die Zeilen (1) und (4) auskommentiert. Zusätzlich ist der Header <memory>(std::make_unique) dem header <utility>(std::forward) gewichen:

// createT1.cpp

#include <utility>

struct MyType{
MyType(int, double, bool){};
};

template <typename T, typename Arg>
T createT(Arg&& arg){
return T(std::forward<Arg>(arg));
}

int main(){

int lvalue{2020};

//std::unique_ptr<int> uniqZero = std::make_unique<int>(); // (1)
auto uniqEleven = createT<int>(2011); // (2)
auto uniqTwenty = createT<int>(lvalue); // (3)
//auto uniqType = std::make_unique<MyType>(lvalue, 3.14, true); // (4)

}

Ein Rvalue (Zeile 2) und eine Lvalue (Zeile 3) bestehen meinen Test.

Manchmal hängt alles an ein paar Punkten. Werden genau neun Punkte an den richtigen Stellen platziert, sind die Zeilen (1) und (4) gültig:

// createT2.cpp

#include <utility>

struct MyType{
MyType(int, double, bool){};
};

template <typename T, typename ... Args>
T createT(Args&& ... args){
return T(std::forward<Args>(args) ... );
}

int main(){

int lvalue{2020};

int uniqZero = createT<int>(); // (1)
auto uniqEleven = createT<int>(2011); // (2)
auto uniqTwenty = createT<int>(lvalue); // (3)
auto uniqType = createT<MyType>(lvalue, 3.14, true); // (4)

}

Wie funktioniert die ganze Magie? Drei Punkte stehen für eine Ellipse. Durch ihre Verwendung werden Args und args zu einem Parameter-Pack. Um genauer zu sein, Args ist ein Template-Parameter-Pack und args ist ein Funktions-Parameter-Pack. Du kannst nur zwei Operationen auf einem Parameter-Pack anwenden: packen und entpacken. Wenn die Ellipse links von Args steht, wird gepackt, wenn sie rechts von Args steht, entpackt. Im Falle des Ausdrucks (std::forward<Args>(args)...) bedeutet dies, dass der Ausdruck entpackt wird, bis er konsumiert ist und ein Komma zwischen die entpackten Komponenten platziert wird. Das ist alles.

CppInsight erlaubt es, unter die Decke zu blicken:

Jetzt bin ich fast fertig. Hier ist meine createT-Fabrikfunktion nochmals:

template <typename T, typename ... Args>
T createT(Args&& ... args){
return T(std::forward<Args>(args) ... );
}

Zwei Schritte fehlen noch, um std::make_unique zu erhalten:

  1. Erzeugen eines std::unique_ptr<T> anstelle eines einfachen T.
  2. Die Funktion in make_unique umbenennen.
template <typename T, typename ... Args>
std::unique_ptr<T> make_unique(Args&& ... args){
return std::unique_ptr<T>(new T(std::forward<Args>(args) ... ));
}

Jetzt habe ich doch beinahe den furchteinflößenden Teil meines Artikels vergessen.

Klar kennst du die C-Funktion printf. Hier ist die Signatur der Funktion: int printf( const char* format, ... );. printf ist eine Funktion, die eine beliebige Anzahl an Argumenten annehmen kann. Ihre Mächtigkeit basiert auf dem Makro va_arg. Damit ist sie nicht typsicher.

Dank Variadic Templates kann printf in einer typsicheren Variante implementiert werden:

// myPrintf.cpp

#include <iostream>

void myPrintf(const char* format){ // (3)
std::cout << format;
}

template<typename T, typename ... Args>
void myPrintf(const char* format, T value, Args ... args){ // (4)
for ( ; *format != '\0'; format++ ) { // (5)
if ( *format == '%' ) { // (6)
std::cout << value;
myPrintf(format + 1, args ... ); // (7)
return;
}
std::cout << *format; // (8)
}
}

int main(){

myPrintf("\n"); // (1)

myPrintf("% world% %\n", "Hello", '!', 2011); // (2)

myPrintf("\n");

}

Jetzt ist eine Erklärung notwendig. Wenn myPrintf lediglich mit einem Formatstring (Zeile 1) aufgerufen wird, kommt die Funktion in der Zeile (3) zum Einsatz. Im Falle von Zeile (2), kommt das Funktions-Template in Zeile (4) zum Einsatz. Das Funktions-Template (Zeile 5) befindet sich in der Endlosschleife, solange das Formatsymbol nicht '\0' ist. Falls das Formatsymbol nicht '\0' ist, sind zwei Kontrollflüsse möglich. Erstens, falls der Formatstring mit '%' beginnt (Zeile 6), werden das erste Argument value ausgegeben und myPrintf nochmals ausgerufen. Dieses Mal aber mit einem neuen Formatsymbol und einem Argument weniger (Zeile 7). Zweitens, falls der Formatstring nicht mit '%' beginnt, wird das Formatsymbol lediglich ausgegeben (Zeile 8). Die Funktion myPrintf (Zeile 3) stellt die Endbedingungen für die rekursiven Aufrufe dar.

Das Programm produziert die erwartete Ausgabe:

Eine Regel zur Variadic Templates gibt es noch. Danach folgt in den Guidelines Template Metaprogrammierung. Ich bin mir noch nicht so sicher, wie tief ich in Template-Metaprogrammierung einsteigen werde. ()