C++23: Eine neue Art der Fehlerbehandlung mit std::expected

C++23 erweitert die Schnittstelle von std::optional und führt den neuen Datentyp std::expected für die Fehlerbehandlung ein.

In Pocket speichern vorlesen Druckansicht 4 Kommentare lesen

(Bild: SocoXbreed/shutterstock.com)

Lesezeit: 6 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Der Datentyp std::optional steht seit C++17 zur Verfügung. Mit C++23 erhält er eine erweiterte monadische Schnittstelle, die ich im Folgenden näher vorstelle. Doch zuvor werfen wir einen kurzen Blick zurück auf den Datentyp.

Der Datentyp std::optional ist sehr praktisch für Berechnungen wie z.B. Datenbankabfragen, die ein Ergebnis haben können. Dieser Datentyp benötigt den Header <optional>.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Mit den verschiedenen Konstruktoren und der Funktion std::make_optional lässt sich einfach ein optionales Objekt opt, mit oder ohne einen Wert definieren. opt.emplace konstruiert den enthaltenen Wert an Ort und Stelle und opt.reset zerstört den Containerwert. Du kannst einen std::optional-Container explizit fragen, ob er einen Wert hat, oder du kannst ihn in einem logischen Ausdruck überprüfen. opt.value gibt den Wert zurück, und opt.value_or gibt den Wert oder einen Defaultwert zurück. Wenn opt keinen Wert enthält, löst der Aufruf opt.value eine std::bad_optional_access-Ausnahme aus.

Hier ein einfaches Beispiel mit std::optional:

// optional.cpp

#include <optional>
#include <iostream>
#include <vector>

std::optional<int> getFirst(const std::vector<int>& vec){
  if ( !vec.empty() ) return std::optional<int>(vec[0]);
  else return std::optional<int>();
}

int main() {

    std::cout << '\n';
    
    std::vector<int> myVec{1, 2, 3};
    std::vector<int> myEmptyVec;
    
    auto myInt= getFirst(myVec);
    
    if (myInt){
        std::cout << "*myInt: "  << *myInt << '\n';
        std::cout << "myInt.value(): " << myInt.value() << '\n';
        std::cout << "myInt.value_or(2017):" << myInt.value_or(2017) << '\n';
    }
    
    std::cout << '\n';
    
    auto myEmptyInt= getFirst(myEmptyVec);
    
    if (!myEmptyInt){
        std::cout << "myEmptyInt.value_or(2017):" << myEmptyInt.value_or(2017) << '\n';
    }

    std::cout << '\n';

}

Ich verwende std::optional in der Funktion getFirst. getFirst gibt das erste Element zurück, wenn es existiert. Wenn nicht, erhältst du ein std::optional-Objekt. Die main-Funktion hat zwei Vektoren. Beide rufen getFirst auf und geben ein std::optional-Objekt zurück. Im Fall von myInt hat das Objekt einen Wert; im Fall von myEmptyInt hat das Objekt keinen Wert. Das Programm zeigt den Wert von myInt und myEmptyInt an. myInt.value_or(2017) gibt den Wert zurück, aber myEmptyInt.value_or(2017) gibt den Defaultwert zurück.

Hier die Ausgabe des Programms:

In C++23 wird std::optional um die monadischen Operationen opt.and_then, opt.transform und opt.or_else erweitert.

  • opt.and_then gibt das Ergebnis des angegebenen Funktionsaufrufs zurück, wenn es existiert, ansonsten ein leeres std::optional.
  • opt.transform gibt einen std::optional zurück, der den transformierten Wert enthält, oder einen leeren std::optional.
  • opt.or_else gibt das std::optional zurück, wenn es einen Wert enthält, oder andernfalls das Ergebnis der angegebenen Funktion.

Diese monadischen Operationen ermöglichen die Komposition von Operationen auf std::optional:

// optionalMonadic.cpp

#include <iostream>
#include <optional>
#include <vector>
#include <string>

std::optional<int> getInt(std::string arg) {
    try {
        return {std::stoi(arg)};
    }
    catch (...) {
        return { };
    }
}

 
int main() {
 
    std::cout << '\n'; 

    std::vector<std::optional<std::string>> strings = {"66", "foo", "-5"};

    for (auto s: strings) {
        auto res = s.and_then(getInt)
                  .transform( [](int n) { return n + 100;})
                  .transform( [](int n) { return std::to_string(n); })
                  .or_else([] { return std::optional{std::string("Error") }; });
        std::cout << *res << ' ';
    }

    std::cout << '\n';

}

Die range-based for-Schleife iteriert durch den std::vector<std::optional<std::string>>. Zuerst wandelt die Funktion getInt jedes Element in eine Ganzzahl um, addiert 100 dazu, wandelt es wieder in einen String um und zeigt es schließlich an. Wenn die anfängliche Umwandlung in int fehlschlägt, wird der String Error zurückgegeben und angezeigt.

std::expected unterstützt bereits die monadische Schnittstelle.

std::expected<T, E> bietet eine Möglichkeit, einen von zwei Werten zu speichern. Eine Instanz von std::expected enthält immer einen Wert: entweder den erwarteten Wert vom Typ T oder den unerwarteten Wert vom Typ E. Dieser Vokabeltyp benötigt den Header <expected>. Dank std::expected kannst du Funktionen implementieren, die entweder einen Wert oder einen Fehler zurückgeben. Der gespeicherte Wert wird direkt innerhalb des vom erwarteten Objekt belegten Speichers zugewiesen. Es findet keine dynamische Speicherzuweisung statt.

std::expected besitzt eine ähnliche Schnittstelle wie std::optional. Im Gegensatz zu std::optional kann std::exptected eine Fehlermeldung zurückgeben.

Mit den verschiedenen Konstruktoren kannst du ein erwartetes Objekt exp mit einem erwarteten Wert definieren. exp.emplace konstruiert den enthaltenen Wert an Ort und Stelle. Du kannst einen std::expected-Container explizit fragen, ob er einen Wert hat, oder du kannst ihn in einem logischen Ausdruck überprüfen. exp.value gibt den erwarteten Wert zurück, und exp.value_or gibt den erwarteten Wert oder einen Defaultwert zurück. Wenn exp einen unerwarteten Wert hat, löst der Aufruf exp.value eine std::bad_expected_access-Ausnahme aus.

std::unexpected steht für den unerwarteten Wert, der in std::expected gespeichert ist.

// expected.cpp

#include <iostream>
#include <expected>
#include <vector>
#include <string>

std::expected<int, std::string> getInt(std::string arg) {
    try {
        return std::stoi(arg);
    }
    catch (...) {
        return std::unexpected{std::string(arg + ": Error")};
    }
}

 
int main() {

    std::cout << '\n';

    std::vector<std::string> strings = {"66", "foo", "-5"};

    for (auto s: strings) {                                 // (1)
        auto res = getInt(s);
        if (res) {
            std::cout << res.value() << ' ';                // (3)
        }
        else {
            std::cout << res.error() << ' ';                // (4)
        }
    }

    std::cout << '\n';

    for (auto s: strings) {                                 // (2)
        auto res = getInt(s);
        std::cout << res.value_or(2023) << ' ';             // (5)
    }

    std::cout << '\n';

}

Die Funktion getInt wandelt jeden String in eine Ganzzahl um und gibt einen std::expected<int, std::string> zurück. int steht für den erwarteten und std::string für den unerwarteten Wert. Die beiden range-based for-Schleifen (Zeilen 1 und 2) iterieren durch den std::vector<std::string>. In der ersten range-based for-Schleife (Zeile 1) wird der erwartete (Zeile 3) oder der unerwartete Wert (Zeile 4) angezeigt. In der zweiten range-based for-Schleife (Zeile 2) wird der erwartete oder der Defaultwert 2023 (Zeile 5) angezeigt.

std::exptected unterstützt monadische Operationen zur bequemen Komposition von Funktionen: exp.and_then, exp.transform, exp.or_else und exp.transform_error.

  • exp.and_then gibt das Ergebnis des angegebenen Funktionsaufrufs zurück, wenn es existiert, oder einen leeren std::expected.
  • exp.transform gibt einen std::expected zurück, der den umgewandelten Wert enthält, oder einen leeren std::expected.
  • exp.or_else gibt den std::exptected zurück, wenn er einen Wert enthält, oder andernfalls das Ergebnis der angegebenen Funktion.
  • exp.transform_error gibt den expected-Wert des std::expected zurück, falls er existiert. Falls nicht, gibt es den transformierten unexpected-Wert des std::expected zurück.

Das folgende Programm basiert auf dem vorherigen Programm optionalMonadic.cpp. Im Wesentlichen habe ich den Datentyp std::optional durch std::expected ersetzt.

// expectedMonadic.cpp

#include <iostream>
#include <expected>
#include <vector>
#include <string>


std::expected<int, std::string> getInt(std::string arg) {
    try {
        return std::stoi(arg);
    }
    catch (...) {
        return std::unexpected{std::string(arg + ": Error")};
    }
}
 
int main() {

    std::cout << '\n';

    std::vector<std::string> strings = {"66", "foo", "-5"};

    for (auto s: strings) {
        auto res = getInt(s)
                   .transform( [](int n) { return n + 100; })
                   .transform( [](int n) { return std::to_string(n); });
        std::cout << *res << ' ';        
    }   

    std::cout << '\n';                                  
                                        
}

Die range-based for-Schleife iteriert durch den std::vector<std::string>. Zuerst wandelt die Funktion getInt jeden String in eine Ganzzahl um, addiert 100 dazu, wandelt sie wieder in eine Zeichenkette zurück und zeigt sie schließlich an. Wenn die anfängliche Umwandlung in eine Ganzzahl fehlschlägt, wird die Zeichenkette arg + ": Error" zurückgegeben und angezeigt.

Die vier assoziativen Container std::flat_map, std::flat_multimap, std::flat_set und std::flat_multiset in C++23 sind ein einfacher Ersatz für die geordneten assoziativen Container std::map,std::multimap, std::set und std::multiset. In C++23 haben wir sie aus einem Grund: Performanz. (map)