Czym jest std::variant?


2018-07-30, 00:00

Silne typowanie to, w rękach wprawnego programisty, potężne narzędzie pozwalające na szybsze znajdowanie poszczególnych rodzajów błędów. Niepoprawne używanie pamięci, jak np. traktowanie łańcuchów znaków jako liczb, staje się niemożliwe, a problemy tego rodzaju są wykrywane zanim
program zostanie uruchomiony.

Problem

Spójrzmy na następujący fragment JSONa:

hello: [
    1337,
    "witaj",
    [1, 2, "o nie"]
]

W jaki sposób obsłużyć taką strukturę w C++? Musimy dokładnie znać typ danych już w czasie kompilacji, więc nasza praca jest trochę utrudniona.

Rozwiązania

Przechowywanie wszystkiego w stringu

Każdy widział to w życiu co najmniej raz.

std::string value;

Przechowywanie wszystkiego jako łańcuch znaków jakoś działa, ale jest dalekie od optymalnego - zarówno jeśli chodzi o zajętość pamięci, potrzebnej do przechowania wszystkiego, co stringiem nie jest, jak i złożoności obliczeniowej przy parsowaniu takich danych za każdym razem, kiedy zachodzi potrzeba odniesienia się do tak przechowywanej wartości. A to tylko wierzchołek góry lodowej jeśli chodzi o potencjalne problemy (np. jak określić typ przechowywanej wartości?)

To, krótko mówiąc, średnie rozwiązanie, więc spróbujmy czegoś innego.

Przechowywanie wszystkiego w klasie, która zawiera każdy typ

Dość szybko można wpaść na pomysł stworzenia klasy, która zawierałaby każdy z możliwych typów i wiedziała, który jest faktycznie używany:

class MyData {
  enum class Type { NUM, STR, VEC };

 public:
  MyData(int i_) : i(i_), type(Type::NUM) {}
  MyData(const std::string& s_) : s(s_), type(Type::STR) {}
  MyData(const std::vector<MyData>& v_) : v(v_), type(Type::VEC) {}

  int& getInt() {
    assert(Type::NUM == type);
    return i;
  };

  std::string& getString() {
    assert(Type::STR == type);
    return s;
  };

  std::vector<MyData>& getVec() {
    assert(Type::VEC == type);
    return v;
  };

 private:
  int i;
  std::string s;
  std::vector<MyData> v;
  Type type;
};

To dużo pisania i dużo potencjalnych problemów. Jest też dość duży narzut pamięciowy - każdy taki obiekt zawiera obiekty każdego z możliwych typów, nawet, jeśli tylko jeden może być w danym momencie użyty.

Unia

Ta konstrukcja wygląda dość podobnie do struktury, ale zamiast przechowywać wszystkie swoje elementy składa się z tylko jednego z nich.

union A {
  int i;
  std::string s;     // od C++11
  std::vector<A> v;  // od C++11
                     // Nie skompiluje się - wymaga destruktora
};

Unie są bardzo potężnymi konstrukcjami, ale należy bardzo uważać korzystając z nich:

  • Nie zapewniają bezpieczeństwa typów - po zainicjowaniu jednego z elementów unii, czytanie pozostałych działa jak robienie reinterpret_cast<>,
  • Do czasu C++11 tylko obiekty typu POD mogły być przechowywane w uniach,
  • I, o ile od C++11 wzwyż można już trzymać swoje ukochane std::string w uniach, to należy pamiętać, że w czasie kompilacji wciąż nie wiadomo, co w tej unii się znajdzie - więc kompilator nie wywoła żadnego destruktora,
  • Ze względu na brak tej wiedzy kompilator nie wywoła też konstruktorów domyślnych, więc korzystanie z konstruktorów kopiujących czy operatorów przypisania nie wchodzi w grę. Trzeba ręcznie inicjalizować pamięć za pomocą placement-new,
  • Należy również pamiętać o ręcznym zwalnianiu pamięci w unii w konstruktorach kopiujących.

Więc w C++11 użycie unii musiałoby wyglądać mniej-więcej następująco:

struct A {
  enum class Type { NUM, STR, VEC };
  Type type;
  A(int i_) : type(Type::NUM), i(i_) {}

  A(const std::string& s_) : type(Type::STR) {
    new ((void*)&s) std::string(s_);
  }

  A(const std::vector<A>& v_) : type(Type::VEC) {
    new ((void*)&v) std::vector<A>(v_);
  }

  A(const A& a) : type(a.type) {
    switch (type) {
      case Type::NUM:
        i = a.i;
        break;
      case Type::STR:
        new ((void*)&s) std::string(a.s);
        break;
      case Type::VEC:
        new ((void*)&v) std::vector<A>(a.v);
        break;
    }
  }

  A& operator=(const A& a) {
    switch (type) {
      case Type::STR:
        s.~basic_string<char>();
        break;
      case Type::VEC:
        v.~vector<A>();
        break;
      case Type::NUM:
        break;
    }
    type = a.type;
    switch (type) {
      case Type::NUM:
        i = a.i;
        break;
      case Type::STR:
        new ((void*)&s) std::string(a.s);
        break;
      case Type::VEC:
        new ((void*)&v) std::vector<A>(a.v);
        break;
    }
    return *this;
  }

  ~A() {
    switch (type) {
      case Type::STR:
        s.~basic_string<char>();
        break;
      case Type::VEC:
        v.~vector<A>();
        break;
      case Type::NUM:
        break;
    }
  }

  int& getInt() {
    assert(Type::NUM == type);
    return i;
  };

  std::string& getString() {
    assert(Type::STR == type);
    return s;
  };

  std::vector<A>& getVec() {
    assert(Type::VEC == type);
    return v;
  };

 private:
  union {
    int i;
    std::string s;
    std::vector<A> v;
  };
};

To zdecydowanie zbyt dużo pracy, a i tak jestem pewny, że ktoś bardziej wprawny w C++ niż ja będzie w stanie znaleźć problemy w tym kodzie. Czy jest jakiś prostszy sposób?

std::variant

C++17 daje nam przyjemną i zapewniającą bezpieczeństwo typów alternatywę dla unii. Zawoła ona dla nas destruktor w odpowiednim momencie automatycznie, jednocześnie uniemożliwiając nam pobranie niewłaściwego typu.

std::variant<int, std::string> v;

Taka konstrukcja automatycznie zwolni pamięć po wyjściu z zasięgu, jak również rzuci wyjątek w sytuacji, kiedy program spróbowałby pobrać wartość innego typu niż ten, który jest w takiej zmiennej przechowywany. Jednak o ile get<T>() i get_if<T>() są dość oczywiste, to prawdziwą perełką jest tutaj std::visit, który pozwala programiście użyć wzorca wizytatora celem pobrania wartości bez konieczności dodatkowego sprawdzania, w ten sposób:

struct MyVisitor {
  std::string operator()(const int input) const {
    return std::to_string(input);
  }
  std::string operator()(const std::string& input) const { return input; }
};

void print(const std::variant<int, std::string>& v) {
  std::cout << std::visitor(MyVisitor(), v) << std::endl;
}

Wiele wartości tego samego typu

std::variant bez trudu przyjmuje wartości tego samego typu kilkukrotnie:

std::variant<int, int> v;

W jaki sposób wyciągnąć z takiej konstrukcji wartość, która nas interesuje? Okazuje się, że możemy zawołać std::get nie tylko z typem, ale również z numerem wartości, którą chcemy wyciągnąć. Ponadto, za pomocą std::in_place_index możemy określić, którą wartość chcemy zainicjalizować:

std::variant<int, int> v2(std::in_place_index<0>, 12);
int i = std::get<0>(v1);
int i = std::get<1>(v1);  // rzuci wyjątkiem

std::variant<int, int> v2(std::in_place_index<1>, 123);
int i = std::get<0>(v2);  // rzuci wyjątkiem
int i = std::get<1>(v2);

// jest niejednoznaczne, więc się nie skompiluje
std::get<int>(v);

To nie powinno być zbyt często potrzebne, ale może się przydać w przypadku bardzo generycznego kodu, kiedy dwa na pierwszy rzut oka różne typy sprowadzają się do tego samego.

Puste varianty nie są dozwolone

std::void nie może być użyty jako jeden z typów std::variant, co mogłoby być kuszące gdybyśmy chcieli utworzyć pustą opcję. Zamiast tego wprowadzony został nowy typ - std::monostate, który posiada tylko jedną dopuszczalną wartość i doskonale się sprawdzi jako typ oznaczający “pusty element”.

Chociaż czasem variant może być pusty

Co, jeśli wartość nie może być pobrana w trakcie jej ustawiania? Jeśli stara wartość została już zniszczona, a nowej nie jesteśmy w stanie ustawić (na przykład, jej konstruktor rzuca wyjątkiem), to std::variant staje się nieustawiony w wyniku wyjątku (valueless_by_exception). To oznacza:

  • valueless_by_exception() zwraca true,
  • każda próba pobrania wartości spowoduje rzucenie wyjątku std::bad_variant_access.

Rekursywne varianty

Przykład dla unii był dość skomplikowany, ponieważ unia ta była rekursywna - jeden z jej typów zawierał obiekty typu tej samej unii. Ale, jako że w przypadku std::variant kwestie pamięciowe są rozwiązywane za nas automatycznie, jedyne co musimy zrobić to opakować nasz std::variant w strukturę i użyć wskaźnika na niekompletny typ owej struktury jako jednego z typu variantu (ponieważ variant sam w sobie nie jest w stanie przyjmować niekompletnych typów):

struct RecursiveVariant;

struct RecursiveVariant {
  using Value =
      std::variant<int, std::string, std::unique_ptr<RecursiveVariant> >;
  Value value;
};

Boost.Variant

Warto zauważyć, że std::variant jest tak naprawdę przyjemniejszą wersją o wiele starszego Boost.Variant, więc jeśli z jakiegokolwiek powodu nie możesz jeszcze użyć std::variant, wciąż możesz spróbować skorzystać z wersji z Boosta - są dosyć podobne. Największą różnicą jest zachowanie w momencie, kiedy nie są w stanie pozyskać wartości:

  • Boost tymczasowo przechowuje kopię starej wartości, więc w przypadku problemów jest w stanie przywrócić starą wartość — Boost.Variant nigdy nie będzie “nieustawiony”,
  • Boost.Variant ma też specjalne narzędzia do tworzenia rekursywnych variantów. Mimo to nie jestem pewien, czy jest to łatwiejsze niż owijanie std::variant w strukturę.

Zobacz też



Michał Rudowicz

Programista C++ od prawie dekady, pasjonat narzędzi i nowych technologii. Cyklista i początkujący radioamator. Największą frajdę sprawia mu prowadzenie szkoleń i dzielenie się wiedzą z młodszymi programistami.

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