Про макросы в 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 в одном месте. Присоединяйтесь!
ChessMax
Ссылка на проект, который можно запустить, конечно, не помешала бы. Количество метаданных в примере с конструктором больше чем кода самого конструктора))
sadhorsephile
Репозиторий со всеми примерами будет во второй части статьи, которая
будет уже вот прямо совсем скороуже вышла)Правда, из-за приведённых ишью (в особенности из-за этой) запустить удастся далеко не все примеры
Surf_Studio Автор
А вот и вторая часть https://habr.com/ru/companies/surfstudio/articles/844640/