KabologHomeXGitHub

Dart 3のリリースに備えて - record型とpatterns

Dart 3 の新機能に関するシリーズの後編です。
前編の switch の新機能はこの記事にも関係するので、把握していない方はあわせてお読みください。

record 型とは

複数の値をまとめて一つの値として扱いたいときに使える型です。
Dart 3 未満まではクラスを作るか、コレクション(List、Map、Set)を使うか、tuple 等のパッケージを使うしかなくて、クラスの場合は特に冗長になって面倒でした。

どんな感じ?

三つの数を受け取り、合計値と平均値の record を返す関数の例です。

({int sum, String average}) calculate(int a, int b, int c) {  
  final sum = a + b + c;  
  final average = (sum / 3).toStringAsFixed(2);  
  return (sum: sum, average: average);  
}  
final r = calculate(2, 5, 7);  
print(r);         // (14, 4.67)  
print(r.sum);     // 14  
print(r.average); // 4.67  

もし代わりに List 型で返すと、二つの要素の型が異なるせいで List<Object> になってしまい、本来の型に戻すためにキャストしなければなりません。
一方 record 型では、フィールド数が決まっていてフィールドごとに型が明確になっているので、取り出してそのまま使えます。

record 型の詳細

dart:core に Record クラスがあり、どの record 型もこれのサブタイプになります。
定義は次のようになっていて、Object クラスから継承したメンバがあるだけです。

abstract final class Record {  
  Type get runtimeType;  
  int get hashCode;  
  bool operator ==(Object other);  
  String toString();  
}  

フィールド

フィールドは関数の引数と似ていて、名前付きと positional があります。
名前付きフィールドの順序が自由であることや positional なフィールドを名前付きの前にも後ろにも置けることも引数と同様です。

record は immutable なのでフィールドの値を書き換えることはできません。
mutable なオブジェクトへの参照を持つことは可能です。

ゲッター

名前付きフィールドではゲッター名はフィールド名と同じです。

final r = (name: 'Dart', version: 3.0);  
print(r.name);    // Dart  
print(r.version); // 3.0  

positional なフィールドでは 1 から始まる番号の前に $ が付いた $1$2 ... となります。

final r = ('Dart', 3.0);  
print(r.$1); // Dart  
print(r.$2); // 3.0  

名前付きと positional が混ざっている場合は次のようになります。

final r = (1.2, name: 's', true, count: 3);  
print(r.$1);    // 1.2  
print(r.name);  // s  
print(r.$2);    // true  
print(r.count); // 3  

なお、(1, 2) のフィールドのゲッターは $1$2 ですが、内部的に ($1: 1, $2: 2) になっているわけではないので (int, int) r = ($1: 1, $2: 2); は不可です。

セッター

先述のとおり record 型は immutable なので、セッターはありません。

record を作るときの制約

いくつかの制約がありますが、それ以外はフィールドは関数の引数にそっくりです。
制約は次のとおりです

  • 同じフィールド名は複数回使えない
  • positional なフィールドが一つだけなら後ろにカンマが必要
    • (int) r = (10,); はエラー
      • (int,) r = (10,); としなければならない
    • final r = (10); は括弧で囲まれた int の値として扱われるので OK
    • final (int,) r = (10); は int 型を record 型の変数に入れようとして実行時エラー
  • (,) は不可
    • final empty = (); は OK
      • () はフィールドのない空の record の定数
  • 特殊な名前はフィールド名として使えない
    • Record クラスのメンバと同じ名前(hashCode 等)
    • アンダースコアで始まる名前
    • positional なフィールドのゲッターと衝突する名前
      • ('a', $1: 'b') は不可
      • ($1: 'a', $2: 'b')('a', $2: 'b') は OK

型アノテーション

型アノテーションとは型を示すために書く部分のことです。
例えば List<String> name;List<String> の部分です。
record 型のアノテーションは () で囲います。

(int, String name, bool) triple; // nameはオプショナルで単に説明用  
({int n, String s}) pair;  
(bool, num, {int n, String s}) quad;  

関数型の引数の書き方(例: void Function(int, {String name}))と同じですが、次の点だけ異なります。

  • required はない
  • 省略可能な positional のフィールドはない

制約

record 型のアノテーションには record を作るときと同じ制約があります。

しかし書き方は異なるので注意しましょう。
record を作るとき、型アノテーションを書くとき、record pattern(後述)を書くときの書き方はそれぞれ異なることを意識して読んでいただくと混同するのを避けやすいと思います。

typedef や extension

他の型と同様に別名を付けたりメソッドを生やしたりすることができます。
でも下記のようなことをするならクラスのほうが適していると思います。

typedef Date = ({int year, int month, int day});  
  
extension on Date {  
  Date add({required int days}) {  
    final dateTime = DateTime(year, month, day).add(Duration(days: days));  
    return (year: dateTime.year, month: dateTime.month, day: dateTime.day);  
  }  
}  
final today = (year: 2023, month: 5, day: 10);  
print(today.add(days: 100)); // (day: 18, month: 8, year: 2023)  

型リテラル

式として使える型リテラルはありません。

Type t1 = int;           // OK  
Type t2 = (int, String); // エラー  

(int, String) は int 型のリテラルと String 型のリテラルを持つ record(Type 型のフィールドを二つ持つ record)になり、record 型のリテラルになりません。

(Type, Type) r = (int, String);  

同一性

record は構造的に型付けられます。
宣言や命名が不要で、shape とフィールドの型が一致すれば同じ record 型と認識されます。
shape とは、positional なフィールドの数(arity)と名前付きフィールドの名前のことです。

  • (x: 1, 2) == (x: 1, 2) は true
  • 名前付きフィールドの順序は shape に含まれないので (x: 1, 2) == (2, x: 1) も true
  • 異なる library にある他の人が作った record に shape とフィールドの型が一致すれば同じ record 型として使えるので、クラスを使う場合より柔軟に扱える

実行時の型

record の実行時の型はフィールドの実行時の型で決まります。
下記コードでは変数は (num, Object) で宣言されていますが、実行時には (int, double) になります。

(num, Object) pair = (1, 2.3);  
print(pair.runtimeType);      // (int, double)  
print(pair is (int, double)); // true  

古い Dart での利用

Dart 3 に対応したライブラリ(誰かが作ったパッケージなど)で record 型が使われている場合、Dart 3 未満のライブラリで読み込んでその record 型を利用することが可能です。

制限

使えない場所がある

record 型やエイリアスは次の場所で使えず、使うとコンパイル時にエラーになります。

  • extends、implements、with の各節
  • mixin の宣言の on 節

extension の on では先ほどの例のように使えます。

フィールドの走査はできない

List、enum 等の要素・フィールドは走査できますが、record のフィールドはできないようです。
これができないと <T extends Record> のジェネリックな record からフィールドを取り出せません。
クラスのプロパティや関数の引数もできないので、それと同様ということかなと思います。

void someFunc<T extends Record>(T record) {  
  // せっかく受け取っても活用できない  
}  

フィールドの展開も(今は)できない

今はまだできませんが、将来的に可能になりそうです。
2023 年 1 月に開催された Flutter Forward の「Bringing pattern matching to Dart」という動画(8:02 あたり~)によると、サポートすることを考慮して record 型は設計されているそうです。
引数リスト内で record をスプレッド演算子(...)によって展開してフィールドの値を引数に直接渡すことまでできるようになりそうです。

Tips

await

フィールドが全て Future の場合に限りますが、並行的に実行して結果を待つことができます。
Future.wait() と違って結果をキャストして元の型に戻す手間もなく使えて便利です。

Future<int> futureTask1() async { ... }  
Future<String> futureTask2() async { ... }  
final (int i, String s) = await (futureTask1(), futureTask2()).wait;  

patterns

値がパターンにマッチする場合に値を分解(destructure)して取り出すことができる機能です。
この記事では record に特に関連するパターンを取り上げます。
record 以外の一部のパターンは 前の記事 に少し書いていますのでお読みください。

いくつかのパターンを紹介しますが、その前に単純な例を見て雰囲気を掴みましょう。

record のフィールドの値を取り出して変数に入れる例

(int i, String s) というのがパターンで、各フィールドの取り出した値が i と s に入ります。

final (int i, String s) = (123, 'abc');  
print(i); // 123  
print(s); // abc  

下記では (123, 'abc')(int i, double d) というパターンに一致しないので、コンパイル時にエラーになります。

final (int i, double d) = (123, 'abc');  

if-case で該当した場合にフィールドの値が取り出される例

final r = (123, 'abc');  
  
if (r case (int i, String s)) {  
  print("(int $i, String '$s')");  
}  

record に関連するいくつかのパターン

Variable pattern

いま上で見た例がこのパターンです。

パターンの各フィールドのところには型アノテーションか var と final のいずれかが必要です。
ただし、宣言時は変数単位で var や final を指定できません。

  • OK
    • case (final int a, final int b)
    • case (int a, int b)
    • case (final a, var b)
    • case (int _, int _)
  • 不可
    • case (var int a, var int b)
    • case (a, b)

宣言時:

  • OK
    • final (int a, int b) = (1, 2);
    • final (a, b) = (1, 2);
    • final (int _, int _) = (1, 2);
    • final (_, _) = (1, 2);
  • 不可
    • final (final int a, final int b) = (1, 2);
    • final (var a, var b) = (1, 2);
    • final (var int a, var int b) = (1, 2);
    • (int a, int b) = (1, 2);

switch や if の case と異なり、for-in では宣言時のルールが適用されるようです。

final countries = [  
  (code: 'JP', country: 'Japan'),  
  (code: 'CA', country: 'Canada'),  
  (code: 'GR', country: 'Greece'),  
];  
  
for (final (:code, :country) in countries) {  
  print('[$code] $country');  
}  

試してみると、for ((:var code, :var country) in countries) とは書けず、括弧の前に var か final が必要で、括弧の中では var も final も使えませんでした。
また、countries という List の要素は全て同じ record 型でなければエラーとなりました。

patterns にはこのような使われる文脈の種類があり、文脈によって書き方が異なることがあります。
記事の最後で解説します。

ワイルドカード

final (_, _) = (1, 2); のようにワイルドカードとしてアンダースコアを使うことができます。
型でマッチさせたいけれど値を変数名に紐づけなくていい場合に役立ちます。

一つのパターンで複数回使うとき、___ のようにアンダースコアの数をずらす必要はありません。

型によるマッチングに使う

ワイルドカードを使っても使わなくても型でマッチさせることはできますが、destructuring が不要で型のマッチングだけが必要な場合にワイルドカードを使えば意図がわかりやすくなります。

switch (r) {  
  case ((_, int _)):  
    print('${r.$2} is of type int.');  
  case ((_, double _)):  
    print('${r.$2.toStringAsFixed(2)} is of type double.');  
}  

なお、型によるマッチングは object pattern でもできます。

switch (r) {  
  case ((_, int())):  
    print('${r.$2} is of type int.');  
  case ((_, double())):  
    print('${r.$2.toStringAsFixed(2)} is of type double.');  
}  

可変性

final (a, b) = (1, 2); のようにして取り出した値が入る変数の mutability は ( の前の varfinal で決まるようです。
フィールドごとに指定できないのは不便に思うときがあるかもしれません。

var (i,) = (10,);  
i = 20; // 可能  
final (i,) = (10,);  
i = 20; // エラー  

swap を簡潔に書けるようになるのは嬉しいです。

var (a, b) = ('left', 'right');  
(b, a) = (a, b);  
print('$a $b'); // right left  

Cast pattern

final (int i, String s) = (123, 'abc');  

これは可能ですが、

final (num, Object) r = (123, 'abc');  
final (int i, String s) = r;  

これはパターンがマッチしないのでできません。
少し変えて下記のようにパターンのところでキャストすると可能になります。

final (i as int, s as String) = r;  

ただしキャストできなければ実行時にエラーが起こります。
r.$1 は num を指定しているものの実行時の型は int なので、double にキャストしようとするとエラーになります。

final (d as double, _) = r; // エラー  

キャストが可能かわからない場合は switch や if-case で型によって分岐するほうが安全です。
少し上のワイルドカードの部分で説明していますので、飛ばした方は戻ってお読みください。

Null-assert pattern

! を付けた null-assert pattern では、パターンマッチングしつつ null でないことを断定できます。

/// フィールドが nullable なrecordを返す  
(int?, int?) recordWithNullables() {  
  final nullable = math.Random().nextInt(2).isEven;  
  return (nullable ? null : 1, nullable ? null : 2);  
}  
if (recordWithNullables() case (int a!, int b!)) {    
  print(a * b);    
}  

変数 a と b が null の可能性があれば a * b という計算はできないはずですが、パターンにマッチすると null でないことが断定済みとなって計算が可能になります。

なお、(int a!, int b!) というパターンは、record の二つのフィールドの片方または両方が null の場合も両方が non-nullable の場合もマッチしますが、片方だけでも null であればエラーになります。
null が入っている変数に対して ! を使ったときと同じエラーです。
null でないことが明らかな場合のみ使いましょう。

Null-check pattern

呼称が null-assert pattern と似ていて紛らわしいですが、こちらは ? を用います。

if (recordWithNullables() case (final a?, final b?)) {  
  print(a * b);  
}  

この場合、フィールドが二つとも null でない場合のみマッチし、a と b は non-nullable になります。
そうでなければマッチしないので if ブロック内は実行されません。

別の方法

null-check の代わりに nullable でない型(下記では int)を指定しても null でない場合のみマッチさせることができます。

if (recordWithNullables() case (final int a, final int b)) {  
  print(a * b);  
}  

Record pattern

record と同じ shape のパターンであればマッチして destructure が行われるというものです。
ここまで他のパターンの例として見てきた case (int a, int b) のような形は record pattern でもあります。

名前付きフィールドはまだ見ていませんでしたので、ここではそれを把握しましょう。

final dimension = (width: 100, height: 200);  
if (dimension case (width: final w, height: final h)) {  
    print('$w x $h');  
}  

前の記事 の object pattern によく似ています。
ゲッター名を識別子として後ろにコロンを付けた「識別子:」の形で対応させる名前付きフィールドを指定します。
名前付きフィールドはパターンにおいても順序は任意です。
コロンの後ろでは case (width :final w as int, height :final h?) のように他のパターンを組み合わせることができます。

識別子の省略

destructure によって代入される変数の名前が識別子と同じなら識別子を省略できます。

final dimension = (width: 100, height: 200);  
if (dimension case (:final width, :final height)) {  
    print('$width x $height'); // 100 x 200  
}  

注意点

識別子の省略によって識別子が被ると当然エラーになります。
次の例では :xx という識別子が省略されている形なので x: y の識別子と被ります。

final (:x, x: y) = (x: 1);  

識別子を推論できない場合もエラーになります。

final (:int _) = (x: 1);  

部分的な destructuring

先ほどの dimension という record の例では width と height というフィールドを両方とも取り出しましたが、一部のフィールドの値を指定し、その値も含めてマッチする場合に他のフィールドの値をを取り出すことも可能です。

if (dimension case (width: 100, :final height)) {    
  print('100 x $height'); // 100 x 200  
}   

なお、次のようにするとエラーになります。

for (final (width: 100, :height) in dimensions) { // ここでエラー  
  print('100 x $height');    
}   

Refutable patterns can’t be used in an irrefutable context.

irrefutable な文脈(後述)では使えないというエラーです。
ちなみにこの width: 100 というパターンは、record pattern のサブパターンが constant pattern になっている形と言えると思います。

refutable と irrefutable

refutable は「否定できる」というような意味です。
パターンがマッチするかどうか検査できる(該当しなくてもエラーにならない)ことを意味します。
switch の case も refutable な文脈で、パターンにマッチしなければ実行がスキップされるだけです。

irrefutable はその逆で、マッチしなければエラーが起こります。

文脈の種類

patterns には 3 種類の文脈 があり、そのうちの二つが irrefutable です。

  • 宣言
    • irrefutable
    • 例: final (w, h) = (1, 2);
  • 代入
    • irrefutable
    • 例: (a, b) = (1, 2);
  • マッチング
    • refutable
    • 例: case (final a, final b)

case とそれ以外でパターンの書き方が違うことがあって戸惑いやすいですが、このような文脈の種類があることを意識すると理解しやすくなると思います。

irrefutable な文脈で使えないパターン

これらはマッチしないことがある refutable なパターンです。
使おうとするとコンパイル時にエラーになります。

この他に、型チェックが行われるパターン(variable pattern、list pattern など)も、マッチした値の static な型が代入先の型に合わない場合にコンパイル時エラーとなります。

参考資料

Dart の 更新履歴 に公式サイトの新しいページへのリンクがあるので、ここにも貼っておきます。
この記事を書いている時点ではまだ Dart 3.0 のリリース前なのでそれらのページは未公開です。

Xに投稿する