【Dart/Flutter】ListとBuiltListの違いとBuilt Collectionについて

こんにちは、エキサイトでアプリアンジニアをしている岡島です。 今回は、BuiltListを使う機会があったので、調べたことについて共有したいと思います。

BuiltListとBuilt Collectionについて

BuiltListは、DartBuilt Collection Libraryに用意されている型です。

Built Collectionで用意されているコレクションは、Dartのcoreライブラリで用意されているコレクションとは異なり、次のような特徴を持ちます。(built_collection library - Dart APIより和訳)

  • 変更不可(Immutable)
  • 比較可能(Comparable)
  • ハッシュ可能(Hashable)
  • nullを拒否(Reject nulls)
  • ジェネリクス型パラメータが必要(Require generic type parameters)
  • 間違った型の要素を拒否(Reject wrong-type elements)
  • 不要なコピーを避けるためCopy-on-Writeを使用(Copy-on-write to avoid copying unnecessarily)

Dartで注意が必要な参照渡し

Dartのcollections(List、Map、Set)は参照渡しとなるので注意が必要です。 以下のように、リストを別の変数に代入した場合、同じメモリを参照するため、一方のリストを変更すると両方のリストに影響が出てしまいます。

List<int> originData = [1, 2, 3];
final copiedData = originData;

copiedData[0] = 99;

print(originData); // [99, 2, 3]
print(copiedData); // [99, 2, 3]

BuiltListで参照渡しの問題を防ぐ

BuiltListは変更不可なため、このようなバグを防ぐことができます。

BuiltList<int> originData = BuiltList<int>([1, 2, 3]);

// originDataをコピーしても、内容を直接変更することはできない
BuiltList<int> copiedData = originData;

// copiedData[0] = 99; // これはエラーになる

// rebuildを使って新しいリストを作成
BuiltList<int> modifiedData = originData.rebuild((b) => b[0] = 99);

print(originData); // [1, 2, 3] - 元のリストは変更されない
print(modifiedData); // [99, 2, 3] - 新しいリストが作成される

Built Collectionの特徴

BuiltListとBuilt Collectionについての章で述べたBuilt Collectionの特徴についてもう少し詳しく見ていきます。

変更不可(Immutable)

Built Collectionは変更不可であり、一度作成した後に変更することができません。データの変更は、rebuildメソッドを使って新しいインスタンスを作成することで行います。

比較可能(Comparable)

List<int> list1 = [1, 2, 3];
List<int> list2 = [1, 2, 3];

print(list1 == list2); // false

普通のListの場合、メモリの参照を比較するので、list1とlist2は異なるインスタンスとして認識されます。

BuiltList<int> list1 = BuiltList<int>([1, 2, 3]);
BuiltList<int> list2 = BuiltList<int>([1, 2, 3]);

print(list1 == list2); // true

Built Collectionは、deep comparisonを行うため、内容が同じ場合は等しいと判定されます。

ハッシュ可能(Hashable)

deep hashCodeが計算されてキャッシュされるようです。

プログラムでみると分かりやすいと思うのでサンプルコードを書いてみました。 含まれているデータの内容を見て、ハッシュコードが計算されるので、 データが同じであればハッシュコードも同じです。

普通のList:

List<List<int>> coreList1 = [
  [1, 2, 3]
];
List<List<int>> coreList2 = [
  [1, 2, 3]
];
// ハッシュコードが異なる
print(coreList1.hashCode == coreList2.hashCode); // false

BuiltList:

BuiltList<BuiltList<int>> builtList1 = BuiltList<BuiltList<int>>([
  BuiltList<int>([1, 2, 3])
]);
BuiltList<BuiltList<int>> builtList2 = BuiltList<BuiltList<int>>([
  BuiltList<int>([1, 2, 3])
]);
// ハッシュコードが同じ
print(builtList1.hashCode == builtList2.hashCode); // true

nullを拒否(Reject nulls)

nullの要素が入っていると、エラーが発生します。

try {
  BuiltList<int> listWithNull = BuiltList<int>([1, null, 3]);
} catch (e) {
  print(e); // エラー
}

ジェネリクス型パラメータが必要

Built Collectionでは型パラメータを指定する必要があります。

Listでは型を指定せずに宣言すると警告が出ず、任意のタイプのListになるため、バグが起こりやすくなります。

List mixedList = [1, "two", 3.0]; // List<dynamic>となり、特定の操作をする時にエラーが発生する可能性がある。

しかし、BuiltListなどのBuilt Collectionでは、型を指定しないとエラーになるため、このような問題は発生しません。

BuiltList mixedList = [1, "two", 3.0]; // エラー

間違った型の要素を拒否(Reject wrong-type elements)

違う型の要素を追加しようとするとエラーが発生します。

BuiltList<int> numbers = BuiltList<int>([1, 2, 3]);
numbers.rebuild((b) => b..add("string")); // エラー

不要なコピーを避けるためCopy-on-Writeを使用(Copy-on-write to avoid copying unnecessarily)

「Copy-on-Write」の原則を使用しており、データが変更されるときのみ新しいインスタンスを作成します。これにより、メモリ使用量が最適化されます。

以下の例で示すように、identicalで同一オブジェクトであるかを判定すると、値が同じものは同一オブジェクトになっています。

BuiltList<int> originalList = BuiltList<int>([1, 2, 3]);
BuiltList<int> sameList = originalList.rebuild((b) => b);

print(identical(originalList, sameList)); // true (同じインスタンス) 

BuiltList<int> modifiedList = originalList.rebuild((b) => b..add(4));

print(identical(originalList, modifiedList)); // false (新しいインスタンスが作成される)

最後に

今回はBuilt Collectionのライブラリについて詳しく見てみました。 この記事がBuilt CollectionやBuiltListの理解を深める助けになれば幸いです。