В стандартной библиотеке языка программирования 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);
На этой радостно�� ноте (мы нашли способ дедублицировать код реализации различных классов-битовых масок: сравните код, который у нас был, и полностью эквивалентный ему код, благодаря метаклассам лишенный всякого бойлерплейта) мы закончим наше скромное введение. Реализация же вышеописанного метакласса оставляется в качестве упражнения внимательному читателю.
