Определение понятия "рефлексия" из Википедии: In computer science, reflective programming or reflection is the ability of a process to examine, introspect, and modify its own structure and behavior. В последние годы разрабатываются варианты ввода рефлексии в стандарт C++. В этой статье мы напишем код на C++ с рефлексией для решения разных задач, скомпилируем и запустим его на форке компилятора с рабочей реализацией рефлексии. Во многих других языках, активно использующихся для бэкенда, рефлексия очень мощная. Пара примеров: В языке Python в run-time можно получить класс объекта; имя класса; все его методы и аттрибуты; добавить методы и аттрибуты в класс; и так далее. По большому счету, каждый объект и класс это dict (с синтаксическим сахаром), который можно изменять как угодно. В языке Java в run-time также можно получить класс объекта; его поля, методы, константы, конструкторы, суперклассы; получать и устанавливать значение поля по его имени; вызывать метод объекта по имени; и так далее. Информация о классах находится в памяти Java Virtual Machine. Действия, описанные выше - как раз то, что обычно подразумевается под словом "рефлексия". В C++ постепенно добавлялись некоторые магические кусочки "языкознания", с помощью которых можно получить часть информацию о коде - например std::is_enum (compile-time), typeid (run-time). Это можно отнести к рефлексии, но функционал спартанский и для великих свершений не годен. История знает разного рода приспособления для уменьшения боли. К этому типу принадлежит protobuf - модный "носитель" данных. В .proto-файле описывается структура данных (message Person), по которой кодогенератор для C++ может создать соответствующий ей класс (class Person) с геттерами/сеттерами, и возможностью сериализовать эти данные без копипаста имени каждого метода. Сериализовать объект класса можно в бинарное представление (оптимальный путь, для передачи по сети), или в человекочитаемое представление (например для логирования). Таким образом, программисту не придется корячить и поддерживать собственную систему сериализации данных, потому что protobuf уже набил все шишки за него. К этому типу принадлежит библиотека Boost.Hana. Для нее нужно описывать структуру нужным образом: Макрос "раскроется" и все сгенерирует. Похоже на "демосцену" - выжимают максимум возможностей из инструмента, который не был для этого предназначен. Интересные вещи можно сделать, проанализировав исходный код. Некоторые инструменты (кодогенераторы/чекеры/etc.) создаются как "плагин" к используемому компилятору. Например, чтобы работать с исходниками на уровне AST (абстрактного синтаксического дерева), можно использовать cppast. AST это промежуточный вариант между исходным кодом и ассемблером. К нему надо привыкнуть, но это проще, чем писать самодельный парсер кода на C++. Если кто-то смотрел исходники GCC или Clang, тот знает, что с нуля написать парсер малореально. В отличие от многих других языков, где с рефлексией работают в run-time, дух C++ требует сделать рефлексию в compile-time. Так как язык старается соответствовать принципу "don’t pay for what you don’t use", то ~95% всей информации из исходников в рантайме просто испаряется. В языке не существует теоретической возможности сделать рефлексию в рантайме без раздувания бинаря чем-нибудь навроде RTTI (с многократно большим объемом). C++ можно рассматривать как сборник из "под-языков", работающих на разных "уровнях". Условное деление: Собственно C++: работа с памятью, объектами, потоками (и вообще с интерфейсом ОС), манипуляция данными. Работает в run-time. Шаблоны: обобщенное программирование в исходниках. Работает (вычисляется) в compile-time. Constexpr-вычисления: это "интерпретируемое" подмножество "Собственно C++", от года в год расширяется. Подробнее о них можно прочитать в моей прошлой статье. Вычисляется в compile-time прямо внутри компилятора. Препроцессор: работает с токенами (отдельными словами) исходников. С C++ имеет очень посредственную связь, абсолютно такой же препроцессор могли бы сделать для Rust/Java/C#/etc. Единственный из "под-языков" не тьюринг-полный. Работает в compile-time. Делать рефлексию в виде препроцессорных директив бессмыслено из-за отсутствия тьюринг-полноты. Остаются только шаблоны или constexpr-вычисления. Сначала рефлексию планировали ввести в шаблонной парадигме, сейчас планируют ввести в constexpr-парадигме (так как возможности constexpr значительно расширились). Я приведу примеры обеих подходов, и где можно скомпилировать код с их использованием. Основной источник информации про рефлексию на шаблонах - pdf-ка Reflection TS, более короткое объяснение есть на cppreference.com. Свой код с использованием рефлексии можно скомпилировать на godbolt.org, выбрав компилятор x86-64 clang (reflection). Вводится оператор reflexpr(X), которому можно "скормить" вместо X что угодно: тип, выражение, имя переменной, вызов метода, и т.д. Этот оператор вернет так называемый meta-object type (далее - магический тип"), который для нас будет являться безымянным incomplete классом. Пример кода: Этот класс будет удовлетворять некоторому множеству концептов (в Reflection TS есть таблица концептов). Например, MetaT удовлетворяет концепту reflect::Enum, и не удовлетворяет reflect::Variable - ссылка на код с проверкой. Работа происходит с помощью "трансформаций" одних магических типов в других. Список доступных трансформаций зависит от того, каким концептам удовлетворяет исходный тип. Например, Reflection TS определяет такой шаблон, доступный только удовлетворяющим reflect::Enum магическим типам: Таким образом, трансформация get_enumerators_t<MetaT> скомпилируется. С ее помощью мы получим другой магический тип, на этот раз удовлетворяющий концепту reflect::ObjectSequence. Выведем название первого элемента enum Color спустя несколько трансформаций: Ссылка на код. Основная претензия к шаблонному подходу - неочевидность, как надо писать код. Мы хотим написать цикл по ObjectSequence? Обычным for-ом это сделать нельзя, есть только размер последовательности и получение элемента из него, и некий unpack_sequence: Если мы хотим сделать такую элементарную задачу, как по значению enum-а получить его строковое представление, надо написать какую-то жуть, в которой совсем ничего не понятно - ссылка на код в gist. Язык развивают живые люди, у них тоже идет кровь из глаз при виде метапрограммирования на шаблонах, поэтому сейчас развивается другой подход к рефлексии. Основные источники информации про текущий вариант рефлексии - документ P2320, видео-выступление Andrew Sutton на ютубе, и частично Wiki в гитхабе реализации. Рефлексия вводится в виде оператора ^X перед рефлексируемой сущностью X. Применение оператора создаст constexpr-объект типа std::experimental::meta::info. После манипуляций с объектом (которые должны происходить в compile-time) можно "вернуть" его в "реальный" мир через оператор  (называется "splice"). Запись  практически эквивалентна X. Andrew Sutton в видео приводит игрушечный пример с созданием объекта типа T****...* (количество звёздочек равно N). Вот так можно сделать через шаблоны: А вот так можно сделать через рефлексию: Код внутри consteval-методов выполняется только в compile-time. Все consteval-методы после компиляции "испаряются", то есть их код в бинарнике отсутствует. Можно вывести имя получившихся типов: Компиляция на godbolt  Записи операторов ^X и  могут не пройти проверку временем и видоизмениться к момента входа в стандарт. Но это будут взаимозаменяющие записи. Ранее вместо ^X был reflexpr(X), вместо  был unreflexpr(X). На данный момент текущая запись является "официальной", что можно увидеть в github-тикете про P2320. Свой код с использованием рефлексии можно запустить на cppx.godbolt.com, выбрав компилятор p2320 trunk. Это не очень удобно и быстро, поэтому я компилирую через терминал. В лучших традициях форк компилятора предлагается собрать самому по инструкции, поэтому я создал docker-образ. docker-образ был создан по этому Dockerfile, собирал ветку paper/p2320. Образ можно загрузить: Пусть ваш исходник code.cpp находится в директории /home/username/cpp, тогда запускать можно так: После компиляции в директории /home/username/cpp будет лежать запускаемый бинарник bin На случай удаления репозитория я сделал форк - https://github.com/Izaron/meta. Теперь попробуем написать что-то рефлексивное. В отличие от "рефлексии на шаблонах", в "рефлексии на constexpr" это сделать намного проще. Пример кода (немного изменил код из видео Andrew Sutton): template for - это фича, которая не успела войти в стандарт C++20. В нашем случае она раскрывает range методом копипаста. Пусть у нас такой enum: Тогда метод раскроется в такой вид: Аналогично можно сделать метод, который по строковому представлению вернет значение enum-а Компиляция на godbolt Можно реализовать проверку на отсутствие видимых "снаружи" (вне единицы трансляции) методов с помощью вызова meta::is_externally_linked. Небольшое отступление - в форке компиляции доступно несколько вспомогательных методов, работающих в compile-time: __reflect_dump - принимает meta::info, выведет в терминал AST соответствующей ему сущности. __compiler_error - принимает строку, завершает компиляцию ошибкой с выводом данной строки. __concatenate - соединяет несколько строковых литералов в один. Первые два метода нужны для удобства разработки compile-time кода. Третий метод нужен, потому что std::string в compile-time пока еще нет в стандарте (но когда-то будет). Про meta::info есть один факт - в некоторых случаях мы не можем написать метод так: потому что компилятор думает, что meta::info протекает в run-time... Зато можем написать так: Теперь попробуем решить нашу задачу. Методы находятся внутри namespace. Поэтому надо проитерироваться по всем членам namespace, являющимися функциями. Также могут быть вложенные namespace, поэтому их также надо проверить рекурсивно. Заведем игрушечный namespace - компиляция будет падать так, как нам нужно: Чтобы компиляция перестала падать, нужно сделать методы имеющими internal linkage. Написать модификатор static Или поместить методы внутри анонимного namespace При желании можно пропарсить всё, до чего только можно дотянуться - если итерироваться по глобальному namespace (он же ::). Рефлексия глобального namespace это ^::. Компиляция на godbolt Можно проверить, что тип является "абстрактным", то есть имеет хотя бы один чисто виртуальный метод, через std::is_abstract. Понятие "интерфейс" в стандарте не зафиксировано, но можно выработать для него требования: Все user-defined методы (т.е. которые юзер написал сам, а не которые сгенерированы компилятором) публичные и чисто виртуальные. У класса нет переменных. В классе есть публичный виртуальный деструктор, являющийся defaulted. Вот как можно описать эти требования: Можно протестировать написанный метод: Компиляция на godbolt Сериализация в JSON это такой FizzBuzz для любителей рефлексии. Каждый уважающий себя разработчик рефлексии рано или поздно это напишет. В своем видео Andrew Sutton разбирает вопрос с JSON, но больше как псевдокод. Мы напишем свою реализацию. Если модель данных немаленькая, то с "голым" JSON работать становится очень неудобно - всё нетипизированно и как будто постоянно лезешь в свалку данных, чтобы получить нужные поля. Можно конвертировать JSON в свои структуры, но это влечет кучу копипаста - чего можно избежать при наличии рефлексии. Базовые типы JSON это Number, String, Boolean, Array, Object; пустое значение - null. Напишем концепты для каждого типа. Number это каждый тип, удовлетворяющий std::is_arithmetic: String это строковой тип, причем объект должен владеть строкой, а не просто знать о ней (как std::string_view). Потому что где сериализация - там и десериализация, поэтому нужен владеющий тип. Это, конечно, только std::string: Boolean это просто bool: Array должен быть контейнером из последовательных элементов. Другими словами, это должен быть SequenceContainer - std::array/std::vector/std::deque/std::forward_list/std::list. К сожалению, готового концепта для них нет - есть только вилами по воде писанные свойства. Поэтому напишем свой концепт с нуля, который определяет, что тип является инстанциацией нужного шаблона: спустя несколько ошибок компиляции... В данный момент сравнение как tmpl == ^std::vector крашит clang, поэтому придется писать так. Object это просто класс/структура. В нем, конечно, должны быть дополнительные ограничения, чтобы сериализовать/десериализовать можно было достаточно простой тип - скажем, без членов со ссылочными типами. Но пока обойдемся базовым примером. Значение null можно ввести для std::optional, который не содержит значения. Теперь можно сериализовать объект в зависимости от того, какому концепту он удовлетворяет. В своем видео Andrew Sutton дает мега-совет - поскольку один тип может удовлетворять нескольким концептам, то не надо писать код вроде: Потому что рано или поздно можно попасть на неоднозначность выбора метода. Поэтому надо делать диспетчеризацию, проверяя концепты по приоритетности: Сделаем класс json_writer, пусть он принимает объект, куда можно стримить выходной поток Реализуем метод для сериализации, который будет "диспетчером" для разных JSON-типов: Методы, которые вызываются из write, могут естественным образом делать рекурсивный запрос в write снова. Реализуем запись nullable-типа: Записи числового, строкового, булевого типов нерекурсивны: Запись массива достаточно проста - надо только правильно ставить запятые, разделяющие объекты: Запись объекта - самая сложная, нужно проитерироваться по членам структуры и записать каждый член отдельно: Создадим модель данных - пусть это будет библиотека, у которой несколько книг, один адрес, и опционально "описание" Зададим библиотеке адрес, добавим несколько книг, и выведем ее в формате JSON: Программа выведет неотформатированный JSON: Компиляция на godbolt Если бы сериализацию/десериализацию надо было сделать в реальном проекте, я бы посоветовал добавить "прокладку" в виде существующей json-библиотеки, например nlohmann/json. То есть мы бы переводили объект "нашей" структуры в объект из json-библиотеки, а этот объект уже конвертировался бы в строку. При десериализации наоборот - строка в json-объект, json-объект в "наш" объект. Это нужно, чтобы не переизобретать велосипед - с "прокладкой" работать проще и надежнее, чем самому что-то парсить. Такой же подход работает для XML, ORM в базу данных, и прочего. Возьмем model::book из предыдущего кода. Если мы попытаемся сравнить два объекта этого типа, то получим ошибку компиляции Можно выработать свои правила для универсального сравнения: Если объекты можно сравнить, то есть вызов a == b скомпилируется, то результат сравнения - вызов этого оператора. Если объект - итерируемый контейнер (как std::vector), то проверим, что размеры совпадают, и сравним каждый элемент контейнера. Иначе проитерируемся по членам типа и сравним каждый член отдельно. Для первого и второго пункта концепты пришлось написать самому, так как существующие не нашел... Теперь напишем наш метод, как и планировали - с проверкой с первого по третий пункт? На самом деле нет - первый и второй пункт надо поменять местами Если проверить первый концепт, то можно обнаружить подставу: Сравнение двух объектов типа model::book не скомпилируется, так же, как типа std::vector<model::book>. Но концепт резольвится в true! Дело в том, что концепт смотрит на сигнатуру метода, а не на весь метод. Он видит, что у вектора оператор сравнения объявлен: А в определение метода он не лезет, к тому же это может быть невозможно - определение может лежать в другом translation unit. То, что в итоге код не скомпилируется, для концепта это "уже не его проблемы". Напишем наш метод: Возможно, стоило бы для типов float и double сравнивать их разницу с эпсилоном... Но пока обойдемся без этих подарочков. Проверим метод - в первый раз выведется true, во второй раз false, успех! Компиляция на godbolt И наконец, мы сделаем собственный контейнер для Dependency Injection! Этот паттерн программирования хардкорно используется, например, в Spring - самом популярном Java-фреймворке. В модели управления обычно одни объекты зависят от других объектов. Далее будем писать "компонент" вместо "объект". Смысл паттерна в том, что вместо того, чтобы компонент сам создавал зависимые компоненты, эти компоненты создавал бы фреймворк. И потом давал бы их компоненту через конструктор (все компоненты сразу) либо через сеттер-методы (по одному сеттер-методу на компонент). Во многих случаях такой подход сильно упрощает программирование. В сложных проектах длина цепочки зависимостей может находиться за пределами возможностей человеческого мозга. Создадим модель управления для сервиса а-ля "URL Shortener", который принимает длинные ссылки и отдает короткие (и наоборот). У нас будет, очень условно, четыре компонента (в реальности было бы побольше): s3_storage - сервис, который умеет брать картинку из s3-хранилища и возвращать ее. database - сервис-"прокладка" для работы с базой данных link_shortener - сервис, принимающий длинную ссылку и возвращающий короткую (и наоборот). Зависит от database, где хранит соответствие между ссылками. http_server - сервис, обрабатывающие запросы по http. Зависит от s3_storage (показ лого на сайте), link_shortener (понятно для чего), database (куда пишет всякую статистику про посетителя сайта). Опишем компоненты в коде: Что должен сделать фреймворк: Создать компоненты через std::make_shared, каждый компонент должен быть создан ровно один раз. Вызвать set_component с готовыми зависимыми компонентами. Когда все нужные set_component вызваны, вызвать метод post_construct, если он есть в классе. Сначала вызывается у зависимых компонент, потом у зависящих. Когда "корневой компонент" (в нашем случае http_server) закончит работу post_construct, в правильном порядке уничтожить компоненты, чтобы на момент вызова деструктора все зависимые компоненты были "живы". Создадим заготовку класса: Готовые компоненты хранятся в хешмапе. Значения хешмапы имеют тип std::any, потому что компоненты не имеют общего типа. Создадим метод-"прокладку", который сначала ищет компонент в хешмапе, а если не найдет, то строит компонент: Чтобы построить компонент, надо создать его объект через std::make_shared, потом построить все зависимые компоненты и вызвать для каждого set_component, потом вызвать метод post_construct при его наличии. Сделаем вспомогательный метод, который определяет, имеем ли мы перед собой рефлексию метода с нужным именем: Как мы можем определить зависимые компоненты: Ищем все методы с названием set_component. Пусть мы зафиксировали один такой метод. Проверяем, что в этом методе ровно один параметр. Тип этого параметра должен являться специализацией шаблона std::shared_ptr. Класс, которым был специализирован шаблон - это класс компонента, который нужно создать (или взять готовый, если есть). Вызываем set_component с компонентом из п. 4. С этим планом сделаем метод build_dependent_components: Вызов post_construct выглядит проще: Осталось только установить "корневой компонент" и запустить весь процесс: Если для каждого компонента добавить лог имени вызываемого метода в конструкторе, деструкторе, set_component и post_construct, то можно увидеть, что именно делает фреймворк: Фреймворк все делает правильно! Из того, что можно добавить: Проверку на циклы зависимостей - их быть не должно. Кажется, циклы возможно обнаружить в compile-time. Можно зависеть от интерфейса, а не от реализации, "как в лучших домах Парижу". Сервис s3_storage - это просто реализация сервиса по работе с хранилищем картинок. Можно сделать так, чтобы s3_storage наследовался от интерфейса image_storage, и в http_server был бы метод set_component(std::shared_ptr<image_storage>). Рефлексия могла бы распарсить весь namespace, найти реализацию интерфейса, и создать его. Кроме примеров выше, я сделал hasattr.cpp - имитация методов hasattr и getattr из языка Python, а также opts.cpp - типизированный парсер командной строки. Разбирать их я не стал, потому что новой информации там нет. Все примеры доступны на github - ссылка. Часть методов (например, строковое представление значения enum) нужно иметь в стандартной библиотеке, чтобы не писать велосипеды. Хочется, чтобы рефлексия умела работать с атрибутами, потому что без этого отнимается большой пласт крутых юзкейсов. Когда рефлексия войдет в C++ - пока точно не известно, но вероятнее всего, успеют к стандарту C++26. Программист C++