Про макросы в 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/