[Flutter]immutableなクラスを扱いやすくする機能をコード生成で提供してくれるfreezedを使ってみる

2020.10.29

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Flutterで作ったアプリの構成をProvider + ChangeNotifierからriverpod + state_notifier + flutter_hooks + freezedのに書き換えたいと思っています。

4つのライブラリの内いくつかを使ったコードを読んだり手元で書いてみたりしていて、記事を書く前にそれぞれのライブラリについて日本語で扱った記事を自分も書いておきたいと思いました。今回はfreezedです。

今のところ採用する残りのライブラリについても一つずつ個別に記事を書く予定でいますが、riverpodとstate_notifierはまとめて良い気もしているので記事構成はまだ検討中です。

移行を考えたのは、以下の記事を読んでからです。しばらく変更していなかった個人用のFlutter製はてなフィルタに手を入れることにしました。Flutterでの状態管理手法のこれまでの経緯と現状の管理手法に至った理由も丁寧に説明されています。恐らくFlutterをやっている人は一度は見たことがある記事だと思います。

immutableとmutable

Dartのデータ型の多くはimmutableです。そしてimmutableなデータ型を使用することには多くの利点があります。後から変更できないため、どのコードがアクセスしても同じ内容であることが保証されています。mutableなデータ型と違って防御的なコピーのような仕組みも必要ありません。よりシンプルで堅牢なコードを書くことができます。

しかし、Dartでimmutableなデータ型を扱おうと思うと実装を正しく行わないとコード量が増えて結果としてバグを生みやすくなります。実装によっては速度さえも犠牲にしてしまいます。

先程言及した記事の中でも紹介されていた以下の二つの記事ではDartのimmutableとmutableについて書かれています。

一つ目の記事では、以下のようなことが書かれていると読み取りました。英文で長いですが読む価値のある記事なので私の拙いメモだけでなくリンク先を読まれるとをすすめます。

  • Dartの基本的なデータ型の多くはmutableなデータ型であることと実際にどのような動きをするか
  • Dartでのfinalとconstの違い。
  • Flutterでのimmutableなデータ型の扱われ方(const constructorを持つクラスのインスタンス初期化constの動き、const constructorで初期化しようとしたクラスでfinalを使って宣言した値を使用した場合のエラーと解決方法)
  • Dartでimmutableなデータ型を開発者側で定義する場合の実装。immutableなユーザー定義のクラスのプロパティにmutableなユーザー定義のクラスを使用した場合、コレクションなどの変異可能な複合オブジェクトでの要素の追加、削除、または並べ替えの危険性。チャットアプリのメッセージスレッドを管理するデータ型を定義しながらこれらの問題を解決しようとした実装を例示しながらDartでのimmutableなデータ型の扱いづらさを説明。
  • 多少のボイラープレートは生じるもののimmutableなデータ型を定義する方法を紹介。クラス定義の外で関数を定義して引数で値を受け取って新しいインスタンスを返すパターン、クラスメソッドを利用するパターン、immutableなクラスのプロパティごとに関数やクラスメソッドを提供するのではなく、それらの機能を一つのメソッドに統合するパターン(よく登場するcopyWith)など

紹介されているUnmodifiableListViewは初めて知りました。ちなみにこの記事ではfreezedには触れられていません。代わりにDart謹製のbuild_valueへの言及があります。

二つ目の記事では一つ目の記事の意訳と抜粋を行いながらDartでのimmutableなデータ型を手軽に扱えるようにコードの自動生成を行おうという話からfreezed パッケージが良いよと紹介されています。

freezedを使ってimmutableなクラスを自動生成してみる

pub.devのページ及びドキュメントはこちらです。

pubspec.yamlファイルにfreezedと必要な依存ライブラリを追加します。

dependencies:
  flutter:
    sdk: flutter
  freezed_annotation:

dev_dependencies:
  build_runner:
  freezed:

build_runnerパッケージはDart コードを使用してファイルやコードを生成する方法を提供するライブラリです。アプリケーションコードには不要で開発中に必要なものなのでfreezedと同じdev_dependencies以下にあります。

ドキュメント及びpub.devのページはこちらです。

コードを生成するために必要なクラスに@freezedというアノテーションを使用しますがこのような言語機能をfreezed_annotationが提供してくれます。ドキュメント及びpub.devのページはこちらです。

クラス定義

ドキュメントのRun the generatorという節にあるコードをそのまま利用します。コード生成をするためのコマンドはFlutterアプリケーションの場合はflutter pub run build_runner buildになります。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';

@freezed
abstract class Book with _$Book {
  const factory Book({String name, int numberOfPages}) = _Book;
}

生成されたコードでLinterの警告が出ていた場合はanalysis_options_yamlで生成されたファイルを除外します。

# analysis_options.yaml
analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"

シンタックス、提供される機能について

BookクラスにString型のnameプロパティ、ページ数を表すnumberOfPagesプロパティを追加したい場合は生成元のコードにfactory constructorを記述します。

コマンドを実行してコードが生成されるまではシンタックスエラーになっていますが、生成後はwithで$_Bookをmixinできています。mixinの定義元を見るとプロパティに加えてcopyWithメソッドが宣言されています。

mixin _$Book {
  String get name;
  int get numberOfPages;

  $BookCopyWith<Book> get copyWIth;
}

コンストラクタで名前付き引数を使いたい場合

factory constructorで{}を使用します。不要な場合は{}を使用しないようにします。

先述のコードでは{}を使用していたので利用する時は名前付き引数を必要とします。

import 'package:habity/user.dart';

final book = Book(name: 'こころ', numberOfPages: 200);

定義したクラスにコンストラクタやメソッドを追加したい場合

普通にメソッドを追加するとビルドに失敗します。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';

@freezed
abstract class Book with _$Book {
  const factory Book({String name, int numberOfPages}) = _Book;

  void hoge {}
}

出力されたのは以下です

Error: The non-abstract class '_$_Book' is missing implementations for these members:
 - Book.method
Try to either
 - provide an implementation,
 - inherit an implementation from a superclass or mixin,
 - mark the class as abstract, or
 - provide a 'noSuchMethod' implementation.

class _$_Book implements _Book {
      ^^^^^^^
lib/book.dart:9:8: Context: 'Book.method' is defined here.
  void hoge() {
       ^^^^^^

FAILURE: Build failed with an exception.

freezedアノテーションを付けたクラスに別のコンストラクタやメソッドを追加する場合はwithではなくimplementsを使用します。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';

@freezed
abstract class Book implements _$Book {
  const Book._();
  const factory Book({String name, int numberOfPages}) = _Book;

  void blank_book() {
    const book = Book();
    print("##########");
    print(book.name); // null
    print(book.numberOfPages); // null
    print("############");
  }
}

これでビルド出来るようになります。

コンストラクタでassertを使用したい場合

Assertアノテーションを使います。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';

@freezed
abstract class Book with _$Book {
  @Assert('name.isNotEmpty', 'nameプロパティはnullを引数に渡せません')
  @Assert('numberOfPages >= 0')
  const factory Book({String name, int numberOfPages}) = _Book;
}

コード生成をした後、以下のように空文字を渡してインスタンス化してみます。

import 'package:flutter/material.dart';

import 'book.dart';

void main() {
  final book = Book(name: '', numberOfPages: 20);
  print(book.name);
}

Assertの通りエラーになります。

lib/book.freezed.dart:100:16: Error: Not a constant expression.
      : assert(name.isNotEmpty, 'nameプロパティはnullを引数に渡せません'),
               ^^^^

FAILURE: Build failed with an exception.

生成されたコードにassertがついているコンストラクタが生成されているのがわかります。

const _$_Book({this.name, this.numberOfPages})
      : assert(name.isNotEmpty, 'nameプロパティはnullを引数に渡せません'),
        assert(numberOfPages >= 0);

Nullについて

DartにNull安全が導入されることが以前話題になりました。

freezedでもnullableとnon-nullableを区別して扱うことができます。

freezedでnon-nullableな型を表現したい場合はoptionalでないパラメータ、Defaultアノテーション、requiredアノテーションによる名前付き引数を記述します。nullableな値を表現したい場合はoptionalな引数を記述するかnullableアノテーションを使用します。

やってみます。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';

@freezed
abstract class Book with _$Book {
  const factory Book({
    String name,
    @required int numberOfPages,
    bool isFamous,
  }) = _Book;
}

と記述してコマンドを実行してコードを生成した後、required アノテーションを付与したnumberOfPagesをnullにしてインスタンス化します。

final book = Book(name: '', numberOfPages: null, isFamous: false);
print(book.numberOfPages);

実行すると例外を投げます。

Failed assertion: line 106 pos 16: 'numberOfPages != null': is not true.
#0 _AssertionError._doThrowNew (dart:core-patch/errors_patch.dart:46:39)

必須の名前付きのパラメータをnullableにしたいケースは、例えばnumberOfPagesをnullableにしたい場合には@nullableを使用します。

@nullable @required int numberOfPages,

デフォルト値

Bookクラスのnameプロパティに名無しというデフォルト値を与えたい場合は以下のようにします。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';

@freezed
abstract class Book with _$Book {
  const factory Book({
    @Default("名無し") String name,
    int numberOfPages,
    bool isFamous,
  }) = _Book;
}
final book = Book(numberOfPages: 30, isFamous: false);
print(book.name); // 名無し

Late

Swiftでいうlazyのような初期化を遅延するキーワード、lateが導入されますがfreezedを使用するとlateキーワードを先んじて導入できます。

factory constructorにconst キーワードを使用すると怒られます。

@freezed
abstract class Book with _$Book {
  factory Book({
    @Default("名無し") String name,
    int numberOfPages,
    bool isFamous,
  }) = _Book;

  @late
  Book get blank_book => Book(numberOfPages: 0, isFamous: false);
}

警告は以下です。遅延初期化の使い方を考えると当然ですが、初見で手元でこのミスをしました。

@late cannot be used in combination with const constructors
package:habity/book.dart:6:16

このlateを使ったリファクタリング例がドキュメントに記載してありコードを見たらすぐに使用例が理解できると思うのでページ内リンクを貼ります。

deprecatedであることを示したい場合

deprecatedアノテーションを使用します。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';

@freezed
abstract class Book with _$Book {
  factory Book({
    @Default("名無し") String name,
    int numberOfPages,
    @deprecated bool isFamous,
  }) = _Book;

  @late
  Book get blank_book => Book(numberOfPages: 0, isFamous: false);
}

@deprecatedはannnotations.dartに定義されていてlanguage-tourでも紹介されています。

toString、hashCode、 ==の提供

freezedを使用するとクラスにこれら3つの実装がoverrideされて提供されます。ここまで説明につかってきたBookクラスから生成されたコードを見てみます。実装を見るととても単純ですが自動生成のおかげでこれらのボイラープレートを自力で書かなくて良いのは素晴らしいと思います。

// toString()の実装
@override
  String toString() {
    return 'Book(name: $name, numberOfPages: $numberOfPages, isFamous: $isFamous, blank_book: $blank_book)';
}

// hashCodeの実装
@override
  int get hashCode =>
      runtimeType.hashCode ^
      const DeepCollectionEquality().hash(name) ^
      const DeepCollectionEquality().hash(numberOfPages) ^
      const DeepCollectionEquality().hash(isFamous

// == の実装
@override
  bool operator ==(dynamic other) {
    return identical(this, other) ||
        (other is _Book &&
            (identical(other.name, name) ||
                const DeepCollectionEquality().equals(other.name, name)) &&
            (identical(other.numberOfPages, numberOfPages) ||
                const DeepCollectionEquality()
                    .equals(other.numberOfPages, numberOfPages)) &&
            (identical(other.isFamous, isFamous) ||
                const DeepCollectionEquality()
                    .equals(other.isFamous, isFamous)));
  }

CopyWith

ThemeDataのようなイミュータブルな型で提供されているメソッドです。

例えばThemeDataに定義されているcopyWithメソッドの実装はtheme_data.dartにあります。

レシーバをコピーしてcopyWithの引数に与えたもののみ変更したものを返す非常に便利なメソッドですが、これをimmutableなユーザー定義のクラスに実装しようとすると、プロパティが増える程このようにコード量が増えていってしまいます。

freezedはこのcopyWithメソッドを提供してくれるのでimmutableなクラスのインスタンスを元に任意値を変更したコピーを得ることができます。

final book1  = Book(name: "title1", numberOfPages: 20, isFamous: false)
final book2 = book1.copyWith(numberOfPages: 30);
print(book2.name); // title1

このcopyWith、単純に考えるとshallow copyを想像しますが、freezedはfreezedアノテーションを使って装飾されたオブジェクトが同じくfreezedアノテーションを付与された他のオブジェクトを含む場合はdeep copyが出来るcopyWIthが提供されます。

FromJson/ToJson

freezedを利用することでJSON Encode/Decode出来る機能をクラスで自動で提供できます。また、この機能はjson_serializableと互換性を持ちます。

Bookモデルを例にするとpartbook.g.dart;の追加とfactory Book.fromJson(Map<String, dynamic> json) => *$BookFromJson(json)*というfactory constructorの定義が必要になります。

やってみます。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';
part 'book.g.dart';

@freezed
abstract class Book with _$Book {
  factory Book({
    @Default("名無し") String name,
    int numberOfPages,
    @deprecated bool isFamous,
  }) = _Book;

  @late
  Book get blank_book => Book(numberOfPages: 0, isFamous: false);

  factory Book.fromJson(Map<String, dynamic> json) => _$BookFromJson(json);
}

簡単ですがきちんと動くかわからないのでテストコードで動作が意図通りであることを確認します。テストコードはfreezedが提供しているexampleを参考にしています。

import 'package:flutter_test/flutter_test.dart';
import 'package:habity/book.dart';

void main() {
  test('Book fromJson', () {
    expect(
      Book.fromJson(<String, dynamic>{
        'name': '名無し',
        'numberOfPages': 20,
        'isFamous': false,
      }),
      Book(name: '名無し', numberOfPages: 20, isFamous: false),
    );
  });

  test('Book toJson', () {
    expect(
        Book(name: '名無し', numberOfPages: 20, isFamous: false).toJson(),
        <String, dynamic>{
          'name': '名無し',
          'numberOfPages': 20,
          'isFamous': false,
        });
  });
}

バッチリです。

$ flutter test
00:02 +3: All tests passed!

説明しなかった機能について

freezedを使うとクラスの継承を制限するsealed classやunion typeが持っている値のタグを検査してそのタグに紐付いているヴァリアントを操作できるnamed union typeといったDartでは現在の所提供されていない言語機能をクラスに提供することができます。この記事ではunionやsealed classについて触れていません。

現状Dartなシンプルな言語機能が要件には必要十分な気がしたのと、言語で提供されていない言語機能の導入(言語側で採用されて実装され得る場合は別の認識)には消極的な気持ちがあってfreezedのこの機能は利用しないことにしました。利用する時は使ってみた実装を含めて別途記事を書こうと思います。

Null安全のリリース時に使えるようになる予定lateのキーワードに関してはこれらの機能を説明しなかった理由と同じ理由で採用したいと思っているので少しですが動かしたコードと合わせて説明をしました。

まとめ

freezedを使えるようになるために調べてアプリケーションに導入しながら記事を書きました。freezedを触ったことがない人でも導入できるように書いたつもりですが説明に問題があったりした場合はコメントやTwitterにてご指摘をお願いします。

state_notofierとriverpodはprovider + ChangeNotifierでアプリを組んでいたので抵抗なく触り始められましたが、Dartでコードの自動生成は始めてだったので少々戸惑いました。それでも使い始めるととても便利で状態管理の設計を円滑に進めるだけでなくネットワーク層の実装(FromJson/ToJson)にも使えそうで嬉しいです。

記事内で紹介したリンクの他に参考にしたリンク