Метапрограммирование 2.0: макросы и генерация кода в современном мире

от автора


Забудьте про скучные «Hello, World». Макросы и шаблоны давно стали полноценными инструментами архитектора кода: от хитрых C++-шаблонов до процедурных макросов Rust и Java-аннотаций, автоматически генерирующих целые фреймворки. 

В этой статье мы рассмотрим примеры, где metaprogramming избавляет от рутины и экономит часы работы над проектом. Детали как всегда под катом.

Торопитесь? Просто кликните по нужному разделу:
Template-less метапрограммирование: от классических TMP-хаков к «value-based» подходу
Rust-макросы: procedural-магия и её живые примеры
Java Annotation Processors в бою
Куда двигаться дальше

Template-less метапрограммирование: от классических TMP-хаков к «value-based» подходу

Перед тем как нырнуть в код, давайте признаем: классическое метапрограммирование в C++ часто превращается в болото рекурсивных шаблонных инстанциаций. IDE начинает тормозить, а вы всё сильнее скучаете по простому и понятному коду. 

При этом именно TMP дарит нам гибкость таких арсеналов, как std::variant и std::tuple, позволяет оптимально упаковывать структуры и скрывать массу других «фишек» STL. Но можно ли сохранить всю эту мощь и одновременно избавиться от шаблонного нагромождения? Достаточно сопоставить каждому типу уникальное constexpr-значение и дальше оперировать привычными массивами, циклами и std::ranges вместо std::tuple и std::conditional_t, такое показали и на CppCon 2024.

Давайте посмотрим четыре реальных примера, где «template-less» подход не просто упрощает код, но и ускоряет компиляцию.

Источник

Предлагаю перейти к «value-based» подходу, о котором говорилось в 2024–2025 годах на CppCon. Всё строится вокруг простой идеи: каждому типу T соответствует уникальное constexpr-значение, и дальше мы работаем с обычными массивами и диапазонами, а не с std::tuple и std::conditional_t.

Value-based TMP впервые появляется, когда мы собираем «мета-значения» в однородный контейнер:

// Value-based TMP — собираем мета-значения в std::array std::array ts(<int>, <void>}; // ? template<class T> struct type {     static void id(); // или static constexpr variable...  }; template<class T> inline constexpr auto meta = type<T>::id; static_assert(meta<int> == meta<int>); static_assert(meta<int> != meta<void>); static_assert(typeid(meta<int>) == typeid(meta<void>)); std::array ts { meta<int>, meta<void> };  

Теперь вместо кучи рекурсий по Ts… мы держим в руках простой std::array из адресов функций, каждый из которых однозначно указывает на свой тип.

Дальше, когда нужно отобрать подмножество типов по некоторому условию — например, собрать std::variant только из тех Ts, которые можно сконструировать из T — мы не пишем бесконечные enable_if и std::tuple_cat, а используем std::ranges:

// Value-based TMP — variant_for через std::ranges (C++20) template<class T> constexpr auto variant_for(const std::ranges::range auto& ts)     -> std::ranges::range auto{     auto&& r = ts         | std::views::filter(is_constructible<T>)         | std::views::transform(remove_cvref)         | std::ranges::to<std::vector>() ;     std::ranges::sort(r);     r.erase(std::ranges::unique(r), r.end());     return r; } template<class T> constexpr auto is_constructible = [](auto t) {     return invoke<std::is_constructible, T>(t); }; // ... 

Фильтруем, трансформируем, сортируем, уникализируем — и вуаля, набор типов готов. Ошибки компиляции укажут прямо на filter или transform, а не на сотни строк расползающихся инстанциаций.

Но TMP — это не только про типы, но и про оптимизацию структур. Вот классическая проблема паддингов на x86-64:

// [Examples] Performance / Memory struct unpacked {     char a; static_assert(sizeof(a) == 1u); // x86-64     int  b; static_assert(sizeof(b) == 4u); // x86-64     char c; static_assert(sizeof(c) == 1u); // x86-64 }; /*  * https://eel.is/c++draft/basic.align  */ static_assert(12u == sizeof(unpacked)); static_assert(8u == sizeof(pack_t<unpacked>)); // Powered by TMP static_assert(     requires (pack_t<unpacked> p) { // Powered by TMP         p.a;         p.b;         p.c;     } );  

С pack_t TMP на этапе компиляции перебирает поля, сортирует их по выравниванию и генерирует упакованную структуру размером 8 байт вместо 12. И снова без рекурсивных шаблонов, а чистый constexpr код.

Наконец, взгляд на то, что в TMP прячется прямо в STL:

// [Examples] Standard Template Library (STL) template<class... Ts> template<class T> constexpr variant<Ts...>::variant(T&& t)     : index{ find_index<T, Ts...> } // Powered by TMP , // { } template<size_t I, class... Ts> constexpr auto get(tuple<Ts...>&&) noexcept  ->     typename tuple_element<I, tuple<Ts...>>::type&&; // Powered by TMP template<class TFirst, class... TRest> array(TFirst, TRest...) -> array<         typename Enforce_same<TFirst, TRest...>::type, // Powered by TMP         1 + sizeof...(TRest)        >;  

Это не какой-то экзотический хак: в каждой строчке мы видим TMP-механизмы, которые C++ тащит из коробки. Но теперь представьте, что вместо этих хитросплетений можно писать чуть-чуть «meta + ranges + constexpr» и во многом победить шаблонные сложности.

Итог: «template-less» метапрограммирование переносит TMP-логику в мир значений и диапазонов. Оно ускоряет компиляцию, упрощает отладку и позволяет писать почти обычный C++ вместо вечного пляса с рекурсивными шаблонами.

В вашем проекте гибкие интерфейсы с большим количеством типов и вы страдаете от долгих сборок? Самое время попробовать «meta + constexpr» подход.

Источник

Rust-макросы: procedural-магия и примеры

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

▍ Пример создания пользовательского макроса

Декларативные макросы работают на уровне токенов: вы описываете шаблон, а компилятор подставляет нужные фрагменты. Давайте рассмотрим простой пример написания пользовательского макроса. Макросы Rust определяются с помощью синтаксиса macro_rules!.. Вот макрос, который создает вектор и помещает в него некоторые элементы:

macro_rules! create_vec {     ( $( $x:expr ),* ) => {         {             let mut temp_vec = Vec::new();             $(                 temp_vec.push($x);             )*             temp_vec         }     }; } fn main() {     let v = create_vec![1, 2, 3, 4];     println!(»{:?}», v); }  

Сreate_vec! макрос берет произвольное количество выражений ( $x:expr) и генерирует код. Здесь $( $x:expr ),* — это оператор повторения: он принимает любое число выражений и для каждого разворачивает temp_vec.push($x). Никаких ручных циклов, только лаконичный DSL прямо в исходнике.

▍ Процедурные макросы для runtime-безопасности

Если вам нужно не просто текстовый макрос, а вставить логику на уровне AST, приходят на помощь процедурные макросы (proc_macro). Рассмотрим ключевые кейсы из присланного материала:

Атрибут #[trace] — автоматически логирует вход и выход функций:

use trace::trace; #[trace] fn compute(x: i32, y: i32) -> i32 {     x * y + 42 } // При вызове compute(2, 3) в лог попадёт: // «Entering compute with args: (2, 3)» // «Exiting compute => 48»  

Автосериализация структур с версионированием — комбинируем #[derive(Serialize)] и собственный #[version = 2], чтобы разные поля учитывались по-разному:

#[derive(Serialize, Versioned)] #[version = 2] struct User {     id: u64,     #[version = 1]     name: String,     #[version = 2]     email: Option<String>, }  

Регистрация REST-эндпоинтов через атрибуты:

#[rest(»/users», get)] fn list_users() -> Json<Vec<User>> { /* … */ } #[rest(»/users», post)] fn create_user(new: Json<NewUser>) -> Json<User> { /* … */ } 

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

▍ Подводные камни и лайфхаки

  • Кешируйте AST-парсинг. Каждый вызов proc_macro парсит токены заново через proc_macro2, что может ощутимо замедлить компиляцию. Решение — хранить распарсенные данные в lazy_static или once_cell.
  • Проверяйте вывод макросов. В одном проекте процедурный макрос незаметно встраивал неразрывные пробелы в генерируемый код. Компиляция проходила, но на проде JSON-парсер упорно падал. После cargo expand и просмотра через od -c все артефакты стали видны.

Макросы Rust дают фантастическую гибкость: от написания мини-DSL на macro_rules! до мощных AST-трансформаций в proc_macro.  Однако помните золотое правило: сила требует мудрости. Уважайте инструмент, не злоупотребляйте им — тогда ваш код останется чистым, быстрым и очень выразительным. Ведь как говаривал дядя Бен: «С великой силой приходит и великая ответственность».

Java Annotation Processors в бою

В мире Java регулярно приходится писать однотипный «бытовой» код — геттеры/сеттеры, DTO-мэппинги, REST-эндпоинты и т. п. Вручную поддерживать его неудобно и чревато ошибками, поэтому уже давно придуманы инструменты генерации. Различают однократную генерацию (IDE-шаблоны, геттеры/сеттеры) и непрерывную: изменение спецификации OpenAPI, аннотаций или интерфейса автоматически порождает новый код при каждой компиляции.

MapStruct

Один из самых популярных примеров — MapStruct. Вы пишете интерфейс:

@Mapper public interface CompanyMapper {     CompanyMapper INSTANCE = Mappers.getMapper(CompanyMapper.class);     @Mapping(target = «companyName», source = «name»)     @Mapping(target = «companyAge»,  source = «age»)     CompanyDto map(Company company); } Gradle-конфигурация сообщает компилятору, что нужен процессор аннотаций: dependencies {     annotationProcessor «org.mapstruct:mapstruct-processor:${mapstructVersion}» }  

MapStruct во время компиляции генерирует класс CompanyMapperImpl, где метод map(…) развёртывает все передачи полей и проверки на null, избавляя вас от ручного написания одинаковых строк.

Цикл работы Annotation Processor’а: аннотация → генерация кода → повтор компиляции. Источник

Собственные Annotation Processors

Java предоставляет SPI для процессоров аннотаций через javax.annotation.processing.Processor (чаще всего вы наследуетесь от AbstractProcessor). 

Процесс выглядит так:

  1. С помощью @SupportedAnnotationTypes(«org.example.Builder») или переопределения метода getSupportedAnnotationTypes() указываете, какие аннотации обрабатываете.
  2. В методе process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) получаете все элементы, отмеченные вашей аннотацией (например, @ Builder на классах).
  3. С помощью processingEnv.getFiler().createSourceFile(…) создаёте новый .java-файл и записываете в него сгенерированный код через обычный Writer.

@SupportedAnnotationTypes(«org.example.Builder») public class BuilderAnnotationProcessor extends AbstractProcessor {     @Override     public boolean process(Set<? extends TypeElement> annotations,                            RoundEnvironment roundEnv) {         for (Element e : roundEnv.getElementsAnnotatedWith(Builder.class)) {             String className = e.getSimpleName().toString();             generateBuilderFor(className);         }         return true;     }     private void generateBuilderFor(String className) {         try {             JavaFileObject src = processingEnv.getFiler()                 .createSourceFile(className + «Builder»);             try (Writer w = src.openWriter()) {                 w.write( /* текст класса билдера */ );             }         } catch (IOException ignored) { }     } }  

Чтобы процессор автоматически регистрировался, добавьте в ваш JAR файл META-INF/services/javax.annotation.processing.Processor со строкой:

org.example.BuilderAnnotationProcessor

Или используйте Google AutoService, который сгенерирует этот файл за вас.

▍ Преимущества и сценарии использования

  • Быстрая синхронизация спецификации и кода. Правки в интерфейсе или аннотациях сразу отражаются в сгенерированных классах.
  • Чистый репозиторий. Тривиальный код (геттеры, сеттеры, мэппинги) не хранится в VCS, а живёт в артефактe, снижая шум при ревью.
  • Гибкость. MapStruct, Lombok, AutoValue, собственные процессоры — каждый выбирает свою стратегию, но общий принцип один: разметил аннотацию — получил готовый код.

Генерация через аннотации сегодня — стандартный путь борьбы с шаблонным кодом. Хотите избавить команду от сотен строк рутинных методов? Добавьте пару аннотаций, и Java Compiler сам всё сделает.

Куда двигаться дальше

Источник

В 2024–2025 годах метапрограммирование стало не просто способом сократить код, а полноценной парадигмой. Rust-макросы, C++ TMP, Java-аннотации, все они обещают одно: «Сделайте это один раз, и рутина исчезнет». На деле получаете 3–5 раз меньше boilerplate. Например, современные Rust-макросы или C++-TMP позволяют описывать бизнес-логику декларативно, а всю рутину — регистрацию эндпоинтов, сериализацию, проверку контрактов — брать на себя на этапе компиляции. Этот эффект подтверждается и в корпоративных отчётах, и в докладах на TeamleadConf 2024, где подчёркивается, что снижение ручного кода ускоряет внедрение новых фич и снижает количество ошибок, связанных с копипастом.

Второе преимущество — статическая проверка. Это как страховка, которая сработает на этапе сборки, а не в проде. В C++ с concepts или Rust через proc_macro вы заранее блокируете ошибки: если типы несовместимы, компилятор скажет «нет» сразу. Это даёт уверенность: ваш API не сломается из-за опечатки в методе, который «вроде бы должен быть». А еще сколько часов тестирования экономит — просто песня.

Однако у метапрограммирования есть и обратная сторона. Вы написали макрос, который генерирует код за вас. Отлично, пока он работает. А если сломается? Тогда начинается самое интересное: никто не помнит, как он устроен, а документация? Она вообще есть? Это затрудняет отладку и ревью, приводит к ситуации, когда один человек становится единственным экспертом по макросу, что создаёт кадровые риски. 

Ещё одна проблема — «невидимые зависимости»: генераторы кода могут скрывать логику от IDE, ломая автодополнение и рефакторинг. Новичкам в команде приходится разбираться, как всё это собирается, вместо того чтобы сразу приступить к фичам. В Java и других аннотационных языках чрезмерное наложение декораторов приводит к «декораторной путанице»: изменение одного класса может вызвать цепную реакцию изменений в сгенерированных классах, что сложно отследить и протестировать.

Метапрограммирование имеет смысл, когда вы повторяете одни и те же шаблоны кода и хотите централизовать их в едином «source of truth», получая при этом гарантии ещё на этапе компиляции. Но если проект совсем небольшой, а IDE не видит ваш сгенерированный код, вместо выгоды вы можете получить путаницу и потерю читаемости.

И да, в 2025-м AI-ассистенты уже неплохо справляются с написанием небольших функций по комментариям, а стартапы из области BCI обещают скоро давать возможность рисовать код в воздухе пальцем. Звучит как фантастика? В мире IT стоит ждать чего угодно.

А вы как справляетесь с рутиной в коде? Используете макросы, аннотации или полагаетесь на умных помощников? Расскажите в комментариях о своих находках и подводных камнях!

© 2025 ООО «МТ ФИНАНС»

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻


ссылка на оригинал статьи https://habr.com/ru/articles/916552/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *