Flutter/Dartにおけるimmutableの実践的な扱い方

mono 
Flutter 🇯🇵
Published in
29 min readMay 10, 2021
Photo by JJ Ying on Unsplash

以下の記事でも触れている StateNotifier は状態値をimmutableで扱うことが実質必須ですが、そのimmutableプログラミングがあまり理解できていない様子が散見されたり質問を受けることが多いのでそれについて記事にしてみました。

immutableについての良い解説記事なども色々ありますが、Flutter/Dartにおいての実践的な扱い方に対して飛躍がある気がしていて、それを埋められることを意識して順を追って説明してきます。

以下で示したコードは https://github.com/mono0926/riverpod_example/blob/main/test/test.dart に大体含まれています。

mutableなクラスの挙動

まず、mutableなクラスの挙動は以下のようになります。

class Mutable {
Mutable(this.value);
int value;
}
void main() {
test('mutable', () {
final x1 = Mutable(1);
final x2 = x1;
x1.value++;
expect(x1.value, 2);
// x2とx1の参照先は同一なのでx1のvalueが変更されるとm2も変更される
expect(x2.value, 2);
expect(x1 == x2, isTrue);
expect(identical(x1, x2), isTrue);
});
}

複数の変数が同じインスタンスへの参照を共有しているため、片方で変更操作をするともう片方にもその副作用が影響を及ぼします。ごく小規模だったら、この副作用に気を付けるだけで済みますし、むしろその副作用をうまく利用した書き方でコード量を減らせることなどもあるかもしれません。

一方、規模が大きくなってくると、こういった副作用がいつどこでされるかの把握が困難になっていくため、意図しない副作用に起因するバグに繋がるデメリットの方が大きくなっていきがちです。そして、その副作用の心配を完全に無くすのがimmutableプログラミングです。

immutableなクラスの挙動

最低限なimmutableクラスの定義をすると、その挙動は以下のようになります。

class Immutable {
Immutable(this.value);
final int value;
}
void main() {
test('immutable', () {
var x1 = Immutable(1);
final x2 = x1;
// 後から変更不可能(=immutable)
// x1.value++;
// immutableなオブジェクトは変更操作ができず
// 違う値を持たせたい場合は再生成が必要
x1 = Immutable(x1.value + 1);
expect(x1.value, 2);
// x1を変更したわけではなく再生成したのでx2のvalueは元のまま
expect(x2.value, 1);
// 参照も違う
expect(identical(x1, x2), isFalse);
});
}

初め、x2x1 と同じ参照を持ちますが、 x1 の変更操作は新しいオブジェクトを生成して再代入することで行われるため、 x2 には影響が及ばず元の値が維持されていることが分かります。

@immutableアノテーションの活用

immutableクラス定義の際には@immutableアノテーションを付けると良いです。

// @immutableアノテーション付ける
@immutable
class Immutable2 {
// 全てのフィールドがfinalだと生成後に不変であることが保証されるのでconstにできる。
// (@immutableにより、constを付けないと警告がでて指定漏れを教えてくれる)
const Immutable2(this.value);
// finalが欠けるとimmutableとして成り立たないのでコンパイルエラー
final int value;
}

主に以下のメリットを得られます:

  • immutableなクラスとしての要件を満たしていない時に警告が出る
  • コンストラクターに const の付与が促される(漏れていると警告が出る)
  • クラス利用者に、それがimmutableなクラスであることが一目で伝わる

const とは?

上で、コンストラクターに const 指定しましたが、初め理解が難しい部分ですが、以下のコードを見ると分かりやすいのではと思います。

void main() {
test('immutable const', () {
final x1 = Immutable2(1);
final x2 = Immutable2(1);
expect(identical(x1, x2), isFalse);
const x3 = Immutable2(1);
const x4 = Immutable2(1);
expect(identical(x3, x4), isTrue);
});
}

どれも Immutable2(1) で生成したインスタンスですが、 x1x2 のインスタンスは別物となっている一方、 x3x4 の参照は同一になっています。それらの違いは、変数が final で宣言されているか const で宣言されているかだけです。 const で宣言されていると定数として扱われ、Immutable2(1) で生成されたものは同一であることがコンパイル時に保証されます(もちろんImmutable2(2) などのように別の値で初期化させると別物になります)。つまり、Immutable2(1) と何度実行しようとも同じものが使い回されてパフォーマンス的に有利になります。

Flutterで const SizedBox() など使うことがよくあると思いますが、アプリ各所で何度こう書いても実際に生成されるインスタンスは1つで、それが使い回されます。また、 const Widgetの build() 結果も基本的に同じになるため、それを利用してリビルドの最小化がなされるようにFlutterフレームワーク側で実装されています。
(厳密には、依存しているInheritedWidgetの変更などで build() 結果が変わることもあり、それら含めて過不足なくリビルドされるように実装されています。)

const で済むところは自動的にそう扱ってくれれば良いと思うかもしれませんが、別インスタンス扱いにしたいケースなども考慮して現状のような仕様になっているのだと思います。
( const x = [[1, 2]]; のように書いた時に、左辺の const の条件を満たすように右辺の外側と内側の2つのListを const [] 扱いにしてくれるようにはなっています。)

あるいは、以下の例を見ると、開発者による const 明示の必要性がより分かりやすいはずです。

void main() {
test('const list', () {
final x1 = <int>[];
x1.add(1);
// Unsupported operation: Cannot add to an unmodifiable list
const x2 = <int>[];
x2.add(1);
});
}

前者の非 const Listだと普通に変更操作できますが、後者の const Listだと List.unmodifiable と同様に変更操作ができなくなっています。定数なので妥当ですね。このように、 const かどうかで振る舞いに大きな差が生まれることもあり、「 const 指定可能な時なコンパイラが自動的にそうみなしてしまおう」という機械的な対応は不可能です。

immutableなクラスの実装はfreezed を使うと楽

freezedパッケージを使うと、immutableなクラスの実装が楽になります。

import 'package:freezed_annotation/freezed_annotation.dart';part 'test.freezed.dart';@freezed
class Immutable3 with _$Immutable3 {
const factory Immutable3(int value) = _Immutable3;
}
void main() {
test('immutable freezed', () {
var x1 = const Immutable3(1);
final x2 = x1;
// freezedを使うと、特定のフィールドを更新しつつ
// それ以外のフィールドの値は維持された
// 新しいオブジェクトとしてコピーするメソッドを自動生成してくれる
// (手動でももちろんそういうメソッドを定義すれば済むが、
// 自動生成だと楽かつ実装ミスでのバグの心配も不要)
x1 = x1.copyWith(value: x1.value + 1);
expect(x1.value, 2);
expect(x2.value, 1);
expect(identical(x1, x2), isFalse);
});
}

上の例では、以下の恩恵を得られていることが分かります:

  • factoryコンストラクターの定義だけで済んでいる(フィールド宣言を手動で書く必要がない)
  • copyWith メソッドの自動生成

これだけだと大差ないと思うかもしれませんが、 ==toString() メソッドの適切な実装などもしてくれたり、 copyWith はnull値での上書きにも対応している(手動実装では対応困難)など、実際に使ってみるととても便利です(良いことばかりではなくコード自動生成が面倒というマイナス面もあります)。

また、ネストしたクラスのimmutable操作は煩雑になりがちですが、 copyWith はそのケースにも対応していて素晴らしいです👏

https://pub.dev/packages/freezed#deep-copy

https://pub.dev/packages/freezed#the-features に機能が列挙されています。

以上でimmutableなクラスの前置きが終わりましたが、次はFlutterでの実践的な扱いについて見ていきます。

StateNotifierはimmutableなstate値であることが前提となっている

StateNotifierはimmutableなstate値であることが前提となっていて、以下のような使い方は誤用で、きちんと動きません。
(StateNotifierが作られた時に参考にされた ValueNotifier でも同様です。)

class Mutable {
Mutable(this.value);
int value;
}
class _Controller extends StateNotifier<Mutable> {
_Controller() : super(Mutable(0));
void increment() {
state.value++;
// 無理矢理以下のように書くと一応動く
// state = state;
}
}
class _Home extends ConsumerWidget {
const _Home({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.watch(_controller.notifier);
// stateのvalueが変わってもリビルドが起こらない
   final state = ref.watch(_controller);
return Scaffold(
appBar: AppBar(),
body: Center(
child: ElevatedButton(
onPressed: controller.increment,
child: Text('${state.value}'),
),
),
);
}
}

フルのコード: https://github.com/mono0926/riverpod_example/blob/main/lib/ex/main_change_notifier.dart

値変更後にstate = state; と書くと一応動くと書いてありますが、その場合でも以下のように select を使うとまたリビルドが起こらなくなってしまいます。

final state = ref.watch(_controller.select((s) => s));

これは select で変更前後の Mutable インスタンス同士を比較しようとしつつ、参照が同じなため変更後の同一インスタンス同士の比較となってしまって必ず同一値とみなされリビルドがトリガーされないからです。
(ちなみに、selectで比較処理の実装はこちらです。)

つまり、StateNotifierだけでなく、ChangeNotifierなどを使う場合であっても、Provider/Riverpodのselect でアクセスする際は immutable プログラミングが徹底できてないと、意図通りにUI反映がされないバグに繋がる可能性があるということです。

ちなみに、 select が全く機能しないわけではなく、次のように参照先のimmutableな値までselectすれば普通に機能します。

final state = ref.watch(_controller.select((s) => s.value));

mutableなクラスがあると、うっかりそのmutableな値をselectしてしまって更新されないバグを踏む可能性がある一方、immutalbeプログラミングが徹底できているとその可能性自体を潰せる、ということです。

コレクションの扱い

まず、以下のように普通にListの変更操作をすると、同じListを参照している他の変数も影響を受けてしまいます(初めにmutableなクラスの挙動について説明したのと同じです)。

void main() {
test('list mutable', () {
final list1 = [Mutable(1), Mutable(2)];
final list2 = list1;
list1[0] = Mutable(3);
// list1の変更にlist2が完全に巻き込まれる
print(list1); // [value: 3, value: 2]
print(list2); // [value: 3, value: 2]
expect(list1 == list2, isTrue);
});
}

シャローコピー

List.of() メソッドなどを使ってシャローコピー(List自体は再生成しつつも要素の参照は同一)すると、上述のような副作用の影響を受けずに済むようになります。

void main() {
test('list mutable shallow copy', () {
var list1 = [Mutable(1), Mutable(2)];
final list2 = list1;
list1 = List.of(list1);
list1[0] = Mutable(3);
// list1はシャローコピーされてから変更されたため、
// list2はその影響を受けない
print(list1); // [value: 3, value: 2]
print(list2); // [value: 1, value: 2]
expect(list1 == list2, isFalse);
});
}

ただ、次のようにmutableなインスタンスの変更操作をすると、その影響を受けてしまいます。

void main() {
test('list mutable shallow copy broken', () {
var list1 = [Mutable(1), Mutable(2)];
final list2 = list1;
list1 = List.of(list1);
list1[0] = Mutable(3);
list1[1].value = 4;
// シャローコピー後でも、mutableな同一インスタンスの
// 変更操作をされるとその影響を受けてしまう
print(list1); // [value: 3, value: 4]
print(list2); // [value: 1, value: 4]
expect(list1 == list2, isFalse);
});
}

コレクションの要素がimmutableならシャローコピーだけで充分

要素がimmutableであれば、シャローコピーするだけで、他の変数経由での書き換えを防げます。シャローコピーはコーディングの手間的にも実行時の処理量・メモリ使用量のコスト的にも低めなのが良いです。

void main() {
test('list mutable shallow copy broken', () {
var list1 = const [Immutable3(1), Immutable3(2)];
final list2 = list1;
list1 = List.of(list1);
list1[0] = const Immutable3(3);
// mutate操作は不可能
// list1[1].value = 4;
// list2自体に触れずにその変更をすることは不可能
print(list1); // [value: 3, value: 4]
print(list2); // [value: 1, value: 2]
expect(list1 == list2, isFalse);
});
}

ただ、以下のようにコレクションのネストの時には問題が起こり得ます。そもそも、Listはmutableなクラスであって、それがネストされる場合は「コレクションの要素がimmutableなら」という条件を満たさなくなるので必然ですね。

void main() {
test('list immutable shallow copy borken', () {
var list1 = [
[Immutable3(1)],
[Immutable3(2)]
];
final list2 = list1;
list1 = List.of(list1);
list1[0].clear();
// list2自体に触れずにその変更できてしまう
print(list1); // [[], [value: 2]]
print(list2); // [[], [value: 2]]
expect(list1 == list2, isFalse);
});
}

同様にimmutableクラスでもListのようなmutableなフィールドを持つ場合、次のように副作用を与えられることに注意です。

@freezed
class Immutable4 with _$Immutable4 {
const factory Immutable4(List<int> values) = _Immutable4;
}
void main() {
test('Immutable mutable list', () {
final x1 = Immutable4([1, 2]);
final x2 = x1;
x1.values.clear();
print(x1); // values: []
print(x2); // values: []
});
}

UnmodifiableListViewなどを使えば、Listに変更操作がされたタイミングで実行時エラーにして誤用を防ぐことも可能です。

void main() {
test('lImmutable mutable unmodified list', () {
final x1 = Immutable4(UnmodifiableListView([1, 2]));
final x2 = x1;
// 実行時エラー(Unsupported operation: Cannot clear an unmodifiable list)
x1.values.clear();
});
}

誤用や外からの意図しない変更操作を確実になされないことを保証したい場合は、そうやってUnmodifidable系のクラスを使うと良いです。

Xxx.unmodifiable()はシャローコピーもされるため元のコレクションが変わっても影響を受けなくなりますが、以下のようなケースではmutate操作のためにすでにシャローコピー済みなのにさらに2重にシャローコピーするのは無駄なので、UnmodifiableXxxViewで済ますのが良いはずです。

@freezed
class Immutable4 with _$Immutable4 {
const factory Immutable4(List<int> values) = _Immutable4;
}
class FooNotifier extends StateNotifier<Immutable4> {
FooNotifier() : super(const Immutable4([]));
void add(int value) {
state = state.copyWith(
// 2. 外から `state.values`に対して直接mutate操作されることも防ぐ
values: UnmodifiableListView(
// 1. シャローコピーで元のvaluesと相互に変更の影響を受けないようにする
[
...state.values,
value,
],
),
);
}
}

UnmodifiableXxxViewは処理的にもかなり軽く済むので、これを念のために挟むのは費用対効果が高く感じるためけっこう賛成です。僕は少なくとも1人で書く場合にはアプリコードのStateNotifier実装などにおいてこの誤用はしないので普段書いてないですが、ライブラリなどを実装する場合はこういうケアがあった方が良いと思います。

コンパイル時点で防ぎたい場合はfast_immutable_collectionsパッケージなど使う方法もありますが、Listなどの基本的なコレクションの扱いで外部パッケージにがっつり依存するのが気が進まず僕は今のところ採用していません。

あるいは、mutableコレクション系をディープコピーしておくという手もあり得ますが、個人的には次に述べる理由でそもそもコレクション系クラスがmutableなことをあまりネックに思っておらず、その解決のために実装コスト・処理コストを上げることはしたくなく、実アプリでそういう実装を選んだことはないですし、今後もそうだと思います。

実際には意図しないコレクションのmutate操作をしにくい

もちろん、immutableプログラミングに反する操作を100%完璧にエラー(できればコンパイル時)で弾けるのが理想ですが、Dartの標準コレクションクラスはmutableなインターフェースのものしかないため、上で紹介した何かしらの折衷案が必要です。個人的にはコレクション系のクラスがmutableであっても現実的にはそれに起因するバグを起こしにくいのでシャローコピーをするだけで済ませて後は特に気にしない、という選択を取っています。

まず、状態値のクラスをimmutableで用意して更新時は copyWith してセットし直すというお作法と同様に、(広いスコープの)コレクションのmutate操作をしないというお作法を守ることはそれほど難しいものとは思いません。immutableプログラミングとは何かを本質的に理解していれば、@immutableの付いたクラス・freezedで定義されたクラスのフィールドのmutate操作をしようとする感覚には決してならないはずです(コレクションなどがmutate操作できるようになっているかどうかに関わらず)。型で守る以前に考え方として容易に守れる部分だと思います。

また、特にStateNotifier(やValueNotifier)を使っている場合、例えば以下のように state.values.add(1); のようなコレクションのmutate操作をしてもリビルドされません。

final _controller = StateNotifierProvider<_Controller, Immutable4>(
(_) => _Controller(),
);
@freezed
class Immutable4 with _$Immutable4 {
const factory Immutable4(List<int> values) = _Immutable4;
}
class _Controller extends StateNotifier<Immutable4> {
_Controller() : super(Immutable4([]));
void f() {
state.values.add(1);
}
}

state = state; と書いたら一応リビルドが誘発されるものの、

void f() {
state.values.add(1);
state = state;
}
// `f()`実行後、buildメソッドが呼ばれる
final state = ref.watch(_controller);

次のようにそのListを select すると、同一インスタンスゆえに常に同一になってしまいビルドが間引かれてしまってUI更新がまともにされないというバグに繋がって、その誤用にすぐ気付けます。

final state = ref.watch(_controller.select((s) => s.values));

つまり、StateNotifierを普通に使っていると、コレクションのimmutableな操作が自然と矯正されていきます(誤用すると分かりやすい誤動作があってすぐに気付けます)。

というわけで、StateNotfierのstateの扱いに関しては特別な工夫せずとも、コレクションクラスがmutableであることに起因するトラブルとは個人的には無縁で済んでいます。初めはうっかりmutate操作してしまってUI更新がされずに戸惑うことはあっても、mutate操作実装にしたまま他に意図せぬ副作用を与えるようなミスをしたことは一度も無いです。

あるいは、もしどうしてもコレクションのimmutable操作を守ることが難しいなら、無理してimmutableで組もうとするのではなく、割り切ってmutableに寄せてChangeNotifierメインで済ませる方が合理的だと思います。mutable前提でその副作用に気を付けながら組むのも現実的な1つのやり方です。

コレクション系クラスのimmutable操作紹介

初見だと「コレクション系クラスのimmutable操作」といっても、具体的にどう書けば良いのか迷うかもしれませんが、基本的に以下を覚えておくだけで大抵の操作をわりと簡潔に書けます👌

  • .of() メソッドでシャローコピーしてからmutate操作( .. (cascade notation)との組み合わせることが多い)
  • ... (スプレッド演算子)で展開しつつ(この時シャローコピーされる)、追加・上書きしたい値を添える

基本的に後者の方がmutate操作が出てこず、いかにもimmutableプログラミングっぽくて良い気がして個人的にはそれ中心で書いてますが、要素の削除操作をしたい場合などは前者の書き方も必要ですね。

そもそもFlutterでのアプリ開発において、immutableプログラミングは必要か?

まず、Flutterでのアプリ開発においてimmutableプログラミングは必須ではないです。
Flutterを初めて触るときになぞるであろうチュートリアルの https://flutter.dev/docs/get-started/codelab#step-4-create-an-infinite-scrolling-listview でもStatefulWidgetのStateで保持しているListをmutate操作してたり、Flutter初学者向けの情報はimmutableプログラミングになっていないものが多いです。状態管理をStatefulWidget使ってベタ書きする次のステップとして選択されることの多いChangeNotifier + Providerもmutate操作で利用されることが多いです(ChangeNotifierは使いようによってはimmutableプログラミングとの組み合わせもできますが、そうする場合はValueNotifier/StateNotifierの方が適してます)。

一方、Flutterフレームワークの実装ではimmutableプログラミングが多用されています。特にInheritedWidgetが絡むところで頻出です。InheritedWidgetを継承して実装する場合、下層に伝えるデータを保持しますが、updateShouldNotifyを実装する際、そのデータがimmutableでないと比較処理の実装が不可能です(mutableの場合は同一参照なため常にtrueになってしまうので)。そのため、InheritedWidgetを継承した_InheritedThemeが持つThemeはimmutableなクラスになっていて、お馴染みの copyWith も実装されています。

https://github.com/flutter/flutter/blob/4a939d7a57427f74763bb0107dec4ddf3c75f1df/packages/flutter/lib/src/material/theme.dart#L156
https://github.com/flutter/flutter/blob/b8a2456737c9645e5f3d7210fba6267f7408486f/packages/flutter/lib/src/material/theme_data.dart#L197
https://github.com/flutter/flutter/blob/b8a2456737c9645e5f3d7210fba6267f7408486f/packages/flutter/lib/src/material/theme_data.dart#L1326

Flutterでダークモード切り替えなどThemeが切り替わる時、色などが滑らかに変わりますが、それは変更前後の値を元にlerpメソッドによってその中間状態を算出することによって実装されています。これはimmutableクラスであり変更前後のそれぞれの値を得られるからこそ容易に実現できています。

また、Widgetもimmutableクラスであり、Flutterでレイアウトを組むにあたって開発者は自然とimmutableプログラミングをしていると言えます。こう考えると、Widgetのコンストラクターや build メソッドなどで副作用のあるmutate操作などをするのが明らかな誤用であることも直感的に分かりやすいのではないでしょうか。

ただ、アプリ開発における状態管理をimmutableに寄せるかどうかは、好きな方を選べば良いという考えです。僕はimmutableをうまく扱った時のパワフルさを評価して、基本的にimmutableプログラミングを選択しています。mutableはイージー、immutableはシンプル(+人によってはハード?)、なのかなと思います。

その際、immutableプログラミングを選択した時のトータルのメリットが、mutableで済ませた場合を上回るのが重要だと考えていて、immutableプログラミングに拘るあまり手間がかかり過ぎたり標準的な書き方から大きく逸脱するのは避けたいところです。

Flutterとimmutableプログラミングの相性は良いものの、Dartの言語仕様は以下のようにそうではないという認識です:

  • Struct・data classのようなimmutableプログラミングに適した入れ物が無い
  • 標準コレクションクラスはmutableなインターフェースのものしかない

その課題を現時点でバランス良く解決する手段として、本記事で述べた以下で済ませる選択しています。

  1. immutableクラスの実装はfreezed 活用 (手動で書くのが怠くミスする可能性もある copyWith の自動実装メリットが大きい)
  2. immutableクラスの利用を徹底した前提で、コレクション系は標準クラスを使いつつimmutalbe操作はシャローコピーでOK(意図せぬ外部変更が気になるなら、さらにUnmodifiableXxxViewで守っておく)

freezed はやや賛否両論もありつつ、 copyWith など手動で書く場合の標準的な慣例に従ってて、一見安心感のあるGoogle製のbuilt_valueなどよりもむしろずっと良いと評価しています。プロジェクト規模が大きくなると自動生成にかかる時間が長くなるのが気になりますが、以下のような工夫で短縮した際は許容範囲です(あるいはfreezed利用クラスをパッケージ分割するのも良いかもしれません)。

以上、immutableの扱いに関する1つの指針として伝われば幸いです。ちなみに、以下のツイートをより丁寧に説明した内容でした( ´・‿・`)

--

--