Czym jest std::variant?
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()
zwracatrue
,- 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ż owijaniestd::variant
w strukturę.