Wszystko co chcielibyście wiedzieć o std::any z C++17


2018-10-25, 00:00

Wraz z std::optional możemy określać dowolny typ, lub obiekt “pusty”. W std::variant możemy przechowywać kilka zróżnicowanych typów tak, aby były traktowane jako jedna encja. C++17 daje nam jeszcze jedną możliwość: std::any, które może przechowywać dowolnego rodzaju typ w bezpieczny sposób.

Podstawy

Jak dotąd w C++ nie mieliśmy zbyt wielu opcji na deklarowanie obiektu który może przybierać różne typy. Oczywiście mogliśmy używać void*, ale to nie było zbyt bezpieczne.

Potencjalnie, void* mógł być opakowywany w klasie posiadającej dyskryminator typu.

class MyAny
{
    void* _value;
    TypeInfo _typeInfo;
};

Jak możemy zauważyć, mamy tutaj podstawową formę typu, ale wymaga to trochę więcej kodu, aby móc zapewnić klasie MyAny bezpieczeństwo ze strony typowania. Dlatego najlepszym rozwiązaniem jest używanie Biblioteki Standardowej niż własnych implementacji.

I właśnie tym jest std::any pochodzące ze standardu C++17 w jego podstawowej formie. Daje nam ono możliwość zapisu zupełnie dowolnej wartości/typu wewnątrz obiektu oraz zgłasza błędy (bądź wyrzuca wyjątki), kiedy zechcemy uzyskać dostęp do typu, który obecnie nie jest aktywny.

Prosty przykład:

std::any a(12);

// zapisujemy dowolną wartość:
a = std::string("Witaj!");
a = 16;

// odczytujemy wartość:

// możemy odczytać wartość jako int
std::cout << std::any_cast<int>(a) << '\n'; 

// ale nie możemy odczytać jej jako string:
try 
{
    std::cout << std::any_cast<std::string>(a) << '\n';
}
catch(const std::bad_any_cast& e) 
{
    std::cout << e.what() << '\n';
}

// resetujemy i sprawdzamy, czy zawiera jakąś wartość:
a.reset();
if (!a.has_value())
{
    std::cout << "a jest pusta!" << "\n";
}

// możemy używać go w kontenerze:
std::map<std::string, std::any> m;
m["integer"] = 10;
m["string"] = std::string("Witaj świecie");
m["float"] = 1.0f;

for (auto &[key, val] : m)
{
    if (val.type() == typeid(int))
        std::cout << "int: " << std::any_cast<int>(val) << "\n";
    else if (val.type() == typeid(std::string))
        std::cout << "string: " << std::any_cast<std::string>(val) << "\n";
    else if (val.type() == typeid(float))
        std::cout << "float: " << std::any_cast<float>(val) << "\n";
}

Powyższy kod po skompilowaniu zwróci nam na wyjście:

16
bad any_cast
a jest pusta!
float: 1
int: 10
string: Witaj świecie

Uruchom ten kod w @Coliru.

Mamy kilka rzeczy, które zostały zaprezentowane w powyższym przykładzie:

  • std::any w przeciwieństwie do std::optional oraz std::variant nie jest typem szablonowym.
  • domyślnie nie jest przechowywana żadna wartość, można to sprawdzić wywołując metodę .has_value().
  • możemy zresetować obiekt any metodą .reset().
  • działa on na bazie std::decay - więc przed przypisaniem, inicjalizacją oraz umieszczaniem typ wartości jest transformowany przez std::decay.
  • podczas przypisywania wartości o typie innym niż obecnie przechowywany, informacje o obecnym typie są tracone.
  • dostęp do wartości uzyskamy poprzez używając std::any_cast<T>, które wyrzuci wyjątek bad_any_cast kiedy aktywny typ jest różny od T.
  • informację na temat obecnie przechowywanego typu uzyskamy wywołując metodę .type(), która zwraca nam obiekt klasy std:: type_info.

Powyższy przykład wygląda naprawdę imponująco. Jeżeli lubisz JavaScript, to możesz jako typ każdej zmiennej używać std::any i czuć się, jakbyś w nim pisał :)

Ale może są jakieś bezpośrednie wskazania do używania std::any ?

Kiedy powinniśmy używać std::any?

Podczas gdy określiłem void* jako ekstremalnie niebezpieczny wzorzec (poza kilkoma przypadkami), std::any zapewnia bezpieczeństwo typu. To dlatego ma ono realne wykorzystanie w praktyce.

Kilka możliwości użycia:

  • W bibliotekach - kiedy typ biblioteczny powinien przetrzymywać dowolną wartość bez konieczności znajomości wszystkich możliwych do przechowywania typów.
  • Parsowanie plików - kiedy naprawdę nie potrafimy określić wszystkich wspieranych typów wartości
  • Przesyłanie komunikatów.
  • Wspólna praca wraz z językiem skryptowym
  • Implementowanie interpretera dla języka skryptowego
  • Interfejs użytkownika - kontrolki mogą przechowywać dowolną wartość
  • Encje w edytorach

Myślę, że bardzo często spotkamy się jednak z sytuacjami, kiedy jesteśmy w stanie określić wszystkie wspierane przez program typy. Do tego celu lepszym wyborem może okazać się std::variant. Oczywiście, trudnym się stanie wykorzystanie go, kiedy nasza biblioteka nie zna aplikacji, które będą jej wykorzystywały. Zatem nie będziemy również znali wszystkich dostarczanych przez tą aplikację typów.

Zapoznaliśmy się z podstawami na temat std::any. W dalszej części wpisu odkryjemy więcej detali na jego temat, zatem zapraszam do dalszej lektury.

Tworzenie std::any

Istnieje kilka możliwości na utworzenie obiektu std::any:

  • domyślna inicjalizacja - wtedy obiekt jest pusty
  • bezpośrednia inicjalizacja wartością/obiektem
  • poprzez użycie std::in_place_type
  • używając std::make_any

Wszystkie te możliwości zaprezentowałem w poniższym przykładzie:

// domyślna inicjalizacja:
std::any a;
assert(!a.has_value());

// inicjalizacja obiektem:
std::any a2(10); // int
std::any a3(MyType(10, 11));

// in_place:
std::any a4(std::in_place_type<MyType>, 10, 11);
std::any a5{std::in_place_type<std::string>, "Witaj świecie"};

// make_any
std::any a6 = std::make_any<std::string>("Witaj świecie");

Ten kod możesz uruchomić na @Coliru.

Zmiana wartości

Kiedy chcesz zmienić aktualnie przechowywaną wartość wewnątrz std::any, masz do wyboru dwie opcje: użycie metody emplace lub instrukcji przypisania:

std::any a;

a = MyType(10, 11);
a = std::string("Witaj");

a.emplace<float>(100.5f);
a.emplace<std::vector<int>>({10, 11, 12, 13});
a.emplace<MyType>(10, 11);

Ten kod możesz uruchomić na @Coliru.

Czas życia obiektu

Podstawą dla bezpiecznego typowania dla std::any jest niewyciekanie zasobów. Aby to osiągnąć, std::any niszczy aktualnie przechowywany obiekt przed przyjęciem nowej wartości.

std::any var = std::make_any<MyType>();
var = 100.0f;
std::cout << std::any_cast<float>(var) << "\n";

Ten kod możesz uruchomić na @Coliru

Ten kod wyprodukuje nam:

MyType::MyType
MyType::~MyType
100

Obiekt any został zainicjalizowany obiektem typu MyType. Przed zmianą wartości na nową (100.0f) wywołany zostaje destruktor klasy MyType.

Dostępność przechowywanej wartości

Aby odczytać aktualnie przechowywaną w std::any wartość, w zasadzie mamy tylko jedną opcję: użyć std::any_cast. Ta funkcja zwraca wartość o żądanym typie, pod warunkiem że taka znajduje się w obiekcie.

Jednakże, ta funkcja jest nieco sprytniejsza, i pomaga osiągnąć kilka celów:

  • zwrócenie kopii przechowywanej wartości. W przypadku niepowodzenia wyrzucony zostanie wyjątek std::bad_any_cast
  • zwrócenie referencji (zapisywalnej). W przypadku niepowodzenia wyrzucony zostanie wyjątek std::bad_any_cast
  • zwrócenie wskaźnika na wartość (stałego lub nie). W przypadku niepowodzenia zostanie zwrócona wartość nullptr.

Spójrzmy na przykład:

struct MyType
{
    int a, b;

    MyType(int x, int y) : a(x), b(y) { }

    void Print() { std::cout << a << ", " << b << "\n"; }
};

int main()
{
    std::any var = std::make_any<MyType>(10, 10);
    try
    {
        std::any_cast<MyType&>(var).Print();
        std::any_cast<MyType&>(var).a = 11; // odczyt/zapis
        std::any_cast<MyType&>(var).Print();
        std::any_cast<int>(var); // wyjątek!
    }
    catch(const std::bad_any_cast& e) 
    {
        std::cout << e.what() << '\n';
    }

    int* p = std::any_cast<int>(&var);
    std::cout << (p ? "zawiera int... \n" : "nie zawiera int'a...\n");

    MyType* pt = std::any_cast<MyType>(&var);
    if (pt)
    {
        pt->a = 12;
        std::any_cast<MyType&>(var).Print();
    }
}

Uruchom ten kod na @Coliru

Jak widać, mamy dwie opcje dla obsługi błędów: wyrzucanie wyjątków (std::bad_any_cast) lub zwracanie wskaźników (lub nullptr). Ta funkcja zostaje przeciążona i oznaczona jako noexcept.

Wydajność i zużycie pamięci

std::any wygląda naprawdę imponująco i możecie śmiało używać go do przechowywania zmiennych wraz z informacjami o ich typach… ale na pewno zapytacie o cenę tej elastyczności?

Główny problem: dodatkowe dynamiczne alokacje pamięci

Zarówno std::variant jak i std::optional nie wymagają dodatkowego alokowania pamięci, ponieważ doskonale znają one przechowywany wewnątrz siebie typ (typy). Natomiast std::any nie posiada wiedzy na ten temat, dlatego potrzebuje zaalokować pamięć na stercie.

Zastanówmy się, czy pamięć będzie alokowana zawsze? Kiedy zostanie ona zaalokowana? Sprawdźmy, co stanie się z wartością typu int:

Najpierw dowiedzmy się, co na ten temat mówi standard:

Implementations should avoid the use of dynamically allocated memory for a small contained value. Example: where the object constructed is holding only an int. Such small-object optimization shall only be applied to types T for which is_nothrow_move_constructible_v<T> is true. ¹

Podsumowując: implementacje są zachęcane do użycia “Small Buffer Optimisation” (szczególnie dla typów prostych). Niestety, niesie to za sobą pewien koszt: minimalna wielkość obiektu będzie większa, ponieważ musi zostać dopasowana do wielkości bufora.

Sprawdźmy, jaka może być wielkość dla std::any w zależności od kompilatora i systemu operacyjnego.

Poniżej zebrałem wyniki dla trzech kompilatorów:

Compiler sizeof(any)
GCC 8.1 (Coliru) 16
Clang 7.0.0 (Wandbox) 32
MSVC 2017 15.7.0 32-bit 40
MSVC 2017 15.7.0 64-bit 64

Uruchom kod na @Coliru

Jak możemy zobaczyć, std::any nie jest “prostym” typem i niesie ze sobą spory narzut. Narzut, który nie jest mały - nawiązując do BO - może o być 16 lub 32 bajty (GCC lub CLang) lub nawet 64 bajty (MSVC)!

Migracja z boost::any

Boost Any zostało wprowadzone około 2001 roku (w wersji 1.23.0). Co więcej, autor tej biblioteki - Kevlin Henney - jest również autorem propozycji dodania std::any do Standardu C++. Zatem możemy zauważyć, że obydwa te typy są ze sobą mocno związane, a wersja z STL jest mocno bazowana na poprzedniku.

Główne zmiany²:

Feature Boost.Any (1.67.0) std::any
Extra memory allocation Yes Yes
Small buffer optimization No Yes
emplace No Yes
in_place_type_t in constructor No Yes

Główna różnica to brak użycia Bufora Optymalizacji ze strony boost.any, co czyni go znacznie mniejszymm (GCC8.1 zgłasza nam 8 bajtów). Konsekwencją tego będzie każdorazowa alokacja pamięci, nawet dla typu int.

Przykłady z std::any

Główną zaletą std::any jest elastyczność. W poniższych przykładach znajdziemy kilka pomysłów (a nawet implementacji), gdzie przetrzymywanie typu zmiennej może znacznie uprościć kod aplikacji.

Parsowanie plików

W moich przykładach dotyczących std::variant kliknij tutaj) możemy zauważyć, w jaki sposób można analizować pliki konfiguracyjne, po czym zapisać wyniki jako kilka alternatywnych typów. Kiedy będziemy chcieli wykorzystać ten kod w sposób generyczny (może jako część biblioteki), wtedy nie będziemy znali wszystkich możliwych do przechowywania typów.

Przechowywanie std::any jako wartość właściwości mogła by być wystarczająco dobra zarówno ze względu na wydajność, jak i elastyczność rozwiązania.

Przesyłanie komunikatów

W Api systemu Windows, które jest głównie napisane w C, istnieje system przesyłania komunikatów. Ten system używa identyfikatorów z dwoma opcjonalnymi parametrami, które przechowują wartość wiadomości. Bazując na tym mechanizmie możemy zaimplementować WndProc, które obsługuje wiadomości wysyłane do naszego okna / kontrolki:

LRESULT CALLBACK WindowProc(
  _In_ HWND   hwnd,
  _In_ UINT   uMsg,
  _In_ WPARAM wParam,
  _In_ LPARAM lParam
);

Trikiem tutaj jest to, że wartości przetrzymywane w wParam oraz lParam występują w różnych formach. Czasami jest to tylko kilka bitów w wParam

Co, jeśli wykorzystamy tutaj std::any, tak aby komunikat mógł przyjąć cokolwiek do metody obsługującej?

Na przykład:

class Message
{
public:
    enum class Type 
    {
        Init,
        Closing,
        ShowWindow,
        DrawWindow
    };

public:
    explicit Message(Type type, std::any param) :
        mType(type),
        mParam(param)
    {   }
    explicit Message(Type type) :
        mType(type)
    {   }

    Type mType;
    std::any mParam;
};

class Window
{
public:
    virtual void HandleMessage(const Message& msg) = 0;
};

Przykładowo możemy wysłać komunikat do okna:

Message m(Message::Type::ShowWindow, std::make_pair(10, 11));
yourWindow.HandleMessage(m);

Wtedy okno może odpowiedzieć nam komunikatem podobnym do:

switch (msg.mType) {
// ...
case Message::Type::ShowWindow:
    {
    auto pos = std::any_cast<std::pair<int, int>>(msg.mParam);
    std::cout << "ShowWindow: "
              << pos.first << ", " 
              << pos.second << "\n";
    break;
    }
}

Uruchom ten kod na @Coliru

Oczywiście, musimy zdefiniować to, w jaki sposób te wartości są określane (jakie są typy wartości komunikatu), ale teraz możemy używać prawdziwych typów zamiast nagich integerów.

Właściwości

Oryginalny dokument, który wprowadza std::any do Standardu C++, N1939 pokazuje nam przykład dla klasy właściwości.

struct property
{
    property();
    property(const std::string &, const std::any &);

    std::string name;
    std::any value;
};

typedef std::vector<property> properties;

Obiekty properties wyglądają bardzo interesująco, jako że możemy przetrzymywać w nich dowolne wartości. Pierwsze co mi przyszło do głowy to generator UI oraz edytor gier.

Przekazywanie obiektów przez granice systemów

Jakiś czas temu na reddicie pojawił się wątek o std::any. Pojawił się tam conajmniej jeden świetny komentarz podsumowujący wszystko o tym typie:

The general gist is that std::any allows passing ownership of arbitrary values across boundaries that don’t know about those types.

Link do komentarza znajdziecie tutaj.

Wszystko, o czym wspomniałem można zawrzeć w trzech punktach:

  • Biblioteka dla UI: nigdy nie wiesz, jakie będą ostateczne typy danych wprowadzonych przez użytkownika
  • Przesyłanie komunikatów: ta sama idea, powodująca sporą elastyczność dla użytkownika
  • Parsowanie plików: wspieranie dowolnego formatu pliku

Podsumowanie

Wszystko, co powinniśmy zapamiętać na temat nowego typu:

  • std::any nie jest klasą szablonową
  • std::any używa Bufora Optymalizacji, dzięki czemu dla prostych typów (takich jak integer, double) nie zachodzi konieczność dynamicznej optymalizacji. Jednak dla większych typów zostanie wywołany operator new.
  • std::any może być uważany za ‘ciężki’, ale oferuje sporą elastyczność i bezpieczeństwo typu
  • dostęp do wartości przechowywanej przez std::any uzyskamy poprzez użycie any_cast, które oferuje nam kilka trybów pracy: może wyrzucić wyjątek, lub zwrócić wartość nullptr
  • używajmy go wszędzie tam, gdzie brakuje nam informacji na temat przechowywanych typów. W przeciwnym razie używajmy std::variant.

Teraz mam kilka pytań do Was:

  • Czy używaliście kiedykolwiek std::any albo boost::any?
  • Możecie podzielić się Waszymi przypadkami, kiedy były one użyteczne?
  • Gdzie jeszcze std::any może być pomocne?

¹ - Treść zostawiona w formie oryginalnej.
² - Tabelka zostawiona w formie oryginalnej.



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.