Про макросы в Dart написали уже кучу статей, в этой и следующей — минимум теории и максимум практики и рассуждений.
Вместе с Серёжей, Flutter Developer Surf, мы пройдём путём разработчика, который только начал изучать макросы, и будем:
-
придумывать способы упростить свою жизнь с помощью макросов;
-
формировать гипотезы (описывать то, что хотим получить);
-
писать код и проверять гипотезы;
-
радоваться результатам или разбираться, что пошло не так.
Вперед, к первой части.
Знакомство с макросами
Макросы — это проявление метапрограммирования в языке Dart. Подробнее о них можно прочитать тут:
-
Макросы на Dart: первые ощущения от использования и лайфхаки на будущее;
-
Пишем собственный макрос на Dart 3.5 вместо старого генератора кода.
Здесь же мы слегка пробежимся по основным моментам, которые нам понадобятся дальше.
Действующие лица
Макрос:
-
непосредственно то, что пишет разработчик;
-
класс, с точки зрения Dart;
-
должен иметь константный конструктор (как и любой класс, который можно использовать в качестве аннотации);
-
имеет доступ к информации о цели;
-
генерирует код на основании этой информации.
Цель:
-
то, к чему применяется макрос;
-
может быть классом, методом, полем, top-level переменной, top-level функцией, библиотекой, конструктором, миксином, расширением, перечислением, полем перечисления, type alias’ом;
-
может быть целью нескольких макросов сразу.
Сгенерированный код:
-
появляется в режиме редактирования кода по мере изменения кода макроса/цели;
-
readonly;
-
форматирование кода — прерогатива разработчика, поэтому обычно на него без слёз не взглянешь.
Устройство макроса
Итак, макрос — это класс. Помимо этого:
-
этот класс должен иметь ключевое слово
macro
в объявлении; -
реализовывать один (или несколько) из интерфейсов макросов. Каждый из интерфейсов определяет, к какой цели и в какой фазе макрос будет применён.
Фазы макросов
Фаза определения типов
-
выполняется первой;
-
только в этой фазе доступно объявление новых типов (классов, typedef, перечислений и других);
-
позволяет добавлять интерфейсы и расширения классов к цели (если цель — это класс);
-
практически не имеет доступа к уже имеющимся типам;
-
По сути, на этом всё.
Фаза объявления
-
выполняется после фазы типов;
-
в этой фазе можно объявлять новые поля, методы (но не классы и прочие типы);
-
имеет доступ к уже объявленным типам — если они указаны явно;
-
самая полезная и свободная фаза — можно писать практически любой код — как в класс, так и в файл.
Фаза определения
-
выполняется последней;
-
в этой фазе можно дополнять (
augment
) уже объявленные поля, методы, конструкторы; -
можно узнать типы полей, методов и другое (даже если они не указаны явно).
Как выбрать интерфейс макроса?
-
Выбираем цель.
-
Определяем, что мы хотим сделать с этой целью — то есть, выбираем фазу.
-
Путём несложной комбинации получаем название интерфейса (за исключением части Macro в конце).
-
Список доступных интерфейсов можно найти в репозитории с пакетом
macros
(пока он находится тут).
Таблица интерфейсов
Цель/Фаза |
Фаза определения типов |
Фаза объявления |
Фаза определения |
---|---|---|---|
Библиотека |
LibraryTypesMacro |
LibraryDeclarationsMacro |
LibraryDefinitionMacro |
Класс |
ClassTypesMacro |
ClassDeclarationsMacro |
ClassDefinitionMacro |
Метод |
MethodTypesMacro |
MethodDeclarationsMacro |
MethodDefinitionMacro |
Функция |
FunctionTypesMacro |
FunctionDeclarationsMacro |
FunctionDefinitionMacro |
Поле |
FieldTypesMacro |
FieldDeclarationsMacro |
FieldDefinitionMacro |
Переменная |
VariableTypesMacro |
VariableDeclarationsMacro |
VariableDefinitionMacro |
Перечисление |
EnumTypesMacro |
EnumDeclarationsMacro |
EnumDefinitionMacro |
Значение перечисления |
EnumValueTypesMacro |
EnumValueDeclarationsMacro |
EnumValueDefinitionMacro |
Миксин |
MixinTypesMacro |
MixinDeclarationsMacro |
MixinDefinitionMacro |
Расширение |
ExtensionTypesMacro |
ExtensionDeclarationsMacro |
ExtensionDefinitionMacro |
Расширение типа |
ExtensionTypeTypesMacro |
ExtensionTypeDeclarationsMacro |
ExtensionTypeDefinitionMacro |
Конструктор |
ConstructorTypesMacro |
ConstructorDeclarationsMacro |
ConstructorDefinitionMacro |
Type Alias |
TypeAliasTypesMacro |
TypeAliasDeclarationsMacro |
— |
Важно!
Можно выбрать несколько интерфейсов для одного макроса — таким образом, мы применим макрос к разным целям в разные фазы.
При применении макроса к цели будет выполнен только тот код, который относится к цели.
Например, если макрос реализует интерфейсы FieldDefinitionMacro
и ClassDeclarationsMacro
и применён к классу, то будет выполнен только код фазы объявления по отношению к классу.
Рубрика «Эксперименты»
Да начнётся практика! Но сперва определим то, как она будет проходить.
Каждый пункт этого раздела будет основываться на ответах на следующие вопросы:
-
зачем — обоснование полезности;
-
как это должно выглядеть — ожидаемый результат в виде кода;
-
как это реализовать — реализация);
-
работает ли это? Если нет, то почему — разбор особенностей и ограничений.
Авто-конструктор
Зачем?
Будем честны, даже с помощью IDE создание конструктора класса с большим количеством полей — это не лучший вариант времяпрепровождения. Да и довольно утомительно бывает дополнять уже существующий конструктор новыми полями. Кроме того, конструктор для класса с большим количеством полей может занимать много строк кода, что не всегда положительно сказывается на читаемости.
Как это должно выглядеть?
Важно
Для простоты предлагаем опустить кейсы с super-конструкторами и с приватными именованными полями — нам и так будет, чем заняться.
Поля класса могут инициализироваться:
-
позиционными параметрами конструктора;
-
именованными параметрами конструктора;
-
константными значениями по умолчанию;
-
обязательными;
-
необязательными;
-
не в конструкторе.
Надо предусмотреть все эти случаи. Для этого используем аннотирование полей класса:
@AutoConstructor() class SomeComplicatedClass { final int a; @NamedParam() final String b; @NamedParam(defaultValue: 3.14) final double c; @NamedParam(isRequired: false) final bool? d; @NamedParam(isRequired: true) final bool? e; final List<int> f; } augment class SomeComplicatedClass { SomeComplicatedClass(this.a, this.f, {required this.b, this.c = 3.14, this.d, required this.e}); }
Как это реализовать?
Начнём с самого простого — в отдельном файле создадим класс NamedParam
для аннотирования полей класса:
class NamedParam { final bool isRequired; final Object? defaultValue; const NamedParam({this.defaultValue, this.isRequired = true}); }
Теперь создадим макрос, который сделает всю работу за нас. Заодно порассуждаем, какая фаза макроса нам подходит:
-
мы не собираемся определять новые типы, поэтому фаза типов точно не подходит;
-
фаза объявления позволяет писать код внутри класса, а также оперировать полями класса, что нам и нужно;
-
фаза определения позволяет дополнять конструктор класса, но не даёт возможность писать конструктор с нуля (то есть, конструктор уже должен присутствовать в классе) — не наш вариант.
Мы выбираем фазу объявления. Создаём макрос AutoConstructor
, получаем список полей и начинаем складывать код конструктора и параметры:
import 'dart:async'; import 'package:macros/macros.dart'; macro class AutoConstructor implements ClassDeclarationsMacro { @override FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async { final fields = await builder.fieldsOf(clazz); /// Сюда мы будем собирать код. /// Начнём с объявления конструктора. /// Например: /// ClassName( /// final code = <Object>[ '\t${clazz.identifier.name}(\n', ]; /// Список всех позиционных параметров. final positionalParams = <Object>[]; /// Список всех именнованных параметров. final namedParams = <Object>[]; } }
Следующая задача, которую нам нужно решить — научиться определять, есть ли у поля аннотация NamedParam
. И если есть — какие у неё параметры. Для этого мы пройдёмся по всем аннотациям поля и найдём нужную:
for (final field in fields) { /// Список всех аннотаций поля. final annotationsOfField = field.metadata; /// Достаём аннотацию NamedParam (если она есть). final namedParam = annotationsOfField.firstWhereOrNull( (element) => element is ConstructorMetadataAnnotation && element.type.identifier.name == 'NamedParam', ) as ConstructorMetadataAnnotation?; }
Небольшое пояснение к коду выше
Аннотации в Dart могут быть двух типов:
-
константное значение (например,
@immutable
или@override
); -
вызов конструктора (например,
@Deprecated('Use another method')
).
Поскольку NamedParam
относится ко второму типу, мы ищем аннотацию-вызов конструктора с именем NamedParam
. Иначе нам бы потребовался не ConstructorMetadataAnnotation
, а IdentifierMetadataAnnotation
.
У аннотации есть два именованных параметра — defaultValue
и isRequired
. Достанем их:
if (namedParam != null) { final defaultValue = namedParam.namedArguments['defaultValue']; final isRequired = namedParam.namedArguments['isRequired']; ... }
И вот тут начинаются проблемы — мы не можем узнать значение isRequired
(то есть, сделать что-то вроде if (isRequired) {
). Это происходит потому, что API макросов не даёт прямой доступ к значению поля. Он а предоставляет только объект типа ExpressionCode
— код выражения, который будет подставлен в конечный код уже на этапе его формирования.
Важно
Что такое код?
В рамках макросов мы можем строить код из трёх типов объектов:
-
String
— обычная строка. Эта строка добавляется в код как есть; -
Identifier
— ссылка на именованное объявление (название переменной или поля, его/её типа и т.д.); -
Code
— сущность, которая представляет собой набор Dart-кода. Состоит из частей, которые также могут быть одним из этих трёх типов. Имеет множество подклассов для различных конструкций языка (например,DeclarationCode
,TypeAnnotationCode
,ExpressionCode
и другие). Подклассы использует сериализатор для корректной генерации различных конструкций.
В случае с Identifier
и Code
мы не можем получить значение, которое попадёт в итоговый код — это своего рода метаданные о коде, не сам код.
Но мы не сдадимся — давайте создадим отдельную аннотацию для обязательных полей — requiredField
. Эта аннотация может быть не классом, а констатным значением:
const requiredField = Required(); class Required { const Required(); }
Отредактируем исходный класс:
@AutoConstructor() class SomeComplicatedClass { final int a; @requiredField @NamedParam() final String b; @NamedParam(defaultValue: 3.14) final double c; @NamedParam() final bool? d; @requiredField @NamedParam() final bool? e; final List<int> f; }
Теперь найдём эту аннотацию у поля:
if (namedParam != null) { final defaultValue = namedParam.namedArguments['defaultValue']; final isRequired = annotationsOfField.any( (element) => element is IdentifierMetadataAnnotation && element.identifier.name == 'requiredField', ); ... }
Сформируем код с инициализацией именованных параметров.
Что должно получиться:
required this.b, this.c = 3.14, this.d, this.e,
Как мы это сделаем:
namedParams.addAll( [ '\t\t', if (isRequired && defaultValue == null) ...[ 'required ', ], 'this.${field.identifier.name}', if (defaultValue != null) ...[ ' = ', defaultValue, ], ',\n', ], );
Теперь займёмся позиционными параметрами — тут всё проще, нужно просто добавить их в список:
if (namedParam != null) { ... } else { positionalParams.add('\t\tthis.${field.identifier.name},\n'); }
Соберём всё и добавим код в класс:
{ ... code.addAll([ ...positionalParams, if (namedParams.isNotEmpty) ...['\t\t{\n', ...namedParams, '\t\t}',], '\n\t);', ]); builder.declareInType(DeclarationCode.fromParts(code)); }
Результат
Применим макрос к классу SomeComplicatedClass
:
@AutoConstructor() class SomeComplicatedClass { final int a; @requiredField @NamedParam() final String b; @NamedParam(defaultValue: 3.14) final double c; @NamedParam() final bool? d; @requiredField @NamedParam() final bool? e; final List<int> f; }
И получим следующий результат:
augment library 'package:test_macros/1.%20auto_constructor/example.dart'; augment class SomeComplicatedClass { SomeComplicatedClass( this.a, this.f, { required this.b, this.c = 3.14, this.d, this.e, } ); }
Вот полный код макроса:
macro class AutoConstructor implements ClassDeclarationsMacro { const AutoConstructor(); @override FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async { final fields = await builder.fieldsOf(clazz); /// Сюда мы будем собирать код. final code = <Object>[ '\t${clazz.identifier.name}(\n', ]; /// Список всех позиционных параметров. final positionalParams = <Object>[]; /// Список всех именнованных параметров. final namedParams = <Object>[]; for (final field in fields) { /// Список всех аннотаций поля. final annotationsOfField = field.metadata; /// Достаём аннотацию NamedParam (если она есть). final namedParam = annotationsOfField.firstWhereOrNull( (element) => element is ConstructorMetadataAnnotation && element.type.identifier.name == 'NamedParam', ) as ConstructorMetadataAnnotation?; if (namedParam != null) { final defaultValue = namedParam.namedArguments['defaultValue']; final isRequired = annotationsOfField.any( (element) => element is IdentifierMetadataAnnotation && element.identifier.name == 'requiredField', ); namedParams.addAll( [ '\t\t', if (isRequired && defaultValue == null) ...[ 'required ', ], 'this.${field.identifier.name}', if (defaultValue != null) ...[ ' = ', defaultValue, ], ',\n', ], ); } else { positionalParams.add('\t\tthis.${field.identifier.name},\n'); } } code.addAll([ ...positionalParams, if (namedParams.isNotEmpty) ...['\t\t{\n', ...namedParams, '\t\t}',], '\n\t);', ]); builder.declareInType(DeclarationCode.fromParts(code)); } }
Мы почти достигли желаемого результата, но при этом столкнулись с ограничением API макросов. Мы не можем оперировать значениями ExpressionCode
. В некоторых случаях (в нашем, например), мы можем обойти это ограничение окольными путями. Но бывает, что это становится реальным препятствием.
Кроме того, есть ещё пара моментов, которые портят всё:
-
в
NamedParam
можно передать значение по умолчанию любого типа — то есть, отличного от поля, которому присваивается значение). Но это не большая проблема, потому что анализатор предупредит нас о неправильном типе; -
в самом макросе мы используем строковое название классов аннотаций и их параметров, что может привести к ошибкам, если эти названия изменятся. Это проблема макросов в целом, но она решается путём хранения аннотаций и макроса в одной библиотеке.
Есть проблема посерьёзнее — проект с этим макросом не запускается, выдавая ошибку отсутствия конструктора у класса. При этом ошибок анализатора нет — сгенерированный код выглядит корректно. Немного покопавшись в исходном классе, мыобнаружили, что он работает в таком виде:
@AutoConstructor() class SomeComplicatedClass { final int a; final String b; final double c; final bool? d; final bool? e; final List<int> f; }
Мы полностью убрали аннотации. Судя по всему, на момент запуска проекта аннотации не обрабатываются и класс не имеет конструктора, что приводит к ошибке. Press F. Флешка с доказательствами уже в Гааге Issue на GitHub уже создана, но сейчас мы ничего не можем сделать.
Делаем важный вывод — анализатору мы доверять больше (или пока что) не можем.
Публичные Listenable-геттеры
Зачем?
Актуально для тех, кому надоело постоянно писать что-то такое:
final _counter = ValueNotifier<int>(0); ValueListenable<int> get counter => _counter;
или
final counterNotifier = ValueNotifier<int>(0); ValueListenable<int> get counter => counterNotifier;
Как это должно выглядеть?
@ListenableGetter() final _counter = ValueNotifier<int>(0); @ListenableGetter(name: 'secondCounter') final _counterNotifier = ValueNotifier<int>(0);
Как это реализовать?
Для начала выберем фазу макроса:
-
мы не планируем создавать новый тип, поэтому фаза типов не подходит;
-
фаза объявления позволяет нам добавлять код внутри класса — то, что нужно;
-
фаза определения позволяет дополнять уже имеющиеся объявления, а не создавать новые.
Создадим макрос ListenableGetter
. В качестве интерфейса макроса берём FieldDeclarationsMacro
, потому что целью макроса будет именно поле класса:
import 'dart:async'; import 'package:macros/macros.dart'; macro class ListenableGetter implements FieldDeclarationsMacro { final String? name; const ListenableGetter({this.name}); @override FutureOr<void> buildDeclarationsForField(FieldDeclaration field, MemberDeclarationBuilder builder) async { /// } }
Для начала добавим проверку, что поле имеет вид ValueNotifier
:
@override FutureOr<void> buildDeclarationsForField(FieldDeclaration field, MemberDeclarationBuilder builder) async { final fieldType = field.type; if (fieldType is! NamedTypeAnnotation) { builder.report( Diagnostic( DiagnosticMessage('Field doesn\'t have type'), Severity.error, ), ); return; } if (fieldType.identifier.name != 'ValueNotifier') { builder.report( Diagnostic( DiagnosticMessage('Field type is not ValueNotifier'), Severity.error, ), ); return; } }
Применяем макрос к классу и получаем ошибку — ‘Field doesn’t have type’. Это происходит из-за того, что тип поля не указан явно. При этом в фазе объявления мы не можем получить доступ к типу поля напрямую, если оно не указано явно. И тут нам на помощь приходит фаза определения, у которой таких ограничений нет.
Новый план таков:
-
определяем геттер для поля в фазе объявления как
external
— его реализацию мы добавим в фазе определения; -
в фазе определения добавляем реализацию геттера.
В итоге получаем:
import 'dart:async'; import 'package:macros/macros.dart'; macro class ListenableGetter implements FieldDefinitionMacro, FieldDeclarationsMacro { final String? name; const ListenableGetter({this.name}); String _resolveName(FieldDeclaration field) => name ?? field.identifier.name.replaceFirst('_', ''); @override FutureOr<void> buildDeclarationsForField(FieldDeclaration field, MemberDeclarationBuilder builder) async { builder.declareInType(DeclarationCode.fromParts([ '\texternal get ', _resolveName(field), ';', ])); } @override FutureOr<void> buildDefinitionForField(FieldDeclaration field, VariableDefinitionBuilder builder) async { var fieldType = field.type is OmittedTypeAnnotation ? await builder.inferType(field.type as OmittedTypeAnnotation) : field.type; if (fieldType is! NamedTypeAnnotation) { builder.report( Diagnostic( DiagnosticMessage('Field doesn\'t have type'), Severity.error, ), ); return; } if (fieldType.identifier.name != 'ValueNotifier') { builder.report( Diagnostic( DiagnosticMessage('Field type is not ValueNotifier'), Severity.error, ), ); return; } final type = await builder.resolveIdentifier( Uri.parse('package:flutter/src/foundation/change_notifier.dart'), 'ValueListenable'); builder.augment( getter: DeclarationCode.fromParts([ type, '<', fieldType.typeArguments.first.code, '> get ', _resolveName(field), ' => ', field.identifier.name, ';', ]), ); } }
Результат
Применим макрос к классу WidgetModel
:
class WidgetModel { @ListenableGetter() final _counter = ValueNotifier<int>(0); @ListenableGetter(name: 'secondCounter') final _secondCounter = ValueNotifier(0); } void foo() { final a = WidgetModel(); a.counter; // ValueListenable<int> a.secondCounter; // ValueListenable<int> }
И получим следующий результат:
augment library 'package:test_macros/2.%20listenable_getter/example.dart'; import 'package:flutter/src/foundation/change_notifier.dart' as prefix0; import 'dart:core' as prefix1; augment class WidgetModel { external get counter; external get secondCounter; augment prefix0.ValueListenable<prefix1.int> get counter => _counter; augment prefix0.ValueListenable<prefix1.int> get secondCounter => _secondCounter; }
Эксперимент удался — мы получили то, что хотели. Мы использовали две фазы макросов, но благодаря этому нам не нужно явно указывать тип поля.
Выводы
Мы убедились в том, что макросы могут оказаться очень полезными в привычных активностях разработчика.
Но это далеко не всё. Ещё больше примеров — и негативных, да — во второй части этой статьи.
Больше полезного про Flutter — в Telegram-канале Surf Flutter Team.
Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!
ссылка на оригинал статьи https://habr.com/ru/articles/844614/
Добавить комментарий