ntaoo blog

主にDart, Flutter, AngularDartについて書いていきます。ときどき長文。日本語のみ。Twitter: @ntaoo

Dart BuiltCollection, BuiltValueの使いかた 基本編

残念なことに、DartのコアライブラリにImmutable Collectionライブラリは無いが、BuiltCollection, BuiltValueパッケージを使えばImmutable Collectionを扱うことができる。ここでは、それらの使い方の基本を簡単に解説していく。

今回のサンプルコード

https://github.com/ntaoo/built_collection_value_practice

Mutable Collectionの問題

DartのコアライブラリのListは他のほとんどの言語と同じくmutable List。

class C {
  C(this.name);
  String name;
}

test('Standard List is mutable.', () {
  final c = C('a');
  final l1 = [c];
  final l2 = List.from(l1);

  expect(l1 == l2, isFalse, reason: 'Shallow comparison.');
  expect(l1.first == l2.first, isTrue,
      reason: 'They share the same element.');

  c.name = 'b';

  expect(l1.first == l2.first, isTrue,
      reason: 'They share the same element.');
  expect(l2.first.name, 'b', reason: 'They share the same element.');
});

https://github.com/ntaoo/built_collection_value_practice/blob/master/test/built_collection_test.dart

上記のコードのように、うっかりとmutable elementの参照値を複数のListで共有して、意図せぬ挙動に悩まされた経験はだれもがしているだろう。 Immutable CollectionとValueを組み合わせると、意図せずそのようなコードを書いてしまう心配がなくなる。

Built CollectionとBuiltValueの特徴

Built Collectionは、Immutable Collectionを提供するパッケージ。BuiltValueは、Valueを提供する。

Built Collectionは、Collectionのmutationではなく、elementの追加、変更、削除のたびに新しいCollectionを生成するため、意図せずCollectionをmutationするありがちな不具合を防げる。

また、BuiltValueと組み合わせれば、Collectionのelementまで含めたdeep comparisonが容易になり、そのパフォーマンスが良い。

さらに、変更したelement以外は変更前のListのelementの参照を引き継ぐため、効率的なコードを簡単に書くことができる。

他の選択肢

準コアライブラリとも言えるpackage:collectionにはUnmodifiableListViewがあり、生成後のmutation操作ができなくなる。また、コアライブラリのList.unmodifiableコンストラクタを使えば同様になる。しかしImmutable collectionからその一部のelementを変更したImmutable collectionを作るといった操作は提供されていない。

また、package:collectionには、elementがValueでなくともコアライブラリのCollectionの同一性を確かめるライブラリが提供されている。

BuiltCollection, BuiltValueのようなImmutable Collection / Valueを必要としない、またはコアライブラリのCollectionを使用するべき理由があれば、こちらの選択肢を検討する。

名前の由来

Builder patternで表現しているため、Built Collection, Built Value

他言語におけるImmutable Collection

Mutable collectionに加えてimmutable collectionも提供している言語は、Scala, C#(.NET), Java(Guava), Racket、等々。

Haskellはimmutable collectionしか提供していない。PHP, Python, Ruby, JavaScriptなどのスクリプト言語では自分が知る限り提供されていない。

BuiltCollection, BuiltValueの基本的な使い方

Built CollectionにおけるImmutable ListであるBuiltListを例に、BuiltCollection, BuiltValueの使い方を紹介する。

class C {
  C(this.name);
  String name;
}

test(
    'BuiltList provides deep comparison, but the non BuiltValue elements can still be mutable.',
    () {
  final c = C('a');
  final l = [c];
  final l1 = BuiltList<C>(l);
  final l2 = BuiltList<C>.from(l);

  expect(l1 == l2, isTrue, reason: 'thanks to the deep comparison.');
  expect(l1.first == l2.first, isTrue, reason: 'they share same element.');

  c.name = 'b';

  expect(l1.first == l2.first, isTrue, reason: 'they share same element.');
  expect(l2.first.name, 'b',
      reason:
          'mutated, because they share the same mutable element. NOT recommended.');
});

https://github.com/ntaoo/built_collection_value_practice/blob/master/test/built_collection_test.dart

生成

final c = C('a');
final l = [c];
final l1 = BuiltList<C>(l);
final l2 = BuiltList<C>.from(l);

コンストラクタが用意されている。

比較

expect(l1 == l2, isTrue, reason: 'thanks to the deep comparison.');
expect(l1.first == l2.first, isTrue, reason: 'they share same element.');

Mutable Listとは異なり、==でdeep comparisonができている。ただし、

c.name = 'b';

expect(l1 == l2, isTrue, reason: 'they share the same element.');
expect(l1.first == l2.first, isTrue, reason: 'they share the same element.');
expect(l2.first.name, 'b',
    reason:
        'mutated, because they share the same mutable element. NOT recommended.');

cがmutableなelementである場合、BuiltListでも同一判定されるにもかかわらず、実際はmutationしてしまう。そこで、BuiltValueと組み合わせることが推奨されている。

BuiltCollectionをBuiltValueと組み合わせる

test('BuiltList can be deeply immutable with built values.', () {
  SimpleValue buildValue() {
    return SimpleValue((b) => b
      ..anInt = 1
      ..aString = 'a'
      ..aListOfStrings = ListBuilder<String>(['one', 'two', 'three']));
  }

  final l1 = BuiltList<SimpleValue>.from([buildValue()]);
  final l2 = BuiltList<SimpleValue>.from([buildValue()]);

  expect(l1 == l2, isTrue, reason: 'thanks to the deep comparison.');
  expect(l1.first == l2.first, isTrue, reason: 'value comparison.');

  final l3 =
      l2.rebuild((b) => b.first = b.first.rebuild((b) => b..aString = 'b'));

  expect(l2 == l3, isTrue);
  expect(l1 == l2, isFalse, reason: 'l2 was rebuilt.');
  expect(l1 == l3, isFalse, reason: 'l3 is l2.');
  expect(l2.first.aString, 'b');
  expect(l3.first.aString, 'b');
});

SimpleValueはBuiltValue。

l2をrebuildし、そこでvalueをrebuildしている。期待通りl1 == l2がfalseとなった。

rebuildで変更

final l3 =
    l2.rebuild((b) => b.first = b.first.rebuild((b) => b..aString = 'b'));

expect(l2 == l3, isTrue);
expect(l1 == l2, isFalse, reason: 'l2 was rebuilt.');
expect(l1 == l3, isFalse, reason: 'l3 is l2.');
var newList = list.rebuild((b) => b
    ..add(4)
    ..addAll([7, 6, 5])
    ..sort()
    ..remove(1));

BuiltCollectionのインターフェース

test('BuiltList is not a List, but an Iterable', () {
  expect(BuiltList<int>([1]), isNot(isList));
  expect(BuiltList<int>([1]) is Iterable, isTrue);
});

BuiltListにList Interfaceがない。add, addAllなどのListの変更のためのメソッドは提供されていない。 BuiltListにInterable Interfaceはある。

test('BuiltList can return self as standard Immutable List', () {
  final list = BuiltList<int>([1, 2, 3]).asList();
  expect(() => list.add(4), throwsA(TypeMatcher<UnsupportedError>()));
  expect(() => list.first = 4, throwsA(TypeMatcher<UnsupportedError>()));
});

ただし、コアライブラリListを返すことはできるが、変更を試みるとランタイムエラーが発生する。

BuiltValue

生成

SimpleValue build() {
  return SimpleValue((b) => b
    ..anInt = 1
    ..aString = 'a'
    ..aListOfStrings = ListBuilder<String>(['one', 'two', 'three']));
}

コンストラクタでクロージャを使って、メソッドカスケーディングで値を組み立てる。 各fieldの指定漏れはランタイムエラーとなる。標準のfinal field指定では静的解析の支援を受けられたが、BuiltValueでは受けられない。 Filedの数が10以上に増えてきた場合は、別途factory named constructorを用意して静的解析できるようにすることも検討したらよい。

BuiltValueのfieldにはデフォルトではnullを許容しない。Non Nullable By Default。 @nullable annotationを付与するとnullを許容する。

比較

test('It is a value.', () {
  final s1 = build();
  expect(s1.anInt, 1);
  expect(s1.aString, 'a');
  expect(s1.aListOfStrings, ['one', 'two', 'three']);
  expect(s1.aListOfStrings, BuiltList<String>(['one', 'two', 'three']));

  final s2 = build();
  expect(s1, s2);
});

BuiltValueはValueなので、deep comparisonで同一object判定される。 言うまでもなく、BuiltValueでない通常のobjectの比較では、異なるobjectと判定される。

rebuildで変更。

test('can rebuild', () {
  final s1 = build();
  final s2 = s1.rebuild((b) => b
    ..anInt = 2
    ..aString = 'b');

  expect(s1 == s2, isFalse);
  expect(s2.anInt, 2);
  expect(s2.aString, 'b');
  expect(s2.aListOfStrings, BuiltList<String>(['one', 'two', 'three']),
      reason: 'it still holds the privious value.');
});

BuiltValueのNest

group('NestedValue', () {
  SimpleValueContainer build() {
    return SimpleValueContainer((b) => b
      ..simpleValue.aString = 'a'
      ..simpleValue.anInt = 1
      ..anInt = 1);
  }

  test('can build nested values.', () {
    final c1 = build();
    expect(c1.anInt, 1);
    expect(c1.simpleValue.anInt, 1);
    expect(c1.simpleValue.aString, 'a');
  });

  test('can rebuild nested values.', () {
    final c1 = build();
    final c2 = c1.rebuild((b) => b
      ..simpleValue.anInt = 2
      ..simpleValue.aString = 'b');
    expect(c1 == c2, isFalse);
    expect(c2.anInt, 1, reason: 'it still holds the previous value.');
    expect(c2.simpleValue.anInt, 2);
    expect(c2.simpleValue.aString, 'b');
  });
});

BuiltValueのfieldがBuiltValueの場合、コンストラクタ引数のクロージャの中では、またクロージャを指定せずとも、

SimpleValueContainer((b) => b
  ..simpleValue.aString = 'a'
  ..simpleValue.anInt = 1
  ..anInt = 1);

のように書ける。rebuild()でも同様。

Serialization, Deserialization.

BuiltValueにはSerialization, Deserializationのストラテジが用意されており、StandardJsonPluginを組み合わせるとJSON string互換のMapと変換できる。

https://github.com/ntaoo/built_collection_value_practice/blob/master/lib/serializers.dart

test('can serialize to json Map.', () {
  final s1 = build();
  final result = standardSerializers.serialize(s1);
  expect(result is String, isFalse);
  expect(result is Map, isTrue);
  expect((result as Map)['\$'], 'SimpleValue');
  expect((result as Map).keys.toSet(),
      Set.from(['\$', 'aString', 'anInt', 'aListOfStrings']));
  expect((result as Map)['aListOfStrings'], isList);
});

test('can serialize to json Map, with familiar format.', () {
  final s1 = build();
  final result = standardSerializers.serializeWith<SimpleValue>(
      SimpleValue.serializer, s1);

  expect(result is String, isFalse);
  expect(result is Map, isTrue);
  expect((result as Map).containsKey('\$'), isFalse);
  expect((result as Map).keys.toSet(),
      Set.from(['aString', 'anInt', 'aListOfStrings']));
  expect((result as Map)['aListOfStrings'], isList);
});

メンテナンス

BuiltCollection, BuiltValueはsource_genでソースコードジェネレーションしているので、使用の際にはbuildが必要となる。

pub run build_runner build

ファイルの変更を検知して自動的に再ビルドする場合は、watchを使用する。

pub run build_runner build watch

Flutterの場合は、flutter packagesを付加する。

flutter packages pub run build_runner build

ユースケース

  • RPCのデータ送受信
    • Http request/response
    • Firestore request/response
  • UIでの表示のためのModelからのデータ送信
  • 並列プログラミング
  • その他、高頻度でListをmutationしパフォーマンスが必要なユースケース以外のすべて

欠点

  • 構文の簡潔さはList literalにはもちろん及ばない。
  • 一般的ではない(not familiar)ため、習得コストがかかる。
  • コアライブラリではない。したがって、開発したライブラリの公開APIには代わりにIterableを指定できないか検討する。BuiltListはIterableインターフェースを実装している.
  • 高頻度でCollectionの中身を書き換えたい場合はMutable Collectionよりもパフォーマンスが悪い。

したがって、初心者向けではないかもしれないかもしれない。それに、我々はMutable Listの動作に慣れきっているので、いきなりImmutable Listを強制すれば混乱して生産性が落ちそうだ。 しかしImmutable Listを第一の選択肢とすれば、つまらない不具合に悩ませられる機会が大幅に減るだろう。

いつかはImmutable Listがコアライブラリに取り込まれてほしい。