📌

Dart 3.3のExtension typesはいいぞ

2024/02/25に公開
2

はじめに

Dart 3.3で導入したExtension typesはとても便利だなと思ってるので、公式ドキュメントに基づいてその特徴とユースケースを紹介します。

https://dart.dev/language/extension-types

Extension typesの特徴

公式によると、Extension typesは既存の型を異なる、静的のみのインターフェイスでラップするもので、コンパイル時に型を決定します。
既存の型のインターフェースを簡単に変更でき、通常のラッパーを作成した際に発生するコストが発生しなくなるというメリットがあります。

Extension typesの作り方
extension type E(int i) {
  // - コンストラクタが暗黙に宣言されるため、新しいコンストラクタは名前付きで宣言する必要がある
  E.otherName(this.i);

  // - 演算子や、Getter/Setter、メソッドなどを定義できる
  E operator +(E other) => E(value + other.value);
  E get myNum => this;
  bool isValid() => !E.isNegative;

  // - インスタンス変数と抽象メソッドは定義不可
  String value = i.toString(); // Error
  bool isNotValid(); // Error
}

ラッパークラスとの違い

extension type IdNumberA(int id) {
  operator <(IdNumberA other) => id < other.id;

  void printValue() {
    print('Value is $id');
  }
}

class IdNumberB {
  IdNumberB(this.id);

  final int id;

  void printValue() {
    print('Value is $id');
  }
}

上の例では、Extension typesIdNumberAとクラスIdNumberBをそれぞれ作成しているのですが、相違点がいくつかあります。

  • IdNumberAはDart標準のint型としてコンパイルされる

    void main() {
      final safeIdA = IdNumberA(42);
      final unSafeIdA = safeIdA as int; // OK
    
      final safeIdB = IdNumberB(42);
      final unSafeIdB = safeIdB as int; // Error
    }
    

    また、IdNumberAは基本型と互換性を持つものの、意味のある操作のみを定義することができます。

    void main() {
      final idA = IdNumberA(1);
      final idB = IdNumberA(2);
    
      print(idA < idB); // OK
      print(idA > idB); // Error
    }
    
  • Extension typesは完全に静的で、ランタイム時にコンパイルされるため、必要となるコストはほぼゼロ
    従来のラッパークラス[1]と同じ役割を持っているが、ランタイムで余計なオブジェクトを生成することは不要になり、特に重いオブジェクトの場合にはコストを大幅に削減することが可能です。

Extension methodsとの違い

Extension methodsは似たような機能を持ちます。
ただ、Extension methodsは基になる型に対してメソッドなどを直接追加する一方、Extension typesは静的型(static type)である基本型を対象にする構文のみを追加できます。

Extension typesの使い道

以下のように、Extension typesは2つ見た目が全く同じだけど中身が全く違うユースケースがあります。

既存の型のインターフェイスを拡張する

implementsを利用して実装する場合、そのExtension types透過的(transparent)と考えても良いでしょう。
なぜなら、implementsを通して基になる型を「丸見え」にしているからです。

extension type NumberT(int value) implements int {
  // 'int'でないことを明示的に宣言する
  NumberT get i => this;
}

void main () {
  // All OK: 透過性によって`Extension type`を`int`として使える
  var v1 = NumberT(1); // v1 type: NumberT
  int v2 = NumberT(2); // v2 type: int
  var v3 = v1.i - v1;  // v3 type: int
  var v4 = v2 + v1; // v4 type: int
  var v5 = 2 + v1; // v5 type: int

  // Error: 基になる型は`Extension type`のメンバーにアクセスできない
  v2.i;
}

既存の型と異なるインターフェイスを提供する

implementsを使わない(不透明な)Extension typesは、基になる型と異なる、全く新しい型として扱われます。
これにより、基になる型にアサインできなく、内部のメンバーにもアクセスできません。

extension type NumberE(int value) {
  NumberE operator +(NumberE other) => NumberE(value + other.value);

  NumberE get myNum => this;

  bool isValid() => !value.isNegative;
}

void testE() {
  var num1 = NumberE(1);
  int num2 = NumberE(2); // Error: 'NumberE'を'int'にアサインできない

  num.isValid(); // OK: `NumberE`のメンバーを呼び出せる
  num.isNegative(); // Error: 'NumberE'に'isNegative'が定義されていない

  var sum1 = num1 + num1; // OK: 'NumberE'に'+'が定義されている
  var diff1 = num1 - num1; // Error: 'NumberE'に'-'が定義されていない
  var diff2 = num1.value - 2; // OK: 'value'は`int`型である
  var sum2 = num1 + 2; // Error: 'NumberE'は`int`型ではない

  List<NumberE> numbers = [
    NumberE(1),
    num1.next, // OK: 'next'の戻り値は'NumberE'である
    1, // Error: 'int'を'NumberE'にアサインできない
  ];
}

利用する際の注意点

冒頭で述べたような、Extension typesはコンパイル時に型を決定するため、実行時には表示型(基になる型)に扱われます。
すなわち、動的型チェック(e is T)、型変換(e as T)、および他のランタイム時処理(switch (e) ...if (e case ...) など)は、すべて表示型として実行されます。

そのため、Extension typesはランタイム時でも意図せずに基になる型にアクセスすることができてしまい、厳密な型安全ではありません。

まとめ

Extension typesはパフォーマンスが高い、使いやすいなどのメリットがあり、高級版のExtension methodsとも言えるでしょう。
ラッピングオブジェクト(例えばDDDのドメインモデル)を作成するときによく使われると考えています。

ただ、ランタイムには基になる型と混淆しごちゃごちゃになる可能性があるため、扱う際に諸々注意を払う必要があるかなと思います。

脚注
  1. オブジェクトとして扱いたい対象を、クラスとして定義したもの。 ↩︎

GitHubで編集を提案

Discussion

Cat-sushiCat-sushi

特に重いオブジェクトの場合にはコストを大幅に削減することが可能です。

軽い、の誤りではないでしょうか。
通常ラッパオブジェクトのサイズは同じ(リファレンスひとつ)なので、ラップされるオブジェクトが軽いほどオーバヘッドが相対的に重くなります。
特にintの場合は通常リファレンスを格納する領域(ポインタ)に直接格納するとう最適化を行うことが多いので、おそらくラッパオブジェクトの方が何倍も大きいことになります。

もっとも、extension typeの醍醐味はFFI等における他言語オブジェクトに対する透過的かつ概ね型安全なアクセスを提供することにあると思います。
例えば、package: webのAPIを読むと、Javascriptオブジェクトに対するextension typeが多数あります。
他言語オブジェクトそのものではありませんが、JSONをデコードした結果のMap<String, dynamic>にも同様のことが言えます。
手前味噌ですが、Dart 3.3で導入したextension typeのユースケース - Qiitaを参照ください。

RyaRya

コメントありがとうございます!
直感的にわかりやすく伝えるため「重い」という表現を用いました。
また、extension typeの醍醐味とWEB APIについては全くおっしゃる通りです。この記事は言語の特性そのものをメインテーマにしたく、あえて紹介しませんでした。補足していただきありがとうございます!