Про макросы в Dart написали уже кучу статей, в этой и следующей — минимум теории и максимум практики и рассуждений.

Вместе с Серёжей, Flutter Developer Surf, мы пройдём путём разработчика, который только начал изучать макросы, и будем:

  • придумывать способы упростить свою жизнь с помощью макросов;

  • формировать гипотезы (описывать то, что хотим получить);

  • писать код и проверять гипотезы;

  • радоваться результатам или разбираться, что пошло не так.

Вперед, к первой части.

Знакомство с макросами

Макросы — это проявление метапрограммирования в языке Dart. Подробнее о них можно прочитать тут:

Здесь же мы слегка пробежимся по основным моментам, которые нам понадобятся дальше.

Действующие лица

Макрос:

  • непосредственно то, что пишет разработчик;

  • класс,  с точки зрения Dart;

  • должен иметь константный конструктор (как и любой класс, который можно использовать в качестве аннотации);

  • имеет доступ к информации о цели;

  • генерирует код на основании этой информации.

Цель:

  • то, к чему применяется макрос;

  • может быть классом, методом, полем, top-level переменной, top-level функцией, библиотекой, конструктором, миксином, расширением, перечислением, полем перечисления, type alias'ом;

  • может быть целью нескольких макросов сразу.

Сгенерированный код:

  • появляется в режиме редактирования кода по мере изменения кода макроса/цели;

  • readonly;

  • форматирование кода — прерогатива разработчика, поэтому обычно на него без слёз не взглянешь.

Устройство макроса

Итак, макрос — это класс. Помимо этого:

  • этот класс должен иметь ключевое слово macro в объявлении;

  • реализовывать один (или несколько) из интерфейсов макросов. Каждый из интерфейсов определяет, к какой цели и в какой фазе макрос будет применён.

Фазы макросов

Фаза определения типов

  • выполняется первой;

  • только в этой фазе доступно объявление новых типов (классов, typedef, перечислений и других);

  • позволяет добавлять интерфейсы и расширения классов к цели (если цель — это  класс);

  • практически не имеет доступа к уже имеющимся типам;

  • По сути, на этом всё.

Фаза объявления

  • выполняется после фазы типов;

  • в этой фазе можно объявлять новые поля, методы (но не классы и прочие типы);

  • имеет доступ к уже объявленным типам — если они указаны явно;

  • самая полезная и свободная фаза — можно писать практически любой код — как в класс, так и в файл.

Фаза определения

  • выполняется последней;

  • в этой фазе можно дополнять (augment) уже объявленные поля, методы, конструкторы;

  • можно узнать типы полей, методов и другое (даже если они не указаны явно).

Как выбрать интерфейс макроса?

  1. Выбираем цель.

  2. Определяем, что мы хотим сделать с этой целью — то есть, выбираем фазу.

  3. Путём несложной комбинации получаем название интерфейса (за исключением части Macro в конце).

  4. Список доступных интерфейсов можно найти в репозитории с пакетом 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 в одном месте. Присоединяйтесь!

Комментарии (4)


  1. ChessMax
    20.09.2024 06:57

    Ссылка на проект, который можно запустить, конечно, не помешала бы. Количество метаданных в примере с конструктором больше чем кода самого конструктора))


    1. sadhorsephile
      20.09.2024 06:57
      +2

      Репозиторий со всеми примерами будет во второй части статьи, которая будет уже вот прямо совсем скоро уже вышла)
      Правда, из-за приведённых ишью (в особенности из-за этой) запустить удастся далеко не все примеры


    1. Surf_Studio Автор
      20.09.2024 06:57
      +2

  1. alexeyinkin
    20.09.2024 06:57

    Спасибо за цитирование. :)