1. Warum std::visit nützlich ist
Wenn Sie gerade std::variant kennenlernen, sieht alles erstmal harmlos aus: „Typ überprüft — Wert entnommen — Aufgabe erledigt“. Sobald es jedoch mehr als zwei Alternativen gibt und die Verarbeitung etwas komplexer ist als „einfach ausgeben“, bläht sich der Code auf. Irgendwann fängt man an, einen Mini-Interpreter für Typen per Hand zu schreiben — das ist kein C++-Lernen mehr, sondern mühsame Fleißarbeit.
Stellen Sie sich vor, wir haben ein Ergebnis des Parsings eines Befehls in einer Konsolenanwendung (wir entwickeln dasselbe Übungsprojekt weiter — einen Mini-Task-Tracker). Der Parser kann entweder einen Befehl oder einen Fehler zurückgeben. Wir verwenden hier noch nicht std::expected (das kommt in der nächsten Vorlesung), daher reicht uns aktuell std::variant.
Angenommen so:
#include <string>
#include <variant>
struct CmdAdd { std::string text; };
struct CmdList {};
struct ParseError { std::string message; };
using ParseResult = std::variant<CmdAdd, CmdList, ParseError>;
Wenn man das mit get_if behandelt, entsteht ein „Wald“ von Prüfungen. Es funktioniert, skaliert aber schlecht:
#include <iostream>
void handle(const ParseResult& r) {
if (const auto* add = std::get_if<CmdAdd>(&r)) {
std::cout << "ADD: " << add->text << '\n';
} else if (std::get_if<CmdList>(&r)) {
std::cout << "LIST\n";
} else if (const auto* e = std::get_if<ParseError>(&r)) {
std::cout << "ERROR: " << e->message << '\n';
}
}
Das Problem ist nicht nur die Anzahl der Zeilen. Das Problem ist, dass die Verantwortung verteilt ist: leicht vergisst man eine neue Verzweigung, leicht vertauscht man die Reihenfolge, leicht wird es so komplex, dass selbst der Autor kaum noch durchblickt. Genau hier kommt std::visit ins Spiel.
Die Idee von std::visit: den tatsächlichen Typ bekommen und verarbeiten
std::visit ist der standardmäßige Weg, ein std::variant zu verarbeiten: Sie geben ihm einen „Handler“ (Visitor), und er ruft diesen mit dem Argument des tatsächlichen Typs auf, der gerade im variant liegt. Am Ende schreiben Sie die Verarbeitungslogik so, als hätten Sie bereits den konkreten Typ, statt einer „Box mit Überraschung“.
Falls Ihnen jemals erschien, die Standardbibliothek sei manchmal wie „wir haben das Rad neu erfunden, aber man kann damit auch Eier braten“, dann ist std::visit ein Beispiel für ein gutes Rad: ein nützliches, praktisches Werkzeug, das in echtem Code oft eingesetzt wird.
Formell sieht std::visit so aus:
std::visit(visitor, variant_value);
Wobei visitor ein beliebiges „Aufrufbares“ sein kann: Funktion, Lambda, Objekt mit operator(). Und das Beste: der Visitor muss jede Alternative verarbeiten können. Das ist kein persönlicher Wunsch des Kursautors, sondern Typensicherheit: wenn Sie eine Variante vergessen, lässt Sie der Compiler nicht „versehentlich“ etwas aus.
2. Grundsyntax von std::visit
Eine generic-Lambda „für alle Fälle“
Den Einstieg in std::visit sollte man mit einer Situation beginnen, in der die Aktion für alle Alternativen gleich ist. Zum Beispiel „einfach ausgeben“. Hier nimmt die generic-Lambda const auto& und das reicht aus.
Hier ein kleines Beispiel (wichtig ist das Gefühl: visit wählt selbst, was übergeben wird):
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> v = std::string{"hello"};
std::visit([](const auto& x) {
std::cout << x << '\n'; // hello
}, v);
}
Was hier „anschaulich“ passiert: in v liegt eine std::string, also ruft visit Ihre Lambda mit std::string auf — ganz ohne manuelle Prüfungen und Typverzweigungen.
Aber sehr schnell trifft man die Realität: für verschiedene Typen braucht man unterschiedliche Verhaltensweisen. Eine Zahl will man als Zahl behandeln, einen String als Text, einen Fehler als Fehler. Und hier wird es interessant: Visitor durch Überladungen.
Überladene freie Funktionen + std::visit
Wenn ein Anfänger das Wort „Visitor“ hört, erwartet er manchmal magische Pattern-Lösungen wie „wir schreiben 12 Klassen und ein Interface und alles wird schlimmer“. In modernem C++ lässt sich ein Visitor für einfache Aufgaben leicht aus normalen überladenen Funktionen zusammensetzen — etwas, das Sie schon kennen.
Zuerst erstellen wir eine Reihe von Funktionen mit demselben Namen, aber unterschiedlichen Parametern:
#include <string>
int score(int x) {
return x;
}
int score(const std::string& s) {
return static_cast<int>(s.size());
}
Und nun verwenden wir std::visit, um automatisch die richtige Überladung aufzurufen:
#include <iostream>
#include <string>
#include <variant>
int score(int x);
int score(const std::string& s);
int main() {
std::variant<int, std::string> v = std::string{"abcd"};
int s = std::visit([](const auto& x) {
return score(x);
}, v);
std::cout << s << '\n'; // 4
}
Der psychologische Punkt ist: innerhalb der Lambda gibt es kein „Branching nach Typ“. Sie schreiben score(x), und die Überladung wird vom Compiler anhand des tatsächlichen Typs von x ausgewählt. Also sorgt visit dafür, dass der richtige Typ geliefert wird, und die Überladung sorgt für das richtige Verhalten.
Visitor als struct mit überladenem operator()
Manchmal ist es bequemer, den Handler nicht als Menge freier Funktionen, sondern als ein Objekt zu halten, das mehrere Überladungen von operator() besitzt. Dann sieht der Visitor wie eine „Reaktionstabelle“ aus: für diesen Typ so, für jenen anders.
Kehren wir zu unserem Mini-Projekt „TaskTracker“ zurück und definieren die Befehle:
#include <string>
struct CmdAdd { std::string text; };
struct CmdList {};
struct ParseError { std::string message; };
Machen wir ein variant:
#include <variant>
using Parsed = std::variant<CmdAdd, CmdList, ParseError>;
Und jetzt der Visitor-Handler:
#include <iostream>
struct PrintVisitor {
void operator()(const CmdAdd& c) const {
std::cout << "ADD: " << c.text << '\n';
}
void operator()(const CmdList&) const {
std::cout << "LIST\n";
}
void operator()(const ParseError& e) const {
std::cout << "ERROR: " << e.message << '\n';
}
};
Verwendung:
#include <variant>
void debug_print(const Parsed& p) {
std::visit(PrintVisitor{}, p);
}
Schön ist, dass wir nicht vergessen können, einen Typ zu behandeln. Wenn Sie morgen CmdDone hinzufügen und den Visitor nicht aktualisieren, erinnert Sie der Compiler daran. Und zwar sofort, nicht erst beim Benutzer in Produktion, der nur eine Aufgabe als erledigt markieren wollte.
Rückgabe eines Werts aus std::visit: ein einheitlicher Ergebnistyp
Häufig möchte man nicht nur „etwas tun“, sondern ein Resultat berechnen. Zum Beispiel jede Variante des variant in einen String umwandeln (ein einheitliches Format für Logs/Ausgabe), oder einen Rückgabecode erhalten, oder das „Gewicht“ eines Befehls berechnen.
std::visit ist ein Ausdruck und hat genau einen Ergebnistyp. Das bedeutet, alle Überladungen des Visitors müssen kompatible Typen zurückgeben (in der Regel identisch).
Machen wir eine Formatierung des Parse-Ergebnisses:
#include <string>
struct FormatVisitor {
std::string operator()(const CmdAdd& c) const {
return "add \\"" + c.text + "\\"";
}
std::string operator()(const CmdList&) const {
return "list";
}
std::string operator()(const ParseError& e) const {
return "error: " + e.message;
}
};
Anwenden:
#include <iostream>
#include <variant>
std::string format(const Parsed& p) {
return std::visit(FormatVisitor{}, p);
}
int main() {
Parsed p = ParseError{"unknown command"};
std::cout << format(p) << '\n'; // error: unknown command
}
Wenn Sie versehentlich eine Überladung haben, die std::string zurückgibt, und eine andere, die int zurückgibt, wird visit nicht „raten“, was Sie meinten. Er teilt Ihnen ehrlich mit: „Ich habe keinen einheitlichen Ergebnistyp“. Und das ist gut — sonst würde ein großer Teil der Sprache in Telepathie enden.
3. Beispiel: Mini-CLI-Task-Tracker
Parser, der std::variant zurückgibt
Jetzt bauen wir das Szenario so auf, dass klar wird, warum std::visit nicht nur „in der Theorie“, sondern in echtem Code nützlich ist. Stellen Sie sich eine Konsolenanwendung vor, die eine Zeile liest und versucht, einen Befehl zu erkennen. Wir bauen hier keinen perfekten Parser — die Architektur der Ergebnisverarbeitung ist wichtiger.
Angenommen, die Befehle sind:
add Aufgabenbeschreibung, list, und im Fehlerfall ein ParseError.
Ein sehr einfacher Parser (pädagogisch, ohne Heldentum):
#include <string>
#include <string_view>
Parsed parse_line(std::string_view line) {
if (line == "list") return CmdList{};
if (line.starts_with("add ")) return CmdAdd{std::string(line.substr(4))};
return ParseError{"expected 'list' or 'add <text>'"};
}
Nun speichert die „Engine“ der Anwendung die Aufgaben:
#include <string>
#include <vector>
struct Task {
std::string text;
};
using Tasks = std::vector<Task>;
Ausführen von Befehlen mittels Visitor mit Kontext
Und hier ist std::visit besonders praktisch: wir möchten einen Befehl ausführen, und für jeden Typ eine eigene Aktion definieren.
#include <iostream>
struct ExecVisitor {
Tasks& tasks;
void operator()(const CmdAdd& c) const {
tasks.push_back(Task{c.text});
std::cout << "added\n"; // added
}
void operator()(const CmdList&) const {
std::cout << "tasks=" << tasks.size() << '\n';
}
void operator()(const ParseError& e) const {
std::cout << "error: " << e.message << '\n';
}
};
Verarbeiten einer einzelnen Eingabezeile:
void process_line(Tasks& tasks, const std::string& line) {
Parsed p = parse_line(line);
std::visit(ExecVisitor{tasks}, p);
}
Beachten Sie eine kleine, aber wichtige Sache: der Visitor hält eine Referenz Tasks& tasks. Das ist ein normaler, nachvollziehbarer Weg, den Kontext der Verarbeitung zu übergeben. Sie brauchen keine globalen Variablen oder „magischen Singletons“. Der Visitor ist einfach ein Objekt.
Kleines Demo:
#include <iostream>
int main() {
Tasks tasks;
process_line(tasks, "add buy milk"); // added
process_line(tasks, "list"); // tasks=1
process_line(tasks, "wat"); // error: expected 'list' or 'add <text>'
}
So sieht eine „gesunde“ Verarbeitung von variant aus: der Parser liefert ein typisiertes Ergebnis, und der Executor verarbeitet es zentral über visit.
Warum das bequemer ist als Ketten von if/else if-Prüfungen mit get_if
Wenn Sie mit get_if arbeiten, denken Sie meist: „Ich prüfe ja ordentlich, also ist es sicher“. Ja, sicher — aber designmäßig fragil. Sobald ein neuer Ergebnistyp hinzukommt (neuer Befehl, neuer Fehlertyp), müssen Sie manuell alle Stellen aktualisieren, an denen Sie die Prüfungen gemacht haben.
Mit std::visit entsteht ein eher „vertraglicher“ Stil. variant sagt: „das sind all meine Alternativen“. Der Visitor antwortet: „ok, ich kann jede verarbeiten“. Und der Compiler fungiert als Notar: die Signaturen stimmen — alles ist in Ordnung.
Hilfreich ist folgendes Schema im Kopf (nicht formal, eher zur Veranschaulichung):
flowchart TD
A["Parsed (variant)"] -->|std::visit| B["Visitor"]
B --> C["operator()(CmdAdd)"]
B --> D["operator()(CmdList)"]
B --> E["operator()(ParseError)"]
visit macht buchstäblich die „Routing“-Aufgabe und ruft das richtige operator() auf.
4. Typische Fehler bei der Nutzung von std::visit
Fehler Nr. 1: der Visitor behandelt nicht eine der Alternativen des variant.
Das sieht so aus: Sie fügen einen neuen Typ zum std::variant hinzu, z. B. CmdDone, vergessen aber die Überladung operator()(const CmdDone&). Mit manuellen get_if-Prüfungen könnte das Programm vielleicht kompilieren und einfach stillschweigend nichts tun. Mit std::visit erhalten Sie einen Kompilierfehler — und das ist gut: besser, der Compiler meckert, als der Benutzer.
Fehler Nr. 2: innerhalb von visit wieder versuchen, Werte aus variant zu holen.
Manche schreiben einen Visitor, der const auto& x entgegennimmt und dann erneut std::get_if auf dem ursprünglichen variant aufruft. Das ist überflüssig: visit hat Ihnen bereits den konkreten Typ geliefert. Wenn Sie wieder in das variant hineingreifen, ist das so, als gingen Sie nach Lieferung der Pizza nochmal einkaufen — technisch möglich, aber unnötig.
Fehler Nr. 3: unterschiedliche Rückgabetypen in verschiedenen Überladungen.
Wenn eine Überladung std::string zurückgibt und eine andere ein int, kann der Compiler keinen einheitlichen Rückgabetyp für std::visit ableiten. In Übungsaufgaben passiert das oft, wenn man „in einem Zweig einen Fehlertext, im anderen eine Zahl“ zurückgeben will. Wählen Sie vorher einen gemeinsamen Ergebnistyp — meist std::string oder int (Codes), oder führen Sie die Verarbeitung ohne Rückgabe durch (void) und ändern/geben Sie Zustand innerhalb der Zweige.
Fehler Nr. 4: im Visitor Referenzen auf bereits zerstörte Objekte speichern.
Der Visitor ist ein Objekt; falls er eine Referenz auf Tasks& speichert, muss dieses Tasks länger leben als der Aufruf von std::visit. In unseren Beispielen ist das gegeben, weil Tasks in main lebt und der Visitor direkt vor dem visit erzeugt wird. Wenn Sie jedoch anfangen, den Visitor aus einer Funktion zurückzugeben oder ihn irgendwo zu speichern „für später“, können Lebenszeitprobleme auftreten. Eine einfache Regel auf diesem Level: erstellen Sie den Visitor nahe bei std::visit und machen Sie ihn nicht zu einem langlebigen Service.
Fehler Nr. 5: den Visitor zur „riesigen 500-Zeilen-Funktion“ aufblähen.
Es ist verführerisch: wenn visit die „einzige Verarbeitungsstelle“ ist, stopfen manche alles dort hinein. In der Praxis wird daraus wieder ein Monolith. Besser ist es, die Überladungen kurz zu halten und schwere Logik in normale Funktionen wie execute_add(tasks, cmd) oder print_error(err) auszulagern. Dann bleibt visit ein Dispatcher und kein Abladeplatz für Verantwortung.
GO TO FULL VERSION