Техника стирания типов - 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>, который стирает тип любого другого хендла.То есть мы стёрли тип хендла на корутину, которая стёрла тип состояния корутины, которое стёрло в себе полиморное поведение объекта... Кажется эта штука набирает обороты. Интересно как будет выглядеть С++ будущего и какие техники мы сейчас не замечаем также, как в С не замечали стирания типов?

Комментарии (35)


  1. ArXen42
    24.04.2022 15:03

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

    string_view могут быть constexpr, поэтому если объявить константу вида constexpr std::string_view something = "Test", то её прямое использование в моём понимании скорее всего размер соптимизирует.


    1. crea7or
      24.04.2022 18:16

      В конструкторе из литерала внутри std::char_traits<T>::length а он и так constexpr.


  1. Antervis
    24.04.2022 15:46
    +1

    Эти 2 "таски" делают разные вещи, под ними внутри генерируются разные типы "стейт машин", но для наблюдателя их тип одинаковый. А вот поведение при исполнении - разное.

    и что что поведение разное? Типы же не стираются, вся типизация описана в task<T>. Как минимум при использовании корутин мы нигде не обязаны восстанавливать T из возвращаемых корутиной значений, это само по себе является весьма однозначным критерием отсутствия стирания типов.


    1. Kelbon Автор
      24.04.2022 15:47

      task в данном случае это нечто типа void* обернутого над стейт машиной внутри. Скорее всего task состоит из всего одного coroutine_handle<> который в свою очередь состоит из одного void*


      1. Antervis
        25.04.2022 00:28

        Погодите, task это тип возвращаемого значения, которое содержит coroutine_handle<promise>, являющуюся по сути указателем на фрейм корутины (полностью сгенерированный компилятором), содержащий promise. Разработчик нигде не обязан кастить coroutine_handle<promise> в coroutine_handle<void> или обратно, по крайней мере до тех пор, пока не захочет смешивать разнотипные корутины между собой.


  1. sergio_nsk
    24.04.2022 18:13
    -1

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

    Можно это уточнить или привести примеры?

    int main(int argc, char* argv[]) {
      char c = 0;
      char* a = &c;
      int* b = a; 
    }
    1.cc:4:8: error: cannot initialize a variable of type 'int *' with an lvalue of type 'char *'
      int* b = a; 
           ^   ~
    1.c:4:12: warning: initialization of 'int *' from incompatible pointer type 'char *' [-Wincompatible-pointer-types]
        4 |   int* b = a;
          |            ^


    1. kin4stat
      24.04.2022 18:54
      +3

      В C++ вы можете делать так:

      char* data = get_some_data();
      std::cout << *reinterpret_cast<int*>(data) << std::endl;

      Но не можете делать так:

      float* data = get_some_data();
      std::cout << *reinterpret_cast<int*>(data) << std::endl;

      Ну или более наглядный пример

      enum class MyByte : unsigned char{};
      void foo_mybyte(MyByte* data) {
      	std::cout << *reinterpret_cast<int*>(data) << std::endl;
      }
      void foo_uchar(unsigned char* data) {
      	std::cout << *reinterpret_cast<int*>(data) << std::endl;
      }
      void foo_byte(std::byte* data) {
      	std::cout << *reinterpret_cast<int*>(data) << std::endl;
      }
      int value = 5;
      foo_uchar(reinterpret_cast<unsigned char*>(&value)); // ok
      foo_byte(reinterpret_cast<std::byte*>(&value)); //ok
      foo_mybyte(reinterpret_cast<MyByte*>(&value)); // UB

      Если объяснять простыми словами: Вы можете кастовать (char/unsigned char/std::byte)* в любой тип, и обращение по указателю далее будет валидным, если там существует объект. Но нельзя кастовать из произвольного типа в произвольный, и потом обращаться к этому указателю, даже если там существует такой объект


      1. kb31
        24.04.2022 22:42

        Все примеры одинаковое UB - кастить можно только к char* (или unsigned char/std::byte), а не наоборот. Обратно - только memcpy/std::copy/std::bit_cast.

        Референс - https://eel.is/c++draft/basic.lval#11
        Но не возражаю, если опровергнете.

        UPD: перечитав примеры, согласен, что вторая часть вполне себе валидная, но пассаж про каст из char очень неоднозначный


        1. Kelbon Автор
          24.04.2022 22:43

          если вы угадали с типом(динамическим) то можно. Если вы собираетесь флоат в инт переделать, то придётся memcpy


          1. kb31
            24.04.2022 22:50

            Обычно угадывать - такая себе идея, а главное зачем, если вышеупомянутые memcpy/std::copy/std::bit_cast с 99.9% вероятностью сделают то же самое (без оверхеда), но на 100% надежно?

            Но тут речь даже о другом, цитируя комментарий выше:

            Вы можете кастовать (char/unsigned char/std::byte)* в любой тип, и обращение по указателю далее будет валидным, если там существует объект.

            Правило то такое действительно есть, только работает оно строго в другую сторону


            1. Kelbon Автор
              25.04.2022 10:57

              memcpy работает только для тривиально копируемых и разрушаемых объектов. И нужно не угадывать, а гарантировать себе инвариантами нужный тип под void*.

              Других реализаций высокоуровневого type erasure не существует


        1. 0xd34df00d
          25.04.2022 05:16

          Референс — https://eel.is/c++draft/basic.lval#11
          Но не возражаю, если опровергнете.

          Там речь о доступе, тогда как вы писали о касте.


          Однако, каст разрешён тоже далеко не между любыми двумя типами, но точных правил я не помню. Как минимум, нельзя делать reinterpet_cast к типу, имеющему более строгие требования выравнивания (даже если вы читать потом его не будете). Как максимум — к типу, который не может алиасить ваш искомый, но это неточно (в голове у меня такое правило сидит, но сходу пруф в стандарте я не нашёл).


          1. kb31
            25.04.2022 11:20

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

            Что касается просто каста - в directx когда-то был официальный способ сеттить float через интерфейс, принимающий int - ровно reintepret_cast'ом из float в int.

            С алайасингом и у меня коллиззии в памяти, поэтому я без толики иронии и предложил со мной поспорить :)


      1. sergio_nsk
        24.04.2022 23:11

        Речь же шла об исключении только для char* Чем тогда short* менее исключительный?

        short* a = 0;
        int* b = reinterpret_cast<int*>(a);


        1. AspisVipera
          25.04.2022 08:47

          1. short не гарантирует соответствие по длине байту для конкретной реализации и для конкретной машины

          2. Так исторически сложилось, это идиома языка, скорее всего это следствие повсеместного использования кодировки ASCII, длина символа в которой равна 8 битам. А байт это наименьшая адресуемая ячейка памяти (по крайней мере это верно для х86 и возможно для остальных архитектур времен создания языка C).

          3. Так написано в стандарте


  1. sergegers
    24.04.2022 19:41
    -1

    Концепты тоже type erasure. Вообще всё в C++ - type erasure. И ещё всё - монады.


    1. Kelbon Автор
      24.04.2022 20:21
      +3

      Концепты не type erasure, это типы типов


      1. sergegers
        25.04.2022 00:39
        -1

        Ну хорошо. Концепты - это meta type erasure. Часть информации о типе стирается, но результатом является не тип, а ограничение на тип.


        1. Antervis
          25.04.2022 03:36
          +1

          Какой-то ментальный фристайл начался...

          Концепт - это просто функция, принимающая тип и возвращающая булевое значение - соответствует ли тип набору требований. Плюс немного сахарка чтобы этим было удобно пользоваться. Говорить о них в категориях метатипов (в отличие от трейтов) мешает их "утиность" и ленивость. В качестве доказательства - функция, принимающая значение типа T, ограниченного концептом C, должна быть корректной относительно T, а не C.


          1. sergegers
            25.04.2022 11:40

            Говорить о них в категориях метатипов (в отличие от трейтов) мешает их "утиность"

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

            и ленивость

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

            В качестве доказательства - функция, принимающая значение типа T, ограниченного концептом C, должна быть корректной относительно T, а не C

            Если вы имеете ввиду, что реализация функции корректна относительно Т, а не С - это так. Изначально Бьёрн хотел иметь более мощные концепты, ограничивающие не только интерфейс, но и реализацию, но потом отказался от этого из-за проблем с переиспользованием уже существующего кода. Попавшие в стандарт концепты являются развитием предложения Concepts Lite.

            Концепт - это просто функция, принимающая тип и возвращающая булевое значение - соответствует ли тип набору требований. 

            Это не совсем так, ознакомьтесь с моим комментарием к другой статье https://habr.com/ru/post/645321/comments/#comment_23937457


            1. Antervis
              25.04.2022 19:29

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

              я имел в виду ленивость относительно вычисления соответствия типа концепту, которое происходит только при сверке типа с концептом (в процессе поиска перегрузок или инстанцирования шаблонов), а не на этапе формирования типа.

              Почему мешает утиность, мне непонятно. С практической точки зрения это сильно увеличивает сферу применения концептов.

              Это не совсем так, ознакомьтесь с моим комментарием к другой статье

              ну я же даже уточнил: "плюс немного сахарка чтобы этим было удобно пользоваться". Конечно же делать перегрузки по ограничениям через enable_if'ы намного сложнее, чем концептами. Да, сфера применения концептов шире чем у трейтов. Но это никак не меняет их природу, они всё еще остаются булевой функцией.

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


              1. sergegers
                26.04.2022 00:52

                 а не на этапе формирования типа.

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

                Конечно же делать перегрузки по ограничениям через enable_if'ы намного сложнее, чем концептами. 

                Если, например, задействована перегрузка функций с partial ordering аргументов, то условия в enable_if'ах будут расти факториально, что на практике исключает использование такого приёма при N > 3.

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

                Я же вроде объяснил, почему, по крайней мере сейчас, от этого отказались. Скажу честно, на практике это не вызывает особых проблем.


                1. Antervis
                  26.04.2022 01:29

                  Я же вроде объяснил, почему, по крайней мере сейчас, от этого отказались. Скажу честно, на практике это не вызывает особых проблем.

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

                  Но утверждаю я другое - что мыслить о концептах в категориях типов типов, метатипов или "meta type erasure" не совсем корректно. Потому что типы формируются иначе - путем добавления свойств, нежели ограничений. Это как пытаться заказать дизайн веб-сайта А. по наброску и Б. по ТЗ.


                  1. Kelbon Автор
                    26.04.2022 05:49

                    Концепты тоже формируются добавлениями свойств.

                    template<typename T>
                    concept A = B<T> && requires (T value) { value.foo(); };

                    Чем вам не свойства? Если бы концепты требовали полного описания всех свойств типа и любое лишнее было бы ошибкой, то концепту мог бы удовлетворять только один какой то тип))


                    1. Antervis
                      26.04.2022 14:47
                      +1

                      формирование "добавлением свойств" бы выглядело как-то так (псевдосинтаксис):

                      metaclass Fooable {
                      public:
                        int foo();
                      }

                      И мы бы из этой строчки знали, что класс должен обладать методом foo(), который возвращает int, неконстантный, может кидать исключения. И дальше мы бы могли наращивать энтропию, например сделать шаблонный возвращаемый тип.

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


                      1. Kelbon Автор
                        26.04.2022 15:55

                        template
                        concept fooable = requires (T value) {
                        { value.foo() } noexcept -> std::same_as<int>;
                        };

                        }

                        Не вижу отличий


                      1. Antervis
                        26.04.2022 16:46

                        Не вижу отличий

                        ну вы поставили ограничение noexcept (в моей версии функция может кидать исключения), и нет ограничения на то, что foo() неконстантный.


                      1. Kelbon Автор
                        26.04.2022 19:02

                        какая разница какое ограничение я поставил, смысл в том что вот, я задал нужные мне ограничения, в чем проблема?


                      1. Antervis
                        26.04.2022 19:48

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


  1. PyerK
    24.04.2022 21:38

    std::bit_cast замещает многое упомянутое. Тема std::memcpy не раскрыта.


  1. TheCalligrapher
    26.04.2022 05:02
    +2

    Что-то много воды понаписано для объема. Нет никакого "type erasure" в любых формах compile-time типизации. T * - это не type erasure. std::span<T> - это не type erasure. А не то такими темпами мы вообще все шаблонное программирование запишем в type erasure. Или пойдем дальше и фактически поставим знак эквивалентности между type erasure и любыми формами полиморфизма, включая compile-time полиморфизм. Зачем тогда придумывать новый термин, если термин "полиморфизм" уже есть?


    1. Kelbon Автор
      26.04.2022 05:54

      Погодите ка, смотрите, классический пример - std::function, смысл в том что мы не знаем какой конкретно тип под ней на рантайме и можем его подменять, так?

      Тогда объясните в чем разница с std::span< T >, который на рантайме может менять последовательность под ним с вектора на строку и вообще на любой contiguous набор T?


      1. TheCalligrapher
        26.04.2022 17:42

        Про std::functionспору нет - это классический пример настоящего type erasure. std::function- это фактически специализированный вариант std::any, адаптированный специально под нужды функциональных объектов.

        А std::span<T> - это ни что иное как тонкий адаптор над обычным массивом. Очень тонкий. То есть это просто указатель T * и размер. Все, с чем он может работать, должно тривиально превращаться в указатель и размер. Маловато для гордого звания type erasure.


        1. Kelbon Автор
          26.04.2022 19:03

          а почему type erasure должно быть с оверхедом ? Где это правило написано?)


    1. qw1
      26.04.2022 09:50
      +1

      А не то такими темпами мы вообще все шаблонное программирование запишем в type erasure. Или пойдем дальше и фактически поставим знак эквивалентности между type erasure и любыми формами полиморфизма
      Копайте глубже — любая компиляция в машинный код есть type erasure, был тип size_t или void*, а стал регистр rax :)