[Перевод] Пишем собственный макрос на Dart 3.5 вместо старого генератора кода

В Dart 3.5 большое новшество: макросы. Это как старая генерация кода, но прямо в памяти, без временных файлов, плюс ещё много преимуществ.

Пока это бета, и документации мало. Вот план команды Dart:

  • Сейчас они выпустили один макрос @JsonCodable, который заменяет пакет json_serializable и устраняет.g.dart‑файлы. На его примере можно знакомиться с технологией.

  • Этот макрос станет стабильным в течение 2024 года.

  • В начале 2025 можно будет писать собственные макросы.

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

Итак:

  1. Разберём пример использования @JsonCodable от команды Dart.

  2. Напишем свой простейший макрос.

  3. Глубоко разберём мой макрос, который генерирует парсер параметров командной строки по описанию вашего data‑класса.

Готовим эксперимент

Dart 3.5

Скачайте бета‑версию Dart 3.5 и включите использование макросов по официальной инструкции: https://dart.dev/language/macros#set‑up‑the‑experiment

Я просто скачал ZIP‑файл и положил в отдельную папку.

VSCode

Чтобы видеть код, который макросы создают, нужна последняя стабильная версия плагина Dart для VSCode.

pubspec.yaml

Чтобы использовать @JsonCodable, нужна версия Dart 3.5.0-154 или выше. Задайте это требование в pubspec.yaml:

name: macro_client
environment:
  sdk: ^3.5.0-154

dependencies:
  json: ^0.20.2

analysis_options.yaml

Чтобы анализатор не ругался, скажите ему, что экспериментируете с макросами. Для этого создайте такой analysis_options.yaml:

analyzer:
  enable-experiment:
    - macros

Код

Скопируйте официальный пример:

import 'package:json/json.dart';

@JsonCodable() // Аннтоация-макрос.
class User {
  final int? age;
  final String name;
  final String username;
}

void main() {
  // Берём такой JSON:
  final userJson = {
    'age': 5,
    'name': 'Roger',
    'username': 'roger1337',
  };

  // Создаём объект с полями и выводим:
  final user = User.fromJson(userJson);
  print(user);
  print(user.toJson());
}

Запустите программу с экспериментальным флагом в терминале:

dart run --enable-experiment=macros lib/example.dart

Или настройте VSCode для такого запуска. Откройте settings.json:

817d3b0ebf8590b59aeb4507d8bf244e.png

И укажите там:

4e6fdf1db9260e09dbd394db31ed9440.png

Пример выполнится и напечатает:

Instance of 'User'
{age: 5, name: Roger, username: roger1337}

Получается всего 6 строк в классе:

@JsonCodable()
class User {
  final int? age;
  final String name;
  final String username;
}

А с пакетом json_serializable это занимало 16 строк:

@JsonSerializable()
class User {
  const Commit({
    required this.age,
    required this.name,
    required this.username,
  });

  final int? age;
  final String name;
  final String username;

  factory User.fromJson(Map map) => _$UserFromJson(map);

  Map toJson() => _$UserToJson(this);
}

Как посмотреть сгенерированный код

В VSCode нажмите ссылку «Go to Augmentation» под строчкой, где используется макрос @JsonCodable. Откроется код:

b7dca0daf3760c4bff41bfe79cf6b42a.png

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

Если что‑то поменять в исходном файле, сгенерированный код сразу отразит изменения — ничего не нужно запускать вручную.

А если VSCode вам не подходит, я написал программу, которая показывает точно такой же сгенерированный код.

Как это работает: огментация

В сгенерированном коде используется новая возможность языка: огментация («augmentation», переводится как «дополнение»). Это возможность поменять класс или функцию, добавляя члены и заменяя тела функций за пределами того блока, где они изначально описаны.

Это отдельная синтаксическая конструкция, не связанная с макросами. Вот простейший пример её использования:

class Cat {
  final String name; // Ошибка "Uninitialized", потому что нет конструктора.
}

augment class Cat {
  Cat(this.name); //    Исправляем эту ошибку.
}

Огментация может быть и в отдельном файле. По сути основная работа макроса — выдать такой файл с огментацией. И главное отличие макросов от старой генерации кода — это то, что всё происходит в памяти без временных.g.dart‑файлов на диске.

Поэтому в принципе можно было бы переделать пакет json_serializable с использованием огментации и даже без макросов получить такой же короткий код, потому что конструктор можно вынести в огментацию, а форвардеры методов toJson и fromJson больше не нужны.

Главные почести — огментации, а не макросам. Да, макросы важны, но их роль вторична в той революции, которая скоро начнётся вокруг языка.

Пишем собственный hello-world макрос

Создайте hello.dart с кодом макроса:

import 'dart:async';

import 'package:macros/macros.dart';

final _dartCore = Uri.parse('dart:core');

macro class Hello implements ClassDeclarationsMacro {
  const Hello();

  @override
  Future buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {
    final fields = await builder.fieldsOf(clazz);
    final fieldsString = fields.map((f) => f.identifier.name).join(', ');

    final print = await builder.resolveIdentifier(_dartCore, 'print');

    builder.declareInType(
      DeclarationCode.fromParts([
        'void hello() {',
        print,
        '("Hello! I am ${clazz.identifier.name}. I have $fieldsString.");}',
      ]),
    );
  }
}

Этот макрос создаёт метод hello в любом классе, к которому мы его применим. Метод печатает название класса и список полей.

Макрос — это класс с модификатором macro. Здесь мы реализуем интерфейс ClassDeclarationsMacro, который говорит компилятору, что этот макрос применим к классам и выполняется на том этапе, когда мы генерируем декларации в них. Есть много интерфейсов, которые делают макросы применимыми к разным другим конструкциям и позволяют им работать на других этапах генерации кода. Об этом поговорим, когда будем разбирать макрос для парсинга параметров командной строки.

В интерфейсе есть метод buildDeclarationsForClass, который нужно реализовать, и он будет вызываться автоматически. Его параметры:

  1. ClassDeclaration с информацией о классе, к которому применяем макрос.

  2. Билдер, который может анализировать class declaration и добавлять код в класс или глобально в библиотеку.

Мы используем билдер, чтобы получить список полей в классе.

Собственно генерация кода — это просто. У билдера есть метод declareInType, который дополняет класс любым кодом. В простейшем случае можно передать просто строку, но есть хитрость с функцией print.

В примере с JsonCodable выше мы видели, что библиотека dart:core импортируется с префиксом:

import 'dart:core' as prefix0;

Префикс добавляется автоматически, чтобы ваш код не конфликтовал с символами этой библиотеки. Префикс динамический, и заранее его узнать нельзя. Поэтому вызов print(something) нельзя написать в коде просто строкой. Поэтому генерируем код из частей:

final print = await builder.resolveIdentifier(_dartCore, 'print');

builder.declareInType(
  DeclarationCode.fromParts([ // Части:
    'void hello() {',
    print,
    '("Hello! I am ${clazz.identifier.name}. I have $fieldsString.");}',
  ]),
);

Эти части могут быть строками или ссылками на ранее полученные идентификаторы. В конце всё это будет склеено в строку, и идентификаторы получат нужные префиксы.

Код, который использует наш новый макрос:

import 'hello.dart';

@Hello()
class User {
  const User({
    required this.age,
    required this.name,
    required this.username,
  });

  final int? age;
  final String name;
  final String username;
}

void main() {
  final user = User(age: 5, name: 'Roger', username: 'roger1337');
  user.hello();
}

Нажмите «Go to Augmentation» и посмотрите, какой код получился:

c5cfebd52c8de26f38996ca71f1b9bca.png

Обратите внимание, что перед print появился префикс prefix0, под которым библиотека dart:core была импортирована. Кстати, сам импорт был добавлен как побочный эффект включения идентификатора print в код — мы не делали этот импорт вручную.

Запустите:

dart run --enable-experiment=macros hello_client.dart

Программа напечатает:

Hello! I am User. I have age, name, username.

Настоящие полезные макросы

Можете изучить два примера:

JsonCodable

Это пилотный макрос от команды Dart, чтобы знакомиться с технологией. Советую разобрать его код. Почти всему я научился именно из него.

Args

Это мой макрос.

Если пишете консольные программы, то вы работали с аргументами командной строки. Обычно с ними работают с помощью стандартного пакета args:

import 'package:args/args.dart';

void main(List argv) {
  final parser = ArgParser();
  parser.addOption('name');
  final results = parser.parse(argv);
  print('Hello, ' + results.option('name'));
}

Если запустить

dart run main.dart --name=Alexey

То программа напечатает:

Hello, Alexey

Но если аргументов много, то это грязно. В них можно запутаться. Нет гарантии, что вы правильно напишете их в коде. Их трудно переименовывать, потому что это строковые литералы.

Я сделал макрос Args, который оборачивает этот стандартный парсер и даёт гарантию безопасности типов:

import 'package:args_macro/args_macro.dart';

@Args()
class HelloArgs {
  String name;
  int count = 1;
}

void main(List argv) {
  final parser = HelloArgsParser(); // Сгенерированный класс парсера.
  final HelloArgs args = parser.parse(argv);

  for (int n = 0; n < args.count; n++)
    print('Hello, ${args.name}!');
}

Я подробно разберу этот пакет и как я его делал во второй части этой статьи. Подпишитесь, чтобы не пропустить её:

© Habrahabr.ru