Познаем дзен рефлексии и декораторов в TypeScript и Angular
В этой статье мы подробно поговорим о рефлексии, декораторах и что происходит когда мы используем декораторы Injectable, Component, Input, Output, ViewChild и другие в Angular.
Что такое декоратор в TypeScript?
Декораторы берут свое начало еще с других языков программирования, также часто декоратор называют шаблоном проектирования, я не буду останавливаться на истории этого шаблона, в интернете достаточно информации.
Важно знать и понимать, что декоратор в TypeScript это не шаблон проектирования, а всего лишь возможность (feature) самого языка. Есть разные виды декораторов — декораторы классов, декораторы свойств классов, декораторы методов и фабричные декораторы, и еще проще говоря декоратор — это всего лишь функция. Механизм инъекции зависимостей (dependency injection) в Angular работает только благодаря декораторам и самому языку TypeScript.
Рефлексия и декораторы
Здесь нужно остановиться на том как Microsoft решили использовать рефлексию вместе с декораторами. Что такое рефлексия? Рефлексия, в общей сложности, это механизм, который дает возможность получить информацию о типах в рантайме. В других языках программирования, более низкоуровневых, таких как C# (C#— высокоуровневый язык, но я говорю в контексте сравнения с JavaScript) есть статическая рефлексия. Благодаря статической рефлексии компилятор может сгенерировать байткод (C# компилятор генерирует байткод для CLR) еще на этапе компиляции для получения информации в рантайме. Эту информацию о типах принято называть metadata (метаданные). В JavaScript также есть рефлексия, уже давно являющаяся стандартом ecma-262 . Имя ей Reflect API (обязательно к прочтению для дальнейшего понимания). Конечно это API очень тяжело назвать рефлексией, так как объект Reflect всего лишь предоставляет обертку для уже существующих методов. Как пример мы хотим вручную задать указатель контекста внутри функции через call или apply . Внизу приведен код как бы мы это сделали без Reflect и с:
Я хочу настоящую рефлексию, как этого добиться? Есть такая возможность, благодаря разработчику из Microsoft Ron Buckton , который создал полифил reflect-metadata .
reflect-metadata
Нельзя пропустить этот момент, так как рефлексия используется в Angular, многие этого не замечают, но когда вы делаете ng new , то angular-cli создает для вас скелетон с нужными файлами такими как main.ts , polyfills.ts и папка с приложением app . В polyfills.ts есть импорт полифила import ‘core-js/es7/reflect’ ; В принципе команда Angular решила не использовать отдельный npm пакет reflect-metadata , а core-js , так как там уже есть все полифилы для IE. Пакет reflect-metadata можно использовать вообще отдельно от Angular в других проектах, например вы захотите написать свой простенький механизм DI. Также для понимая того, что делает этот пакет нужно знать новые коллекции ES6 — Map и WeakMap .
Коллекция Map — это простой ассоциативный массив, где ключами может быть, что угодно.
Мы создали коллекцию Map , где ключом является строка, а значением объект типа Student . Вы спросите — можно же использовать обычный объект вместо Map , да, можно, но Map предоставляет больше возможностей, а именно методы такие как values , size , entries и так далее. Это более декларативный подход для работы с ассоциативным массивом в отличии от объекта, где чтобы узнать его размер можно сделать Object.keys(object).length , в случае с Map — есть метод size и любому разработчику будет понятно, что он делает.
Не забываем про коллекцию WeakMap , эта коллекция похожа на Map , но разница в том, что ключами в WeakMap могут быть только ссылки на объекты! То есть const students = new WeakMap<string, Student>(); — не скомпилируется, будет ошибка, что ключ не удовлетворяет ограничению, потому что ключ универсальный и он наследует тип object , взглянем на определение типа:
Реальный пример использования WeakMap — представьте, что у вас есть 5 ссылок на сайте и вы хотите повесить на них обработчики событий и также где-то хранить эти обработчики для удаления в будущем:
Ключом в WeakMap listeners есть ссылка на DOM элемент HTMLAnchorElement , это удовлетворяет ограничению, так как все элементы наследуются от Function , HTMLAnchorElement.constructor => Function , а Function.__proto__.__proto__.constructor => Object , также если мы изменим значение ссылки на нулевой указатель — то сборщик мусора автоматически очистит WeakMap по этому ключу, конечно не сразу, а на каком-то этапе цикла “mark and sweep”.
Возвращаемся к полифилу reflect-metadata, этот полифил расширяет возможности объекта Reflect , а именно добавляет следующие методы:
Метод Reflect.defineMetadata позволяет определить любую информацию об объекте в рантайме, например мы хотим по ссылке хранить инстанс функции:
Сам полифил под капотом создает WeakMap :
А теперь после этого лирического отклонения возвращаемся к тому, как работают декораторы в TypeScript 🙂
reflect-metadata + TypeScript
Сам компилятор TypeScript под капотом использует этот полифил. НО, для того, чтобы генерировать метаданные — есть 2 флага компилятора, это experimentalDecorators (разрешает использовать декораторы в коде) и emitDecoratorMetadata (разрешает компилятору генерировать метаданные о типах, только в связке с experimentalDecorators ). Давайте напишем свой декоратор и посмотрим скомпилированный код:
Нам нужна строчка Module = __decorate([ myFirstDecorator ], Module) , теперь я думаю вы догадываетесь, что декоратор — это всего лишь ОБЕРТКА. Метод __decorate генерируется самим компилятором, он принимает массив декораторов, потому что кол-во декораторов неограниченно.Что делает этот метод? Давайте сделаем beautify 🙂
Метод __decorate — проходится в цикле по нашим декораторам (функциям), копирует в переменную decorator и вызывает ее decorator(reflectedClass) , присваивая возвращаемое значение переменной newClass , newClass нужен в тех случаях, если декоратор что-то возвращает, но заметьте, что декоратор принимает аргументом сам конструктор. Подводя итоги по декораторам — декораторы это не магия, у них нет определенной “цели”, это просто синтаксис, декларативный синтаксис.
Возвращаемся к генерации метаданных, если использовать декораторы в связке с объявлениями типов, то компилятор может сгенерировать нужную информацию:
Я отбросил метод __decorate , нас интересует новая функция __metadata , которая использует Reflect API, о котором мы говорили ранее. Как вы сами видите компилятор поместил вызов этой функции в метод __decorate с нужной нам информацией, функция __metadata — это просто обертка поверх Reflect.defineMetadata , заглянем в исходный код компилятора TypeScript:
Метаданные сгенерируются если включен нужный параметр, как вы видите есть условие if (compilerOptions.emitDecoratorMetadata) . Есть 3 ключа, которые определены самой командой Microsoft, по которым хранится информация в рантайме о типах, design:paramtypes — типы в конструкторе (если мы декорируем сам класс), design:type — тип свойства класса (если мы декорируем свойство), design:returntype — тип возвращаемого значения функции(если мы декорируем метод). По дефолту всегда будет Object , если вы не указали никакой тип. Используя Reflect мы можем получить типы в конструкторе:
Стоп! То есть таким образом я могу сделать свой простенький механизм инъекции зависимостей только благодаря возможностям TypeScript? Ну конечно! Давайте сделаем свой декоратор Injectable :
Мы создали коллекцию dependencies , в которой будем хранить наши сервисы, которые нужно внедрить (inject), где ключом является ссылка на конструктор сервиса, а значением будет инстанс сервиса.
Вот и вся магия декораторов в Angular, а точнее всего DI, что делает декоратор Injectable ? Да ничего 🙂 Он просто позволяет компилятору генерировать нужную информацию для инжектора в Angular, механизм DI в Angular немного сложнее, но он не настолько громоздок, потому что представьте ситуацию, что класс Service тоже имеет зависимости и так по цепочке… Для этого в Angular под капотом есть метод resolveDep, который рекурсивно инициализирует зависимости. Injector — это всего лишь LazyMap , ассоциативный массив, который инициализирует нужную зависимость при первом доступе, только ключом выступает ссылка на сервис, в концепции Angular — это называет токен ( OpaqueToken до Angular 4, InjectionToken в Angular 4+).
Component, Input, Output, ViewChild
Что такое Component ? Это фабрика которая возвращает декоратор, как вы помните декоратор это просто обертка:
Если вы не знали, но можно писать и так, результат будет один и тот же, только вот DI работать не будет, потому что как вы помните генерация метаданных работает только в связке с декораторами 🙂 Сначала мы вызываем функцию Component куда передаем параметром объект, в концепции Angular — это называется RenderType . Эта функция фабричная и она возвращает нужный нам декоратор, куда мы передаем наш AppComponent , под капотом декоратор Component всего лишь создает статическое свойство __annotations__ у компонента, если мы сделаем этот компонент видимым в глобальной области window.AppComponent = AppComponent и выведем в консоль свойство __annotations__ , то мы получим массив и по 0 индексу там будет инстанс класса DecoratorFactory , да, команда Angular немного усложнила этот механизм, но сути это не меняет, в инстансе будут свойства:
Шаблон не скомпилированный, changeDetection: 0 — это OnPush стратегия, механизм того, как инициализируется компонент я описывать не буду. Взглянем на другие декораторы.
Что делают декораторы Input и Output ? Это точно также фабрики, они добавляют статическое свойство классу __prop__metadata__ , это объект где ключами являются названия переменных:
Если в консоли браузера вы получите доступ по этому ключу AppComponent.__prop__metadata__ — вы увидите объект, у которого свойства a и myCoolEmitter :
Значением здесь является массив, где по 0 индексу инстанс класс PropDecoratorFactory . bindingPropertyName — это необязательный параметр, который принимает Input и Output , в случае когда вы биндите свойство в шаблоне и название этого свойства не совпадает с названием переменной. Когда запускается самый первый ChangeDetection на текущей вьюхе — Angular просто получает доступ к этому свойство и сеттит нужный нам байндинг в методе updateProp , к которому мы позже можем получить доступ в ngOnInit .
Как работает декоратор ViewChild ? Это фабрика куда мы передаем строкой (берем самый простой вариант) идентификатор задекларированной шаблонной переменной:
Опять же в объект __prop__metadata__ добавляется словарь, где ключом будет название переменной, а значением массив с инстансом PropDecoratorFactory , но уже с другими свойствами:
После вызова конструктора компонента на этапе первого ChangeDetection , Angular строит первый DOM на основе AST (абстрактное синтаксическое дерево), компилятор сперва парсит ваш template в AST , далее делается проверка на то, что параметр, который мы передали во ViewChild — это строка, потому что там также может быть ссылка на конструктор другого компонента, после ngOnChanges и до ngOnInit Angular начинает строить первое дерево:
Это псевдокод, в реалии все декораторы трансформируются в статическую информацию Angular компилятором и механизм намного сложнее.
Декоратор HostListener работает аналогично:
В объект __prop__metadata__ добавляется свойство documentClick , а значением будет массив, где по 0 индексу инстанс PropDecoratorFactory со свойствами:
На этапе компиляции Angular трансформирует декоратор и парсит eventName , поэтому в рантайме Angular уже знает как и чему добавлять обработчик события через DomEventsPlugin . Эти свойства добавляются декораторами в рантайме если мы используем JIT, это происходит благодаря вызову декораторов, если мы используем AOT, то декораторы не вызываются, компилятор просто берет нужную информацию из AST.
Спасибо за чтение! Теперь вы знаете, что большинство механизмов Angular работает благодаря компилятору TypeScript, не зря в 2015 году Google отказались от plain-ES6 и договорились с Microsoft использовать TypeScript.
Рефлексия в C++Next на практике
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++
В 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, тот знает, что с нуля написать парсер малореально.
Особенности рефлексии в C++
В отличие от многих других языков, где с рефлексией работают в 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.
Рефлексия в constexpr
Язык развивают живые люди, у них тоже идет кровь из глаз при виде метапрограммирования на шаблонах, поэтому сейчас развивается другой подход к рефлексии.
Основные источники информации про текущий вариант рефлексии — документ P2320, видео-выступление Andrew Sutton на ютубе, и частично Wiki в гитхабе реализации.
Рефлексия вводится в виде оператора ^X перед рефлексируемой сущностью X . Применение оператора создаст constexpr-объект типа std::experimental::meta::info .
После манипуляций с объектом (которые должны происходить в compile-time) можно «вернуть» его в «реальный» мир через оператор [:X:] (называется «splice»). Запись [:^X:] практически эквивалентна X .
Andrew Sutton в видео приводит игрушечный пример с созданием объекта типа T****. * (количество звёздочек равно N). Вот так можно сделать через шаблоны:
А вот так можно сделать через рефлексию:
Код внутри consteval-методов выполняется только в compile-time. Все consteval-методы после компиляции «испаряются», то есть их код в бинарнике отсутствует.
Можно вывести имя получившихся типов:
Соглашение о записи операторов
Записи операторов ^X и [:X:] могут не пройти проверку временем и видоизмениться к момента входа в стандарт. Но это будут взаимозаменяющие записи.
Ранее вместо ^X был reflexpr(X) , вместо [:X:] был unreflexpr(X) .
На данный момент текущая запись является «официальной», что можно увидеть в github-тикете про P2320.
Компиляция и запуск
Свой код с использованием рефлексии можно запустить на cppx.godbolt.com, выбрав компилятор p2320 trunk .
Это не очень удобно и быстро, поэтому я компилирую через терминал. В лучших традициях форк компилятора предлагается собрать самому по инструкции, поэтому я создал docker-образ.
Сборка с использованием docker-образа
docker-образ был создан по этому Dockerfile, собирал ветку paper/p2320.
Образ можно загрузить:
Пусть ваш исходник code.cpp находится в директории /home/username/cpp , тогда запускать можно так:
После компиляции в директории /home/username/cpp будет лежать запускаемый бинарник bin
На случай удаления репозитория я сделал форк — https://github.com/Izaron/meta.
Рефлексия на практике
Теперь попробуем написать что-то рефлексивное.
Значение enum-а в строковом представлении
В отличие от «рефлексии на шаблонах», в «рефлексии на constexpr» это сделать намного проще. Пример кода (немного изменил код из видео Andrew Sutton):
template for — это фича, которая не успела войти в стандарт C++20. В нашем случае она раскрывает range методом копипаста. Пусть у нас такой enum:
Тогда метод раскроется в такой вид:
Аналогично можно сделать метод, который по строковому представлению вернет значение enum-а
Проверка функций на internal linkage
Можно реализовать проверку на отсутствие видимых «снаружи» (вне единицы трансляции) методов с помощью вызова 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 это ^:: .
Проверка, что тип является интерфейсом
Можно проверить, что тип является «абстрактным», то есть имеет хотя бы один чисто виртуальный метод, через std::is_abstract.
Понятие «интерфейс» в стандарте не зафиксировано, но можно выработать для него требования:
Все user-defined методы (т.е. которые юзер написал сам, а не которые сгенерированы компилятором) публичные и чисто виртуальные.
У класса нет переменных.
В классе есть публичный виртуальный деструктор, являющийся defaulted.
Вот как можно описать эти требования:
Можно протестировать написанный метод:
Сериализация объекта в JSON
Сериализация в 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:
Отформатированный вид такой:
Если бы сериализацию/десериализацию надо было сделать в реальном проекте, я бы посоветовал добавить «прокладку» в виде существующей 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 , успех!
Контейнер Dependency Injection
И наконец, мы сделаем собственный контейнер для 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.
Основные принципы программирования: интроспекция и рефлексия
Часто во время работы программы нам бывает нужна информация о данных — например, какой у них тип или являются ли они экземпляром класса (в ООП). Опираясь на эти знания, нам нужно проводить над данными некоторые операции, или даже изменять их — но необходимого вида данных у нас может и не быть! Если вы ничего не поняли, не расстраивайтесь — мы подробно во всём разберёмся. Всё, что я здесь описал — это иллюстрация целей двух возможностей, присутствующих почти в каждом современном языке программирования: интроспекции и рефлексии.
Интроспекция
Интроспекция — это способность программы исследовать тип или свойства объекта во время работы программы. Как мы уже упоминали, вы можете поинтересоваться, каков тип объекта, является ли он экземпляром класса. Некоторые языки даже позволяют узнать иерархию наследования объекта. Возможность интроспекции есть в таких языках, как Ruby, Java, PHP, Python, C++ и других. В целом, инстроспекция — это очень простое и очень мощное явление. Вот несколько примеров использования инстроспекции:
В Python самой распространённой формой интроспекции является использование метода dir для вывода списка атрибутов объекта:
В Ruby интроспекция очень полезна — в частности из-за того, как устроен сам язык. В нём всё является объектами — даже класс — и это приводит к интересным возможностям в плане наследования и рефлексии (об этом ниже). Если вы хотите узнать об этом больше, советую прочитать мини-цикл Metaprogramming in Ruby.
Прим. перев. Также не будет лишним прочитать нашу статью, посвящённую интроспекции в Ruby.
Вот несколько простых примеров интроспекции с использованием IRB (Interactive Ruby Shell):
Вы также можете узнать у объекта, экземпляром какого класса он является, и даже «сравнить» классы.
Однако интроспекция — это не рефлексия; рефлексия позволяет нам использовать ключевые принципы интроспекции и делать действительно мощные вещи с нашим кодом.
Рефлексия
Интроспекция позволяет вам изучать атрибуты объекта во время выполнения программы, а рефлексия — манипулировать ими. Рефлексия — это способность компьютерной программы изучать и модифицировать свою структуру и поведение (значения, мета-данные, свойства и функции) во время выполнения. Простым языком: она позволяет вам вызывать методы объектов, создавать новые объекты, модифицировать их, даже не зная имён интерфейсов, полей, методов во время компиляции. Из-за такой природы рефлексии её труднее реализовать в статически типизированных языках, поскольку ошибки типизации возникают во время компиляции, а не исполнения программы (подробнее об этом здесь). Тем не менее, она возможна, ведь такие языки, как Java, C# и другие допускают использование как интроспекции, так и рефлексии (но не C++, он позволяет использовать лишь интроспекцию).
По той же причине рефлексию проще реализовать в интерпретируемых языках, поскольку когда функции, объекты и другие структуры данных создаются и вызываются во время работы программы, используется какая-то система распределения памяти. Интерпретируемые языки обычно предоставляют такую систему по умолчанию, а для компилируемых понадобится дополнительный компилятор и интерпретатор, который следит за корректностью рефлексии.
Мне кажется, что мы сказали много об определении рефлексии, но смысла это пока несёт мало. Давайте взглянем на примеры кода ниже (с рефлексией и без), каждый из которых создаёт объект класса Foo и вызывает метод hello.
Этот список отнюдь не исчерпывает возможности рефлексии. Это очень мощный принцип, который к тому же является обычной практикой в метапрограммировании. Тем не менее, при использовании рефлексии нужно быть очень внимательным. Хотя у неё и есть свои преимущества, код, использующий рефлексию, значительно менее читаем, он затрудняет отладку, а также открывает двери по-настоящему плохим вещами, например, инъекции кода через выражения eval.
Eval-выражения
Некоторые рефлективные языки предоставляют возможность использования eval-выражений — выражений, которые распознают значение (обычно строку) как выражение. Такие утверждения — это самый мощный принцип рефлексии и даже метапрограммирования, но также и самый опасный, поскольку они представляют собой угрозу безопасности.
Рассмотрим следующий пример кода на Python, который принимает данные из стороннего источника в Сети (это одна из причин, по которой люди пользуются eval-выражениями):
Защита программы будет нарушена, если кто-то передаст в метод get_data() такую строку:
Для безопасного использования eval-утверждений нужно сильно ограничивать формат входных данных — и обычно это лишь занимает лишнее время.
Заключение
Интроспекция и рефлексия — это очень мощные инструменты современных языков, и их понимание может позволить вам писать по-настоящему крутой код. Ещё раз отметим: интроспекция — это изучение атрибутов объекта, а рефлексия — это манипуляция ими. Будьте внимательны при использовании рефлексии, поскольку она может сделать ваш код нечитаемым и уязвимым. Чем больше сила, тем больше и ответственность — вот девиз всего, что связано с метапрограммированием.