[Из песочницы] Все, что вы должны знать о std::any
Привет, Хабр! Представляем вашему вниманию перевод статьи «Everything You Need to Know About std::any from C++17» автора Bartlomiej Filipek.
С помощью std::optional
вы можете хранить один какой-то тип. С помощью std::variant
вы можете хранить несколько типов в одном объекте. И С++17 предоставляет нам еще один такой оберточный тип — std::any
, который может хранить что угодно, оставаясь при этом типобезопасным.
Основы
До этого стандарт С++ не предоставлял много вариантов решения проблемы хранения нескольких типов в одной переменной. Конечно можно использовать void*
, однако это совсем не безопасно.
Теоретически void*
можно обернуть в класс, где как-то хранить тип:
class MyAny
{
void* _value;
TypeInfo _typeInfo;
};
Как вы видите, мы получили некую базовую форму std::any
, но для обеспечения типобезопасности MyAny
нам требуются дополнительные проверки. Именно поэтому лучше использовать вариант из стандартной библиотеки, чем делать свое решение.
И это то, чем является std::any
из C++17. Он позволяет хранить что угодно в объекте и сообщает об ошибке (бросает исключение), когда вы пытаетесь получить доступ указав не тот тип.
Маленькая демонстрация:
std::any a(12);
// можем записать любое значение:
a = std::string("Hello!");
a = 16;
// чтение из переменной:
// мы можем использовать a как число
std::cout << std::any_cast<int>(a) << '\n';
// но не как строку:
try
{
std::cout << std::any_cast<std::string>(a) << '\n';
}
catch(const std::bad_any_cast& e)
{
std::cout << e.what() << '\n';
}
// сбросим и проверим содержит ли наша переменная какое-то значение:
a.reset();
if (!a.has_value())
{
std::cout << "a is empty!" << "\n";
}
// вы можете использовать any в контейнерах:
std::map<std::string, std::any> m;
m["integer"] = 10;
m["string"] = std::string("Hello World");
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";
}
Этот код выведет:
16
bad any_cast
a is empty!
float: 1
int: 10
string: Hello World
Пример выше показывает некоторые важные вещи:
std::any
— не шаблонный класс какstd::optional
илиstd::variant
- по умолчанию он не содержит значения, вы можете это проверить с помощью метода
.has_value()
- вы можете сбросить любой объект с помощью метода
.reset()
- перед присваиванием или инициализацией новый тип трансформируется с помощью
std::decay
- когда мы присваиваем переменной значение нового типа, старое значение и его тип стираются
- вы можете получить доступ с помощью метода
std::any_cast
, который выбросит исключениеbad_any_cast
, если сейчас переменная хранить знаечние не типа «T» - ты можешь узнать активный сейчас тип с помощью метода
.type()
, который возвращаетstd::type_info
этого типа
Пример выше выглядит впечатляюще — настоящая переменная типа в С++! Если вы очень любите JavaScript, то можете даже сделать все ваши переменные типа std::any
и использовать С++ как JavaScript :)
Но может есть какие-то нормальные примеры использования?
Когда использовать?
В то время как void*
воспринимается мной как очень небезопасная вещь с очень ограниченным кругом возможных использований, std::any
полностью типобезопасный, поэтому у него есть некоторые хорошие способы использования.
Например:
- В библиотеках — когда вашей библиотеке надо хранить или передавать какие-то данные, и вы не знаете какого типа эти данные могут быть
- При парсинге файлов — если вы правда не можете определить какие типы поддерживаются
- Передача сообщений
- Взаимодействие со скриптовыми языками
- Создание интерпретатора для скриптового языка
- Пользовательский интерфейс — поля могут хранить что угодно
Мне кажется, во многих из этих примерах мы можем выделить ограниченный список поддерживаемых типов, поэтому std::variant
может быть более верным выбором. Но конечно сложно создавать библиотеки не зная конечных продуктов, в которых она будет использована. Ты просто не знаешь какие типы там будут храниться.
Демонстрация показала некоторые базовые вещи, но в следующих секциях вы узнаете больше деталей о std::any
, поэтому продолжайте читать.
Создание std::any
Есть несколько способов создать объект типа std::any
:
- стандартная инициализация — объект пустой
- прямая инициализация значением/объектом
- прямо указывая тип объекта —
std::in_place_type
- с помощью
std::make_any
Например:
// стандартная инициализация:
std::any a;
assert(!a.has_value());
// прямая инициализация значением:
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>, "Hello World"};
// make_any
std::any a6 = std::make_any<std::string>("Hello World");
Изменение значения
Изменить значение, которое в данный момент хранится в std::any
, можно двумя способами: методом emplace
или присваиванием:
std::any a;
a = MyType(10, 11);
a = std::string("Hello");
a.emplace<float>(100.5f);
a.emplace<std::vector<int>>({10, 11, 12, 13});
a.emplace<MyType>(10, 11);
Жизненный цикл объекта
Ключевым для безопасности std::any
является отсутствие утечки ресурсов. Для достижения этой цели std::any
уничтожит любой активный объект перед тем, как присваивать новое значение.
std::any var = std::make_any<MyType>();
var = 100.0f;
std::cout << std::any_cast<float>(var) << "\n";
Этот код выведет следующее:
MyType::MyType
MyType::~MyType
100
Объект std::any
инициализируется объектом типа MyType, но перед присваиванием нового значения (100.0f) вызывается деструктор MyType
.
Получение доступа к значению
В большинстве случаев у вас есть только один способ получения доступа к значению в std::any
— std::any_cast
, она возвращает значения заданного типа, если оно хранится в объекте.
Эта функция очень полезная, так как у нее есть много способов использования:
- вернуть копию значения и бросить
std::bad_any_cast
при ошибке - вернуть ссылку на значение и бросить
std::bad_any_cast
при ошибке - вернуть указатель на значение (константный или нет) или nullptr в случае ошибки
Посмотрите пример:
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; // чтение/запись
std::any_cast<MyType&>(var).Print();
std::any_cast<int>(var); // throw!
}
catch(const std::bad_any_cast& e)
{
std::cout << e.what() << '\n';
}
int* p = std::any_cast<int>(&var);
std::cout << (p ? "contains int... \n" : "doesn't contain an int...\n");
MyType* pt = std::any_cast<MyType>(&var);
if (pt)
{
pt->a = 12;
std::any_cast<MyType&>(var).Print();
}
}
Как вы можете видеть, у нас есть два способа отслеживания ошибок: через исключения (std::bad_any_cast
) или возвращая указатель (или nullptr
). Функция std::any_cast
для возврата указателей перегружена и помечена как noexcept
.
Производительность и использование памяти
std::any
выглядит мощным инструментом, и вы скорее всего будете использовать его, чтобы хранить данные разных типов, но какова цена этого?
Главная проблема — дополнительное выделение памяти.
std::variant
и std::optional
не требует никаких дополнительных выделений памяти, но это вызвано тем, что типы хранимых в объекте данных заранее известны. std::any не имеет такой информации, поэтому может использовать дополнительную память.
Это будет происходить всегда или иногда? Какие правила? Это будет происходить даже с такими простыми типами, как int?
Давайте посмотрим, что сказано в стандарте:
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 is true
Реализация должна избегать использование динамической памяти для хранимых данных малого размера. Например, когда объект создается храня только int. Такая оптимизация для малых объектов должна применяется только для типов T, для которых is_nothrow_move_constructible_v является true.
В итоге, для реализаций предлагают использовать оптимизацию малых объектов (Small Buffer Optimization/SBO). Но у этого есть и цена. Это делает тип больше — чтобы покрыть буфер.
Давайте посмотрим на размер std::any
, вот результаты с нескольких компиляторов:
В общем, как вы видите, std::any
это не простой тип, и он приносит дополнительные расходы. Он обычно занимает не мало памяти, из-за SBO, от 16 до 32 байтов (в GCC или clang… или даже 64 байта в MSVC!).
Переход с boost::any
boost::any
был представлен где-то в 2001 году (версия 1.23.0). Кроме того, автор boost::any
(Kevlin Henney) так же является автором предложения std::any
. Поэтому эти два типа тесно связаны, версия из STL сильно основана на предшественнике.
Вот главные изменения:
Главное отличие в том, что boost::any
не использует SBO, поэтому он занимает значительно меньше памяти (в GCC8.1 его размер — 8 байт), но из-за этого он динамически выделяет памяти даже для таких маленьких типов как int.
Примеры использования std::any
Главным плюсом std::any
является гибкость. В примерах ниже вы можете увидеть некоторые идеи (или конкретные реализации), где использование std::any
позволяет сделать приложение немного проще.
Парсинг файлов
В примерах к std::variant
(посмотреть их можно тут [англ]) вы могли видеть, как можно парсить конфигурационные файлы и хранить результат в переменной типа std::variant
. Теперь вы пишите очень общее решение, может быть это часть какой-то библиотеки, тогда вам могут быть не известны все возможные варианты типов.
Хранение данных с помощью std::any
для параметров скорее всего будет достаточно хорошим в плане производительности, и в то же время даст вам гибкость решения.
Передача сообщений
В Windows Api, которое в основном написанно на С, есть система передачи сообщений, которая использует id сообщения с двумя опциональными параметрами, которые хранят данные сообщения. Основываясь на этом механизме ты можешь реализовать WndProc, который обрабатывает переданное в ваше окно сообщение.
LRESULT CALLBACK WindowProc(
_In_ HWND hwnd,
_In_ UINT uMsg,
_In_ WPARAM wParam,
_In_ LPARAM lParam
);
Дело в том: что данные хранятся в wParam
или lParam
в разных формах. Иногда тебе надо использовать только пару байтов wParam
.
Что если мы изменим эту систему так, что сообщение может передавать методу обработки что угодно?
Например:
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;
};
Например, вы можете отправить сообщение окну:
Message m(Message::Type::ShowWindow, std::make_pair(10, 11));
yourWindow.HandleMessage(m);
Окно может ответить на сообщение так:
switch (msg.mType) {
// ...
case Message::Type::ShowWindow:
{
auto pos = std::any_cast<std::pair<int, int>>(msg.mParam);
std::cout << "ShowWidow: "
<< pos.first << ", "
<< pos.second << "\n";
break;
}
}
Конечно, вам приходиться определять как хранится тип данных в сообщениях, но теперь можно использовать настоящие типы вместо разных трюков с числами.
Свойства
Оригинальный документ, который представляет any для С++ (N1939) показывает пример объекта свойства:
struct property
{
property();
property(const std::string &, const std::any &);
std::string name;
std::any value;
};
typedef std::vector<property> properties;
Этот объект выглядит очень полезным, так как может хранить много разных типов. Первым мне приходит в голову пример использования его в менеджере пользовательского интерфейса или в игровом редакторе.
Проходим сквозь границы
В r/cpp был поток о std::any. И там было как минимум один отличный комментарий, который обобщает когда тип должен использоваться.
Из этого комментария:
Суть в том, что std::any позволяет передавать права на произвольные данные через границы, которые не знают о его типе.
Все, о чем я говорил перед этим, близко к этой идее:
- в библиотеке для интерфейса: ты не знаешь какие типы клиент пожелает там использовать
- передача сообщений: та же идея — дать клиенту гибкости
- парсинг файлов: для поддержки любых типов
Итог
В этой статье мы много узнали о std::any
!
Вот вещи, которые надо помнить:
std::any
не является шаблонным классомstd::any
использует оптимизацию малых объектов, поэтому он не будет динамически выделять памяти для таких простых типов как int или double, а для бо́льших типов будет использоваться дополнительная памятьstd::any
можно назвать «тяжелым», но он предлагает типобезопастность и большую гибкость- доступ к данным в
std::any
можно получить с помощьюany_cast
, который предлагает несколько «режимов». Например, в случае ошибки он может бросить исключение или просто вернуть nullptr - используйте его, когда вы точно не знаете возможные типы данных, иначе подумайте об использовании
std::variant