はじめに
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
の目的がプリミティブ型とともに捨てたレベルの効率化、すなわちアンボックスにほぼ特化しているように見えるからです。
そして、アプリの実行効率はマイクロベンチマークでは決まりません。
そのわりに、デリケートで特異な挙動が少なからず存在します。
また、新機能採用基準に「実アプリケーションがあること」というのががあったはずです(参照先失念)。
次回はその具体的なユースケースについて書きたいと思います。