はじめに
Dart 3.3で導入したextension typeは既存の型のメモリ表現を借りたクラス風の型です。
メモリ表現を与えるfinalなフィールドを一つだけ持ちます。
そのフィールドのラッパーオブジェクトを持たないので効率的です。
クラスにも広く導入予定のプライマリコンストラクタ記法で定義します。
Kotlinを知っている人にはvalue classと説明するのが理解への近道かと思います。
ここでは、クラスと比較した特異性を中心に解説します。
想定する使い方
自然数(1以上の整数)に限定のId型を想定します。
extension type Id._(int value) {
Id(this.value): assert(value >= 1);
bool get isEven => value.isOdd; // lying
void show() => print('[$value]');
}
void main() {
Id id = Id(123); // 推奨形は ver id = Id(123);
int i = id.value; // 推奨形は var i = id.value;
id.show(); // [123]
print(id.isEven); // true (deceived)
id = Id(-1); // runtime AssertionError
i = id; // compile-time error
id = i; // compile-time error
}
プライマリコンストラクタ記法ではassert()を指定できません。(参考その2)
ここではassertを使いたかったので無名コンストラクタを型(≒クラス)ボディ内コンストラクタとし、プライマリコンストラクタを(プライベートな)名前付きコンストラクタにしました。
特異性
プライマリコンストラクタ記法が必須
extension typeはfinalなフィールドを一つだけ持つ無名(位置)引数のプライマリコンストラクタ記法で定義する必要があります。
厳密にはextension typeコンストラクタであり、finalは省略します。
裏を返すと、フィールドはfinalであることが強制されます。
その無名引数がfinalフィールドの定義と初期化子を兼ねるプライマリコンストラクタ記法を強制することによって、唯一のフィールドがメモリ表現を提供することを示唆する狙いがあるものと思われます。
なお、先の例のように型ボディ内でも別途コンストラクタを定義できます。
明示的キャスト
extension typeとそのフィールド型は互いに明示的キャストできます。
後述の動的な挙動を静的にも記述可能とするために必要な措置なのだと思いますが、継承関係にないものどうしが明示的キャストできることに違和感が有ります。
また、明示的ダウンキャストではその正当性が実行時にその場でチェックされるのに対し、extension typeへの明示的キャストではユーザがコンストラクタに定義したチェックロジックをすり抜けるという意味でやはり特異です。
いやむしろ、assertを指定できなかったextension typeコンストラクタが呼ばれる、と理解すべきでしょう。
i = id as int; // ok!
i = -1;
id = i as Id; // ok!!
print(id); // -1 !?
従って、新たなlintの導入が検討されています。
なお、はたまた、extension typeコンストラクタにassertが指定できるようになるのかもしれませんたとしてもextension typeコンストラクタが呼ばれるわけではないようです。
Object?の5メンバ
トップObject?と共通のruntimeType、hashCode、operator==、toString、noSuchMethodの5メンバはフィールドのそれらのコピーとして暗黙的に宣言・定義されます。
また、ユーザはObject?の5メンバを再宣言・定義(≒オーバライド)することはできません。
なお、runtimeTypeが返すべきextension type用のType型の値も存在しません。
dynamic変数等を経由したアクセス
extension typeは完全に静的な型であり、dynamic変数経由のアクセスにおける実行時の振る舞いはフィールド型のそれになります。
従って、extension typeで独自に宣言・定義したメンバshow()等を参照すると実行時エラーです。
反対に、extension typeで独自に宣言・定義せず、フィールド型で宣言・定義されたisOddなどが呼べてしまいます。
dynamic d = id;
d.show(); // runtime NoSuchMethodError
d = d + 1; // ok
print(d.isOdd); // false
先のisEvenのように既にフィールド型で宣言・定義され、extension typeで再宣言・定義したメンバは、コンテクストによって動作が異なることになるため、混乱のもとです。
d = id;
print(identical(id, d)); // true
print(id.isEven); // true (deceived)
print(d.isEven); // false (confusing)
同様に、Object?等のスーパータイプ変数を経由したアクセスも要注意です。
runtimeTypeによる判断(is等)のもと型固有のメンバにアクセスしようにも、extension typeには固有のruntimeTypeが存在しませんので。
ちなみに、先述のObject?の5メンバの再宣言・定義が(とりあえず)禁止されているのも、同様の混乱を避けるのが目的です。
仮に、toStringが再宣言・定義できた場合、print(i64)とprint(i64.toString())で異なる結果となる、厄介な問題が発生します。
前者はprint内で動的にintのtoStringに解決し、後者はprintのコールサイトで静的にI64のtoStringに解決します。
仮に、hashCodeとoperator==が再宣言・定義出来た場合はもっと深刻かもしれません。
フィールド型のインタフェースの実装
フィールド型をimplementsできます、それがインタフェースを公開するクラスでなくとも!
そして、フィールドの同名メンバのコピーとして暗黙的に宣言・定義されます。
さらに、それを再宣言・定義(≒オーバーライド)することもできます。
extension type I64(int value) implements int {
bool get isEven => value.isOdd; // lying
}
void main() {
var i64 = I64(123);
print(identical(123, i64)); // true
print(i64.isOdd); // true
print(i64.isEven); // true (deceived)
}
フィールド型への代入
先の例では、I64はintのインターフェースを実装しているのでint型の変数に代入できます。
ここでフィールド型で宣言・定義されたメンバを再宣言・定義していると、コンテクストによって動作が異なることになるため、混乱のもとです。
int i = i64; // ok
print(identical(i64, i)); // true
print(i64.isEven); // true (deceived)
print(i.isEven); // false (confusing)
従って、新たなアノテーション@redeclareとLintannotate_redeclaresが導入されました。
import 'package:meta/meta.dart';
extension type I64(int value) implements int {
@redeclare
bool get isEven => value.isOdd; // lying
}
余談
extension typeというネーミング
メンバ(フィールド、メソッド、ゲッター、オペレータ等)の名前解決はコンパイル時に行えるものだけが許されます。
これはextensionと同じであり、extension typeの名前の由来です。
また、冒頭で述べた通りラッパーオブジェクトなどもともとありませんが、いわば常にアンボックスされているので、フィールドのインライン展開と言えます。
ラッパーオブジェクトのサイズ分省メモリであり、そのデリファレンスの時間分高速です。
機能名の候補にあがっていたinline classはここから来たのでしょう。
なお、inline classとしてプレビューが動くところまで実装されましたが、クラスと呼ぶには特異な挙動が多すぎるという理由の後付のクライテリアで却下され、extension typeとして再実装されました。
ちなみに、同等の機能を持つKotlinのinline classはvalue classと改名されましたが、Dartにおいてvalue classを名乗るのは不適当です。
なぜなら、実行時型が例外なくリファレンス型であるDartにおいて、"value"はリファレンスを持たないことを意味せず、それが不定であることを意味しますが、そもそも存在しないラッパーオブジェクトのリファレンスの議論はナンセンスだからです。
しかも、Dartにおけるvalue classはKotlinにおけるdata class相当になる予定です。(ややこしい)
他には、exetnsion class、typedef type、newtype、Constricted type、record class、static class、view class、view、facade class、skin class等の候補名が有りました。 (72 comments!)
intのextension typeはゼロボックスか?
Dartにおいて、intもご多分に漏れずリファレンス型であり、プリミティブ型ではありません。
また、intはKotlinのLong相当であって、Javaのlong(常にアンボックス)やLong(常にボックス)相当ではありません。
そして、そのintのボックス、アンボックスは最適化イシューです。
従って、Null安全で最適化によるアンボックスの機会が増えたとはいえ、extension typeにおいても、intフィールドのアンボックスまで含めたゼロボックスを保証するわけではありません。
おわりに
プリミティブ型を持たないほど「高級」であることを目指すDartにおいて、extension typeは異色です。
extension typeの目的がプリミティブ型とともに捨てたレベルの効率化、すなわちアンボックスにほぼ特化しているように見えるからです。
そして、アプリの実行効率はマイクロベンチマークでは決まりません。
そのわりに、デリケートで特異な挙動が少なからず存在します。
また、新機能採用基準に「実アプリケーションがあること」というのががあったはずです(参照先失念)。
次回はその具体的なユースケースについて書きたいと思います。