🕶2020/11/17 Immutable Data Patterns in Dart and Flutter要点まとめ

 

Immutable(不変)データとは、初期化後の変更ができないデータ。

文字列(String)、数値(int,doubleなど)、bool値(boolean)の値は変更することができない。

String型として型注釈(型推論)された変数には、文字列(データ)が格納される訳ではない。

文字列データのメモリの場所への参照が格納される。

と言っている。筆者はGDEみたい。こういうの公式ドキュメントにあるのだろうか。無い場合言語仕様見ればあるのだろうか。

final変数でない変数は再代入が可能であり、再代入された変数はその文字列データを指し示すようになる。しかし一度生成された文字列データ自体(の内容・長さ)は変わらない。


var str = "This is a string.";
str = "This is another string.";

上記のサンプルではstrという名の変数が宣言されている。

文字列データ("This is a string.")はメモリに配置される。

変数strにはそのメモリの場所が格納される。

2行目では新しい文字列データが生成され、そのメモリの場所がstrに格納される。

最初の文字列データ自体は消えないが、コード内に、最初の文字列データにアクセスできる変数がなくなったので、unreachable(アクセス不可能)とマークされて、Dartのガベージコレクションにより最終的にはメモリが解放される。


immutableデータを使う利点はいくつかある。

immutableデータを使うとスレッドセーフとなる。immutableデータの内容を変えることはできないので、いかなるコードがアクセスしていても、データが同じであることが保証される。

参照を渡す際に、防御的コピーをする必要がない。状態を維持するために複雑なコードを書く必要がないので変数の中身を推測しやすい。

final、constキーワードを用いたサンプルによりDartのビルトインimmutable機能について議論していく。

変更すべきでない二つのデータを宣言する。


Final variables vs. constants

final変数は一度だけ初期化が可能。必ず初期化する必要がある。

再代入は不可。


final str = "This is a final string.";
str = "This is another string.";  // エラー発生

final変数は実行時にステート(値)が決定されることはあるが、必ず初期化により決定される。

再代入できないこと以外は、普通の変数と同様の振る舞い。


const定数はコンパイル時定数。

constatnt(const定数)の全体の状態はコンパイル時に決定される。

なので実行時のコードにより状態が解決されることはない。

  • 定数値(constant values)は、深く、推移的に不変です。定数コレクション(リスト、マップなど)を作成する場合は、すべての要素も再帰的に定数である必要があります。

定数値(constant values)は、コンパイル時に利用可能なデータにより生成されなければならない。例えば、

DateTime.now()は定数ではない(実行時にのみ利用できるデータ(実行時の日時)により返り値が決まる)ので、定数値(constant values)にはならない。

実行時でないと値が決まらない→コンパイル時に値が決定できない→constではない。

FlutterのSizedBoxはfinalプロパティとconstコンストラクタを持っているので、定数値になり得ます。

  • 任意の定数値に対して、定数式が評価される回数に関係なく、単一のオブジェクトがメモリに作成されます。定数オブジェクトは必要に応じて再利用されますが、再作成されることはありません。

定数に関するサンプルを示します。

const str = "This is a constant string.";
const SizedBox(width: 10);  // a constant object
const [1, 2, 3];            // a constant collection
1 + 2;                      // a constant expression

定数strは文字列リテラルが代入されている。文字列リテラルは常にコンパイル時定数。

2行目のSizedBoxインスタンスはconstantでimmutableです。

Dartはプログラム実行前にSizedBoxインスタンスをセットアップできるからです。SizedBoxの全てのプロパティがfinalであり、引数として数値リテラル(定数)を渡しているからです。

3行目のconstantなリストリテラルも問題ありません。全ての要素がconstant(数値リテラル)だからです。

4行目の1+2もコンパイル時にDartが計算するので、constantと言えます。


constantは正規化され、Dartはデフォルトでidentityを比較しますので、二つの別々の定数インスタンスに見える以下のコードは等値と評価されます。メモリにおいて全く同じものを参照しているからです。

List<int> get list => [1, 2, 3];
List<int> get constList => const [1, 2, 3];

var a = list;
var b = list;
var c = constList;
var d = constList;

print(a == b);  // false
print(c == d);  // true

Dartは「要素の値が等しいか?」を比較しているのではなく、メモリのアドレス(参照)が同じかを見ています。constListゲッターの呼び出しは常にconstantなリスト

const [1,2,3]

への参照を返します。

だからc==dはtrueとなる。

Dartはメモリにリスト( const [1,2,3] )を一度だけ配置します。(constだから)


次にFlutterフレームワークがimmutableデータをどのように利用しているかを見ていきます。

Immutable data in Flutter

Flutterアプリケーションが不変(immutable)の構造を利用して読みやすさやパフォーマンスを向上させることができる場所はたくさんあります。不変(immutable)の形式で構築できるように、多くのフレームワーククラスが作成されています。2つの一般的な例は、SizedBoxとTextです。

Row(
  children: <Widget>[
    const Text("Hello"),
    const SizedBox(width: 10),
    const Text("Hello"),
    const SizedBox(width: 10),
    const Text("Can you hear me?"),
  ],
)

上記サンプルのように、constキーワードを使用してconstコンストラクターを持つクラスのインスタンスを作成すると(詳細は後で説明します)、値はコンパイル時に作成され、一意の各値は1回だけメモリに格納されます。

最初の2つのTextインスタンスは、2つのSizedBoxインスタンスと同様に、メモリ内の同じオブジェクトへの参照に解決されます。

const SizedBox(width:15)

を追加すると、constantなインスタンスが生成されます。

constキーワードを使用せずにこれらすべてのインスタンスを生成できます。これはnewを使っていることと同じです。表面的には(constを使っても使わなくても)同じように機能するように見えますが、アプリのメモリフットプリントを削減し、ランタイムパフォーマンスを向上させるために、できる限りconstを使用することをお勧めします。


別のTextを使ったサンプルを見てみましょう。

final size = 12.0;  //←ここをconstで宣言すればエラーは出なくなる。

const Text(
  "Hello",
//↓TextStyleを生成しようとしているが、constキーワードを使っていない。
  style: TextStyle(  
    fontSize: size,    // エラー発生
  ),
)

constキーワードを使ってconstantなTextインスタンスを生成しようとしています。しかしconstantはその全てがconstant(コンパイル時に値が決まる)でなければなりません。

文字列リテラル”Hello”はconstantなので問題ありません。

上記サンプルでは、TextStyleインスタンス生成箇所でconstキーワードを使用していませんが、それでもDartはTextStyleインスタンスを定数(constant)として生成しようとします。(Dartは、定数であるTextインスタンスのフィールドであるTextStyleインスタンスも定数でなければならない、ということを知っているから)

でもTextStyleインスタンスはconstant(定数)にはなれません。なぜならTextStyleコンストラクタに変数sizeが渡されているからです。

変数sizeはfinal変数なので、実行時に値が決定される。(=実行時でないと値が決定されない。)

ということでエラーが発生する。これを修正するには、

  • TextStyleコンストラクタに渡す値である変数sizeをconstで宣言する
  • TextStyleコンストラクタに12.0などの数値リテラルを渡す

どちらかをする必要がある。


時にアプリの状態データを予期しない変更から守る必要がある場合があります。次に、Dartでこれを実現する方法を考えていく。

Creating your own immutable data classes

シンプルなimmutableなクラスを定義するには、

finalなプロパティ

constコンストラクタ

を用意します。

class Employee {
  final int id;
  final String name;
  
  const Employee(this.id, this.name);
}

Employeeクラスの二つのプロパティ

id

name

はどちらもコンストラクタにより自動的に初期化される。

コンストラクタはconstキーワードを使って宣言されており、これによりDartは、このクラスのインスタンスをconstコンストラクタを使って生成する時に、コンパイル時定数として生成します。

const emp1 = Employee(1, "Jon");
var emp2 = const Employee(1, "Jon");
final emp3 = const Employee(1, "Jon");

上記サンプルで唯一のconstantなEmployeeクラスのインスタンスが生成され、それぞれの変数(定数)に参照が代入されます。

emp1において、コンストラクタにconstキーワードを付ける必要はありません。変数宣言でconstキーワードを付けているからです。

つまり必然的にconstになる部分はconstキーワードを省略できる、ということ。

しかし付けたいのであればコンストラクタにもconstキーワードを付けても構いません。

emp2に関して、emp2はEmployee型の通常の変数です。その通常の変数に、immutableな(constを付けて生成された)定数オブジェクトへの参照を代入しています。

emp3は、finalで宣言されていますので、その後に別の参照を代入することはできません。それ以外はemp2と同じです。

これらの参照をどこで渡したとしても、調べればいつでもオブジェクトのidは1であり、nameは”Jon”であることが確実です。そしてメモリ上で同じ値を調べていることになります。

データクラスのfinalプロパティをプライベートにすることは一般的ではないことに注意してください。それらは変更できず、それらへの読み取りアクセスを制限することによって得られるものは多くありません。詮索好きなコードから値を隠す理由がある場合、または内部状態がクラスのユーザーと無関係である場合は、プライバシーを検討する価値があるかもしれません。


immutableなクラスインスタンスを生成するのに成功した時に、それをDartフレームワークが理解する助けになる方法に関して、以下に示します。

Using a metatag helper

metaパッケージの@immutableメタタグを使用して、immutable(不変)にする予定のクラスに関する有用なアナライザー警告を取得できます。

import 'package:meta/meta.dart';

@immutable
class Employee {
  int id;            // not final
  final String name;

  Employee(this.id, this.name);
}

@immutableタグを付けただけでimmutableなクラスになる訳ではありません。

しかし@immutableタグを使うと、上記のサンプルのように、finalでないフィールドがある場合、「一つ以上のfinalでないフィールドがある」旨の警告を得られます。

@immutableタグを使うと、mutableなフィールドがある時にコンストラクタにconstキーワードを追加すると、エラーが発生します。

@immutableタグを使っているクラスのサブクラスがimmutableでない場合に警告を得られます。


不変性について、オブジェクトとコレクションは少し話が複雑になります。

これらに関して見ていきましょう。

Complex objects in immutable classes

employeeクラスのnameプロパティがString型ではなく、もう少し複雑なEmployeeNameクラスで表される場合どうなるでしょうか?

例えば以下のような感じです。

class EmployeeName {
  String first;
  String middleInitial;
  String last;

  EmployeeName(this.first, this.middleInitial, this.last);
}

Employeeクラスは以下のようになります。

class Employee {
  final int id;
  final EmployeeName name;

  const Employee(this.id, this.name);
}

ほとんどの場合、Employeeクラスは以前と同じように機能しますが、重要な違いが1つあります。EmployeeNameクラスをimmutableなクラスとして定義していないため、そのプロパティは初期化後に変更される可能性があります。

var emp = Employee(1, EmployeeName('John', 'B', 'Goode'));

emp.name = EmployeeName('Jane', 'B', 'Badd');  // (1)blocked
emp.name.last = 'Badd';                        // (2)allowed

emp.nameはfinalで宣言されているので、(1)の処理はできない(エラー発生)。

Employeeクラスはimmutableなクラスとして定義されており、emp.nameはfinalで宣言されているが、(2)の処理はできてしまう。

上記の説明は直接emp.nameのフィールドを変更している例。それもあるし、下記のサンプルがわかりにくい変更の例として挙げられると思われる。

class EmployeeName {
  String first;
  String middleInitial;
  String last;

  EmployeeName(this.first, this.middleInitial, this.last);
}

class Employee {
  final int id;
  final EmployeeName name;

  const Employee(this.id, this.name);
}

void main(){
  var emp1=Employee(12,EmployeeName("fir","mid","las"),);
  
  var emp2=emp1; //代入(コピー)
  emp2.name.first="yyy";
  
  print(emp1.name.first); // yyy
  print(emp2.name.first); // yyy
  //emp2.name.firstを変更した時にemp1.name.firstも
  //変更されることを認識できていれば良いが、
  //できていないと、見つかりにくい不具合が発生することになる。
  //「EmployeeNameクラスの全てのフィールドをfinalにして、
  //EmployeeNameクラスのコンストラクタをconstコンストラクタにして、
  //Employeeクラス自体constとして生成」すれば当然変更できなくなる。
  //そこまでしなくても、「EmployeeNameクラスの全てのフィールドをfinal
  //にする」だけでも、再代入できなくなるので、"yyy"に変更することはできなくなる。
}

 


class Employee {
  final int id;
  final EmployeeName name;

  const Employee(this.id, this.name);
}

class EmployeeName {
  String first;
  String middleInitial;
  String last;

  EmployeeName(this.first, this.middleInitial, this.last);
}

void main() {

  var emp = Employee(1, EmployeeName('John', 'B', 'Goode'));
  print(emp.name.last);  //'Goode'

  //emp.name = EmployeeName('Jane', 'B', 'Badd');  // blocked
  emp.name.last = 'Badd';                        // allowed
  print(emp.name.last);  //'Badd'
}

結局上記のコードではEmployeeクラスのコンストラクタはconstコンストラクタだが、EmployeeNameクラスのコンストラクタがconstコンストラクタではないので、Employeeクラスのコンストラクタをconstコンストラクタとして呼び出すことはできない。つまり↓

  var emp = const Employee(1, EmployeeName('John', 'B', 'Goode'));
/*
エラー発生
The constructor being called isn't a const constructor - 
*/

↑のような呼び出しはできない。Employeeをコンパイル時定数として(constとして)生成するにはそのプロパティも全てconstである必要がある。

https://dart.dev/guides/language/language-tour#final-and-const

 Note: Although a final object cannot be modified, its fields can be changed. In comparison, a const object and its fields cannot be changed: they’re immutable.

このページの前半でも説明している。

しかしEmployeeNameクラスにconstコンストラクタは定義されていないので、EmployeeNameインスタンスをconstで生成することはできない。ということで、Employeeインスタンスもconstコンストラクタでは生成できない。

Employeeクラスのコンストラクタはconstコンストラクタとして定義されているが、constを付けずにコンストラクタを呼び出すとconstでないインスタンスが生成される。

https://dart.dev/guides/language/language-tour#using-constructors

If a constant constructor is outside of a constant context and is invoked without const, it creates a non-constant object:

 

Employeeクラスのnameプロパティはfinalで宣言されているので、emp.nameへの再代入はできません(1)。

しかし、EmployeeNameクラスのプロパティは、Employeeクラスとは違ってfinalで宣言されていません。ですから(2)のような代入はできてしまいます。

従業員データが不変であると期待している場合、これは意図しない脆弱性である可能性があります。この問題を解決するには、データ構成で使用されるすべてのクラスも不変であることを確認してください。


Immutable collections

コレクションは、不変性に対する別の課題を提示します。finalによるリストやマップへの参照であっても、これらのコレクション内の要素はまだ変更可能です。また、Dartのリストとマップは、それ自体が変更可能な複雑なオブジェクトであるため、要素を追加、削除、または並べ替えることができます。

チャットメッセージデータのシンプルなサンプルを考えてみましょう。

class Message {
  final int id;
  final String text;

  const Message(this.id, this.text);
}

class MessageThread {
  final List<Message> messages;

  const MessageThread(this.messages);
}

この設定では、データはかなり安全です。

独自定義したクラスは全てimmutableなクラスとして定義されている。

作成されたすべてのメッセージは不変であり、一度初期化されると、MessageThread内のメッセージのリストを置き換えることはできません。ただし、リスト構造は外部コードで操作できます。

「リスト構造を操作できる」状態は、一般的な言葉の使い方として「リストを置き換えることができる」と言えるような気もするが、説明しようとしていることはわかる。

final thread = MessageThread([
  Message(1, "Message 1"),
  Message(2, "Message 2"),
]);

thread.messages.first.id = 10;                 // blocked
thread.messages.add(Message(3, "Message 3"));  // Uh-oh. This works!

おそらくあなたが意図したものではありません。では、どうすればこれを防ぐことができますか?利用可能ないくつかの異なる戦略があります。

上記説明は直接threadの中身を変更している例。それもあるし、以下のようなケースがわかりにくい例として挙げられると思われる。

class Message {
  final int id;
  final String text;

  const Message(this.id, this.text);
}

class MessageThread {
  final List<Message> messages;

  const MessageThread(this.messages);
}



void main(){
  final thread = MessageThread([
    Message(1, "Message 1"),
    Message(2, "Message 2"),
  ]);
  
  var ot=thread;  //←代入(コピー)
  
  ot.messages[0]=Message(-1,"xxxxx");
  print(thread.messages[0].id); // -1
  //上記のような変更でコピー元のthreadの中身まで
  //変更されていることを認識できていないと、
  //不具合が見つかりにくい。
  //変更できないようにする方法として、MessageThreadクラスのコンストラクタも
  //constコンストラクタとして定義されているので、
  //MessageThreadインスタンスをconstとして定義すれば変更できない。
  //それをしないのであれば、今まで説明されたようにリストの要素が変更可能なので
  //その対策としてこの後説明するような「プライベート+コピーを返すゲッター」
  //のような方法が必要、という話。
}

 

class Message {
  int id;
  String text;

  Message(this.id, this.text);
}

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => _messages.toList();

  const MessageThread(this._messages);
}



void main(){
  final thread = MessageThread([
    Message(1, "Message 1"),
    Message(2, "Message 2"),
  ]);
  
  var messages2=thread.messages;
  messages2.first.text="xxxxx";
  print(thread.messages[0].text); //xxxxx
  
  /*
  Messageクラスのフィールドがfinalじゃない場合、
  上記のような変更もthreadに反映される。
  MessageThreadクラスのゲッタmessagesは
  シャローコピーを返すので、
  messages2.first.textへの代入が、
  thread.messages[0].textにも反映される。
  */

}

 

class Message {
  int id;
  String text;

  Message(this.id, this.text);
}

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => _messages.toList();

  const MessageThread(this._messages);
}



void main(){
  final thread = MessageThread([
    Message(1, "Message 1"),
    Message(2, "Message 2"),
  ]);
  
  var thread2=thread;
  thread2.messages[0].text="yyyyy";
  print(thread.messages[0].text);
  
  /*
  Messageクラスのフィールドがfinalじゃない場合、
  上記のような変更もthreadに反映される。
  MessageThreadクラスのゲッタmessagesは
  シャローコピーを返すので、
  thread2.messages[0].textへの代入が、
  thread.messages[0].textにも反映される。
  */
}

 

class Message {
  int id;
  String text;

  Message(this.id, this.text);
  @override
  toString()=>"$id:$text";
}

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => _messages.toList();

  const MessageThread(this._messages);
 
}



void main(){
  final thread = MessageThread([
    Message(1, "Message 1"),
    Message(2, "Message 2"),
  ]);
  
  var messages2=thread.messages;
  messages2.first=Message(100,"zzzzz");
  
  print(thread.messages.first);
  print(messages2.first);
  
  /*
  ゲッタmessagesがシャローコピーを返すので上記の代入は
  thread.messages.firstには反映されない。
  */
}

 

 


Return a copy of the collection

呼び出し元のコードがコレクションの変更可能なコピーを受け取ることを気にしない場合は、Dartゲッターを使用して、クラスの外部からアクセスされるたびにマスターリストのコピーを返すことができます。

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => _messages.toList();

  const MessageThread(this._messages);
}

thread.messages.add(Message(3, "Message 3"));  // new list

このMessageThreadクラスでは、実際のメッセージリストはプライベートです。コンストラクターを介して一度だけ設定できます。_messagesリストのコピーを返すmessagesという名前のゲッターが定義されています。外部コードがリストのadd()メソッドを呼び出す場合、リストの別のコピーで呼び出すため、元のコードは変更されません。コピーされたリストは新しいメッセージを持つリストになりますが、MessageThreadオブジェクト内の_messagesリストは変更されません。

「プライベートにする」+「ゲッタのみ用意する」→イミュータブル にできる、という話。

このアプローチは単純ですが、欠点がないわけではありません。

まず、リストが非常に大きいか、頻繁にアクセスすると、パフォーマンスに負担がかかる可能性があります。リストのシャローコピー(shallow copy)は、messagesにアクセスされるたびに作成されます。

次に、元のリストの変更が許可されているように見えるため、クラスのユーザーにとって混乱を招く可能性があります。彼らはコピーが返されていることに気付いていないかもしれません。これにより、アプリで驚くべき動作が発生する可能性があります。


Return an unmodifiable collection or view

データクラス内のコレクションへの変更を防ぐ別の方法は、ゲッターを使用して変更不可能なバージョンまたは変更不可能なビューを返すことです。

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => List.unmodifiable(_messages);

  const MessageThread(this._messages);
}

thread.messages.add(Message(3, "Message 3"));  // exception!

このアプローチは一つ前のアプローチと非常に似ています。リストのコピーを生成するのは同じです。しかし今回返すコピーはunmodifiableです。DartのListクラスで定義されているfactoryコンストラクタを使って新しいリストを生成します。

クラスのユーザーが、ゲッターmessagesで返されたリストに別のメッセーージを追加しようとすると、実行時エラーが発生し、追加は拒否されます。

一つ前のアプローチよりはましですが、まだ欠点があります。

実行時にadd()の呼び出しが失敗してDartアナライザーからの警告がありません。ユーザーは、直接参照ではなくリストのコピーを受信して​​いますが、これは認識されていない可能性があります。

dart:collectionライブラリのUnmodifiableListViewクラスを使用して、アプローチを少し改善できます。

import 'dart:collection';

class MessageThread {
  final List<Message> _messages;
  UnmodifiableListView<Message> get messages =>
      UnmodifiableListView<Message>(_messages);

  const MessageThread(this._messages);
}

thread.messages.add(Message(3, "Message 3"));  // exception!

UnmodizableListViewは元のリストのコピーを作成しないため、この方法で実行すると、パフォーマンスが少し向上する可能性があります。代わりに、変更を防ぐビューでオリジナルをラップします。残念ながら、違反は実行時に例外の形でのみ報告されますが、List.unmodifiable()によって提供されるものよりも少し説明的なものです。まだいくつかの欠点がありますが、このアプローチは多くの状況に十分対応できるため、ソリューションとして非常に人気があります。

他のコレクションタイプはどうですか?MapやSetなどの他のコレクションにもunmodifiable() factoryコンストラクターがあり、それらの変更不可能なビューはdart:collectionライブラリで利用できます。

コレクションへの変更を防ぐために考慮すべきことがいくつかあります。


Truly immutable collections

ゲッターを使用してコレクションの不変バージョンを返すためのすべてのトリックは、元のコレクションが変更可能な状態のままだということに気付いたかもしれません。ライブラリ内のコードは、プライベートな_messagesリストの構造を操作できます。これは問題ないかもしれませんが、純粋主義者はそれでも操作不可能であることを望むかもしれません。

一つの方法は、MessageThreadオブジェクト生成時に、リストをunmodifiableなものとして生成することです。

class MessageThread {
  final List<Message> messages;

  const MessageThread._internal(this.messages);

  factory MessageThread(List<Message> messages) {
    return MessageThread._internal(List.unmodifiable(messages));
  }
}

最初に行う必要があるのは、ライブラリの外部のコードから定数コンストラクターをアクセス不可にすることです。アンダースコアプレフィックスが付いた名前付きコンストラクターに変更し、プライベートにします。MessageThread._internal()コンストラクタは、私たちの古いデフォルトコンストラクタがしたまったく同じ仕事をしますが、クラス内からのみアクセスすることができます。

factoryコンストラクタはstaticメソッドに似ています、通常のコンストラクタのように自動的に返すのではなく、クラスのインスタンスを明示的に返す必要があるという点で。

finalなプロパティを初期化する前に、受け取ったメッセージのリストをunmodifiableなものに調整する必要があるので、これは便利な違いです。

ファクトリコンストラクタは、インスタンスを作成するプライベートコンストラクタに渡す前に、受信リストを変更不可能なリストにコピーします。ユーザーは、いつもと同じ方法でインスタンスを作成するため、それに気付きません。

全く同じ方法でMessageThreadインスタンスを生成しているが、factoryコンストラクタを使ったサンプルでは、messagesフィールドに、unmodifiableなリストがセットされる。

final thread = MessageThread([
  Message(1, "Message 1"),
  Message(2, "Message 2"),
]);

これは引き続き機能し、(ソースを覗かずに)通常のコンストラクターではなくファクトリーコンストラクターを呼び出していることを誰も知ることができません。ちなみに、この手法はDartのシングルトンデザインパターンに使用されている手法と似ています。

保存されたリストは変更できないため、同じクラスまたはライブラリ内のコードでさえ変更できません。ただし、データを更新しなくても意味のあることを実行できるアプリはそれほど多くありません。


immutableなデータを安全に更新するにはどうすればよいでしょうか。

Updating immutable data

すべてのアプリの状態を不変の構造に安全に隠しておくと、どのように更新できるのか疑問に思うかもしれません。クラスの個々のインスタンスは(少なくとも外部から)変更可能であってはなりませんが、状態は確かに変更する必要があります。相変わらず、いくつかの異なるアプローチがあり、ここでそれらのいくつかを探求します。

ここから先に、フィールドがint,String型のみのEmployeeクラスについての説明があるが、現時点での認識として、フィールドがプリミティブ型のみであれば、全てのフィールドをfinalにしておけば、更新関数じゃなく、ただの代入でも、その後意図せぬ変更は起きない気がする。「最も単純な例から始めて」と言っている通り、多分最初はシンプルな例で見ていくのが良い、ということかと思われる。それはその通りだと思う。

State update functions

不変の状態を更新する最も一般的な方法の1つは、ある種の状態更新関数を使用することです。でReduxのでは、これはreducerであり、そして使用して類似の構築物が存在するブロックの状態管理のためのパターン。更新関数が存在する場合は常に、入力を受け取り、ビジネスロジックを実行し、入力と古い状態に基づいて新しい状態を出力する役割を果たします。

最も単純な例から始めて、前に紹介した不変のEmployeeクラスのいくつかの可能な状態更新関数を見てみましょう。これらの関数はEmployeeクラスの一部ではないことに注意してください。

class Employee {
  final int id;
  final String name;

  const Employee(this.id, this.name);
}

Employee updateEmployeeId(Employee oldState, int id) {
  return Employee(id, oldState.name);
}

Employee updateEmployeeName(Employee oldState, String name) {
  return Employee(oldState.id, name);
}

このパターンは簡単で、サポートされている更新のみが実行されるようにするのに役立ちます。

基本的に、各関数は前の従業員の状態への参照を取得し、それと新しいデータを使用してまったく新しいインスタンスを構築し、それを呼び出し元に返します。

単純な変数の更新を実行するための定型コードが多いように思われる場合があります。これにより、可変戦略に戻りたいと思う場合があります。

不変データの利点に取り組んでいる場合は、いくつかの追加に慣れる必要があります。

このアプローチのもう1つの欠点は、リファクタリングが困難になることです。Employeeのプロパティのいずれかを追加、削除、または変更した場合、多くのやり直しが必要になる可能性があります。

更新関数は通常、コードベースの完全に異なる部分で記述されるため、このアプローチではビジネスロジックをデータから分離する傾向があります。一部のプロジェクトでは、それが大きな利点になる可能性があります。


State update class methods

「状態」と「状態操作に関連するすべて」をいっしょに保持したい場合は、個別のトップレベル関数の代わりにクラスのメソッドを使用できます。

class Employee {
  final int id;
  final String name;

  const Employee(this.id, this.name);
  
  Employee updateId(int id) {
    return Employee(id, name);
  }

  Employee updateName(String name) {
    return Employee(id, name);
  }
}

このアプローチでは、各更新メソッドがEmployeeクラスに属していることが明らかであるため、名前の冗長性を減らすことができます。また、現在のインスタンスが古い状態であると想定されているため、古い状態を明示的に渡す必要がなくなりました。適切なコードの色付けがないと、両方の更新メソッドが同じコードを持っているように見えるかもしれませんが、updateId()は、受け取った引数idと古いnameを使用してEmployeeの新しいインスタンスを作成しています。updateName()メソッドは逆のことをしています。

この方法で行うことの欠点は、値を更新するためのロジックがある程度固定されており、状態クラスに直接関連付けられていることです。これは、場合によっては正確に必要なことかもしれませんが、どちらの方法でも問題にならない場合もあります。

すべてをまっすぐに保つ:関心の分離は、ほとんどのプロの開発者が熱心に支持していることですが、ニーズを慎重に検討する必要があります。一般的に言って、懸念事項を分離すればするほど、アーキテクチャは柔軟になりますが、分離しすぎると組織の課題が生じる可能性があります。

不変クラスのすべてのプロパティの更新メソッドを作成すると、面倒になる可能性があります。次に、その機能を1つのメソッドに統合する方法を見ていきます。


Copy methods

不変のデータを使用するDartおよびFlutterプロジェクトで使用される一般的なパターンは、クラスにcopyWith()メソッドを追加することです。それはあなたが使用しているどんな戦略もより単純でより均一にすることができます:

class Employee {
  final int id;
  final String name;

  const Employee(this.id, this.name);

  Employee copyWith({int id, String name}) {
    return Employee(
      id ?? this.id, 
      name ?? this.name,
    );
  }
}

このcopyWith()メソッドは通常、デフォルトなしで名前付きオプションパラメータを使用する必要があります。このreturnステートメントは、Dartのif null演算子??、を使用して、従業員のコピーが各プロパティの新しい値を取得するか、既存の状態の値を保持するかを決定します。メソッドがidの値を受け取った場合、その値がnullでなければ、その値がコピーで使用されます。存在しないか、明示的ににnullに設定されている場合は、代わりにthis.idが使用されます。copyメソッドは柔軟性があり、1回の呼び出しで任意の数のプロパティを更新できます。

copyWith()を使ったサンプルです。

final emp1 = Employee(1, "Bob");

final emp2 = emp1.copyWith(id: 3);
final emp3 = emp1.copyWith(name: "Jim");
final emp4 = emp1.copyWith(id: 3, name: "Jim");

このコードが実行されると、emp2変数はemp1更新されたid値でのコピーを参照しますが、name変更されません。emp3コピーには、新しい名前と、元のIDを持つことになります。このEmployeeクラスでは、emp4コピー操作はすべての値を置き換えるため、新しいオブジェクトを作成するのとまったく同じです。

状態更新関数またはメソッドは、copyWith()をタスクを実行するために利用できます。これにより、コードを大幅に簡素化できます。

Employee updateEmployeeId(Employee oldState, int id) {
  return oldState.copyWith(id: id);
}

Employee updateEmployeeName(Employee oldState, String name) {
  return oldState.copyWith(name: name);
}

ここでの状態更新関数の使用は、への呼び出しの非常に薄いラッパーになっているため、やり過ぎだと考えることもできますcopyWith()。多くの場合、元のオブジェクトのデータを破損する方法がないため、外部コードがコピー機能を直接使用できるようにすることは問題ありません。

不変クラスのプロパティも不変クラスである場合、copyWith()ネストされたプロパティを更新するために呼び出しをネストする必要がある場合があります。次に、そのシナリオについて説明します。


Updating complex properties

1つ以上のプロパティが不変オブジェクトでもある場合はどうなりますか?これらの更新パターンは、ツリー全体で機能します。

class EmployeeName {
  final String first;
  final String last;

  const EmployeeName({this.first, this.last});

  EmployeeName copyWith({String first, String last}) {
    return EmployeeName(
      first: first ?? this.first,
      last: last ?? this.last,
    );
  }
}

class Employee {
  final int id;
  final EmployeeName name;

  const Employee(this.id, this.name);

  Employee copyWith({int id, EmployeeName name}) {
    return Employee(
      id: id ?? this.id,
      name: name ?? this.name,
    );
  }
}

現在、EmployeeにはタイプEmployeeNameのプロパティが含まれており、両方のクラスは不変であり、copyWith()更新を容易にするメソッドを備えています。この設定では、従業員の姓を更新する必要がある場合は、次のようにすることができます。

final updatedEmp = oldEmp.copyWith(
  name: oldEmp.name.copyWith(last: "Smith"),
);

ご覧のとおり、従業員の姓を更新するには、両方のバージョンのcopyWith()を一緒に使用する必要があります。


Updating collections

不変のコレクションを更新するために使用するパターンは、コレクションの設定方法と、不変の純粋主義者の程度の両方によって異なります。

更新パターンに焦点を当てた議論を続けるために、非現実的に単純化されたデータクラスを使用します。

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);

  NumberList(this._numbers);
}

このクラスは技術的には可変(mutable)なリストを有していますが、(クラス)外部にはunmodifiableなコピーのみを提供します。このリストを修正するための状態更新関数として、

NumberList addNumber(NumberList oldState, int number) {
  final list = oldState.numbers.toList();
  return NumberList(list..add(number));
}

 

class Number{
  int n;  //finalが無いとコピー元も修正されてしまう。
//(List.unmodifiableがシャローコピーを返すため)
//↑finalを付ければ修正できない。
  Number(this.n);
}
class NumberList {
  final List<Number> _numbers;
  List<Number> get numbers => List.unmodifiable(_numbers);

  NumberList(this._numbers);
}

main() {

  NumberList addNumber(NumberList oldState, Number number) {
    final list = oldState.numbers.toList();
    return NumberList(list..add(number));
  }

  NumberList numList=NumberList([Number(1),Number(2),Number(3),]);
  var newList=addNumber(numList,Number(10));
  newList.numbers[0].n=100;
  print(numList.numbers[0].n); //100
  print(newList.numbers[0].n); //100
}

 

このアプローチは効率的ではありません。

oldState.numbers

は、oldStateのリストのコピーを返します。しかしそれはunmodifiableです。

ですから、toList()メソッドを使ってmutableな別のリストを生成しています。

そして新しいNumberListインスタンスを生成しています。そのコンストラクタに、新しい数値を追加したコピーリストを渡してNumberListインスタンスを生成しています。

Dartのカスケード演算子を使用して、コンストラクタに渡される前に要素の追加を行っています。

 


別の更新メソッドとして、

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);

  NumberList(this._numbers);

  NumberList add(int number) {
    return NumberList(_numbers..add(number));
  }
}

この更新メソッドは良いポイントがあります。冗長性が少なく、コード量も少なくなります。

注意すべき微妙な点の1つは、_numbersを変更して再利用していることです。これは内部コード内からのみ可能であるため、このアプローチには満足できるかもしれませんが、同等性の比較に関して潜在的な副作用があります。

いくつかの状態管理パターンは状態のstreamを生成します。新しい状態が生成される(状態が更新される)度に毎回、streamの結果として新しい状態インスタンスが発せられ、UIコードに届けられます。

効率化を最大化するため、古い状態が新しい状態と異なるかを調べる必要があるかもしれません。上記のadd()メソッドはNumberList型の新しいインスタンスを生成しますが、_numbersインスタンスは共通のものを使っている状況です。

新しい状態と古い状態の比較の実装によっては、状態は全く変わっていない、という間違った結果をもたらすかもしれません。

(add()メソッドも返り値の_numbersフィールドも、元のNumberListインスタンスの_numbersフィールドも同じものだから。)


ということで、変更のたびにリストを再作成することを好む人もいます。

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);

  NumberList(this._numbers);

  NumberList add(int number) {
    return NumberList(_numbers.toList()..add(number));
  }
}

toList()メソッドの呼び出しを追加したことで、「更新後のリスト」としての新しいインスタンス(_numbersのシャローコピー*に新しい要素を追加したインスタンス)を有するNumberListインスタンスが生成されて(addメソッドの返り値として)返されるようになるので、問題が解決しました。

(* :シャローコピーだがこのケースではリストの要素がプリミティブ型(int型)なので、シャローコピーでも問題にならない。)


Conclusion

オブジェクトとコレクションの不変性を処理する方法はたくさんありますが、複雑なデータでを扱う際にも予期せず変化しないようにする方法のいくつかに精通している必要があります。

ここではコードジェネレーションによるimmutablityの実現に関しては触れませんが、それらの方法を用いると自分でコードをタイピングする量を減らせるでしょう。それらの情報が必要な場合build_valueなどのDartパッケージをご覧ください。

参考

https://dart.academy/immutable-data-patterns-in-dart-and-flutter/#

コメントを残す

メールアドレスが公開されることはありません。