Как стать автором
Обновить

Метапрограммирование в C++ и русская литература: через страдания к просветлению

Время на прочтение23 мин
Количество просмотров62K
«Библиотеки для C++ нередко похожи на русскую классику: страдает либо их автор, либо пользователь, либо архитектура». Автор этой цитаты, Сергей Садовников из «Лаборатории Касперского», прошел свой путь от страданий к просветлению и узнал о метапрограммировании в С++ нечто важное и нужное. Сочувствующих приглашаем в волшебный мир макросов, шаблонов, boost и прочих loki или сразу по ссылке в «Лабораторию Касперского».

1. Всё есть страдание
Для начала возьмём довольно простую задачку: написать класс, реализующий вариантный тип. Чтобы пользователь этого класса мог тем или иным образом задать варианты хранимых типов и дальше пользоваться. Например, так:
my_variant<int, char, std::string> val("Hello World!");
Встроенный в язык union для этого не годится — не обеспечивает нужного контроля. Да и более-менее вменяемым (с точки зрения C++) его сделали только начиная с C++11. Другой подход к реализации — с помощью шаблонов. Хороший подход, грамотный, но при его использовании очень быстро начинаются боль и страдания. К счастью, чаще у разработчика библиотеки, а не у пользователя.
Типы конфликтов и страданий в литературе
1.1 Макросы
Если одной бессонной ночью в поисках реализаций вариантных типов в C++ зайти на гитхаб, например сюда, то в коде реализации можно увидеть три, четыре, а то и пять — нет, не звёздочек, а фрагментов наподобие такого:
#define variant_TL1( T1 ) detail::typelist< T1, detail::nulltype >
#define variant_TL2( T1, T2) detail::typelist< T1, variant_TL1( T2) >
#define variant_TL3( T1, T2, T3) detail::typelist< T1, variant_TL2( T2, T3) >
#define variant_TL4( T1, T2, T3, T4) detail::typelist< T1, variant_TL3( T2, T3, T4) >
#define variant_TL5( T1, T2, T3, T4, T5) detail::typelist< T1, variant_TL4( T2, T3, T4, T5) >
#define variant_TL6( T1, T2, T3, T4, T5, T6) detail::typelist< T1, variant_TL5( T2, T3, T4, T5, T6) >
#define variant_TL7( T1, T2, T3, T4, T5, T6, T7) detail::typelist< T1, variant_TL6( T2, T3, T4, T5, T6, T7) >
#define variant_TL8( T1, T2, T3, T4, T5, T6, T7, T8) detail::typelist< T1, variant_TL7( T2, T3, T4, T5, T6, T7, T8) >
#define variant_TL9( T1, T2, T3, T4, T5, T6, T7, T8, T9) detail::typelist< T1, variant_TL8( T2, T3, T4, T5, T6, T7, T8, T9) >
#define variant_TL10( T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) detail::typelist< T1, variant_TL9( T2, T3, T4, T5, T6, T7, T8, T9, T10) >
#define variant_TL11( T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) detail::typelist< T1, variant_TL10( T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) >
#define variant_TL12( T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12) detail::typelist< T1, variant_TL11( T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12) >
#define variant_TL13( T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13) detail::typelist< T1, variant_TL12( T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13) >
#define variant_TL14( T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14) detail::typelist< T1, variant_TL13( T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14) >
#define variant_TL15( T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15) detail::typelist< T1, variant_TL14( T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15) >
#define variant_TL16( T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16) detail::typelist< T1, variant_TL15( T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16) >
См. GitHub.
Звезд у таких библиотек обычно гораздо больше. Тот, кто читал Александреску, без труда узнает здесь описание генераторов списка типов. Для чего нужны такие списки в реализации вариантных типов — догадаться несложно. Разработчик упрощает себе жизнь, обобщая реализацию различных функций, манипулирующих с вариантом. А для этого хранимые в варианте типы может оказаться проще передавать в виде одного типа-параметра (списка-кортежа), чем в виде явного перечня.
Да, да, конечно, новые стандарты (начиная с C++11) допускают создание шаблонов с переменным списком параметров. Это упрощает жизнь, иногда сильно, но манипулировать такими списками может оказаться не всегда удобно.
Впрочем, сейчас речь о макросах, которые использовались для реализации задач метапрограммирования ещё тогда, когда Бьерн Страуструп только придумывал C++. Макросы — это инструмент препроцессора, который позволяет сгенерировать часть исходного текста программы непосредственно во время компиляции. Сгенерировать по определённым правилам, как в примере выше. Упоминание макроса в исходном тексте заменяется на его тело, при этом может быть выполнена конкатенация токенов, некоторые другие их простые преобразования. И программисту на C++ становится чуть проще, так как чуть меньше кода приходится писать непосредственно руками. В некоторых случаях макросы действительно упрощают жизнь и уменьшают уровень страдания. Но, если увлечься, можно начать придумывать конструкции типа таких:
# /* Property-holder class property presence checker */
#
# define HAS_PROPERTY_PROPERTY_IMPL_GENERIC(ClassName, PI) \
else if (name == BOOST_PP_STRINGIZE(PROP_NAME(PI))) {return true;}
# define HAS_PROPERTY_PROPERTY(_, ClassName, PI)\
BOOST_PP_CAT(HAS_PROPERTY_PROPERTY_IMPL_, PROP_KIND(PI))(ClassName, PI)
# define PROPERTIES__DO_HAS_PROPERTY(ClassName, S) \
BOOST_PP_SEQ_FOR_EACH(HAS_PROPERTY_PROPERTY, ClassName, S)
# define MAKE_BASE_HAS_PROPERTY_CALL(ClassName, BaseClassName) \
return static_cast<BaseClassName const*>(m_Host)->properties__.has_property(name);
Полный набор можно посмотреть здесь.
После чего сказать, как оно работает и во что будет раскрываться, становится довольно сложной задачей. И, главное, чтобы писать такие конструкции, надо довольно хорошо представлять себе, как именно работает препроцессор, при этом всегда держа в голове простой факт: препроцессор работает до компилятора. Он работает с исходным текстом как с набором токенов, а не как с программой на C или C++. В сущности препроцессор можно использовать для обработки любых текстовых файлов. Эта его особенность, его сила (и одновременно слабость) не позволяет использовать конструкции препроцессора в задачах, где требуется вмешательство в структуру исходного текста на уровне семантики.
1.2 Шаблоны
В этом случае на помощь приходят шаблоны. Долгое время (до известной книжки Александреску) шаблоны использовались в основном утилитарно. Но пришёл 2001 год и стало понятно, что в спецификации C++, сугубо императивного языка, присутствует тьюринг-полный декларативный язык, имеющий много атрибутов функционального. И понеслась...
Диалог на собеседовании:
— Александреску читали?
— Да.
— В продакшн-коде его подходы используете?
И долгий испытующий взгляд на кандидата в ожидании ответа.

В метапрограммировании на шаблонах, пока сложность решаемых задач ограничивается несложными функциями или классами, где реализация и используемые подходы очевидны, — всё хорошо. Но вот вернёмся к нашему my_variant и попробуем написать для него оператор вывода в поток, инициализирующий конструктор или, скажем, метод apply_visitor (с нуля). Сложность кода возрастает сразу и по экспоненте, даже если выражаться всё будет в десятке строчек. Например, так:
template< size_t NumVars, typename Visitor, typename ... V >
struct VisitorImpl
{
    typedef decltype(std::declval<Visitor>()(get<0>(static_cast<const V&>(std::declval<V>()))...)) result_type;
    typedef VisitorApplicator<result_type> applicator_type;
};
} // detail
// No perfect forwarding here in order to simplify code
template< typename Visitor, typename ... V >
inline auto visit(Visitor const& v, V const& ... vars) -> typename detail::VisitorImpl<sizeof ... (V), Visitor, V... > ::result_type
{
    typedef detail::VisitorImpl<sizeof ... (V), Visitor, V... > impl_type;
    return impl_type::applicator_type::apply(v, vars...);
}
В рамках C++14 этот код будет выглядеть несколько проще. Но если все же требуется совместимость с одиннадцатым или, о ужас, ноль-третьим, то разнообразные удовольствия и приключения разработчику обеспечены. В любом случае, независимо от минимального стандарта, в его арсенале должны быть такие техники, как SFINAE (для шаблонных функций и классов), работа с пакетами параметров (их индексация, модификация), знание элементов функционального подхода (каррирование, фолдинг, pattern matching, функции второго порядка и т. п.). Он должен хорошо представлять себе, что можно и чего нельзя сделать с помощью шаблонов ну и, конечно, уметь разбираться с такими ошибками:
Или такими.
И, что важно, у всех этих техник есть один нюанс: в продакшене кто-то должен их поддерживать. Порог входа в эту область разработки очень высок, а потому программистов на C++, способных без гугла и stackoverflow написать что-нибудь этакое на шаблонах с использованием SFINAE и прочего, не то чтобы много. И тут главное, чтобы те, к кому такой вот код попадёт на поддержку, не оказались теми самыми маньяками, которые знают, где искать автора, и которых вопрос: «тварь я дрожащая или право имею» не остановит. Мне вот, скажем, коллеги до сих пор вспоминают несколько довольно заковыристых фрагментов кода, что я когда-то писал в сильной запарке. Очень надеюсь, эти куски уже выпилили.
1.3 boost и прочие loki
Странно было бы думать, что разработчики никак не пытаются облегчить страдания себе и другим. Хотя бы пользователям библиотек. И да, есть энтузиасты (и не очень), которым это действительно удаётся. Они тратят много времени и сил на разработку инструментария, который помогает сгладить эффект от отсутствия в языке вменяемой поддержки метапрограммирования, рефлексии и прочих подобных штук. Одним из первых был Андрей Александреску со своей Loki. Библиотека прекрасно иллюстрировала концепции, изложенные в его книге, и была прорывом для своего времени. Сейчас поддерживается энтузиастами, а автор (работая в Facebook) пишет folly и активно трудится над языком D.
Примерно в то же время в boost появилась библиотека MPL, которую написали Алексей Гуртовой (Aleksey Gurtovoy) и Дэвид Абрахамс (David Abrahams). Она же легла в основу многих более дружественных к пользователю решений. Основная идея MPL — позволить разработчику оперировать с коллекциями типов так же, как с обычными. В библиотеке реализованы, собственно, коллекции разного вида (списки, массивы, наборы и т. п.), операции над ними и различные метафункции. Если серьёзно забираться в метапрограммирование — boost.mpl сильно упрощает жизнь, поскольку в ней уже реализован весь тот инструментарий, который так или иначе пришлось бы писать вручную. Но, с другой стороны, дизайн библиотеки таков, что с привычными обычному C++-разработчику императивными и объектно-ориентированными взглядами, разобраться в boost.mpl будет непросто.
Потом появился boost.fusion, или «кортежи на стероидах» за авторством Джоэля де Гузмана (Joel de Guzman), Дэна Марсдена (Dan Marsden) и Тобиаса Швингера (Tobias Schwinger). Библиотека, по дизайну напоминающая одновременно MPL и STL, является этаким мостиком между миром компайл-тайма и рантайма. Позволяя создавать (или описывать) сложные кортежи типов (или пар «тип-значение») с одной стороны, она предоставляет алгоритмы манипуляции с этими кортежами в рантайме с другой. Например:
vector<int, char, std::string> stuff(1, 'x', "howdy");
struct print_xml
{
    template <typename T>
    void operator()(T const& x) const
    {
        std::cout
            << '<' << typeid(x).name() << '>'
            << x
            << "</" << typeid(x).name() << '>'
            ;
    }
};
for_each(stuff, print_xml());
А последние пару лет набирает популярность boost.hana. Это boost.fusion, но с constexpr'ом и лямбдами. Автор hana, Луис Дионе (Louis Dionne), используя на полную мощь все возможности новых стандартов, в некотором смысле очеловечил метапрограммирование. С помощью hana всяческие манипуляции с типами, их кортежами, отображениями, а также компайл- и рантайм-работа с ними обрели практически человеческое лицо и читаемый вид. Ну, с поправкой на синтаксис C++, разумеется. Вот, например, как выглядит универсальный сериализатор структур, написанный с помощью hana:
// 1. Give introspection capabilities to 'Person'
struct Person {
  BOOST_HANA_DEFINE_STRUCT(Person,
    (std::string, name),
    (int, age)
  );
};
// 2. Write a generic serializer (bear with std::ostream for the example)
auto serialize = [](std::ostream& os, auto const& object) {
  hana::for_each(hana::members(object), [&](auto member) {
    os << member << std::endl;
  });
};
// 3. Use it
Person john{"John", 30};
serialize(std::cout, john);
Ну да, структуры приходится описывать особым образом. А кому сейчас легко? И тут хочется также отметить библиотеку tinyrefl за авторством Manu Sánchez (Мануэля Санчеса). Довольно неплохая попытка принести в мир C++-разработки статическую рефлексию без расширения компилятора. Для своей работы библиотека требует стороннюю утилиту (cppast), но зато предоставляет довольно удобный доступ к информации о структурах, которую можно использовать в процессе разработки программы. Преимущество этой библиотеки (по сравнению с другими) в том, что для работы с ней не надо использовать «птичий язык», а элементы структур (как и сами структуры) можно произвольным образом атрибутировать прямо в исходном коде:
struct [[serializable]] Person {
    std::string name;
    int age;
};
template<typename Class>
auto serialize(std::ostream& os, Class&& object) -> std::enable_if_t<
        tinyrefl::has_metadata<std::decay_t<Class>>() &&
        tinyrefl::has_attribute<std::decay_t<Class>>("interesting"),
    std::ostream&>
{
    tinyrefl::visit_member_variables(object, [&os](const auto& /* name */, const auto& var) {
        os << var << std::endl;
    });
    return equal;
}
Таких примеров можно привести ещё много (или найти на гитхабе или гитлабе). Объединяет все эти библиотеки и инструменты одна особенность: значительно облегчая жизнь своим пользователям, они имеют такую реализацию, что остаётся лишь предполагать, какое количество страданий испытали их разработчики. Достаточно заглянуть в реализацию, увидеть забор из ifdef'ов и угловых скобок — и всё понятно. Нередко эти реализации делаются на грани возможностей компиляторов. workaround'ы банальных ошибок (или особенностей реализации языка конкретной версии конкретного компилятора с конкретными флагами) здорово раздувают их код. Желание поддерживать несколько стандартов сразу заставляет придумывать зубодробильные конструкции. Безусловно, простая попытка реализовать что-нибудь подобное здорово поднимает уровень владения языком (иногда — и самооценку). Но в какой-то момент, после многочасовой возни с очередной ошибкой, или непроходящим тестом, или крэшем компилятора руки опускаются, хочется плюнуть и бросить, ибо силы бороться иссякают.
1.4 Генерация кода
Но это, как говаривал Барон, ещё не все. Однажды в мир разработки C++ пришёл clang со своим фронтендом (а чуть позже — с libtooling), и мир изменился, заиграл новыми красками. Появилась надежда. Ибо появился инструмент, позволяющий относительно просто (относительно!) анализировать исходные тексты на C++ и что-нибудь с результатами этого анализа делать. Появились (и стали набирать популярность) утилиты типа clang tidy, clang format, clang static analyser и прочие. А некоторые разработчики приспособили clang frontend для решения задач рефлексии. Так, скажем, работают упомянутые выше cppast (за авторством Jonathan Müller) и tinyrefl на её базе. Так работает автопрограммист, используемый в нашей компании для кодогенерации (или его open-source-аналог).
Разумеется, и до появления clang писались генераторы на основе исходного кода C++. Кто-то использовал для этого Python с самописными парсерами, кто-то разбирал C++ с помощью regexp'ов. Кто-то, как автор этой статьи, писал самопальный парсер на C++. Но, полагаю, с развитием инструментария это будет уходить в прошлое.
Идея проста. Берётся исходный текст на C++ (например, объявления структур), пропускается через clang frontend, и на основе полученного AST генерируется новый исходный текст. Например вот так:
out::BracedStreamScope flNs("\nnamespace flex_lib", "\n\n", 0);
hdrOs << out::new_line(1) << flNs;
for (reflection::EnumInfoPtr enumInfo : enums)
{
    auto scopedParams = MakeScopedParams(hdrOs, enumInfo);
    {
        hdrOs << out::new_line(1) << "template<>";
        out::BracedStreamScope body("inline const char* Enum2String($enumFullQualifiedName$ e)", "\n");
        hdrOs << out::new_line(1) << body;
        hdrOs << out::new_line(1) << "return $namespaceQual$::$enumName$ToString(e);";
    }
    {
        hdrOs << out::new_line(1) << "template<>";
        out::BracedStreamScope body("inline $enumFullQualifiedName$ String2Enum<$enumFullQualifiedName$>(const char* itemName)", "\n");
        hdrOs << out::new_line(1) << body;
        hdrOs << out::new_line(1) << "return $namespaceQual$::StringTo$enumName$(itemName);";
    }
}
См. GitHub.
Можно отобразить отрефлексированный AST внутрь какого-нибудь движка текстовых шаблонов, например на базе Jinja2, и получить возможность писать генерируемые c++/h-файлы в виде относительно простых текстовых шаблонов:
inline const char* {{enumName}}ToString({{scopedName}} e)
{
    switch (e)
    {
    {% for itemName in enum.items | map(attribute="itemName") | sort%}
    case {{prefix}}{{itemName}}:
        return "{{itemName}}";
    {% endfor %}
    }
    return "Unknown Item";
}
См. GitHub.
Подход с текстовым шаблонизатором особенно хорош тем, что позволяет отделить собственно шаблоны, по которым генерируются файлы, от средства генерации. Кроме того, он позволяет работать со структурой отрефлексированного текста в императивном стиле, привычном большинству разработчиков на C++. Таким образом, например, в последнем проекте, над которым я работаю, из нескольких заголовочных файлов с объявлениями конфигурационных структур генерируются (в процессе сборки) сериализаторы, валидаторы, отображения структур на RPC-сущности. Разработчику достаточно лишь написать с помощью Jinja2-нотации шаблон нужного файла и добавить пару инструкций в cmake-файл.
Знание всех этих приёмов и техник хорошо и полезно. И чем опытнее C++-разработчик, чем лучше он знает, как облегчить себе жизнь, и тем более сложные вещи он может создавать. Но всё это не отменяет как высокую сложность, так и высокий порог входа в эту сферу разработки на C++. И страданий (иногда вполне реальных), которые можно испытывать при написании такого вот кода.
2. Существует причина: сложные абстракции
У этих страданий существует причина. Это желание. Желание манипулировать в процессе разработки сложными абстракциями и делать это удобно и эффективно. Простое и логичное, в общем, желание. В разработке и проектировании существует принцип DRY: Don't Repeat Yourself. Парадигмы C++ позволяют многое обобщать. Но (как можно увидеть выше) с определённого уровня всё становится очень сложно, и возникает вопрос цены такого обобщения для конкретной команды или разработчика. Всё хорошо, пока задача укладывается в классическое ООП, но вот когда требуется более сложное обобщение — на уровне классов, групп типов и прочее... В более молодых языках (Java, C#, Python и т. п.) есть встроенная рефлексия и она помогает. В C++ же, увы, рефлексия пока только на уровне пропозала и технической спецификации.
Типы конфликтов и страданий в литературе
2.1 Тривиальные задачи
Вот, скажем, один из самых популярных примеров. Можно сказать, классических. Сериализуются данные в, скажем, json. В структуре есть enum-поле, которое хочется сохранять в текстовом виде (а не числом). Всё. Стоп. Простого способа решить эту элементарную задачу на C++ не существует. Предлагаемые варианты решения — от описания enum'ов с помощью макросов, как в примере выше про boost.hana, до perl-скриптов для парсинга C++-исходников и генерации нужных функций. Магия на define'ах, магия на шаблонах, магия на скриптах, кодогенераторы — для решения задачи, которая (по всякому разумению) должна решаться средствами «из коробки». Вместо этого, как Лев Николаевич, строчка за строчкой, том за томом выводишь что-то на смеси C++ и препроцессора и страдаешь:
#define FL_MAKE_STRING_ENUM_NAME(Entry) Entry
#define FL_MAKE_WSTRING_ENUM_NAME(Entry) L ## Entry
#define FL_DECLARE_ENUM2STRING_ENTRY_IMPL(STRING_EXPANDER, EntryId, EntryName) \
        case EntryId: \
                result = STRING_EXPANDER(EntryName); \
                break;
#define FL_DECLARE_STRING2ENUM_ENTRY_IMPL(STRING_EXPANDER, EntryId, EntryName) result[STRING_EXPANDER(EntryName)] = EntryId;
#define FL_DECLARE_ENUM2STRING_ENTRY(_, STRING_EXPANDER, Entry) FL_DECLARE_ENUM2STRING_ENTRY_IMPL(STRING_EXPANDER, BOOST_PP_TUPLE_ELEM(3, 0, Entry), BOOST_PP_TUPLE_ELEM(3, 2, Entry))
#define FL_DECLARE_STRING2ENUM_ENTRY(_, STRING_EXPANDER, Entry) FL_DECLARE_STRING2ENUM_ENTRY_IMPL(STRING_EXPANDER, BOOST_PP_TUPLE_ELEM(3, 0, Entry), BOOST_PP_TUPLE_ELEM(3, 2, Entry))
        
#define FL_DECLARE_ENUM_STRINGS(EnumName, MACRO_NAME, S) \
inline EnumName ## _StringMap_CharType const* EnumName##ToString(EnumName e) \
{ \
    EnumName ## _StringMap_CharType const* result = NULL; \
    switch (e) \
    { \
        BOOST_PP_SEQ_FOR_EACH(FL_DECLARE_ENUM2STRING_ENTRY, MACRO_NAME, S) \
    } \
                \
    return result; \
}
См. GitHub.
На StackOverflow я как-то находил примерно шестнадцать вопросов на эту тему. Полагаю, сейчас их больше.
Да и собственно любая попытка в общем виде реализовать ту же сериализацию (ну так, чтобы универсально и без лишней магии) — хоть в json, хоть в бинарный поток, хоть в базу данных — очень быстро закончится танцами с макросами, шаблонами, специальными компиляторами (как у protobuf, скажем) и всё это с массой условностей и под аккомпанемент шаманского бубна. Ну нельзя в C++ (по крайней мере пока) взять произвольный тип и забраться к нему в потроха.
2.2 Паттерны
Знаменитая книжка GoF про паттерны сопровождается множеством примеров на C++. Примеры простые, аккуратные, иллюстративные. Для каждого паттерна. И только читавшие Александреску понимают, как на самом деле реализуются относительно несложные архитектурные концепции на C++. Вот тот же паттерн Visitor («Посетитель»). Иначе как: «Господа, я пришёл сообщить вам пренеприятнейшее известие: нам надо реализовать обобщённый Visitor» про него и не скажешь. И если с динамическими визиторами для закрытых иерархий типов всё ещё более-менее ничего (здесь можно посмотреть классическую реализацию), то вот со статическими...
Разработчики вариантных типов обычно заморачиваются, делая интерфейс максимально удобным для пользователя. Чаще всего достаточно объявить класс с перечнем методов для нужных типов, как-то так:
struct ObjectInjector : boost::static_visitor<>
{
public:
    ObjectInjector(ClassInfoPtr targetClass, reflection::AccessType accessType)
        : m_target(targetClass)
        , m_accessType(accessType)
    {
    }
    
    void operator()(MethodInfoPtr method) const
    {
        auto m = std::make_shared<MethodInfo>(*method);
        if (m_accessType != reflection::AccessType::Undefined)
        {
            m->accessType = m_accessType;
        }
        
        m_target->methods.push_back(m);
    }
    
    template<typename U>
    void operator()(U) const
    {
    }
    
private:
    ClassInfoPtr m_target;
    reflection::AccessType m_accessType;
};
Cм. GitHub.
И вызвать метод, применяющий такой визитор к конкретному экземпляру варианта:
boost::apply_visitor(ObjectInjector(targetClass, ConvertVisibility(visibility)), obj.GetValue());
Но вот если заглянуть под капот реализации, всё становится значительно интереснее и мрачнее. Рука сама тянется к топору, а глаза ищут ту процентщицу, что ссудила несколько книжек по C++ в счёт будущей зарплаты, говоря, что это — именно то, что надо для старта! Фарш из макросов и шаблонов на несколько сот строчек при желании написать обобщённую реализацию с поддержкой n-арной диспетчеризации — это именно то, что нужно для простого в сущности типа. Ну да, нет статической рефлексии. Нет возможности легко покопаться в свойствах типов и функций, размножить нужный кусок кода. Вот и приходится — либо как по ссылке выше, либо с использованием той же Jinja:
template<typename Visitor, typename V1>
static R apply(const Visitor& v, const V1& arg)
{
    switch( arg.index() )
    {
    {% for n in range(NumParams) -%}
    case {{n}}: return apply_visitor<{{n}}>(v, arg);
    {% endfor %}
    default: return R();
    }
}
См. GitHub.
Тут надо отметить, что паттерн «мультиметод» — это отдельная головная боль. Референсная реализация от Александреску занимает 416 строк. Реализация «в полный рост» с поддержкой нескольких стандартов, n-aрной диспетчеризации — уже 719 при сопоставимом объёме комментариев. И, что важно, сходу, имея лишь общие представления о возможностях метапрограммирования в C++, такое не напишешь. Локальные классы, шаблоны шаблонов, шаблоны-члены классов и прочий весьма специфичный инструментарий — это то, с чем придётся познакомиться довольно быстро.
И так — с любой более-менее сложной обобщённой концепцией или паттерном. Стоит ступить чуть дальше простого наследования (или простых шаблонов), попытаться обобщить чуть больше — наступают боль и страдания. К счастью, финал этой истории не так мрачен, как у большинства произведений русских классиков, и луч света в этом царстве чёрной магии и шаманства хорошо различим. И луч этот — метаклассы.
3. Выход есть: метаклассы
Недавно на выставке GPU Technology Conference 2019 nVidia показала интересный инструмент для «начинающих художников». Человек рисует нечто разноцветно-абстрактное на виртуальном холсте, а программа делает (в реальном времени) из этого рисунка пейзаж. Теперь даже не надо смотреть видео-уроки Боба Росса: мазок коричневым тут, линия синим там, и вот на экране — реки, поля, горы, водопады... Полагаю, с таким инструментом Остап не спрашивал бы у Кисы: «Вы рисовать умеете?» Впрочем, нет. Сеятеля ему бы пришлось всё равно рисовать самому. Слышал, что нечто подобное сделали для верстальщиков. Инструмент, который по дизайн-макету делает готовую вёрстку в html. К счастью, для программистов ничего подобного, на специально и глубоко обученных нейросетях, не придумали. Пока. Зато придумали кое-что другое.
Типы конфликтов и страданий в литературе
3.1 Основы концепции
Макросы (C++), шаблоны (C++98), свойства типов и constexpr'ы (C++11), концепты (C++20) — с каждым новым стандартом и витком развития язык предоставляет разработчику всё больше информации о том, что творится в недрах компилятора: начиная от манипуляции на уровне лексем и заканчивая compile-time-вычислениями и декларативным описанием свойств пользовательских типов. Логичное завершение (или продолжение?) этой цепочки — предоставление разработчику возможности напрямую манипулировать процессом компиляции кода на уровне AST. И такое предложение уже есть.
Это может выглядеть забавно, но сейчас уже заходит речь об интеграции в компилятор оптимизирующих JIT-движков. Потому как constexpr'ы как-то медленно вычисляются...
Метаклассы. Новая для C++ сущность. Новые возможности. И, разумеется, новые способы отстрелить себе ноги аж по шею, если переборщить. Тем не менее, презентация Саттера, посвящённая этой концепции, сделанная два года назад на ACCU 2017, произвела фурор. Из головы не выходила мысль: «Shut up and take my money!» Ибо слишком много проблем разом снимает этот подход, суть которого проста: пишется специального вида функция (называемая «метаклассом»), которая в compile-time позволяет управлять процессом компиляции кода для конкретного пользовательского типа. Например:
constexpr void interface(meta::type target, const meta::type source) {   // 1
    compiler.require(source.variables().empty(), "interfaces may not contain data"); // 2
    
    for (auto f : source.functions())  {   // 3
        compiler.require(!f.is_copy() && !f.is_move(), "interfaces may not copy or move; consider a virtual clone() instead"); // 3.1
        
        if (!f.has_access()) // 3.2
            f.make_public();
        
        compiler.require(f.is_public(), "interface functions must be public"); // 3.3 
        
        f.make_pure_virtual();   // 3.4
        
        ->(target) f; // 3.5
    }
    ->(target) { virtual ~(source.name()$)() noexcept {} } // 4
}
См. pdf.
Что делает эта функция-метакласс:
  1. Берёт декларацию типа, к которому этот метакласс применили (параметр source).
  2. Проверяет, что декларация не содержит переменных и, если содержит, генерирует ошибку.
  3. Для каждой функции, объявленной в декларации source выполняет следующее:
    1. Проверяет, что это не copy/move-конструктор (иначе — ошибка).
    2. Для функций без явного заданного спецификатора видимости задаёт его как 'public'.
    3. Проверяет, что функция — не private или protected.
    4. Делает функцию pure virtual.
    5. Добавляет её в генерируему декларацию (target).
  4. Добавляет виртуальный деструктор.
И теперь, будучи применённой, скажем, таким вот образом к реальной декларации в коде:
interface Shape {
    int area() const;
    void scale_by(double factor);
};
или, альтернативный вариант:
class(interface) Shape {
    int area() const;
    void scale_by(double factor);
};
('interface' здесь - это название метакласса) внутри компилятора создаст декларацию:
class Shape {
public:
    virtual ~Shape() {}
    virtual int area() const = 0;
    virtual void scale_by(double factor) = 0;
};
Что, собственно, произошло? Не ошибусь, если скажу, что в каждой компании, в которой есть CSG, и в которой в архитектуре ПО используются интерфейсы, в этом самом coding style guide есть раздел примерно следующего содержания:
  1. Все функции интерфейсного класса должны быть pure virtual и public.
  2. В интерфейсном классе не должно быть членов данных.
  3. Деструктор интерфейсного класса должен быть явно объявлен и должен быть виртуальным.
Кроме того, в этом же CSG наверняка будут пункты в стиле «Явное лучше неявного» и «То, что может быть проверено на этапе компиляции, должно быть проверено» (если таких пунктов в вашем CSG нет, то их определённо стоит туда добавить). Так вот. Если посмотреть на пример метакласса interface — то легко заметить, что какой-то десяток строк немножко странного кода выбивает пять из пяти этих пунктов. Кроме того, архитектурная концепция (в данном случае «интерфейс») явным образом фиксируется, а её свойства — энфорсятся на уровне компилятора. Теперь разработчик совершенно точно не «забудет» добавить виртуальный деструктор (ну кто хотя бы раз не наступал на эти грабли?), все интерфейсы обладают общими свойствами, для модификации/дополнения этих свойств достаточно изменить всего лишь одно объявление. Объявление метакласса. Ну не чудеса ли? Итак, метаклассы:
  • Позволяют фиксировать умолчания («должен быть виртуальный деструктор» и «все методы должны быть public pure virtual);
  • Позволяют изменять структуру исходной декларации, добавляя/модифицируя/удаляя её элементы («добавить виртуальный деструктор», "сделать метод pure virtual);
  • Позволяют не просто проверять, но накладывать и фиксировать ограничения на пользовательские типы (проверка на отсутствие переменных, проверка на отсутствие непубличных методов и т. п.).
До сих пор ни один элемент языка C++ (или их комбинация) не позволял всего этого в комплексе. Ровно как до сих пор ни один элемент языка не позволял напрямую вмешиваться в процесс интерпретации компилятором деклараций, а также в процесс их «дописывания». Но возможным это станет при соблюдении двух условий: а) появления в языке статической рефлексии и б) реализации механизмов code injection/code generation. И то, и другое — дело пусть и не отдалённого, но будущего. Как и сам пропозал по метаклассам за авторством Герба Саттера.
3.2 Статическая рефлексия
То, что давно есть в других мейнстримовых языках (рефлексия) в C++ до сих пор приходится тем или иным образом эмулировать (см. примеры в первом разделе). Добавленные в одиннадцатый стандарт свойства типов — штука полезная, но с точки зрения рефлексии не более, чем паллиатив. Обойти поля структуры? Элементы перечисления? Параметры функции? Декларации в namespace'е? Всё это недоступно. И вот, каждый пилит себе эрзац этой самой рефлексии под ехидные замечания в стиле: «Пилите, Шура, пилите...» И пилят. А в комитете тем временем пилят техническую спецификацию под названием «C++ Extensions for Reflection» Последняя редакция носит номер N4766. Хороший документ, интересный. Его автор (David Sankel) предлагает ввести новый оператор 'reflexpr(...)', результатом применения которого будет нечто шаблонное, зависящее от аргумента, но обязательно содержащее отрефлексированную информацию об этом аргументе. Например:
template <typename T> std::string get_type_name() {
    namespace reflect = std::experimental::reflect;
    // Для шаблонного параметра reflexpr вернёт типа reflect::Alias с информацией об алиасе некоего типа:
    using T_t = reflexpr(T);
    // Теперь мы получаем информацию об оригинальном типе, с которым инстанцировали get_type_name:
    using aliased_T_t = reflect::get_aliased_t<T_t>;
    // Ну и получаем имя этого типа
    return reflect::get_name_v<aliased_T_t>;
}
Пример (взятый из N4766) несколько искусственный (то же самое в две строчки делается через typeid), но хорошо демонстрирует идею, саму возможность на этапе компиляции получать и анализировать всю доступную информацию о типах, их структуре и свойствах. А дальше — наворачивать поверх собственную логику (как в примере с интерфейсом): обойти все функции, члены данных, проверить спецификатор доступа, обойти базовые классы, и так далее. Собственно, именно то, что так давно хотелось. Появится ли это в двадцать третьем стандарте, или позже — вопрос пока открытый. Но работа идёт, и эта работа над тем, чего так давно хотелось иметь в языке (ну, после концептов и модулей, разумеется).
К сожалению, пропозал Саттера по метаклассам (я в качестве референсного использую p0707 rev. 3) базируется немного на другом подходе к статической рефлексии. Одном из альтернативных, описанном в p0712. Разумеется, когда дело дойдёт до серьёзного обсуждения уже на уровне технической спецификации, одно придёт в соответствие с другим.
В отличие от reflexpr из N4766, Саттер использует следующие операторы:
$... — для получения информации (точнее, метаинформации) о той или иной сущности периода компиляции. Например, $std вернёт коллекцию с информацией о всех декларациях в неймспейсе std.
...$ — для обратного преобразования. Например, f.name()$, встреченное в коде, компилятор должен заменить на результат вызова (в compile-time) f.name(), то есть на некий идентификатор.
Приведённый выше пример в нотации Саттера выглядел бы так:
template <typename T> std::string get_type_name() {
    auto T_t = $T;
    return T_t.name();
}
Насколько я смог понять, у комитета к этой нотации возникли вопросы (относительно использования символа '$'), и Саттеру предложили рассмотреть какие-то другие варианты.
3.3 Code injection
Зачастую недостаточно просто понять, как устроен тот или иной пользовательский тип. Необходимо иметь возможность его модифицировать, иначе метаклассы потеряют добрую половину своей прелести. Традиционно этот механизм называется code injection, и в C++ пока только один пропозал по реализации этого механизма: p0712. Суть подхода довольно проста. У оператора «->» появляется новый контекст использования, который позволяет в compile-time внедрять в код (на самом деле, в дерево разбора) новые элементы. Например, так:
->(target) {virtual ~(source.name()$)() noexcept {} }
Здесь в сущность target (которая отображена на создаваемый экземпляр метакласса) внедряется виртуальный деструктор, имя которого совпадает с именем этой самой сущности. Более сложный вариант:
template<basic_enum E> // constrained to enum types
std::string to_string(E e) {
    switch (value) {
    constexpr {
        for (const auto o : $E.variables())
            if (!o.default_value.empty()) 
                -> { case o.default_value()$: return std::string(o.name()$); }
    }
    }
}
См. pdf.
Именно так может выглядеть метод, преобразующий значение некоего enum'а в строку. Конструкция constexpr в четвёртой строчке обозначает, что блок должен выполниться в compile-time. И этот блок, на самом деле, генерирует тело оператора switch (весь нужный набор case'ов) с помощью выполняемого на этапе компиляции цикла for. Пусть и выглядит довольно необычно, но это всяко гораздо лучше привлечения сторонних утилит или игр с макросами.
Основная сложность всего этого механизма (для реализации в компиляторе) — это смесь конструкций и их контекстов. В примере с to_string для enum'а компилятор «должен понимать», что ключевое слово case имеет право появляется внутри такого вот блока. А в примере с генерацией деструктора — что этот самый деструктор (или что-то на него похожее) может быть объявлен вне контекста класса. Грамматика C++ окончательно становится контекстно-зависимой — к чему, впрочем, все потихоньку привыкают. В конце концов, нам, разработчикам на C++ шашечки или ехать? Ну а страдания разработчиков компиляторов — кому они интересны?
Сергей Садовников
@FlexFerrum / «Лаборатория Касперского»
Автор – архитектор и разработчик ПО с опытом работы более 25 лет. Сейчас занимается IoT Security в «Лаборатории Касперского». Активно интересуется вопросами разработки библиотек общего назначения, ведет самостоятельные проекты в области автогенерации кода (на базе C++). О своих страданиях рассказал накануне конференции C++ Russia, на которой как раз собирается поделиться тем, как можно избавиться от этих страданий и достичь просветления. Все решения, которые Сергей озвучит на C++ Russia, будут опубликованы в блоге его родной компании.
Теги:
Хабы:
+63
Комментарии39