Wszystko co chcielibyście wiedzieć o std::any z C++17
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 dostd::optional
orazstd::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ątekbad_any_cast
kiedy aktywny typ jest różny odT
. - 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 whichis_nothrow_move_constructible_v<T>
istrue
. ¹
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 operatornew
.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życieany_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
alboboost::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.