はじめに
タイトルでご察しの通り、オブジェクト(クラスインスタンス)のアイデンティティの話です。
Recordではidentical()
の結果に注意が必要です。
Recordにおいてidentical()
は2つの値が異なる(!=
)場合にfalse
を返すこと以外は何も保証しません。
つまり、identical((1, 2), (1, 2))
はtrue
かもしれないし、false
かもしれません。
これでは使い物にならないかというとそうでもなく、まともなhashCode
ゲッターと==
オペレーターが自動生成(オーバライド出来ない)されるので、Map
のkey
としての利用を含めて、まず困ることは無いでしょう。
したがって、本記事はRecord利用上のアドバイスというよりは技術ポエムです。
それでも、言語理解のためのメンタルモデル構築の役には立つと思います。
前提
全般
Dartにおいて型は例外なくJSで言うところのオブジェクト型です。
つまり、DartにJSで言うところのプリミティブ型は存在しません。
また、Dartにおいて型は例外なくリファレンス型です。
変数は常にリファレンスを保持します。
そのリファレンスはざっくりいうとポインタですが、例外については後述します。
そして、オブジェクトのアイデンティティは例外なくリファレンスです。
なお、バリュー型が無いとまでば言っていませんので注意ください。詳細は後述します。
bool
型
演算で動的に生成される値は無く、規定のtrue
とfalse
だけです。
そして、それらのインスタンスはキャノニカライズ(原典化)されています。
また、イミュータブルなので、最適化がその値をスタックやインラインに展開する可能性があります。
言い換えるとbool
はいわばバリュー型です(リファレンス型とバリュー型の二重性)。
キャノニカライズするくせにスタックやコールサイトに展開するというのは、最適化による「キャノニカライズの嘘」と呼ばれるそうです。
当然ながらスタックやインラインに展開されても意味論的には変わりません。
Null
型
Null
型はbool
型と同じです。値は規定のnull
だけです。
Enum
型
Enum
型もbool
型と同じです。値はユーザが静的に定義しものだけです。
int
型、double
型、String
型
演算で動的に生成される値のインスタンスも自動的にキャノニカライズされています。
また、イミュータブルなので、最適化がその値をスタックやインラインに展開する可能性があります。
値の小さなint
をタグ付けし、ポインタ格納領域やレジスタに保持する可能性さえあります。
言い換えると、これらはいわばバリュー型です(二重性)。
自動的にキャノニカライズするとか、そのくせスタックやインライン、ポインタやレジスタに展開するというのは、最適化による「キャノニカライズの嘘」、大嘘ですね。
定数
型ではありませんが、定数についても説明します。
変数は定数のリファレンスを保持します。(例: var v = const [1];
)
定数はキャノニカライズされています。
その型がミュータブルかイミュータブルかに関わらず、定数はイミュータブルなので、最適化がその値をスタックやインラインに展開する可能性があります。
キャノニカライズするくせにスタックやインラインに展開するというのは、最適化による「キャノニカライズの嘘」です。
リファレンス ≠ ポインタ (前出の例外の説明)
var a = 1;
でvar b = 2;
の状況において、a + b
は3
と同じint
インスタンスへのリファレンスを持ちます。
演算a + b
によって新たなint
インスタンスが生成されるという立場ではなく、キャノニカライズされた概念的なインスタンス3
が存在し続け、演算a + b
によってその概念的インスタンスへのリファレンスが出現するという立場です。
なお、裏を返すと、値(概念的インスタンス)3
を使用しない限りそのリファレンスも出現しません。
つまり、「多重人格」ではないが、しばしばアイデンティティを喪失します。
引いては、だれも参照しない概念的インスタンスはメモリ表現すら持たないでしょう。
「灼眼のシャナ」でいうところの「存在の力」みたいなものでしょうか?
いや、因果が逆であって、映画「リメンバー・ミー」と言ったほうが良いかもしれません。
つまり、リファレンスは厳密には値への「アクセス」であって、それは必ずしもオブジェクトへのポインタではありません。
実際にint
型、double
型、String
型の値におけるidentical()
の実装はポインタではなく値のメモリ表現を比較します。
本題
ようやく本編です。
Recordの値は不変のアイデンティティを持たない
Recordのインスタンスはいわば「多重人格」です。
Recordは関数の引数や戻り値に多用され、その値をスタック上に展開する最適化を重視してイミュータブルなバリュー型です。
なお、公式見解では、Recordが唯一のバリュー型です。
同時にRecordもご多分に漏れずリファレンス型であり、アイデンティティを持っています。
実際に変数はリファレンスを保持します。
しかし、各フィールドの値の(再帰的な)比較はコストが高すぎるため、最適化が「キャノニカライズの嘘」をつくことはありません。
つまり、Recordは正々堂々とアイデンティティの不変性を放棄する設計判断をしています。
したがって、前述の通りidentical((1, 2), (1, 2))
はtrue
かもしれないし、false
かもしれないのです。
もはや、Recordをidentical()
検査することが(少なくとも意味論的には)ナンセンスです。
なお、identical(const (1, 2), const (1, 2))
は実装上true
で、今後も変更されないでしょう。
これは、前述の通り定数がキャノニカライズされることを保証しているからです。
ただし、Recordではキャノニカルの定義をリファレンスの一致ではなく構造的等価性としているので、アイデンティカルとキャノニカルは厳密には違う、とか言われない限りです。
蛇足: Expando
Expando
はdart:core
のクラスで、使い勝手は全く異なりますが、JSにおける既存オブジェクトへのプロパティ追加をエミュレートするものです。
このExpando
はint
、double
、String
、bool
、Null
型に対して適用することが出来ません。
これらの型もオブジェクト型なので、プリミティブ型だからという理由付けは成立しません。
同様に、これらの型はリファレンス型なので、アイデンティティが無いからという理由付けも成立しません。
不変のアイデンティが無い他の理由でGCのタイミングを得られない型のサポートを避けたということです。
ところで、Enum
を含む定数もGCのタイミングを得られないことには変わりがありませんが、その値のバリエーションがコーディングしたものに限られるという理由でExpando
の対象となっています。
それでも、true
、flase
やnull
が対象から外れているのは愛嬌です。
実際のところは、JSからの移行者のために、JSと同様の制限を設けたということでしょう。
おわりに
不変のアイデンティティを持たないという性質はDart 3で導入したばかりのRecordに特有のものです。
私がこの記事を書くことになったのもRecordの仕様を調べたのがきっかけです。
Record
のAPIやidentical()
のAPIにもちゃんとそのように書いてあります。
また、この性質は間もなく導入される予定のStruct(data class)にも当てはまるはずです。
StructにはRecordと同様にイミュータブルなバリュー型であるという特徴がありますので。
ちなみに、定義構文(プライマリブコンストラクタ)もRecordの型宣言とそっくりです。
なお、Expando
のAPIではint
、double
等について、当初私にはidentical()
と真逆のことが書いてあるように読めました。
Since you can always create a new number reference that is identical to an existing number instance, it means that an expando property on a number could never be released.
※ 斜体は筆者が補足として追加
そのことをGitHubのこのissueで報告したところ、リファレンスやキャノニカライゼーションについての深い理解を得ることができました。
DartチームのIrhnさん、ありがとうございます。