Eine kleiner Einblick in std::optional

C++17 ist jetzt schon ein paar Monde alt und ich hatte die Zeit mich ein wenig intensiver damit zu beschäftigen. Vorab: Das hier ist keine spezielle Erklärung für std::optional allein. Dieses Pattern gibt es in ähnlicher oder sogar identischer Form auch in anderen Bibliotheken (z.B. boost::optional).

Was bedeutet optional?

Prinzipiell ist es eine zusätzliche Abstraktionsebene zu einem reinen Wert. Letztendlich dient es vor allem dazu, den Code selbst-dokumentierend zu gestalten. Nehmen wir zum Beispiel eine Funktion die einen 8 bit integer zurück gibt. int8_t myFunction(); Der Autor dieser Funktion hatte vorgesehen, dass die Werte 0 – 127 (also alle nicht negativen) gültige Werte sind; -1 ist der default „irgendwas ist falsch gelaufen“ Wert. Nun, soweit ist das auch kein Problem, nur was ist, wenn jemand fremdes den Code benutzt? Als Autor einer Bibliothek  muss man in jedem Fall dafür Sorge tragen, dass dieses Verhalten dieser Funktion irgendwo dokumentiert wird – notfalls auch direkt im Code!

// Werte 0 - 127 sind gültig; -1 ist ein error Code
int8_t myFunction()

Danach muss der Nutzer der Funktion die Dokumentation lesen und verstehen, und dessen – zumindest das er es ließt – kann der Autor sich nicht sicher sein. Wäre es da nicht besser, wir hätten ein Objekt, dass direkt anzeigt, ob es einen korrekten Wert enthält, oder nicht?

Und hier kommt optional ins Spiel. std::optional<int8_t> myFunction(); Nun hat man als Nutzer der Funktion die Möglichkeit, eine simple Abfrage zu nutzen.

if (auto value = myFunction())
{
    // do something ...
}

Hiermit ist sofort semantisch ersichtlich, dass die Funktion auch ungültige Werte zurück liefern kann.

An den eigentlich Wert von value kommen wir entweder mit dem operator * und mit der value() Methode oder dem operator ->.

if (auto value = myFunction())
{
    std::cout << *value;
}

 

Die Idee an sich ist nicht neu. Um es nochmal ein wenig zu verdeutlichen, hier ein wenig Code:

struct Vector
{
    int x;
    int y;
}

Vector getSomeFancyVector();

Das ist etwas, was – zumindest bei mir – relativ häufig auftritt. Ich lasse mir einen Vector zurück geben; soweit nichts spektakuläres. Wenn die Funktion allerdings auch ungültige Vectoren zurück liefern soll, dann wird es knifflig. Da in einem üblichen Koordinatensystem negative Werte auch zum Gültigkeitsbereich gehören, haben wir über das Objekt an sich keinerlei Möglichkeit zu identifizieren ob der Vector nun gültig ist oder nicht.

Es gab hier verschiedene Ansätze; ich hab schon vieles gesehen. Das eine mehr, das andere weniger gut (kommt natürlich immer auf den Kontext an).

Vector* getSomeFancyVector();                 // (1) ist ok, wenn der Aufrufer nicht für das Aufräumen verantwortlich ist

std::unique_ptr<Vector> getSomeFancyVector(); // (2) gibt den Besitz an den Aufrufer ab; benutzt aber unnötigerweise den Heap

std::pair<bool, Vector> getSomeFancyVector(); // (3) emulieren von std::optional

Generell wende ich alle Methoden mehr oder weniger häufig an; je nach Anwendungsfall. Prinzipiell ist std::optional eine semantische Verbesserung zu (3), mit ein wenig mehr Kapselung (das Objekt regelt selbst, ob es gesetzt wurde oder nicht). Das sind meine Kriterien, wann ich ein std::optional nutze:

  • es kann ein ungültiger Zustand entstehen, der semantisch nicht durch das Objekt vom Typ T erkennbar ist (Beispielsweise bieten manche Klasse isEmpty(), isNull(), isValid() Methoden an)
  • der Besitz soll an den Aufrufer übertragen werden
  • T ist kein polymorpher Typ

Für unser Beispiel mit Vector trifft das alles zu, daher bietet es sich an, std::optional zu nutzen.

struct Vector
{
    int x;
    int y;
}
std::optional<Vector> getSomeFancyVector();


if (auto vec = getSomeFancyVector())
    std::cout << " Vector hat die position x: " << vec->x << " y: " << vec->y;
else
    std::cout << "Vector ist invalid";

 

Under the hood:

Intern ist es eigentlich relativ unspektakulär. Neben der Variabel T, hält optional noch einen bool. Ist std::optional durch den default Constructor erstellt worden, ist er false; wurde ihm ein Wert zugewiesen (bei der Erstellung oder Zuweisung) ist er true.

Wir haben also einen bool Overhead je optional Objekt und eine zusätzliche Indirektion; das gilt es zu wissen!

Zusätzliche Vorsicht ist geboten, wenn man auf das Objekt T eines std::optional zugreifen möchte. Ist das Objekt bisher nicht zugewiesen worden (also das interne bool von optional auf false), ist es undefiniertes Verhalten mit den beiden operatoren * und -> darauf zu zugreifen. Im debug Mode wird man durch assertions unterstützt, im release Mode hingegen kann es zu sehr schwer nachvollziehbaren Fehlern kommen. Es ist also immer erforderlich, std::optional (zumindest einmalig) auf Gültigkeit zu überprüfen. Mit der value() Methode hingegen hat man den Schutz durch Exceptions; allerdings auch permanent eine interne Bedingung ob das optional Objekt nun gültig ist oder nicht, was einen entsprechenden Overhead mit sich bringt (Exceptions sind hier von Compiler zu Compiler zu unterschiedlich, als dass sich eine pauschale Aussage treffen lässt).

 

Andere Anwendungsfälle:

Natürlich lassen sich noch viele andere Anwendungsfälle finden; man ist hier nicht rein auf return Werte beschränkt.

void doSomeFancyWork(std::optional<Vector> _vec)
{
    std::cout "Ausführung von \"doSomeFancyWork\" an ";
    if (_vec)
        std::cout << "Position x: " << _vec->x << " y: " << _vec->y << std::endl;
    else
        "keiner Position";

    // ...
}

Wir haben hier eine Funktion, die einen optionalen Parameter erhalten kann. Das Beispiel ist natürlich nicht sonderlich komplex oder gar sinnvoll; soll hier allerdings auch nur dazu dienen das Prinzip zu verdeutlichen. Es ist eine Alternative zu dieser, durchaus gängigen Methode:

void doSomeFancyWork(const Vector* _vec)
{
    std::cout "Ausführung von \"doSomeFancyWork\" an ";
    if (_vec)
        std::cout << "Position x: " << _vec->x << " y: " << _vec->y << std::endl;
    else
        "keiner Position";

    // ...
}

Prinzipiell sollte klar sein, was hier passiert. Allerdings ist hier die Gefahr, dass der Aufrufer fälschlicherweise an nimmt, dass er den Besitz des Vectors an die Funktion übergibt und den Vector deswegen unnötiger Weise auf dem Heap erstellt und danach nicht mehr löscht. Das ist mit optional eindeutiger. Der Nachteil ist jedoch die zusätzliche Kopie; die entfällt bei richtiger Anwendung der zweiten Methode.

Es macht allerdings keinen Sinn, jetzt alles und jeden bei jeder Gelegenheit optional zu deklarieren; wie jedes Pattern auch, soll es semantisch unterstützen und Code klarer, wartbarer und fehlerfreier gestalten. Ein übermäßiger Einsatz hätte nicht nur Performance Nachteile, sondern wäre auch semantisch mehr als falsch (weil es kann eben nicht alles optional sein!). Setzt es lediglich dort ein, wo es auch Sinn macht.

 

Abschließend möchte ich noch erwähnen, dass es auch für die nicht C++17 Nutzer eine Möglichkeit gibt, std::optional zu nutzen. Die gängigsten Compiler Hersteller haben den experimental namespace eingefügt; dort kann man optional finden, sollte man kein C++17 benutzen (warum auch immer, das Update lohnt sich!).

#include <experimental/optional>
std::experimental::optional<T>

Quellen:

http://en.cppreference.com/w/cpp/experimental/optional
http://en.cppreference.com/w/cpp/utility/optional