Обзор всего доступного в С++ type erasure

в 11:05, , рубрики: C, c++, C++20, coroutines, type erasure, Программирование

Техника стирания типов - type erasure - известна довольно давно, хоть её и долго не замечали. Тем не менее кажется только в последнюю декаду она стала из игрушки и костылей превращаться в мощный инструмент, использующийся каждый день в разработке.

Если спросить современного С++ разработчика какие примеры type erasure он видел / использовал, то вероятно он ответит что то про std::function и возможно про std::any, но это лишь малая часть всех применений этого замечательного инструмента!

В статье я постараюсь описать все возможные виды type erasure в современном С++, но начать стоит с определения.

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

Начнём с того, что было уже в С и о чём часто забывают говоря об erasure

void* - мы стёрли всю информацию о типе под указателем, не можем ничего прочитать, но с другой стороны доступ к данным у нас абсолютно без оверхеда! Достаточно угадать тип. Часто внутри именно на этом и построены другие более сложные стирания. Ну и конечно примерно в эту труху из байтов компилятор перетирает всю нашу систему типов в процессе работы.

Кстати, насчёт байтов:

std::byte (since C++17) / unsigned char / char так исторически сложилось, что в С все использовали чары для работы с сырыми байтами, поэтому для них в языке С++ исключение и указатель на них можно приводить к указателю на любой другой тип. Это не обходится без последствий и иногда из-за этого строки теряют некоторые оптимизации, поэтому сначала добавили std::byte, а потом начали потихоньку заменять чары (char8_t since C++20), но это уже совсем другая история. В контексте стирания типов нам важно, что мы получили способность читать данные из стёртого типа, а составив массив мы получим ещё и верхнюю границу размера типа, что конечно немного, но с void и так нельзя.

Вот мы тут про указатели поговорили. Но вы к ним приглядитесь получше. Видите стирание типов? А оно есть

T* (некий тип T) стирает бесконечное число типов массивов T[1] T[2] T[3] и т.д., при этом он массивы в С неявно приводятся к указателям. Это конечно прогрессивное решение для времён зарождения С... И спорное. Кстати заметьте - ссылка из С++ не делает ничего подобного, под ней вы уверены что лежит ровно одна штука! А под указателем от 0 до бесконечности! Тут мы уже точно знаем тип, но не знаем сколько там штук.

Ну, кажется с сишными стираниями покончено. Перейдём к С++?

И ВНЕЗАПНО std::string_view ! Вот уж тут многие незамечают никакого стирания. Но на самом деле мы стираем все возможные массивы чаров, указатели, строки (а некоторые в плохом коде ещё и массивы байтов(НЕ ДЕЛАЙТЕ ТАК))

Получаем мы одну удобную обёртку для всего этого, с которой можно без потери производительности и понятности работать, сократив количество бойлер плейта на написание всяких перегрузок до 0. Хотя стоит отметить, что относительно массивов char нам приходится хранить размер, который мы могли бы знать на компиляции.

Чуть шире std::span<T> - обёртка над лежащими в памяти подряд элементами типа Т. Стирается только контейнер в котором лежали элементы(например string, vector, сишный массив, std::array и т.д.)

И только тут наконец мы дошли до std::function / any / move_only_function (C++23), которые могли бы прийти к вам в голову первыми при упоминании type erasure.

Если включить в эту группу также похожие на них boost::any_range boost::asio::any_executor и прочие any_*, а потом хорошенько приглядеться, то окажется что это всё одна и та же форма стирания типов, а именно разделение байт хранящих значение и функций обрабатывающих эти данные + замена обрабатывающих функций на рантайме с помощью чего-то наподобие vtable.

Я бы отнёс это в общую категорию "стирание типов для использования в полиморфном контексте" как бы сложно это ни звучало

Сейчас в С++ уже есть удобные инструменты для унификации создания таких типов, хотя пока они и не в стандартной библиотеке.

Но не думайте, что это всё! Сначала разберём интересный случай - std::variant <Types...> мы вроде как добиваемся полиморфного поведения через std::visit, но с другой стороны сохраняем всю полноту информации о типах(и теряем только информацию о том что же там хранится в данный момент). К какой категории это относить - пусть решают учёные, а мы просто пропустим эту штуковину

Для подготовки к последнему мы должны упомянуть ещё один сишный способ стирания

Указатель... На функцию.под тип Ret(*)(Args...) мы можем положить произвольную функцию и вызвать, а значит добиться полиморфного поведения. При этом важно подметить, что функция возвращающая ничего и принимающая void* это не меньшие возможности, как могло бы показаться(т.к. мы снизили количество входных аргументов), а буквально любая функция, так как void* можно реинтерпретировать как что угодно, в том числе пак параметров + указатель на возвращаемое значение

Осталось посмотреть на последний и самый оригинальный тип стирания типов - корутины!

Представим вот такой С++20 код

task<int> Foo() {...}
task<int> Bar() {...}

Эти 2 "таски" делают разные вещи, под ними внутри генерируются разные типы "стейт машин", но для наблюдателя их тип одинаковый. А вот поведение при исполнении - разное. Получается мы тут где то стёрли тип! При этом мы получили возможность сохранить состояние вместе с кодом (в отличие от простого указателя на функцию)

Дополняет это всё std::coroutine_handle<void>, который стирает тип любого другого хендла.То есть мы стёрли тип хендла на корутину, которая стёрла тип состояния корутины, которое стёрло в себе полиморное поведение объекта... Кажется эта штука набирает обороты. Интересно как будет выглядеть С++ будущего и какие техники мы сейчас не замечаем также, как в С не замечали стирания типов?

Автор:
Kelbon

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js