Продолжаю серию публикаций Fil по CppCon 2017. В докладе представлены ранние наработки по добавлению рефлексии и кодогенерации в C++, а также по метаклассам, которые позволят генерировать части классов C++. В стандарт эти новшества попадут не ранее, чем в C++23.
Предисловие: операторы сравнения
Пусть мы хотим написать класс, объекты которого можно сравнивать, например, класс строки, не чувствительной к регистру — CIString
(Case-Insensitive String). Для этого, как минимум, нам потребуется написать 6 операторов сравнения: ==
, <
, !=
, >
, >=
, <=
. Причём весь код, кроме первых двух, будет абсолютно шаблонным. Если надо уметь сравнивать нашу строку с const char*
без копирования, то добавьте ещё 12 операторов.
Проблема дублирования кода в операторах сравнения стояла настолько остро, что было написано предложение к стандарту C++, добавляющее в язык так называемый "spaceship operator":
class CIString {
string s;
public:
// ... остальные функции ...
std::weak_ordering operator<=>(const CIString& b) const { ... }
std::weak_ordering operator<=>(const char* b) const { ... }
};
Мы пишем одну функцию <=>
, где возвращаем std::weak_ordering::less
, ::equivalent
или ::greater
, а компилятор генерирует реализации все функции сравнения. Поддерживаются 5 основных типов сравнения, в том числе, std::strong_ordering
и генерация только функций ==
/!=
.
Предложение о "spaceship operator" имеет ценность само по себе, но, как будет показано далее, с помощью метаклассов можно реализовать и его, и много других сценариев кодогенерации, причём на чистом C++, без необходимости встраивать их в компилятор или стандарт языка.
Рефлексия
Для любого типа, функции или другой сущности, $T
— это constexpr
-выражение, возвращающее значение метатипа. На нём можно вызывать методы, чтобы получать различную метаинформацию, относящуюся к T
.
Замечание: это очень ранняя версия предложения к стандарту, и $expr
могут заменить на reflect(T)
, или вроде того.
Например, получить названия переменных произвольного enum
можно с помощью метода variables()
:
template<typename E>
auto print_strings() {
for (auto o : $E.variables())
cout << o.name() << endl;
}
enum class state { started = 1, waiting, stopped };
cout << print_strings<state>() //=> started waiting stopped
Так как шаблоны инстанцируются только при использовании, то названия переменных и прочая метаинформация окажется в бинарнике, только если мы используем её в программе.
Кодогенерация (injection)
constexpr
-блок может находиться в любом месте программы: в функции, в классе и т.д. Вот как это выглядит:
// обычный код
constexpr {
// код, выполняемый при компиляции
}
// обычный код
Конструкция -> { ... }
используется внутри блока constexpr
, чтобы вставить на его месте обычный код:
constexpr {
// ...
-> {
// обычный код
}
// ...
}
Пример: печать названия конкретной переменной перечисления
template<Enum E> // почему бы не использовать ещё и концепты?
auto to_string(E value) {
switch (value) {
constexpr {
for (auto o : $E.variables())
-> { case o.value(): return o.name(); }
}
}
}
enum class state { started = 1, waiting, stopped };
cout << to_string(state::stopped); //=> stopped
После предварительной обработки компилятор получит код, эквивалентный расписанному вручную switch
.
Хороший вопрос — как отлаживать такой код? Без специальной поддержки со стороны отладчика, позволяющей взглянуть на сгенерированный код, отлаживать такие программы будет тяжело. Тем не менее, функция отладки кода, выполняющегося на этапе компиляции, была бы в любом случае полезна, ровно как и отладки кода, сгенерированного компилятором (многие хотели бы видеть default-конструкторы и операторы копирования при отладке).
Метаклассы
Определение метакласса (не путать с метатипом) начинается с ключевого слова $class
, внутри можно определять генерируемые функции и условия, накладываемые на класс. При определении обычного класса можно вместо class
указать имя метакласса, чтобы с его помощью преобразовать код класса. Выглядит это так:
$class interface {
// только публичные чисто виртуальные функции
// не содержит переменные, конструкторы копирования и перемещения
// виртуальный деструктор
};
interface Shape {
int area() const;
void scale_by(double factor);
};
Такое определение Shape
эквивалентно следующему:
class Shape {
public:
virtual int area() const = 0;
virtual void scale_by(double factor) = 0;
virtual ~Shape() noexcept { };
};
В определении interface
можно использовать constexpr
-блоки и рефлексию, а также получать и модифицировать "свой" метатип:
$class interface {
~interface() noexcept { }
constexpr {
compiler.require($interface.variables().empty(),
"interfaces may not contain data members");
for (auto f : $interface.functions()) {
compiler.require(!f.is_copy() && !f.is_move(),
"interfaces may not copy or move; consider a virtual clone()");
if (!f.has_access()) f.make_public();
compiler.require(f.is_public(), "interface functions must be public");
f.make_pure_virtual();
}
}
}
Изменять произвольный класс уже после объявления невозможно, то есть ODR остаётся в силе.
Некоторые размышления о метаклассах
Метакласс можно понимать как подвид класса. У классов C++ так много свойств и видов переопределяемого поведения, что часто бывает удобно ограничить их возможности, чтобы уменьшить путаницу.
Важно, что метаклассы определяются в коде C++, а не в компиляторе. Комитет стандартизации C++ работает медленно, и потребовались бы годы, прежде чем можно было бы добиться утверждения конкретных метаклассов в стандарте C++.
Примеры метаклассов
value
value Point {
int x = 0;
int y = 0;
Point(int, int);
}
То, что Point
является value
, означает, что у него определены конструктор по умолчанию, операции копирования/перемещения и сравнения; гарантируется отсутствие виртуальных функций.
На примере value
была показана композиция ("наследование") метаклассов:
$class basic_value { ... }
$class ordered { ... }
$class value : basic_value, ordered { }
Понятно, что не нужно никакого operator<=>
, чтобы использовать ordered
и генерировать операторы сравнения на основе полей класса.
literal_value
Планируется, что с помощью метаклассов можно будет генерировать и вспомогательные классы/функции, вроде swap и специализации std::hash. Тогда при помощи гипотетического метакласса literal_value
можно будет написать простое и красивое определение std::pair
, которого этот класс заслуживает:
template<class T1, class T2>
literal_value pair {
T1 first;
T2 second;
};
enum_class
Страуструп молодец, что не стал внедрять ключевые слова вроде interface
в язык, так что теперь все пользовательские типы можно определять при помощи ключевого слова class
… Кроме enum
! К счастью, метаклассы — достаточно мощный инструмент, чтобы определить enum class
. В результате можно будет писать:
enum_class state {
auto started = 1, waiting, stopped;
};
state s = state::started;
while (s != state::waiting) { ... }
Эти enum_class
могут быть даже лучше, чем существующие за счёт дополнительных автоматически генерируемых функций и информативных сообщений об ошибках.
flag_enum
Значения flag_enum
можно использовать как множества флагов, с операциями |
, &
, ^
:
flag_enum openmode {
auto in, out, binary, ate, app, trunc; // 1, 2, 4, 8, 16, 32
}
openmode mode = openmode::in | openmode::out;
assert(mode != openmode::none);
assert(mode & openmode::out);
property
(свойства)
classx MyClass {
property<int> value { }; // get/set по умолчанию
// ...
};
Маркер property<>
используется метаклассом classx
для генерации свойства:
class MyClass {
int value;
public:
void set_value(int v) { value = v; }
int get_value() const { return value; }
};
Также при определении property
можно задать свои функции get
/set
.
QClass
Qt использует утилиту moc, которая по определению класса генерирует данные для рефлексии времени выполнения, свойства и прочее. Метакласс QClass
мог бы со всем этим справиться, позволяя собирать код, использующий Qt, только при помощи компилятора C++:
QClass MyClass {
property<int> value { };
signal mySignal();
slot mySlot();
};
Помимо Qt moc, метаклассы позволят избавиться от нагромождения макросов и от проприетарных расширений языка, вроде WinRT и C++/CX.
podio
Библиотека podio
, работающая с физикой частиц, использует YAML-файлы специального вида для определения структур данных:
ExampleHit :
Description : "Example Hit"
Author : "B. Heigner"
Members:
- double x
- double y
- double z
- double energy
При этом на выходе генерируется сразу 5 классов: X
, XCollection
, XConst
, XData
, XObj
. Утверждается, что с помощью метакласса получится делать то же самое.
CRTP
CRTP — это техника, при которой базовому классу передаётся производный в качестве шаблонного параметра:
template<typename Derived>
class EqualityComparable {
public:
friend bool operator !=(const Derived& a, const Derived& b)
{ return !(a == b); }
};
class X : public EqualityComparable<X> {
public:
friend bool operator==(const X& a, const X& b) { ... }
};
Фактически, мы уже используем некоторое подобие метаклассов в своих проектах. Только вот в достаточно сложном коде с шаблонами сообщения об ошибках оставляют желать лучшего.
Демонстрация
Herb продемонстрировал модификацию Clang, поддерживающую всё, что было показано выше, кроме генерации нескольких классов/функций из одного класса. Код, полученный после применения метакласса, можно просматривать и отлаживать.
Вопросы
Что если N компаний определят interface
?
С классами строк уже это происходит, но это не значит, что от обычных классов надо отказаться. Основные метаклассы, вроде interface
, можно будет добавить в std
.
Какова польза от возможности определения interface
в коде C++, если вы всё равно хотите стандартизировать некоторые популярные метаклассы?
Чтобы сейчас внести в стандарт interface
, потребуется огромное количество времени, и проблема, связанная, с его отсутствием в языке, не настолько велика, чтобы кто-либо стал ей заниматься. Если же interface
можно будет реализовать в 10 строк C++, то он пролетит через комитет стандартизации "со свистом".
Вы говорили, что метаклассы, определяющие сущности C++ в самом C++, лучше, чем определение их в стандарте. Также вы говорили, что язык стандарта C++ очень сложен. Не получится ли, что C++ усложнится до уровня языка стандарта C++?
Мы не пытаемся определить мета-C++. Метаклассы выполняют конкретную функцию генерации элементов класса, и это можно реализовать без чрезмерного усложнения языка.
Метаклассы модифицируют свой класс "на месте". Не было бы лучше, если бы у метакласса был "входной класс", и он генерировал бы код в "выходном классе"?
Да, мы собираемся поменять синтаксис, чтобы всё работало именно так. Выходной класс будет называться $prototype
. Вначале он будет пустым, и метакласс будет вставлять в его определение строки одну за другой.
Вы сказали, что из одного метакласса можно будет генерировать несколько классов. Значит ли это, что все классы будут вложены в основной, или это будут отдельные классы?
Это будут отдельные классы. Текущая версия нашего компилятора не поддерживает эту функцию, но мы над ней работаем. Без генерации вспомогательных внешних функций, например, невозможно определить literal_value
.
Если literal_value
окажется в стандарте, будет ли класс std::pair действительно изменён так, как было показано?
Если удастся определить literal_value
так, чтобы в точности соответствовать текущему поведению std::pair, то да.
Получится ли полностью избавиться ото всех приёмов с шаблонами при генерации кода C++?
Шаблоны будут использоваться с метаклассами. Посмотрите пример с property<>
.
Не получится ли так, что в каждой компании будет свой "обязательный" базовый метакласс, от которого должны будут наследоваться все классы, что приведёт к фрагментации языка?
Это уже происходит, и это называется "корпоративный стиль кода". Отличие в том, что он описывается словами и, возможно, проверяется различными инструментами. Теперь он будет соблюдаться более строго, предсказуемо и корректно. Когда мы тестировали удобство использования метаклассов на реальных проектах, мы обнаружили, что в каждом проекте им нашлось различное применение. Например, один из проектов включал библиотеку для управления роботом, которая требовала, чтобы все ваши классы следовали определённому шаблону. Сейчас они следят за соблюдением этих правил вручную. Периодически о них забывают, появляются трудноотлавливаемые ошибки и т.д. С помощью всего одного метакласса в 20-30 строк кода они могли бы устранить эту проблему раз и навсегда.
CRTP — отличная техника, похожая на метаклассы, работающая уже сейчас. Если её дополнить рефлексией и constexpr
-блоками, то у них будет столько же возможностей, что и у метаклассов. Зачем тогда они нужны?
У метаклассов больше возможностей. CRTP — это замечательный "хак", но шаблоны изначально не были для этого предназначены, это недостаточно точный и функциональный инструмент. Например, CRTP позволяет по ошибке переопределить генерируемые методы в производном классе. Используя CRTP, не получится определять вспомогательные глобальные функции и классы.
Автор: Anton3