Another article about macros. Part 1

There have been a ton of articles written about macros in Dart, this one has a minimum of theory and a maximum of practice and reasoning. Together with Seryozha, Flutter Developer Surf, we will follow the path of a developer who has just started learning macros, and we will:

  • come up with ways to make your life easier using macros;

  • form hypotheses (describe what we want to get);

  • write code and test hypotheses;

  • be happy with the results or figure out what went wrong.

Introduction to Macros

Macros is a manifestation of metaprogramming in the Dart language. You can read more about them here:

Here we will briefly go over the main points that we will need later.

Characters

Macro:

  • directly what the developer writes;

  • class, from Dart's point of view;

  • must have a constant constructor (like any class that can be used as an annotation);

  • has access to information about the target;

  • generates code based on this information.

Target:

  • what the macro is applied to;

  • can be a class, method, field, top-level variable, top-level function, library, constructor, mixin, extension, enumeration, enumeration field, type alias;

  • can be the target of several macros at once.

Generated code:

  • appears in code editing mode as the macro/target code is changed;

  • readonly;

  • Code formatting is the developer's prerogative, so it's usually hard to look at it without tears.

Macro device

So, a macro is a class. Besides that:

  • this class must have a keyword macro in the ad;

  • implement one (or more) of the macro interfaces. Each interface defines to what target and in what phase the macro will be applied.

Macro phases

Type Definition Phase

  • is performed first;

  • only in this phase is it possible to declare new types (classes, typedefs, enumerations, and others);

  • allows you to add interfaces and class extensions to a target (if the target is a class);

  • has virtually no access to existing types;

  • That's basically it.

Announcement phase

  • performed after the types phase;

  • in this phase you can declare new fields, methods (but not classes and other types);

  • has access to already declared types – if they are specified explicitly;

  • the most useful and free phase – you can write almost any code – both in a class and in a file.

Determination phase

  • is performed last;

  • in this phase you can add (augment) already declared fields, methods, constructors;

  • You can find out the types of fields, methods, etc. (even if they are not specified explicitly).

How to choose a macro interface?

  1. We select a target.

  2. We determine what we want to do for this purpose – that is, we choose a phase.

  3. By means of a simple combination we get the name of the interface (except for the Macro part at the end).

  4. The list of available interfaces can be found in the package repository. macros (while he is here).

Interface table

Goal/Phase

Type Definition Phase

Announcement phase

Determination phase

Library

LibraryTypesMacro

LibraryDeclarationsMacro

LibraryDefinitionMacro

Class

ClassTypesMacro

ClassDeclarationsMacro

ClassDefinitionMacro

Method

MethodTypesMacro

MethodDeclarationsMacro

MethodDefinitionMacro

Function

FunctionTypesMacro

FunctionDeclarationsMacro

FunctionDefinitionMacro

Field

FieldTypesMacro

FieldDeclarationsMacro

FieldDefinitionMacro

Variable

VariableTypesMacro

VariableDeclarationsMacro

VariableDefinitionMacro

Enumeration

EnumTypesMacro

EnumDeclarationsMacro

EnumDefinitionMacro

Enumeration value

EnumValueTypesMacro

EnumValueDeclarationsMacro

EnumValueDefinitionMacro

Mixin

MixinTypesMacro

MixinDeclarationsMacro

MixinDefinitionMacro

Extension

ExtensionTypesMacro

ExtensionDeclarationsMacro

ExtensionDefinitionMacro

Type extension

ExtensionTypeTypesMacro

ExtensionTypeDeclarationsMacro

ExtensionTypeDefinitionMacro

Constructor

ConstructorTypesMacro

ConstructorDeclarationsMacro

ConstructorDefinitionMacro

Type Alias

TypeAliasTypesMacro

TypeAliasDeclarationsMacro

Important!

It is possible to select multiple interfaces for one macro – this way we will apply the macro to different targets in different phases.

When you apply a macro to a target, only the code that applies to the target will be executed.

For example, if a macro implements interfaces FieldDefinitionMacro And ClassDeclarationsMacro and applied to a class, then only the declaration phase code for the class will be executed.

Column “Experiments”

Let the practice begin! But first, let's define how it will take place.

Each item in this section will be based on answers to the following questions:

  • why – justification of usefulness;

  • what it should look like – the expected result in code form;

  • how to implement it – implementation);

  • Does it work? If not, why not – an analysis of the features and limitations.

Auto-constructor

For what?

Let's be honest, even with the help of an IDE, creating a class constructor with a large number of fields is not the best way to spend your time. And it can be quite tedious to supplement an existing constructor with new fields. In addition, a constructor for a class with a large number of fields can take up many lines of code, which does not always have a positive effect on readability.

What should it look like?

Important

For simplicity, we suggest omitting cases with super-constructors and private named fields – we will have enough to do anyway.

Class fields can be initialized:

  • positional parameters of the constructor;

  • named constructor parameters;

  • constant default values;

  • mandatory;

  • optional;

  • not in the constructor.

We need to provide for all these cases. To do this, we use annotation of class fields:

@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});
}

How to implement this?

Let's start with the simplest thing – create a class in a separate file NamedParam to annotate class fields:

class NamedParam {
  final bool isRequired;
  final Object? defaultValue;
  const NamedParam({this.defaultValue, this.isRequired = true});
}

Now let's create a macro that will do all the work for us. At the same time, let's discuss which phase of the macro is right for us:

  • we are not going to define new types, so the types phase is definitely not appropriate;

  • the declaration phase allows you to write code inside the class, as well as operate on the class fields, which is what we need;

  • the definition phase allows you to supplement the class constructor, but does not allow you to write a constructor from scratch (that is, the constructor must already be present in the class) – not our option.

We select the announcement phase. We create a macro AutoConstructorwe get a list of fields and start adding the constructor code and parameters:

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>[];
  }
}

The next problem we need to solve is to learn how to determine if a field has an annotation NamedParam. And if there is, what are its parameters. To do this, we will go through all the field annotations and find the right one:

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?;
    }
A little explanation of the code above

Annotations in Dart can be of two types:

  • constant value (eg @immutable or @override);

  • call a constructor (eg @Deprecated('Use another method')).

Because NamedParam belongs to the second type, we are looking for an annotation-call of a constructor with the name NamedParamOtherwise we would not need ConstructorMetadataAnnotationA IdentifierMetadataAnnotation.

The annotation has two named parameters – defaultValue And isRequired. Let's get them:

if (namedParam != null) {
        final defaultValue = namedParam.namedArguments['defaultValue'];
        
        final isRequired = namedParam.namedArguments['isRequired'];
      ...
      }

And here is where the problems begin – we cannot find out the meaning isRequired (that is, to do something like if (isRequired) {). This is because the macro API does not provide direct access to the field value. It only provides an object of type ExpressionCode — the expression code that will be substituted into the final code at the stage of its formation.

Important

What is code?

Within macros, we can build code from three types of objects:

  • String — a regular string. This string is added to the code as is;

  • Identifier — a reference to a named declaration (the name of a variable or field, its type, etc.);

  • Code — an entity that represents a set of Dart code. It consists of parts, which can also be one of these three types. It has many subclasses for different language constructs (for example, DeclarationCode, TypeAnnotationCode, ExpressionCode and others). Subclasses use the serializer to correctly generate various constructions.

In the case of Identifier And Code we can't get a value that will be included in the final code – this is a kind of metadata about the code, not the code itself.

But we won't give up – let's create a separate annotation for required fields – requiredField. This annotation may not be a class, but a constant value:

 const requiredField = Required();

  class Required {
    const Required();
  }

Let's edit the original class:

@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;
}

Now let's find this annotation near the field:

if (namedParam != null) {
        final defaultValue = namedParam.namedArguments['defaultValue'];
        
        final isRequired = annotationsOfField.any(
          (element) => element is IdentifierMetadataAnnotation && element.identifier.name == 'requiredField',
        );
      ...
      }

Let's generate code with initialization of named parameters.

What should happen:

required this.b,
    this.c = 3.14,
    this.d,
    this.e,

How we will do it:

namedParams.addAll(
          
          [
            '\t\t',
            if (isRequired && defaultValue == null) ...[
              'required ',
            ],
            'this.${field.identifier.name}',
            if (defaultValue != null) ...[
              ' = ',
              defaultValue,
            ],
            ',\n',
          ],
        );

Now let's move on to the positional parameters – everything is simpler here, you just need to add them to the list:

if (namedParam != null) {
        ...
      } else {
        positionalParams.add('\t\tthis.${field.identifier.name},\n');
      }

Let's put everything together and add the code to the class:

{
    ...
    code.addAll([
      ...positionalParams,
      if (namedParams.isNotEmpty)
      ...['\t\t{\n',
      ...namedParams,
      '\t\t}',],
      '\n\t);',
    ]);

    builder.declareInType(DeclarationCode.fromParts(code));
  }

Result

Apply the macro to the class 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;
}

And we get the following result:

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,
		}
	);
}

Here is the full macro code:

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));
  }
}

We almost achieved the desired result, but we encountered a limitation of the macro API. We cannot operate on values ExpressionCode. In some cases (ours, for example), we can get around this limitation in a roundabout way. But sometimes it becomes a real obstacle.

Besides, there are a couple more things that spoil everything:

  • V NamedParam you can pass a default value of any type – that is, different from the field to which the value is assigned). But this is not a big problem, because the analyzer will warn us about the wrong type;

  • In the macro itself, we use the string name of the annotation classes and their parameters, which can lead to errors if these names change. This is a problem with macros in general, but it is solved by storing the annotations and the macro in the same library.

There is a problem more seriously — the project with this macro does not start, giving an error about the absence of a constructor for the class. At the same time, there are no analyzer errors — the generated code looks correct. Having dug a little into the original class, we found that it works like this:

@AutoConstructor()
class SomeComplicatedClass {
final int a;

final String b;

final double c;

final bool? d;

final bool? e;

final List<int> f;
}

We have removed annotations completely. Apparently, when the project is launched, annotations are not processed and the class does not have a constructor, which leads to an error. Press F. Flash drive with evidence already in The Hague Issue on GitHub has already been created, but now we can’t do anything.

We make an important conclusion: we cannot trust the analyzer anymore (or for now).

Public Listenable Getters

For what?

Relevant for those who are tired of constantly writing something like this:

final _counter = ValueNotifier<int>(0);
    ValueListenable<int> get counter => _counter;

or

 final counterNotifier = ValueNotifier<int>(0);
    ValueListenable<int> get counter => counterNotifier;

What should it look like?

  @ListenableGetter()
    final _counter = ValueNotifier<int>(0);

    @ListenableGetter(name: 'secondCounter')
    final _counterNotifier = ValueNotifier<int>(0);

How to implement this?

First, let's select the macro phase:

  • we don't plan to create a new type, so the types phase is not suitable;

  • the declaration phase allows us to add code inside the class – whatever we need;

  • The definition phase allows you to supplement existing ads rather than create new ones.

Let's create a macro ListenableGetter. We take as the macro interface FieldDeclarationsMacrobecause the target of the macro will be the class field:

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 {
    ///
  }
}

First, let's add a check that the field has the form 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;
    }
  }

We apply the macro to the class and get an error — 'Field doesn't have type'. This happens because the field type is not specified explicitly. At the same time, in the declaration phase, we cannot access the field type directly if it is not specified explicitly. And here the definition phase comes to our aid, which has no such restrictions.

The new plan is:

  • we define a getter for the field in the declaration phase as external — we will add its implementation in the definition phase;

  • In the definition phase we add the getter implementation.

As a result we get:

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,
        ';',
      ]),
    );
  }
}

Result

Apply the macro to the class 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>
}

And we get the following result:

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;
}

The experiment was a success – we got what we wanted. We used two phases of macros, but thanks to this we don't have to explicitly specify the field type.

Conclusions

We have found that macros can be very useful in a developer's usual activities.

But that's not all. There are even more examples – and negative ones, yes – in the second part of this article.

More useful information about Flutter is available in the Surf Flutter Team Telegram channel.

Cases, best practices, news and vacancies for the Flutter Surf team in one place. Join us!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *