4
2

More than 1 year has passed since last update.

DartのRecordは多重人格

Last updated at Posted at 2023-06-15

はじめに

タイトルでご察しの通り、オブジェクト(クラスインスタンス)のアイデンティティの話です。

Recordではidentical()の結果に注意が必要です。
Recordにおいてidentical()は2つの値が異なる(!=)場合にfalseを返すこと以外は何も保証しません。
つまり、identical((1, 2), (1, 2))trueかもしれないし、falseかもしれません。
これでは使い物にならないかというとそうでもなく、まともなhashCodeゲッターと==オペレーターが自動生成(オーバライド出来ない)されるので、Mapkeyとしての利用を含めて、まず困ることは無いでしょう。

したがって、本記事はRecord利用上のアドバイスというよりは技術ポエムです。
それでも、言語理解のためのメンタルモデル構築の役には立つと思います。

前提

全般

Dartにおいて型は例外なくJSで言うところのオブジェクト型です。
つまり、DartにJSで言うところのプリミティブ型は存在しません。

また、Dartにおいて型は例外なくリファレンス型です。
変数は常にリファレンスを保持します。
そのリファレンスはざっくりいうとポインタですが、例外については後述します。

そして、オブジェクトのアイデンティティは例外なくリファレンスです。

なお、バリュー型が無いとまでば言っていませんので注意ください。詳細は後述します。

bool

演算で動的に生成される値は無く、規定のtruefalseだけです。
そして、それらのインスタンスはキャノニカライズ(原典化)されています。
また、イミュータブルなので、最適化がその値をスタックやインラインに展開する可能性があります。
言い換えるとboolはいわばバリュー型です(リファレンス型とバリュー型の二重性)。
キャノニカライズするくせにスタックやコールサイトに展開するというのは、最適化による「キャノニカライズの嘘」と呼ばれるそうです。
当然ながらスタックやインラインに展開されても意味論的には変わりません。

Null

Null型はbool型と同じです。値は規定のnullだけです。

Enum

Enum型もbool型と同じです。値はユーザが静的に定義しものだけです。

int型、double型、String

演算で動的に生成される値のインスタンスも自動的にキャノニカライズされています。
また、イミュータブルなので、最適化がその値をスタックやインラインに展開する可能性があります。
値の小さなintをタグ付けし、ポインタ格納領域やレジスタに保持する可能性さえあります。
言い換えると、これらはいわばバリュー型です(二重性)。
自動的にキャノニカライズするとか、そのくせスタックやインライン、ポインタやレジスタに展開するというのは、最適化による「キャノニカライズの嘘」、大嘘ですね。

定数

型ではありませんが、定数についても説明します。
変数は定数のリファレンスを保持します。(例: var v = const [1];)
定数はキャノニカライズされています。
その型がミュータブルかイミュータブルかに関わらず、定数はイミュータブルなので、最適化がその値をスタックやインラインに展開する可能性があります。
キャノニカライズするくせにスタックやインラインに展開するというのは、最適化による「キャノニカライズの嘘」です。

リファレンス ≠ ポインタ (前出の例外の説明)

var a = 1;var b = 2;の状況において、a + b3と同じ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

Expandodart:coreのクラスで、使い勝手は全く異なりますが、JSにおける既存オブジェクトへのプロパティ追加をエミュレートするものです。

このExpandointdoubleStringboolNull型に対して適用することが出来ません。
これらの型もオブジェクト型なので、プリミティブ型だからという理由付けは成立しません。
同様に、これらの型はリファレンス型なので、アイデンティティが無いからという理由付けも成立しません。
不変のアイデンティが無い他の理由でGCのタイミングを得られない型のサポートを避けたということです。
ところで、Enumを含む定数もGCのタイミングを得られないことには変わりがありませんが、その値のバリエーションがコーディングしたものに限られるという理由でExpandoの対象となっています。
それでも、trueflasenullが対象から外れているのは愛嬌です。
実際のところは、JSからの移行者のために、JSと同様の制限を設けたということでしょう。

おわりに

不変のアイデンティティを持たないという性質はDart 3で導入したばかりのRecordに特有のものです。
私がこの記事を書くことになったのもRecordの仕様を調べたのがきっかけです。
RecordのAPIidentical()のAPIにもちゃんとそのように書いてあります。

また、この性質は間もなく導入される予定のStruct(data class)にも当てはまるはずです。
StructにはRecordと同様にイミュータブルなバリュー型であるという特徴がありますので。
ちなみに、定義構文(プライマリブコンストラクタ)もRecordの型宣言とそっくりです。

なお、ExpandoのAPIではintdouble等について、当初私には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さん、ありがとうございます。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2