[Из песочницы] Все, что вы должны знать о std::any

Привет, Хабр! Представляем вашему вниманию перевод статьи «Everything You Need to Know About std::any from C++17» автора Bartlomiej Filipek.

image

С помощью 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::anystd::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

© Habrahabr.ru