Eine std::variant mit dem Overload Pattern besuchen

Modernes C++ Rainer Grimm  –  0 Kommentare

Eine std::variant besitzt einen Wert aus einem ihrer Datentypen. std::visit ermöglicht es, einen Besucher auf sie anzuwenden. Hier kommt das praktische Overload Pattern ins Spiel.

Eine std::variant mit dem Overload Pattern besuchen

In meinem letzten Artikel "Clevere Tricks mit Parameterpacks und Fold Expressions" habe ich das Overload Pattern als einen cleveren Trick zum Erstellen eines Overload Set mit Lambdas vorgestellt. Typischerweise wird das Overload Pattern verwendet, um den Wert einer std::variant zu besuchen.

Aus meinen C++-Seminaren weiß ich, dass viele Entwicklerinnen und Entwickler std::variant und std::visit nicht kennen und stattdessen eine Union verwenden. Deshalb möchte ich std::variant und std::visit kurz und kompakt vorstellen.

std::variant (C++17)

Eine std::variant ist eine typsichere Union. Eine Instanz von std::variant besitzt einen Wert einer ihrer Datentypen. Der Wert darf keine Referenz, kein C-array und nicht void sein. Eine std::variant kann einen Datentyp mehr als einmal besitzen. Eine default-initialisierte std::variant wird mit ihrem ersten Datentyp initialisiert. In diesem Fall muss der erste Typ einen Default-Konstruktor anbieten. Hierzu ein auf cppreference.com basierendes Beispiel:

// variant.cpp

#include <variant>
#include <string>

int main(){

std::variant<int, float> v, w;
v = 12; // (1)
int i = std::get<int>(v);
w = std::get<int>(v); // (2)
w = std::get<0>(v); // (3)
w = v; // (4)

// std::get<double>(v); // (5) ERROR
// std::get<3>(v); // (6) ERROR

try{
std::get<float>(w); // (7)
}
catch (std::bad_variant_access&) {}

std::variant<std::string> v("abc"); // (8)
v = "def"; // (9)

}

Ich definiere die beiden Varianten v und w. Sie können einen int- und einen float-Wert besitzen. Ihr Startwert ist 0. v erhält den Wert 12 (Zeile 1). std::get<int>(v) gibt den Wert zurück. In Zeile (2) - (4) siehst du drei Möglichkeiten, der Variante v die Variante w zuzuweisen. Dabei musst du einige Regeln beachten. Du kannst den Wert einer Variante aufgrund ihres Datentyps (Zeile 5) oder mit einem Index (Zeile 6) abfragen. Der Typ muss eindeutig und der Index gültig sein. In Zeile (7) enthält die Variante w einen int-Wert. Dadurch verursache ich eine std::bad_variant_access-Exception. Wenn entweder der Konstruktoraufruf oder der Zuweisungsoperator eindeutig ist, findet eine einfache Umwandlung statt. Das ist der Grund dafür, dass eine std::variant<std::string> in Zeile (8) mit einem C-String konstruiert oder der Variante ein neuer C-String zugewiesen werden kann (Zeile 9).

Natürlich gibt es noch viel mehr über std::variant zu schreiben. Lies dazu beispielsweise den Artikel "Everything You Need to Know About std::variant from C++17" von Bartlomiej Filipek.

Dank der Funktion std::visit bietet C++17 eine praktische Möglichkeit, die Elemente einer std::variant zu besuchen.

std::visit

Was wie das Besuchsmuster des klassischen Entwurfsmusters klingt, ist in Wirklichkeit eine Art Besucher für die Werte einer Variante.

std::visit ermöglicht es, einen Besucher auf einen Container mit Varianten anzuwenden. Der Besucher muss ein Callable sein. Ein Callable ist eine Einheit, die du aufrufen kannst. Typische Callables sind Funktionen, Funktionsobjekte oder Lambdas. In meinem Beispiel verwende ich Lambdas:

// visitVariants.cpp

#include <iostream>
#include <vector>
#include <typeinfo>
#include <variant>


int main(){

std::cout << '\n';

std::vector<std::variant<char, long, float, int, double, long long>> // (1)
vecVariant = {5, '2', 5.4, 100ll, 2011l, 3.5f, 2017};

for (auto& v: vecVariant){
std::visit([](auto arg){std::cout << arg << " ";}, v); // (2)
}

std::cout << '\n';

for (auto& v: vecVariant){
std::visit([](auto arg){std::cout << typeid(arg).name() << " ";}, v); // (3)
}

std::cout << "\n\n";

}

In Zeile (1) erstelle ich einen std::vector von std::variants und initialisiere jede Variante. Jede Variante kann einen char-, long-, float-, int-, double- oder long long-Wert enthalten. Es ist ganz einfach, den Vektor der Varianten zu durchlaufen und das Lambda auf ihn anzuwenden (siehe Zeile (2) und (3)). Erstens zeige ich den aktuellen Wert an (2), und zweitens erhalte ich dank des Aufrufs typeid(arg).name() (3) eine String-Darstellung des Datentyps des aktuellen Wertes.

Eine std::variant mit dem Overload Pattern besuchen

Gut? Nein! Ich habe in dem Programm visitVariant.cpp ein generisches Lambda verwendet. Daher sind die String-Darstellungen der Typen mit dem GCC ziemlich unleserlich: "i c d x l f i". Ehrlich gesagt, möchte ich auf jeden Typ der Varianten eine spezielle Lambda anwenden. Jetzt kommt das Overload Pattern zum Einsatz.

Overload Pattern

Dank des Overload Patterns kann ich jeden Datentyp mit einem lesbaren String anzeigen und jeden Wert auf eine geeignete Weise darstellen.

// visitVariantsOverloadPattern.cpp

#include <iostream>
#include <vector>
#include <typeinfo>
#include <variant>
#include <string>

template<typename ... Ts> // (7)
struct Overload : Ts ... {
using Ts::operator() ...;
};
template<class... Ts> Overload(Ts...) -> Overload<Ts...>;

int main(){

std::cout << '\n';

std::vector<std::variant<char, long, float, int, double, long long>> // (1)
vecVariant = {5, '2', 5.4, 100ll, 2011l, 3.5f, 2017};

auto TypeOfIntegral = Overload { // (2)
[](char) { return "char"; },
[](int) { return "int"; },
[](unsigned int) { return "unsigned int"; },
[](long int) { return "long int"; },
[](long long int) { return "long long int"; },
[](auto) { return "unknown type"; },
};

for (auto v : vecVariant) { // (3)
std::cout << std::visit(TypeOfIntegral, v) << '\n';
}

std::cout << '\n';

std::vector<std::variant<std::vector<int>, double, std::string>> // (4)
vecVariant2 = { 1.5, std::vector<int>{1, 2, 3, 4, 5}, "Hello "};

auto DisplayMe = Overload { // (5)
[](std::vector<int>& myVec) {
for (auto v: myVec) std::cout << v << " ";
std::cout << '\n';
},
[](auto& arg) { std::cout << arg << '\n';},
};

for (auto v : vecVariant2) { // (6)
std::visit(DisplayMe, v);
}

std::cout << '\n';

}

Zeile (1) erzeugt einen Vektor von Varianten mit ganzzahligen Typen und Zeile (4) einen Vektor von Varianten mit einem std::vector<int>, double und einem std::string.

Fahren wir mit der ersten Variante vecVariant fort. TypeOfIntegral (2) ist ein Overload Set, das für einige ganzzahlige Typen seine String-Darstellung zurückgibt. Wenn der Typ nicht von dem Overload Set angeboten wird, gebe ich als Fallback den String "unknown type" zurück. In Zeile (3) wende ich das Overload Set auf jede Variante v mit std::visit an.

Die zweite Variante vecVariant2 (4) hat zusammengesetzte Typen. Um ihre Werte darzustellen, erstelle ich ein Overload Set (5). Im Allgemeinen kann ich den Wert einfach in std::cout ausgeben. Für den std::vector<int> verwende ich eine range-basierte for-Schleife (6), um seine Werte darzustellen.

Hier ist die Ausgabe des Programms:

Eine std::variant mit dem Overload Pattern besuchen

Ich möchte noch ein paar Worte zu dem in diesem Beispiel verwendeten Overload Pattern (7) schreiben. Ich habe es bereits in meinem letzten Beitrag "Clevere Tricks mit Parameterpacks und Fold Expressions" vorgestellt.

template<typename ... Ts>                                  // (1)
struct Overload : Ts ... {
using Ts::operator() ...;
};
template<class... Ts> Overload(Ts...) -> Overload<Ts...>; // (2)

Zeile (1) ist das Overload Pattern und Zeile (2) ist der Deduction Guide dafür. Die Struktur Overload kann beliebig viele Basisklassen (Ts ...) haben. Sie leitet von jeder Klasse public ab und bringt den Aufrufoperator (Ts::operator...) jeder Basisklasse in ihren Geltungsbereich. Die Basisklassen benötigen einen überladenen Aufrufoperator (Ts::operator()). Lambdas stellen diesen Aufrufoperator zur Verfügung. Das folgende Beispiel ist so einfach wie möglich gehalten:

#include <variant>

template<typename ... Ts>
struct Overload : Ts ... {
using Ts::operator() ...;
};
template<class... Ts> Overload(Ts...) -> Overload<Ts...>;

int main(){

std::variant<char, int, float> var = 2017;

auto TypeOfIntegral = Overload { // (1)
[](char) { return "char"; },
[](int) { return "int"; },
[](auto) { return "unknown type"; },
};

}

Wenn du dieses Beispiel in C++ Insights verwendest, wird die Magie erkennbar. Zunächst bewirkt der Aufruf (1) die Erstellung eines vollständig spezialisierten Klassen-Templates.

Eine std::variant mit dem Overload Pattern besuchen

Zweitens bewirken die verwendeten Lambdas im Overload Pattern wie [](char) { return "char"; } die Erstellung eines Funktionsobjekts. In diesem Fall gibt der Compiler dem Funktionsobjekt den Namen __lambda_15_9.

Eine std::variant mit dem Overload Pattern besuchen

Die Untersuchung der automatisch generierten Datentypen zeigt einen weiteren interessanten Punkt. Der Aufrufoperator von __lambda_15_9 ist für char überladen: const char * operator() (char) const { return "char"; }.

Der Deduction Guide (template<class... Ts> Overload(Ts...) -> Overload<Ts...>;) (Zeile 2) wird nur für C++17 benötigt. Dieser teilt dem Compiler mit, wie er aus Konstruktor-Argumenten Template-Parameter erzeugen kann. C++20 kann dieses Klassen-Template automatisch ableiten.

Wie geht es weiter?

Die "Freundschaft" von Templates ist etwas Besonderes. In meinem nächsten Beitrag erkläre ich, warum.