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

Так, стандарт вводит отдельное требование BitmaskType, описывающее свойства, какими должны обладать битовые маски в стандартной библиотеке: для них должен быть определены операции «и», «или», «не», а значение 0 должно представлять пустую маску.

В стандартной библиотеке классов, от которых требуется соблюдение этого требования, очень много: std::chars_format, std::launch, std::filesystem::perms, std::filesystem::perm_options, std::filesystem::copy_options, std::filesystem::directory_options... Единственное, чем они отличаются — это набором возможных значений. Реализации же битовых операций над ними похожи как две капли воды.

Сравните сами реализации битовых операций для perms, directory_options и perm_options (примеры взяты из libc++ — одной из самой популярных реализаций стандартной библиотеки): comparison table. Они идентичны один в один за исключением того, что используют различные типы в качестве основы (underlying type у enum class). Их реализация — чистый копипаст. Кроме того не могу не обратить ваше внимание на недочет, закравшийся в реализацию битовых операций для perm_options в результате такого копипаста: в качестве underlying type для perm_options определен unsigned char, а приводим мы аргументы операторов (в static_cast) к unsigned (unsigned int). Это не является ошибкой, но тем не менее способно ввести в заблуждение программиста, работающего с данным кодом.

Примечательно то, что на данный момент в плюсах нет способа устранить этот копипаст. Ни наследование, ни какой-либо другой метод в данном случае не помощник.

Решение данной проблемы, предложенное (P0707R3) Гербом Саттером, председателем совета по стандартизации C++метаклассы, сущность, позволяющая управлять процессом компиляции для конкретного пользовательского типа.

Рассмотрим их возможности на следующем примере:

// Определение метакласса
constexpr void interface(meta::type target, const meta::type source) {
  compiler.require(source.variables().empty(), "interfaces may not contain data");
  for (auto f: source.functions()) {
    compiler.require(!f.is_copy() && !f.is_move(), "interfaces may not copy or move; consider a virtual clone() instead");
    if (!f.has_access()) f.make_public();
    compiler.require(f.is_public(), "interface functions must be public");
    f.make_pure_virtual();
    ->(target) f;
  }
  ->(target) { virtual ~(source.name()$)() noexcept { } }
}
// Использование метакласса
interface Shape {
  int area() const;
  void scale_by(double factor);
};

Метакласс определяется с помощью функции, выполняющейся на этапе компиляции и принимающей два аргумента: target — тип, представляющий результат преобразования исходного класса, и source — исходный пользовательский класс:

constexpr void interface(meta::type target, const meta::type source) {

В нем доступны следующие возможности:

1. Генерация ошибок и предупреждений компиляции. Например, следующий код выдаст пользователю ошибку компиляции с сообщением «interfaces may not contain data», если в исходном классе определены какие-либо поля:

compiler.require(source.variables().empty(), "interfaces may not contain data");

2. Получение информации об исходном классе через методы аргумента source. Тип meta::type инкапсулирует всю информацию о классе: информацию о его полях, методах, базовых классах, модификаторах, примененных к нему, и так далее.

3. Модификация (инъекция нового исходного кода) результирующего класса target при помощи специального синтаксиса. Например, следующий код добавит в него виртуальный деструктор (знак $ необходим для того, чтобы результат вызова source.name() подставился в инъектируемый исходный код):

->(target) { virtual ~(source.name()$)() noexcept { } }

А следующий — поле valueтипа int:

->(target) { int value; }

Теперь мы можем осмыслить полностью, что делает вышеприведенный код.

Он определяет метакласс с именем interface:

constexpr void interface(meta::type target, const meta::type source) {

Который выдает ошибку компиляции, если в исходный класс содержит какие-нибудь поля:

compiler.require(source.variables().empty(), "interfaces may not contain data");

Итерируется по всем его методам (получаемым по значению, так что мы можем их модифицировать), выдавая ошибку, если они являются copy/move конструкторами или операторами присваивания:

for (auto f: source.functions()) {
	compiler.require(!f.is_copy() && !f.is_move(), "interfaces may not copy or move; consider a virtual clone() instead");

Присваивая им, если разработчик явно не указал их модификатор доступа, public модификатор:

    if (!f.has_access()) f.make_public();

И выдавая ошибку, если разработчик определил метод не как public, а как private или protected:

    compiler.require(f.is_public(), "interface functions must be public");

Делая их чисто виртуальными:

    f.make_pure_virtual();

Добавляя их в результирующий класс target (который в начале функции является абсолютно пустым):

    ->(target) f;

И, наконец, проитерировавшись по всем классам, добавляет в target виртуальный деструктор с пустым телом:

  ->(target) { virtual ~(source.name()$)() noexcept { } }

Теперь мы можем применять метакласс следующим образом:

interface Shape {
	int area() const;
	void scale_by(double factor);
};

Компилятор, разобрав на этапе компиляции это определение, ввиду того, что мы применили к нему метакласс, передаст наш класс Shape в качестве аргумента source в функцию, реализующую метакласс interface, а в качестве target передаст тип, представляющий пустой класс (единственно обладающий тем же именем, что и исходный).

Когда функция исполнится, он заменит наш исходный класс классом, который был передан в функцию interface как target. Таким образом, метакласс попросту преобразует на этапе компиляции вышеприведенный класс в следующий (вышеприведенный и нижеприведенный код семантически эквивалентны):

class Shape {
public:
  virtual int area() const = 0;
  virtual void scale_by(double factor) = 0;
  virtual ~Shape() { }
};

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

Его использование может выглядеть следующим образом:

// В коде библиотеки
bitmask perm_options: unsigned char {
	auto replace, add, remove, nofollow;
}

// В пользовательском же коде он используется так же
// как использовался оригинальный perm_options с кучей бойлерплейта
// метакласс реализовал весь необходимый функционал за нас

perm_options options = (perm_options::add | perm_options::remove) & (~perm_options::replace);

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

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


  1. code_panik
    31.08.2022 23:14
    +1

    Примечательно то, что на данный момент в плюсах нет способа устранить этот копипаст. 

    "Можно ли реализовать обобщенную арифметику для enum?" - можно https://godbolt.org/z/Yee376o9Y

    "Нужно ли это авторам libc++?" - нет.


    1. eoanermine Автор
      31.08.2022 23:14

      Хорошее замечание, спасибо.
      Действительно, можно было бы решить поставленную в статье проблему следующим образом: godbolt.org (чуть модифицированное ваше решение). Но в коде стандартной библиотеки, я согласен с вами, такое неоправданно.


      1. ReadOnlySadUser
        01.09.2022 00:18

        чуть модифицированное ваше решение

        Не очень я понял смысла этой великой модификации. Ну, то что переехать функция должна была в `namespace std` - это было понятно из контекста, а вот что даёт анонимный namespace чёт хоть убейте вдупить не могу)


        1. eoanermine Автор
          01.09.2022 22:50

          Если мы оставим шаблонную реализацию битовых операций в namespace std, то using namespace std в пользовательском коде подтянет их в пользовательский неймспейс — загрязнит его реализациями битовых операций для пользовательских enumов.


          1. Playa
            03.09.2022 12:50

            Поздравляю, теперь в каждом TU, который использует эти операторы, будет их копия. Не нужно использовать anonymous namespace в заголовках.


    1. fk0
      01.09.2022 12:30

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


  1. eoanermine Автор
    31.08.2022 23:43

    Хочу сам отметить занятную вещь: если в libc++ и libstdc++ для дедубликации кода не сделано ничего, то в Microsoft STL для этого используется макрос.


  1. Apoheliy
    01.09.2022 17:22

    По мне в ряде случаев это спорная функциональность.

    Например, при использовании POD вдруг могут появиться новые поля, размер данных поплывёт.

    Даже в приведённых примерах: экземпляр а-ля-Shape допустимо создавать (функции определяем). В случае interface Shape уже будут вопли компилятора. Т.е. нужно будет изучать, а что такое "interface" (это то же кастомный код, не библиотечный)? А насколько хорошо он написан? В общем, напоминает переопределение #define sizeof(x) ... - а дальше отладчик это наше всё :(((


    1. eoanermine Автор
      01.09.2022 22:58

      Метаклассы предлагают программисту контракт: какие поля они добавляют, какую функциональность реализуют. Применяя метакласс к своему классу, программист должен осознавать вносимые им изменения.
      В приведенных примерах interface Shape не компилируется, потому что статья описывает функциональность, которая только предложена для включения в стандарт, но еще не принята и не реализована.
      Такие метаклассы как interface вполне могут стать частью стандартной библиотеки.


  1. nickolaym
    02.09.2022 01:18

    Я не понял, а концепты для этого нельзя применять?

    • пишем шаблоны всяких внешних функций (тех же операторов), принимающих не произвольные типы, а экземпляры концепта

    • объявляем интересующие нас типы экземплярами концепта

    • и вуаля?

    https://gcc.godbolt.org/z/zs4f1G8WW

    Концепты доступны в C++20, а концепты-для-бедных в виде обычного SFINAE - ещё в C++11, если не 03.

    https://gcc.godbolt.org/z/oMc5G4v7x


  1. nickolaym
    02.09.2022 02:01

    Если некий класс должен удовлетворять сразу нескольким концептам - ну, например, value, ordered и aggregate, - тогда как быть?

    • синтаксически

    • семантически

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

    То есть, определения этих плагинов компилятора надо писать с оглядкой на возможные комбинации?

    Либо же комбинирование синтаксически невозможно, поэтому если кто хочет, то пусть пишет комбинированные плагины по своему вкусу.
    Будут там value_ordered, value_aggregate, value_aggregate_ordered, ordered_aggregate_value... ?


  1. nickolaym
    02.09.2022 02:17

    Ещё одна точка сопротивления: время компиляции.
    Компилятор всё больше превращается в интерпретатор компилятора.
    Причём ему придётся загружать этот код для интерпретации в каждой единице трансляции независимо: а вдруг там от дефайнов или скоупа что-нибудь зависит!

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

    Конечно, главный довод ЗА - это уменьшить количество языков.
    Сейчас исходники на C++ содержат два языка: макропроцессора и собственно C++. На Qt - ещё добавляется один язык кодогенератора.
    И нужно постараться, чтоб они не конфликтовали (а макросы, как мы знаем, отлично конфликтуют, особенно, с шаблонами).
    Но может быть, не надо упихивать всё в один язык и один исходник?
    Пусть в проекте будет два исходника - один с пользовательскими плагинами компилятора, другой - с кастомизированным C++?


  1. NeoCode
    02.09.2022 09:05
    +1

    Вот ведь как складывается история... С++ начинался с попытки слегка улучшить Си, добавить классы, тонкий синтаксический сахар над структурами и функиями. А сейчас приходят (но еще не пришли окончательно) к синтаксическим макросам, по сути плагинам к компилятору. Правда, выглядит все это до крайности криво, именно из-за эволюционного развития языка. Интересно, дойдет ли язык в этой эволюции до простой кодогенерации любых произвольных фрагментов кода? А до использования внешних языков для доступа к синтаксическому дереву (по типу связки HTML + JavaScript)?


  1. Kelbon
    03.09.2022 12:47

    Всё это на С++ уже реализуемо, называется template.
    Енам не может быть template, но зато можно сделать шаблон оператора | & и т.д. для них с необходимыми ограничениями на аргументы. Но никому это не нужно