【Flutter】イミュータブル(immutable)とは?

【Flutter】イミュータブル(immutable)とは?

「イミュータブル(immutable)」とは「不変」「変わらない」という意味の単語で、プログラミング界隈でよく聞く概念です。

イミュータブルとは?

プログラミングにおいてイミュータブルとは、一度生成されたら内容を変更できないオブジェクトやデータのことです。

ミュータブルを具体例で解説

Userクラスがnameageという2つのフィールドを持つとします。

Userクラスのインスタンスuser1を作った後にuser2に参照をコピーします。

UserクラスがMutableなクラスとして定義されていれば、user1.age = 0などで値を書き換えることができます。

つまり、このインスタンスの状態は可変で後から変わる可能性があるということです。

この時、user2も同じインスタンスを参照しているため、知らないところで値が変わり、意図しない問題を引き起こす可能性があります。

実際に、以下のコードでは、mutableUser2は直接値を変更していないにも関わらず、mutableUser1の値の変更に影響を受け、値が変わってしまいました。

// Mutableな可変クラス
class MutableUser{
  String name;
  int age;
  MutableUser({required this.name,required this.age});

  @override
  String toString() {
    return "私は$nameです。$age歳です。";
  }
}

void main() {
  final MutableUser mutableUser1 = MutableUser(name:"tanaka", age:30);
  final MutableUser mutableUser2 = mutableUser1;
  
  print("MutableUser1 変更前");
  print("mutableUser1: $mutableUser1");
  print("mutableUser2: $mutableUser2");
  
  // MutableUser 変更前
  // mutableUser1: 私はtanakaです。30歳です。
  // mutableUser2: 私はtanakaです。30歳です。

  // finalなので再代入はできないように保護できる
  // mutableUser2 = MutableUser(name:"suzuki",age:99);  
  
  // mutableUser1のフィールドの上書きは可能
  mutableUser1.name = "suzuki";
  mutableUser1.age = 0;

  // mutableUser1とmutableUser2は同じ参照を持っているため、mutableUser2の値も変わる
  print("MutableUser1 変更後");
  print("mutableUser1: $mutableUser1");
  print("mutableUser2: $mutableUser2");
  
  // MutableUser 変更後
  // mutableUser1 私はsuzukiです。0歳です。
  // mutableUser2 私はsuzukiです。0歳です。
}

イミュータブルを具体例で解説

イミュータブルとして定義して、値を書き換えたい場合は、コピー・クローンをした上で値を変更し、別のインスタンスとして扱います。

// @immutableアノテーションをつけると、Immutableになっていない場合に警告が出る
@immutable
class ImmutableUser{
  final String name;
  final int age;
  const ImmutableUser({required this.name,required this.age});

  // print時の文字列をカスタマイズ
  @override
  String toString() {
    return "私は$nameです。$age歳です。 ハッシュ値=$hashCode";
  }
  
  // コピー用のメソッド
  ImmutableUser copyWith({String? name,int? age}){
    // 値が設定されていればその値を使い、なければ既存の値を使って新しいImmutableUserを返す
    return ImmutableUser(name: name ?? this.name, age: age ?? this.age);
  }
}

void main() {
  const ImmutableUser immutableUser1 = ImmutableUser(name:"tanaka", age:30);
  const ImmutableUser immutableUser2 = immutableUser1;

  // constなので再代入はできない
  // immutableUser2 = MutableUser(name:"tanaka", age:99);  
  // immutableUser1.name = "suzuki";
  
  // 既存のImmutableUserの値を変えて使いたい場合は、copyWithでコピーをして使う
  final ImmutableUser immutableUser3 = immutableUser1.copyWith();
  final ImmutableUser immutableUser4 = immutableUser1.copyWith(name:"yamada");

  print(immutableUser1);
  print(immutableUser2);
  print(immutableUser3);
  print(immutableUser4);
  
  // 私はtanakaです。30歳です。 ハッシュ値=764617667
  // 私はtanakaです。30歳です。 ハッシュ値=764617667
  // 私はtanakaです。30歳です。 ハッシュ値=998675158
  // 私はyamadaです。30歳です。 ハッシュ値=1060001993
}

immutableUser2immutableUser1と同じインスタンスを参照していますが、値を書き換えることができないため安全です。

immutableUser3immutableUser4はcopyWithを使って別のインスタンスを作ることで、コピー時に値を変えても、元々のimmutableUser1immutableUser2も含めてお互いに干渉しないため安全です。

イミュータブルのメリット

  • 再レンダリングの最小化
    • constキーワードを使ったウィジェットやデータは再レンダリングを抑制し、パフォーマンスを最適化します。
  • バグの予防
    • 意図しない変更が発生しないため、バグの発生を予防できます。
  • デバッグが簡単
    • 状態が予期せずに変わることがないため、バグの原因を特定しやすくなります。
  • テストがしやすい
    • イミュータブルなデータは、状態が変わらないため、テストが簡単になります。

イミュータブルを意識したコードの書き方

finalとconst

イミュータブルを実装するためには、finalconstを使うのが一般的です。

また、immutableアノテーションを使用して、クラス全体をイミュータブルにすることも可能です。

  • final : 一度値を設定すると変更できなくなります。
  • const : 完全に定数として扱われ、コンパイル時に決まった不変の値として扱われます。
  • @immutable : クラス全体をイミュータブルにします。linterがクラスのインスタンス内でフィールドの再代入や変更を検出し、警告を出してくれます。
import 'package:meta/meta.dart'; // metaパッケージを使用

@immutable
class User {
  final String name;
  final int age;

  const User(this.name, this.age);
}

Widgetでも同様にfinalconstを使います。

class MyWidget extends StatelessWidget {
 final String title;
 const MyWidget({required this.title});
 
 @override
 Widget build(BuildContext context) {
   return Text(title);
 }
}

おそらくFlutterを書く人であれば、日常的にイミュータブルな書き方になっているのではないでしょうか?

freezedパッケージ

freezedを使うと、イミュータブルなデータクラスを簡単に作成できます。

freezedで生成されたクラスは、すべてのフィールドがfinalで宣言され、変更が不可能なオブジェクトとして生成されるため、手動で@immutablefinalを付ける手間を省区くことができます。

ここでは詳細な説明は割愛させていただきます。

イミュータブルデータの場合、どのように差分を検出すればいいか?

これはデータが変更できない(イミュータブル)場合に、以前のデータと新しいデータの違い(差分)を見つける方法のことです。

イミュータブルデータでは、データの一部が変わったとしても元のデータを直接変更できません。

そのため、変更を反映する際は、新しいデータのインスタンスを生成します。

このとき、古いデータと新しいデータを比較することで差分を検出し、どの部分が変更されたのかを把握します。

ここでshallow compare(浅い比較)deep compare(深い比較)が非常に有効です。

どちらを使うかはデータの構造とアプリケーションの要求により異なります。

shallow compare(浅い比較)deep compare(深い比較) については以下の記事で詳しく解説しているので、ここでは記事を読んだ上での話を進めます。

イミュータブルデータとShallow Compareの適用

イミュータブルデータでは、データが変更されると新しいインスタンスが生成されるため、浅い比較で差分を確認できます。

例えば、FlutterのsetStateProviderなどの状態管理ライブラリで、参照が変わったかどうかで再描画のトリガーが行われるのも、shallow compareのメリットを利用しています。

また、UI再描画の制御や状態の変化を差分検知する場合、mutable なエンティティやストアを使うと、状態が変更されたにもかかわらず差分が検知されず、意図しないバグが発生するリスクが高まります。

そのため、このような課題がある場合はimmutableを検討するよ良さそうです。

以下の例を見てみましょう。

@immutable
class User {
  final String name;
  final int age;

  const User(this.name, this.age);
}

void main() {
  final oldUser = User("Alice", 30);
  final newUser = User("Alice", 31); // ageが変わったので、新しいインスタンス

  // Shallow Compareでの差分検出
  if (oldUser != newUser) {
    print("User data has changed!");
  }
}

oldUser != newUsertrueを返し、変更があったことが検出されます。

これにより、オブジェクトの内容が変わったかをshallow compareで簡単に確認できます。

イミュータブルデータとDeep Compareの活用

イミュータブルデータでも、リストやネストされたオブジェクトがある場合、shallow compareでは変更の有無を確認しづらいことがあります。

その場合には、deep compareを使って、配列やマップの中身まで比較して差分を特定することができます。

例えば、Userオブジェクトのリストがある場合、リストの変更を検出するためにdeep compareが必要になることがあります。

import 'package:collection/collection.dart';

@immutable
class User {
  final String name;
  final int age;

  const User(this.name, this.age);
}

void main() {
  final oldUsers = [User("Alice", 30), User("Bob", 25)];
  final newUsers = [User("Alice", 30), User("Bob", 26)]; // ageが変わった

  // Deep Compareでの差分検出
  final isEqual = const DeepCollectionEquality().equals(oldUsers, newUsers);

  if (!isEqual) {
    print("User list has changed!");
  }
}

ここでDeepCollectionEquality(ネストされたコレクションやカスタムオブジェクトも含めて、オブジェクト同士が同一の内容かどうかを確認するための等価性比較を提供するクラス)を使ってリスト内の各オブジェクトをdeep compareしています。

リストの内容が変わるとisEqualfalseを返し、変更が検出されます。

イミュータブルデータでのShallow CompareとDeep Compareの使い分け

  • Shallow Compare
    • 単純な変更の検出が必要な場合や、参照の変更のみで十分なケースに適している
    • パフォーマンスが良いため、UIの更新トリガーに最適
  • Deep Compare
    • 複雑なデータ構造や、リスト・ネスト構造のデータが含まれる場合、正確に差分を検出する必要がある場合に利用
    • 細かくデータの変化を追跡したい場合に便利

まとめ

イミュータブルデータを使用することで、shallow compareによる簡単かつ高速な差分検出が可能になります。

また、データが複雑な場合はdeep compareを使うことで、イミュータブルの利点を活かしつつ、データの変更を細かく確認できます。

このような差分検出方法の使い分けにより、アプリケーションのパフォーマンスを維持しつつ、変更の検出精度を高めることができます。

Anycloudではプロダクト開発の支援を行っています

プロダクト開発をお考えの方はぜひAnycloudにご相談ください。

まずは相談する

記事を書いた人

Matsuura

エンジニア

Matsuura

Twitter

Anycloudでエンジニアしてます!主にFlutterやWebフロントをやっていて、最近はバックエンドやインフラに挑戦・苦戦中。