Понимание того, как Any Type, такой как std::any, может помочь нам упростить наш код и как он работает под капотом, чтобы мы могли определить, удовлетворяет ли он наши потребности при разработке наших приложений.

Сначала мы обсудим, почему нам нужно использовать Any Type, с примерами, затем рассмотрим, как он реализуется с помощью метода Type Erasure, и, наконец, как он выделяет память, чтобы увидеть, соответствует ли он нашим потребностям для написания наших приложений на C++.

Передача любого типа — зачем это нужно

Динамически типизированный язык

В языке программирования с динамической типизацией, таком как Python, мы можем передать любой тип функции и проверить во время выполнения, имеет ли он атрибут, который нужен функции, или нет. Например, у функции есть параметр с именем input.

Если мы передаем неправильный тип параметра, например, int, function(3), будет возбуждено исключение AttributeError.

AttributeError: 'int' object has no attribute 'x'

Когда мы передаем тип с атрибутом «x», наша функция выполняется успешно. То же самое и с методами, если метод с таким именем существует, он будет выполнен, иначе будет возбуждено исключение.

Статически типизированный язык

В статически типизированном языке программирования, таком как C++, нам строго нужен тип для нашего параметра. В отличие от Python, проверка типов выполняется во время компиляции, поэтому нам нужно знать тип.

Если мы передадим неправильный тип параметра, например, int, function(3), мы получим следующую ошибку компиляции.

error: invalid initialization of reference of type ‘const Input&’ from expression of type ‘int’

Мы видим, что разница заключается во времени, когда выполняется проверка типов, во время выполнения и во время компиляции.

В каких сценариях нам нужно передать Any Type в C++?

В некоторых сценариях при использовании статически типизированного языка, такого как C++, мы хотели бы передать любой тип в нашу функцию. Давайте рассмотрим пример, чтобы продемонстрировать сценарий.

Нам нужен класс с именем AnimalFeeder, который хранит Animal объектов в контейнере, таком как std::map, и мы хотим реализовать в этом классе единую юниформ-функцию с именем Feed(animalType, food).

В нашем примере у нас есть четыре разных типа животных, которые едят разную пищу:

  • Корова ест траву
  • Птица ест семена
  • Рыба ест червей
  • Слон ест фрукты

Чтобы хранить их в контейнере, они должны наследоваться от того же класса под названием Animal.

Без возможности передачи любого типа мы можем реализовать перегрузки функций, как показано ниже.

И мы реализуем класс AnimalFeeder следующим образом:

Это только для 4 типов, представьте, если у нас есть 100 или более типов, нам нужно добавить много очень похожих кодов.

std::any на помощь

Помните, что наша цель — иметь единый интерфейс, чтобы избежать дублирования и упростить чтение кода. Для этого мы можем использовать std::any, который был добавлен в C++17.

std::any — типобезопасный контейнер для одного объекта/значения. Мы можем обернуть любой тип с помощью std::any.

Давайте теперь посмотрим на наш код, когда мы используем std::any.

Теперь у нас может быть один интерфейс, void Feed(const std::any&) для всех типов животных. А ниже представлена ​​реализация класса AnimalFeeder с std::any.

Теперь это намного проще по сравнению с перегруженной версией. Конкретная реализация классов Animal, таких как Cow, может вернуть тип с помощью std::any_cast<Grass>(food).

Теперь, когда мы увидели, как наличие класса Any Type может помочь нам упростить наш код, давайте посмотрим, как он работает внутри. Изучив, как это работает под капотом, мы можем определить, можем ли мы использовать это для наших приложений или нет.

Стирание текста для переноса любого текста — резюме

Чтобы класс мог обернуть любой тип, мы можем использовать технику под названием Type Erasure в C++. Этот метод стирает тип содержащегося объекта, комбинируя утиную типизацию времени компиляции — шаблоны и объектно-ориентированное программирование. Я написал статью, в которой обсуждаются детали техники Type Erasure ниже. Пожалуйста, прочтите ее, чтобы больше узнать о Type Erasure.



Чтобы правильно управлять временем жизни содержащегося объекта, оболочка должна сделать копию и, возможно, выделить для него память из свободного хранилища. Для наглядности ниже приведена диаграмма классов для нашего примера выше — любой тип, обертывающий разные виды кормов для животных.

Когда мы оборачиваем наш объект в std::any, происходит то, что он заворачивается в шаблон класса с именем Wrapper<> и сохраняется в свободном хранилище, возможно, в интеллектуальном указателе.

Когда мы вызываем std::any food = Grass() для переноса нашего объекта, происходит следующее.

Создается уникальный указатель типа Wrapper<Grass>. Это просто для иллюстрации того, что происходит внутри, некоторые детали опущены.

Правильное возвращение типа

Теперь наш тип упакован и скопирован, но как нам преобразовать его обратно в исходный тип? Это одно из преимуществ использования std::any, в противном случае, если он не может вернуть нам исходный тип, это то же самое, что и использование указателя void (void *) с некоторыми дополнительными затратами.

В нашем классе-оболочке мы должны реализовать единый интерфейс, который возвращает тип содержащегося объекта. Для этой цели мы можем использовать оператор typeid в C++, который возвращает объект типа std::type_info. Полный код нашей оболочки, реализующей технику Type Erasure, выглядит следующим образом.

И мы можем реализовать функцию, не являющуюся членом, для получения исходного типа путем его приведения, если запрашиваемый тип совпадает с типом содержащегося объекта.

С помощью этой реализации мы можем использовать ее, например, для обертывания целого числа и преобразования его обратно в целое число, как показано во фрагменте кода ниже.

Вы можете видеть, что происходит под капотом, когда мы вызываем cast<int>(any), так это то, что мы делаем несколько вызовов функций, а также динамическую диспетчеризацию (вызов функции через виртуальную таблицу). Это дополнительная стоимость, которую мы платим за эту абстракцию.

Как он справляется с небольшими объектами?

Есть еще один аспект реализации Any Type, связанный с производительностью, это оптимизация небольших объектов.

В последнем фрагменте кода выше мы можем обернуть небольшие объекты, такие как примитивные типы (int, float и т. д.), и если мы не будем осторожны, поскольку мы выделяем память из свободного хранилища/кучи, наша реализация может вызвать сегментацию памяти.

Чтобы понять подробности того, как может происходить сегментация памяти, см. мою статью о выделении/освобождении памяти.



Согласно стандарту C++, реализация std::any должна избегать динамического выделения памяти из свободного хранилища/кучи для небольших объектов. Но это зависит от реализации и не гарантируется.

В целом существует два разных подхода к работе с небольшими объектами:

  • Используйте пул памяти для небольших объектов
  • Храните мелкие предметы на месте

Пул памяти для небольших объектов

Не существует стандарта относительно того, что означает «маленький», некоторые компиляторы используют размер два указателя, а другие используют больший размер. При таком подходе мы можем предварительно выделить большой блок памяти и управлять запросом на выделение/освобождение памяти внутренне, переопределяя операторы new и delete, чтобы мы могли контролировать, откуда мы выделяем память. Подробнее об этом в другой статье в будущем.

Как вы понимаете, размер памяти, которую мы хотим предварительно выделить, зависит от реализации.

Храните мелкие предметы на месте

При таком подходе во время компиляции нам нужно определить, является ли размер содержащегося объекта «маленьким» или нет. Если он небольшой, мы сохраняем содержащийся объект на месте, в противном случае мы оставляем его среде выполнения C++ для динамического выделения памяти для него.

Детали довольно сложны, и мы можем вернуться к ним в следующем посте. Но идея состоит в том, чтобы иметь союз, состоящий из двух членов.

Хранение на месте выбирают, когда:

  • тип удовлетворяет is_nothrow_move_constructible
  • размер содержащегося типа объекта ≤ размера static_alloc

В противном случае будет выбрано динамическое хранилище.

Ключевые выводы

Обобщить,

  • Мы можем передавать функции любого типа в C++ так же, как в Python.
  • std::any поддерживается с C++17
  • В некоторых сценариях полезно упростить наш код и сделать его более читабельным.
  • Реализация Any Type использует технику Type Erasure, которая представляет собой комбинацию Template + OOP.
  • Оболочка реализует унифицированный интерфейс std::type_info (*behavior)(), чтобы мы могли вернуть исходный тип.
  • Стандарт C++ поощряет оптимизацию выделения памяти для небольших объектов, но это не гарантируется.
  • Если сегментация памяти является проблемой для вашего приложения, вы можете написать свой собственный Any Type, который использует пул памяти вместо простого подхода на месте/динамического хранилища, реализованного в большинстве библиотек.