🐟

Dart の Macros 機能を触ってみた

2024/12/13に公開

はじめに

この記事は ギフティ Advent Calendar 2024 の13日目の記事です。

弊社では Flutter を用いてネイティブアプリを開発しているのですが、先日 FlutterKaigi 2024 に参加する機会がありました。
その中で最も興味深かった、ちゅーやんさんの Dart Macros についてのセッション「体験!マクロ時代のFlutterアプリ開発」を参考に Dart Macros を触ってみたので感想を共有します。

https://2024.flutterkaigi.jp/session/61d31fb9-fbc5-4d01-aad5-139e6568db15

Dart Macros とは

https://dart.dev/language/macros

Dart Macros は現在 Dart のみに提供されている試験的な機能です。
公式の説明によると、Static Metaprogramming により、コンパイル時にコードを解析し、新たなコードを生成する機能です。

例えばデータモデルクラスに毎回 fromJson 等を書く事があると思いますが、Dart Macros を利用することによって自動的に fromJson 関数を生成することができたりします。

つまりこういうことが出来るイメージです。

macro が動いている画像

これは一例ですが、ボイラープレートコードを排除して開発効率を上げることができたりします。

Dart Macros を触ってみる

では実際に簡単なマクロを試します。
今回は以下のサンプルプロジェクトを参考に、データモデルのボイラープレートコードを生成する @Model マクロを作成してみます。
ただ、サンプルプロジェクトをそのまま持ってくると細かい実装に気を取られてしまうため、今回は JSON 形式の文字列からデータモデルのインスタンスを生成する fromJson 関数が自動で生成される部分のみ見ていきます。

https://github.com/millsteed/macros

マクロを生成してデータモデルを作成する際のステップは以下の通りです。

  1. macro クラスの作成
  2. macro クラスに生成ロジックを作成
  3. マクロを利用したデータモデルの作成

1. macro クラスの作成

まずプログラムの書き換えを行う macro クラスを作成していきます。
マクロクラスを作成する際は必ず class の前に macro を記述する必要があります。また、コンストラクタも必須であり、インスタンスを生成するために必要な初期化処理を指定します。

macro class Model {
  const Model();
}

次に作成したクラスに Macro 用に用意されたインターフェースクラスを implement します。
この Macro 用のインターフェースは用途によって使い分けが必要です。例えば今回の場合はクラスに対して非型宣言を追加するため ClassDeclarationsMacro を指定します。

macro class Model implements ClassDeclarationsMacro {

[FYI] 他にも様々なインターフェースがあります。色んな使い方を試してみてください。

Dart 公式のインターフェース定義

2. macro クラスに生成ロジックを作成

次に作成した macro クラスにコード生成のロジックを書いていきます。
ここは自由に処理を記述できるので、「俺の考えた最強のマクロ」を書いてみてください。

今回はサンプルコードに倣い、JSON文字列からインスタンスを生成する fromJson の関数をクラスに生やす処理をマクロで作成します。
(サンプルコードには toJson, copyWith, toString 等の生成ロジックもありますが、説明が長くなってしまうので、今回は toJson のみとします)


今回利用するインターフェースである ClassDeclarationsMacro には buildDeclarationsForClass が用意されていて、この関数を override することで元のクラスの要素を取得してマクロ処理を書く事ができます。


FutureOr<void> buildDeclarationsForClass(
  ClassDeclaration classDeclaration, # 元のクラスの要素(クラス名、フィールド情報)
  MemberDeclarationBuilder builder,  # この builder を使うとコードが生成されます
) {}

この中に fromJson 関数を生成する処理を実装していきます。
まずは関数実装に必要な要素を組み立てていきます。

  • className ... 対象のクラス名
  • fields ... クラスに作成した field 群
  • fieldNames ... field 名群
  • fieldTypes ... field に設定された型群

FutureOr<void> buildDeclarationsForClass(
  ClassDeclaration classDeclaration,
  MemberDeclarationBuilder builder,
) async {
  final className = classDeclaration.identifier.name;
  final fields = await builder.fieldsOf(classDeclaration);
  final fieldNames = <String>[];
  final fieldTypes = <String, String>{};

  for (final field in fields) {
    final fieldName = field.identifier.name;
    fieldNames.add(fieldName);

    final fieldType = (field.type.code as NamedTypeAnnotationCode).name.name;
    fieldTypes[fieldName] = fieldType;
  }

  // fromJson を生成する関数(後述)
  _buildFromJson(builder, className, fieldNames, fieldTypes);
}

必要な要素が揃ったので、あとは fromJson のコードが生成されるように _buildFromJson の中身を書いていきます。

void _buildFromJson(
  MemberDeclarationBuilder builder,
  String className,
  List<String> fieldNames,
  Map<String, String> fieldTypes,
) {
  final code = [
    '  factory $className.fromJson(Map<String, dynamic> json) {',
    '    return $className(',
    for (final fieldName in fieldNames) ...[
      "      $fieldName: json['$fieldName'] as ${fieldTypes[fieldName]},",
    ],
    '    );',
    '  }',
  ].join('\n');

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

この実装では1行毎に文字列で関数コードを構築し、builder に渡しています。
DeclarationCode を builder の declareType に渡すことでコードを生成してくれます。

以上でマクロ側の実装は完了です。

3. マクロを利用したデータモデルの作成

最後にマクロを利用して実際にデータモデルを書いていきます。
マクロを利用する際は、アノテーションを利用します。今回の場合は Model というクラス名でマクロを作成したので、クラス名をそのまま指定します。

()
class User {}

そのままデータモデルの field を書いていきます。

()
class User {
  User({
    required this.username,
    required this.password,
  });

  final String username;
  final String password;
}

利用する側はこれだけで完了です👍

生成されたコードを見てみる

マクロを指定したクラスに何らかの変更があると、マクロ側が変更を検知してリアルタイムで buildDeclarationsForClass の処理を実行します。
VSCode の場合は、データモデル側に表示されている「Go to Augmentation」をクリックすると、生成されたコードを確認することが出来ます。

Go to Augmentation の位置を示す画像

ファイル名は同じですが、別ファイルとして開かれて以下のようなコードが生成されていることが分かると思います。ちゃんとマクロ側で実装した fromJson の関数が生成されていそうですね!

augment class User {
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      username: json['username'] as String,
      password: json['password'] as String,
    );
  }
}

このコードの生成は Dart がコンパイル時にコード生成を行うため、flutter pub run build_runner build --delete-conflicting-outputs のようなコマンドを打つこと無く(見た目上)リアルタイムにコードが生成されます。

macro が動いている画像

感想

今回は Dart Macros 機能を試してみた感想をお伝えしました。この機能は現時点で Dart の試験的な機能として提供されていますが、非常に魅力的で、今後 Flutter でも stable になることを期待しています。特に以下の点に強く魅力を感じました。

  • ボイラープレートコードを書く必要がなく、体験部分にフォーカスできる
  • build_runner のようなコマンドによる生成ではないので開発体験がよい
  • メタプログラミングは書いていて楽しい

私自身、Ruby を長く書いてきたこともあって メタプログラミングが出来るのはとても楽しいです。 Dart のような静的型付け言語でメタプログラミングを試すのは初めてだったため、とても新鮮な体験でした。

一方で、プロジェクトの中でマクロが成熟していくとメンテナンスの問題が出てきそうだなと感じています。1つのマクロファイルが多くのソースコードで利用されると、マクロファイルを変更するコストが大きくなるからです。開発効率を上げる一方で負債になる可能性も孕んでいるので、用法用量を守って利用する必要があると感じます。


Dart Macros は試験的な機能ですが、Flutter に導入された際には盛り上がっていきそうな機能なので、この記事が理解の助けになれば幸いです。
最後まで読んでいただきありがとうございました。

参考

https://dart.dev/language/macros

https://zenn.dev/koichi_51/articles/8d9db8dfed4441

Discussion