Jak używać std::visit z wieloma wariantami


2018-09-12, 00:00

std::visit to potężne narzędzie pozwalające na wywołanie funkcji na aktualnym typie przechowywanym wewnątrz std::variant. Potrafi znaleźć odpowiednie przeciążenia funkcji, a co więcej, działa dla wielu wariantów naraz.

Przyjrzyjmy się zatem kilku przykładom użycia tej funkcjonalności.

Genialny std::visit

Poniżej mamy klasyczny przykład dla jednego wariantu:

struct Fluid { };
struct LightItem { };
struct HeavyItem { };
struct FragileItem { };

struct VisitPackage
{
    void operator()(Fluid& ) { cout << "fluid\n"; }
    void operator()(LightItem& ) { cout << "light item\n"; }
    void operator()(HeavyItem& ) { cout << "heavy item\n"; }
    void operator()(FragileItem& ) { cout << "fragile\n"; }
};

int main()
{
    std::variant<Fluid, LightItem, HeavyItem, FragileItem> package { FragileItem() };
    std::visit(VisitPackage(), package);
}

Wyjście programu:

fragile

Mamy jeden wariant, który reprezentuje cztery różne rodzaje opakowania, po czym używamy super zaawansowanej struktury VisitPackage w celu znalezienia jego zawartości. Ten przykład pokazuje nam, że możemy wywołać operację polimorficzną na zestawie klas, o zupełnie innych typach bazowych.

Przypomnienie - zachęcam do przeczytania mojego wpisu wprowadzającego do std::variant: Everything You Need to Know About std::variant from C++17.

Możemy użyć również “wzorca overload w celu użycia kilku wyrażeń lambda:

template<class... Ts> struct overload : Ts... { using Ts::operator()...; };
template<class... Ts> overload(Ts...) -> overload<Ts...>;

int main()
{
    std::variant<Fluid, LightItem, HeavyItem, FragileItem> package;

    std::visit(overload{
        [](Fluid& ) { cout << "fluid\n"; },
        [](LightItem& ) { cout << "light item\n"; },
        [](HeavyItem& ) { cout << "heavy item\n"; },
        [](FragileItem& ) { cout << "fragile\n"; }
    }, package);
}

W powyższym przykładzie nasz kod staje się bardziej przejrzysty, ponieważ nie ma potrzeby deklarowania osobnej struktury przetrzymującej przeładowania dla operatora operator().

Przy okazji: czy wiesz, jaka będzie oczekiwana wartość w przykładzie powyżej? Jaka będzie domyślna wartość dla zmiennej package?

Wsparcie dla wielu wariantów

Co więcej std::visit współpracuje z wieloma wariantami!

Jeżeli spojrzymy na jego specyfikację, zauważymy że jest on zadeklarowany jako:

template <class Visitor, class... Variants>
constexpr ReturnType visit(Visitor&& vis, Variants&&... vars);

visit woła funkcję std::invoke dla wszystkich typów będących w std::variant:

std::invoke(
    std::forward<Visitor>(vis),
    std::get<is>(std::forward<Variants>(vars))...
) ;

// gdzie `is...` to `vars.index()...`.

visit zwraca typ zwracany przez wybrane przeładowanie funkcji.

Przykładowo, możemy wywołać funkcję dla dwóch wariantów przechowywyjących typ opakowania:

std::variant<LightItem, HeavyItem> basicPackA;
std::variant<LightItem, HeavyItem> basicPackB;

std::visit(overload{
    [](LightItem&, LightItem& ) { cout << "2 light items\n"; },
    [](LightItem&, HeavyItem& ) { cout << "light & heavy items\n"; },
    [](HeavyItem&, LightItem& ) { cout << "heavy & light items\n"; },
    [](HeavyItem&, HeavyItem& ) { cout << "2 heavy items\n"; },
}, basicPackA, basicPackB);

Po uruchomieniu, ten kod wyświetli nam:

2 light items

Jak widać trzeba dostarczyć funkcje przeciążające dla wszystkich wariacji typów, które mogą wystąpić w funkcji.

Poniżej znajduje się diagram pomocniczy:

std::visit na dwóch wariantach

Jeżeli mamy dwa warianty -std::variant<A, B, C> abc oraz std::variant<X, Y, Z> xyz, to to musimy przygotować zestaw przeładowań dla dziewięciu możliwych wariacji:

func(A, X);
func(A, Y);
func(A, Z);

func(B, X);
func(B, Y);
func(B, Z);

func(C, X);
func(C, Y);
func(C, Z);

W następnej sekcji przyjrzymy się, jak możemy wykorzystać tą funkcjonalność aby dopasować odpowiedni przedmiot do odpowiadającego mu typu opakowania.

Przykład z pakowaniem paczki

std::visit nie tylko obsługje wiele wariantów, ale również radzi sobie z wariantami klas o różnych typach bazowych.

Spróbuję zilustrować Wam to na podstawie następująco:

Wyobraźmy sobie, że mamy przedmiot (płynny, ciężki, lekki lub delikatny) oraz chcielibyśmy dopasować go do odpowiedniego opakowania (szklanego, kartonowego, wzmocnionego lub odpowiednio zamortyzowanego).

W C++17 z std::variant oraz std::visit możemy spróbować zaimplementować to w podobny sposób:

struct Fluid { };
struct LightItem { };
struct HeavyItem { };
struct FragileItem { };

struct GlassBox { };
struct CardboardBox { };
struct ReinforcedBox { };
struct AmortisedBox { };

variant<Fluid, LightItem, HeavyItem, FragileItem> item { 
    Fluid() };
variant<GlassBox, CardboardBox, ReinforcedBox, AmortisedBox> box { 
    CardboardBox() };

std::visit(overload{
    [](Fluid&, GlassBox& ) { 
        cout << "fluid in a glass box\n"; },
    [](Fluid&, auto ) { 
        cout << "warning! fluid in a wrong container!\n"; },
    [](LightItem&, CardboardBox& ) { 
        cout << "a light item in a cardboard box\n"; },
    [](LightItem&, auto ) { 
        cout << "a light item can be stored in any type of box, "
                "but cardboard is good enough\n"; },
    [](HeavyItem&, ReinforcedBox& ) { 
        cout << "a heavy item in a reinforced box\n"; },
    [](HeavyItem&, auto ) { 
        cout << "warning! a heavy item should be stored "
                "in a reinforced box\n"; },
    [](FragileItem&, AmortisedBox& ) { 
        cout << "fragile item in an amortised box\n"; },
    [](FragileItem&, auto ) { 
        cout << "warning! a fragile item should be stored "
                "in an amortised box\n"; },
}, item, box);

Kod ten zwróci nam na wyjście:

warning! fluid in a wrong container!

Możecie sami przeanalizować ten przykład na @Coliru.

Mamy zatem cztery typy przedmiotów oraz cztery zestawy opakowań. Chcielibyśmy znaleźć opakowanie do odpowiedniego typu przedmiotu.

std::visit przyjmuje dwa warianty: item oraz box i wywołuje odpowiednie przeciążenie, a następnie sprawdza czy typy się zgadzają lub nie.
Przedstawione przeze mnie typy są bardzo proste, ale nic nie stoi na przeszkodzie, aby rozszerzyć je poprzez dodanie takich właściwości jak waga, rozmiar lub inne.

W teorii powinniśmy dostarczyć funkcji dla wszystkich kombinacji: to znaczy 4*4 = 16 funkcji. Jest jednak sposób na zmniejszenie tej ilości. Możemy dostarczyć tylko 8 niezbędnych funkcji.

Zatem, jak możemy ominąć niektóre z kombinacji?

Jak pominąć niektóre z kombinacji w std::visit?

Wygląda na to, że możemy użyć lambdy generycznej (C++14), która dostarcza nam możliwość implementacji “domyślnej” funkcji przeciążającej!

Na przykład:

std::variant<int, float, char> v1 { 's' };
std::variant<int, float, char> v2 { 10 };

std::visit(overloaded{
        [](int a, int b) { },
        [](int a, float b) { },
        [](int a, char b) { },
        [](float a, int b) { },
        [](auto a, auto b) { }, // << domyślna funkcja!
    }, v1, v2);

W powyższym przykładzie możecie zauważyć, że użyłem tylko czterech funkcji przeciążających z konkretnymi typami parametrów - nazwijmy je prawidłowymi lub znaczącymi. Reszta obsłużona zostaje przez lambdę generyczną (dostępną w standardzie C++14).

Lambda generyczna zostaje rozwiązana jako funkcja szablonowa. Podczas etapu kompilacji jej priorytet jest niższy niż priorytet dla funkcji posiadających wyszczególnione typy parametrów.

Jeżeli twój visitor został zaimplementowany z użyciem konkretnego typu, to znaczy, że możesz użyć pełnego wsparcia dla labdy generycznej:

template <typename A, typename B>
auto operator()(A, B) { }

Myślę, że ten wzorzec może być przydatny, szczególnie kiedy używacie std::visit na wariantach dostarczających więcej niż 5 czy 7 przeciążeń, w celu uniknięcia powtórzeń w kodzie.

W naszym głównym przykładzie z przedmiotami i pudełkami ta technika przbierze odrobinę inną postać. Na przykład:

[](FragileItem&, auto ) { 
    cout << "warning! a fragile item should be stored "
            "in an amortised box\n"; },

Generyczna lamda obsłuży wszystkie możliwości dla argumentu typu FragileItem, gdzie drugi argument staje się nieważny.

Podsumowanie

Ten artykuł pokazał nam jak używać std::visit dla wielu wariantów. Jest to technika pozwalająca na wprowadzenie algorytmów dostosowywujących odpowiedni wzorzec. Mamy zestaw wielu typów, ale nasz algorytm chcemy wykonać jedynie dla typów odpowiednio dopasowanych. To tak, jakbyśmy chcieli wykonać operacje polimorficzne bez wykorzystania mechanizmu metod wirtualnych.

Jeżeli chcecie dowiedzieć się, jak std::visit działa pod maską, zachęcam Was do zapoznania się z wpisem Variant Visitation autorstwa Michael Park.

Czy stosowaliście kiedyś std::visit dla wielu wariantów? Możecie pochwalić się swoimi przykładami?



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.