Jak używać std::optional z C++17


2018-10-04, 00:00

W tym wpisie opiszę, czym jest std::optional - nowy type-helper dodany w C++17. Jest to klasa dekorująca tworzone przez nas typy oraz flaga wskazująca na to, czy obiekt tej klasy jest zainicjalizowany. Sprawdźmy zatem, gdzie ono może być pomocne oraz w jaki sposób możemy go używać.

Wprowadzenie

Poprzez dodanie flagi boolean-owej do innego typu możemy osiągnąć funkcjonalność nazywaną “nullable types”. Jak wspomniałem już wcześniej, flaga ta jest wykorzystywana do wskazywania, czy wartość jest dostępna, czy nie. Dekorator utworzonej przez nas klasy reprezentuje obiekt, który może być pusty w bardzo przejrzysty sposób.

Dopóki określamy wartość “null” używając predefiniowanych wartości (-1, infinity, nullptr), nie jest to tak jasne, co użycie klasy dekorującej. Alternatywnie, moglibyśmy użyć std::unique_ptr<Type> oraz traktować pustą wartość wskaźnika jako niezainicjalizowaną - to działa, ale niesie to koszt alokowania pamięci dla tworzonego obiektu.

Typy opcjonalne - które pochodzą ze świata programowania funkcyjnego - przynoszą bezpieczeństwo typu oraz ekspresyjność. Większość języków posiada podobny element: na przykład std::optional w Rust, Optional<T> w Java, Data.Maybe w Haskell’u.

Typy opcjonalne zostały dodane do języka C++ w standardzie C++17. Są one wzorowane na boost::optional, które jest dostępne w bibliotece boost od wielu lat. Od C++17 możemy po prostu zrobić #include <optional> i używać go.

Dekorator klasy wciąż jest typem przedstawiającym wartość, więc możemy go kopiować, używając mechanizmu kopiowania głębokiego. Co więcej, std::optional nie alokuje pamięci na stercie.

Mechanizm std::optional jest częścią typów słownikowych, do których należą również std::any, std::variant oraz std::string_view.

Kiedy warto użyć std::optional?

Zazwyczaj typów opcjonalnych można używać w następujących scenariuszach:

  • Kiedy chcesz w ładny sposób prezentować obiekty, które mogą mieć wartość pustą

    • Zamiast używania unikalnych wartości (takich jak -1, nullptr, NO_VALUE)
    • Przykład: drugie imię użytkownika może być opcjonalne. Moglibyśmy stwierdzić, że pusta wartość może spełniać swoją rolę, ale wiedza o tym, czy użytkownik wprowadził swoje drugie imię lub nie - może być dla nas ważna. Z std::optional<std::string> możemy mieć więcej informacji na ten temat.
  • Podczas zwracania wartości w funkcji liczącej (przetwarzającej), kiedy nie możemy wyprodukować wartości, ale nie jest to efekt błędu.

    • Na przykład podczas przeszukiwania słownika: jeżeli w słowniku nie ma elementu pod kluczem podamym przez użytkownika. To nie jest błąd, ale potrzebujemy poprawnie obsłużyć tą sytuację.
  • Do uzyskania efektu ładowania zasobów przy pierwszym żądaniu (lazy-load)

    • Na przykład w sytuacji, kiedy typ zasobu nie posiada konstruktora domyślego, a jego utworzenie jest kosztowne. Możemy zdefiniować go jako std::optional<Resource> oraz przekazywać go wewnątrz systemu, a załadować wtedy, kiedy jest to konieczne.
  • Do przekazywania opcjonalnych parametrów do funkcji.

Bardzo lubię, w jaki sposób użycie boost::optional zostało opisane w dokumentacji: Kiedy używać boost::optional

Jest rekomendowane, aby używać optional<T> w sytuacjach, kiedy jest tylko jeden jasny (dla wszystkich stron) powód do braku posiadania wartości dla typu T, oraz posiadanie braku wartości jest równie naturalne, co posiadanie wartości przez klasę T.

O ile czasami podjęcie decyzji o używaniu std::optional może być niejasne, to nie powinniśmy używać go do obsługi błędów. Należy wybierać takie sytuacje, kiedy to że wartość jest pusta jest normalnym zachowaniem się programu.

Prosty przykład użycia

Poniżej znajduje się prosty przykład, w jaki sposób możemy używać std::optional:

std::optional<std::string> UI::FindUserNick()
{
    if (nick_available)
        return { mStrNickName };

    return std::nullopt; // to samo co return { };
}

// użycie:
std::optional<std::string> UserNick = UI->FindUserNick();
if (UserNick)
    Show(*UserNick);

W powyższym listingu definiujemy funkcję, która zwraca typ opcjonalny ze stringiem. Jeżeli nick użytkownika jest dostępny, zostanie zwrócona wartość typu string. W przeciwnym wypadku zwrócona zostanie wartość nullopt. Następnie możemy zainicjalizować zwróconą wartość do obiektu typu std::optional i sprawdzić (dzięki bezpośredniej konwersji do typu bool), czy zwrócony obiekt posiada wartość. Dzięki zdefiniowanemu przez std::optional operatorowi operator* możemy dobrać się do przechowywanej wewnątrz wartości.

W następujących sekcjach dowiemy się, jak stworzyć std::optional, jak operować na nim, przekazywać oraz sprawdzimy, jaką wydajność niesie za sobą używanie go.

Tworzenie std::optional

Jest kilka sposobów na to, aby utworzyć std::optional:

// pusta wartość
std::optional<int> oEmpty;
std::optional<float> oFloat = std::nullopt;

// bezpośrednio
std::optional<int> oInt(10);
std::optional oIntDeduced(10); // dedukcja typu

// make_optional
auto oDouble = std::make_optional(3.0);
auto oComplex = std::make_optional<std::complex<double>>(3.0, 4.0);

// std::in_place
std::optional<std::complex<double>> o7{std::in_place, 3.0, 4.0};

// utworzy wektor bespośrednio zainicjalizowany wartościami {1, 2, 3}
std::optional<std::vector<int>> oVec(std::in_place, {1, 2, 3});

// kopiowanie/przekazywanie wartości:
auto oIntCopy = oInt;

Jak możemy zauważyć w powyższym kodzie, mamy sporą elastyczność przy tworzeniu typu opcjonalnego. Jest to bardzo proste dla typów prostych oraz znacznie uproszczone dla typów złożonych.

Bardzo interesująca jest konstrukcja “in_place”, zwłaszcza to, że tag std::in_place jest wspierany również przez std::any oraz std::variant.

Na przykład, możemy napisać:

// https://godbolt.org/g/FPBSak
struct Point
{
    Point(int a, int b) : x(a), y(b) { }

    int x;
    int y;
};

std::optional<Point> opt{std::in_place, 0, 1};
// vs
std::optional<Point> opt{{0, 1}};

To oszczędza nam tworzenia tymczasowego obiektu klasy Point.

Zwracanie std::optional

Jeżeli w funkcji zwracamy std::optional, wygodnym staje się zwracanie wartości std::nullopt lub wartości obliczonej przez naszą funkcję.

std::optional<std::string> TryParse(Input input)
{
    if (input.valid())
        return input.asString();

    return std::nullopt;
}

W powyższym przykładzie możemy zauważyć, że zwracany jest rezultat wyrażenia input.asString(), czyli obiekt typu std::string, który następnie jest dekorowany przez std::optional. Jeżeli nie możemy zwrócić wartości, powinniśmy zwrócić wartość pustą std::nullopt.

Oczywiście, możemy również deklarować pusty std::optional na początku funkcji, a jeżeli możemy zwrócić wartość, to nadpisujemy tą zmienną. Możemy w takim razie zamienić powyższy przykład na:

std::optional<std::string> TryParse(Input input)
{
    std::optional<std::string> oOut; // pusta wartość

    if (input.valid())
        oOut = input.asString();

    return oOut;
}

To, która wersja jest lepsza zależy od obecnego kontekstu. Ja preferuję krótkie funkcje, więc wybrałbym pierwszą opcję (z wielokrotnym returnem).

Dostęp do przechowywanej wartości

Prawdopodobniej najważniejszą operacją dla typów opcjonalnych (poza ich tworzeniem) jest to, w jaki sposób możemy dostać się do przechowywanej przez nie wartości.

Mamy tutaj kilka opcji:

  • operator* oraz operator->- podobne do iteratorów. Jeżeli std::optional nie przechowuje wartości, to zachowanie tych operatorów jest niezdefiniowane!
  • value() - zwraca wartość lub rzuca wyjątek typu std::bad_optional_access
  • value_or(defaultVal) - zwraca wartość jeżeli ta jest dostępna. W przeciwnym wypadku zwraca wartość podaną w parametrze (defaultVal).

Aby sprawdzić, czy wartość jest dostępna, możemy użyć metody has_value() lub skonstruować warunek if (optional), ponieważ std::optional potrafi być automatycznie konwertowane do typu bool.

Tutaj mamy przykład:

// przez operator*
std::optional<int> oint = 10;
std::cout<< "oint " << *opt1 << '\n';

// przez value()
std::optional<std::string> ostr("hello");
try
{
    std::cout << "ostr " << ostr.value() << '\n';
}
catch (const std::bad_optional_access& e)
{
    std::cout << e.what() << "\n";
}

// przez value_or()
std::optional<double> odouble; // pusta wartość
std::cout<< "odouble " << odouble.value_or(10.0) << '\n';

Zatem pamiętajmy, by zawsze na początku sprawdzić, czy wartość jest dostępna, a następnie ją pobierajmy.

// obliczamy wartość typu std::string
std::optional<std::string> maybe_create_hello();
// ...

if (auto ostr = maybe_create_hello(); ostr)
    std::cout << "ostr " << *ostr << '\n';
else
    std::cout << "ostr is null\n";

Możliwe operacje na std::optional

Spójrzmy, co jeszcze możemy zrobić z naszym typem opcjonalnym:

Zmiana wartości

Jeżeli mamy istniejący już obiekt typu std::optional, to w łatwy sposób możemy zmienić przechowywaną przez niego wartość używając takich funkcji jak emplace, reset, swap oraz assign. Jeżeli zainicjujesz (lub zresetujesz) obiekt wartością nullopt to w przechowywanym wewnątrz obiekcie zostanie wywołany destruktor.

Tutaj drobne podsumowanie:

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

class UserName
{
public:
    explicit UserName(std::string str) : mName(std::move(str))
    {
        std::cout << "UserName::UserName(\'";
        std::cout << mName << "\')\n";
    }
    ~UserName()
    {
        std::cout << "UserName::~UserName(\'";
        std::cout << mName << "\')\n";
    }

private:
    std::string mName;
};

int main()
{
    std::optional<UserName> oEmpty;

    // emplace:
    oEmpty.emplace("Steve");

    // wywołuje ~Steve i tworzy obiekt Mark:
    oEmpty.emplace("Mark");


    // resetujemy, zatem obiekt będzie pusty
    oEmpty.reset(); // wywołuje ~Mark
    // to samo co:
    //oEmpty = std::nullopt;

    // przypisanie nowej wartości:
    oEmpty.emplace("Fred");
    oEmpty = UserName("Joe");
}

Powyższy kod jest dostępny pod linkiem: @Coliru

Porównywanie

Typ std::optional zezwala nam na porównywanie przechowywanych obiektów w prawie “normalny” sposób (istnieje kilka wyjątków dla wartości nullopt). Spójrzcie poniżej:

#include <optional>
#include <iostream>

int main()
{
    std::optional<int> oEmpty;
    std::optional<int> oTwo(2);
    std::optional<int> oTen(10);

    std::cout << std::boolalpha;
    std::cout << (oTen > oTwo) << "\n";
    std::cout << (oTen < oTwo) << "\n";
    std::cout << (oEmpty < oTwo) << "\n";
    std::cout << (oEmpty == std::nullopt) << "\n";
    std::cout << (oTen == 10) << "\n";
}

Ten kod wygeneruje nam:

true  // (oTen > oTwo)
false // (oTen < oTwo)
true  // (oEmpty < oTwo)
true  // (oEmpty == std::nullopt)
true  // (oTen == 10)

Możecie sprawdzić ten kod, klikając link: @Coliru

Więcej przykładów z użyciem std::optional

Łapcie dwa odrobinę obszerniejsze przykłady, gdzie typ opcjonalny pasuje idealnie.

Użytkownik z opcjonalnym nickiem oraz wiekiem

#include <optional>
#include <iostream>

class UserRecord
{
public:
    UserRecord (std::string name, std::optional<std::string> nick, std::optional<int> age)
    : mName{std::move(name)}, mNick{nick}, mAge{age}
    {
    }

    friend std::ostream& operator << (std::ostream& stream, const UserRecord& user);

private:
    std::string mName;
    std::optional<std::string> mNick;
    std::optional<int> mAge;

};

std::ostream& operator << (std::ostream& os, const UserRecord& user) 
{
    os << user.mName << ' ';
    if (user.mNick) {
        os << *user.mNick << ' ';
    }
    if (user.mAge)
        os << "age of " << *user.mAge;

    return os;
}

int main()
{
    UserRecord tim { "Tim", "SuperTim", 26 };
    UserRecord nano { "Nathan", std::nullopt, std::nullopt };

    std::cout << tim << "\n";
    std::cout << nano << "\n";
}

Kod dostępny jest pod linkiem: @Coliru

Parsowanie integerów z poziomu terminala

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

std::optional<int> ParseInt(char*arg)
{
    try 
    {
        return { std::stoi(std::string(arg)) };
    }
    catch (...)
    {
        std::cout << "cannot convert \'" << arg << "\' to int!\n";
    }

    return { };
}

int main(int argc, char* argv[])
{
    if (argc >= 3)
    {
        auto oFirst = ParseInt(argv[1]);
        auto oSecond = ParseInt(argv[2]);

        if (oFirst && oSecond)
        {
            std::cout << "sum of " << *oFirst << " and " << *oSecond;
            std::cout << " is " << *oFirst + *oSecond << "\n";
        }
    }
}

Kod ten jest dostępny pod linkiem: @Coliru

Powyższy kod używa std::optional w celu wskazania, czy konwersja nastąpiła, czy nie. Zwróćmy uwagę na to, że zamieniliśmy obsługę wyjątków na typy opcjonalne, zatem ominęliśmy potencjalne problemy, które mogły wystąpić. Takie rozwiązanie budzi wiele kontrowersji, ponieważ zazwyczaj o błędach powinniśmy komunikować.

Kolejne przykłady

  • Reprezentowanie rekordów opcjonalnych. Na przykład obiekt zalogowanego użytkownika. Lepiej jest napisać std::optional<Key> niż użyć komentarza, np. // wartość 0x7788 oznacza brak użytkownika lub coś podobnego :)
  • Zwracanie wartości dla funkcji szukającej Find*() (zakładam, że nie obsługujesz błędów połączenia z bazą danych, błędu zapytania i podobnych).

Wydajność oraz narzut pamięci

Kiedy używamy std::optional, musimy liczyć się ze zwiększonym zużyciem pamięci. Jest to koszt conajmniej jednego dodatkowego bajtu.

Koncepcyjnie, nasza implementacja biblioteki standardowej mogłaby wyglądać następująco:

template <typename T>
class optional
{
  bool _initialized;
  std::aligned_storage_t<sizeof(t), alignof(T)> _storage;

public:
   // operacje...
};

W skrócie std::optional jedynie dekoruje nasz typ, przygotowując przestrzeń dla niego, a następnie dodaje parametr typu bool. To znaczy, że rozszerza nasz typ według zasad wyrównania (pamięci).

Na reddicie pojawił się komentarz do tej konstrukcji: “Żadna biblioteka standardowa nie może implementować opcjonalnego typu w taki sposób (potrzebują użyć uni z powodu constexpr)”. Zatem powyższy przykład jest jedynie poglądową implementacją.

Wyrównanie pamięci jest ważne, co definiuje standard:

Class template optional [optional.optional]:
The contained value shall be allocated in a region of the optional storage suitably aligned for the type T.

Na przykład:

// sizeof(double) = 8
// sizeof(int) = 4
std::optional<double> od; // sizeof = 16 bajtów
std::optional<int> oi; // sizeof = 8 bajtów

Dopóki typ bool zajmuje tylko jeden bajt, dopóty typ opcjonalny musi respektować zasady związane z zasadami wyrównania, co czyni całą klasę dekorującą większą wyłącznie o sizeof(YourType) + 1 bajt.

Na przykład, jeżeli mamy typ:

struct Range
{
    std::optional<double> mMin;
    std::optional<double> mMax;
};

to zabierze on więcej przestrzeni, niż jeżeli zwykłego typu:

struct Range
{
    bool mMinAvailable;
    bool mMaxAvailable;
    double mMin;
    double mMax;
};

W pierwszym przykładzie zużywamy aż 32 bajty! Druga opcja zużywa jedynie 24 bajty.

Przetestuj ten kod używając narzędzia Compiler Explorer

Świetny opis wydajności oraz zasobności pamięciowej znajduje się w dokumentacji biblioteki boost: Performance considerations - 1.67.0.

Również we wpisie Efficient optional values | Andrzej’s C++ blog autor porusza temat tego, jak napisać własny dekorator typów opcjonalnych, który może być odrobinę szybszy.

Zastanawiam się, czy jest jakaś szansa, aby poczarować trochę z kompilatorem i spróbować zaoszczędzić pamięć, wrzucając flagę inicjalizacji wewnątrz dekorowanego typu. Gdyby było to możliwe, to nie ponosilibyśmy żadnych dodatkowych kosztów pamięciowych

Migrowanie z boost::optional

Typ std::optional został zaciągnięty bezpośrednio z boost::optional, zatem wydajność obydwu rozwiązań jest taka sama. Przeprowadzka z jednego rozwiązania na drugie powinna być prosta, ale oczywiście, z drobnymi różnicami.

W artykule: N3793 - A proposal to add a utility class to represent optional objects (Revision 4) - z 2013-10-03 znalazłem taką tabelę (próbowałem ją nieco zaktualizować)¹:

aspect std::optional boost::optional (as of 1.67.0)
Move semantics yes no yes in current boost
noexcept yes no yes in current boost
hash support yes no
a throwing value accessor yes yes
literal type (can be used in constexpr expressions) yes no
in place construction `emplace`, tag `in_place` emplace(), tags in_place_init_if_t, in_place_init_t, utility in_place_factory
disengaged state tag nullopt none
optional references no yes
conversion from optional<U> to optional<T> yes yes
explicit convert to ptr (get_ptr) no yes
deduction guides yes no

Szczególne przypadki

O ile możemy używać std::optional dla każdego typu, szczególną uwagę należy poświęcić na dekorowanie typów bool oraz wskaźników.

Opcja std::optional<bool> ob - co ona przedstawia? Ta konstrukcja pozwala nam na skonstruowanie zmiennej trójwartościowej. Jeżeli na prawdę tego potrzebujemy, to chyba lepiej jest używać prawdziwego typu trzystanowego takiego jak boost::tribool.

Co więcej, bardzo mylące może być używanie tego typu, ponieważ ob jest konwertowane do typu bool, jeżeli w środku znajduje się wartość.

Podobnie ma się rzecz w związku ze wskaźnikami:

// nie róbcie tak, to tylko przykład!
std::optional<int*> opi { new int(10) };
if (opi && *opi)
{
   std::cout << **opi << std::endl;
   delete *opi;
}
if (opi)
    std::cout << "opi is still not empty!";

Wskaźnik do typu int z natury obsługuje wartości null-owe, zatem opakowywanie ich w std::optional powoduje jedynie dodatkowe zamieszanie.

Podsumowanie

Uff… ! Tak dużo treści o std::optional, a to jeszcze nie wzystko :)

Dotychczas omówiliśmy podstawowe użycie, tworzenie oraz operacje na tym tak pomocnym typie dekoracyjnym. Wierzę, że mamy wiele przypadków użycia std::optional, wiele więcej niż podczas używania predefiniowanych wartości reprezentujących typy nullowe.

Chciałbym, abyśmy zapamiętali następujące rzeczy o std::optional:

  • std::optional jest dekoratorem typu wyrażającym typy bez wartości
  • std::optional nie używa dynamicznej alokacji pamięci
  • std::optional zawiera wartość, lub jest pusty
    • użycie operator *, operator->, value() lub value_or() w celu dostępu do przechowywanej wartości.
  • std::optional jest bezpośrednio konwertowane do typu bool, dzięki czemu możemy wstawić opcjonalne typy do konstrukcji if.

¹ Tabelka pozostawiona w formie oryginalnej, ponieważ lepiej się ją czyta w takiej postaci.



Bartłomiej Filipek

Programista i pasjonat C++ z ponad 11-letnim doświadczeniem. Bloguje od wielu lat, głównie o naszym ulubionym języku programowania. Autor ksiązki C++17 In Detail.

Blog Bartka
Profil na LinkedIn
Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.