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がコアライブラリに取り込まれてほしい。