きちんと理解する Dart3 の Patterns

先日の Flutter3.10.0 のリリースに合わせて、Dart3 がリリースされ、待望だった records, patterns, sealed class などが実装されました。

中でも patterns は適用できる場所が多く使われ方も多様なので、特に Dart の書き方に大きな影響を与える変更だと思います。

この記事では便利な反面複雑でとっつきづらい patterns をできるだけわかりやすく紹介します。

サンプルコードは 公式のドキュメント を引用しています。 原典が気になる人はそちらを参照してください。

Dart の expressionsstatements

このセクションは patterns の説明とは直接関係があるわけではないですが、 先に読んでもらえると patterns の後の説明がより腑に落ちやすくなると思います。

プログラミング言語における形式的な構造のルールを構文と呼びます。例えば Dart では関数の構文は以下のような形であると決まっています。

<返り値の型※> <関数名>(<引数>) async※ {<関数の本体>}
<返り値の型※> <関数名>(<引数>) async※ => <関数の返り値>

// ※は省略可

これはいわば Dart のコンパイラとプログラマの間の約束事であり、このルールに則っていることがコードを書く上での最低条件になります。

Dart にはたくさんの構文がありますが、中でも代表的なものは 式(expressions)文(statements) です。

式は大雑把に言ってしまうと「値を返すもの」です。例えば以下のようなものは式にあたります。

それに対して文は、「値を返さないが特定の処理を行うもの」です。例えば以下のようなものは文にあたります。

普段プログラマが書いている Dart のコードの大部分は基本的に式か文のどちらかにあたります。 特に関数のボディ内にかけるのは必ず式か文のどちらかです。例えば enum A {} は式でも文でもないので関数のボディ内には書けません。

例外的に switch は、Dart3 から式としても文としても使うことができます。 式として使うか文として使うかで全然書き方が違うので、きちんと別物として区別しておく必要があります。


patterns とは何か

patterns は Dart に新しく追加された構文の種類です。 式っぽく動く patterns も文っぽく動く patterns もあるので混同しがちですが、 あくまで patternspatterns なので全くの別物として理解する方が混乱しないと思います。

大きく分けて 2 つの機能があり、それが マッチング値の分解 です。

マッチング

switch (number) {
  // Constant pattern matches if 1 == number.
  case 1:
    print('one');
}

マッチングの代表的な例は case pattern です。特定の条件に合致するかどうかを検証することができます。 これ以外にもさまざまな条件をマッチングに用いることができます。

値の分解

var numList = [1, 2, 3];
// List pattern [a, b, c] destructures the three elements from numList...
var [a, b, c] = numList;
// ...and assigns them to new variables.
print(a + b + c);

patterns を使うことで、Listrecord などの特定のオブジェクトを分解しつつ、値を変数に取り出すことができます。

マッチングと値の分解は組み合わせて使うこともでき、「マッチングした値を分解して変数に代入する」「分解した値をマッチングに利用する」などの操作も可能です。

基本的に patterns の使い方はこの二つですが、いくつかの種類と組み合わせ方法があるので、それぞれサンプルコードを見ながら確認していきましょう。


patterns はどこで使えるか

変数宣言

// Declares new variables a, b, and c.
var (a, [b, c]) = ('str', [1, 2]);

変数宣言の左辺に patterns を使うことで、record や List に含まれる値を取り出して別々の変数に入れることができます。

あくまで変数宣言の左辺に使えるだけで、いきなり patterns を書けるわけではないため、以下のようなコードはエラーになります。

(var a, [var b, var c]) = ('str', [1, 2]);

変数代入

var (a, b) = ('left', 'right');
(b, a) = (a, b); // Swap.
print('$a $b'); // Prints "right left".

すでに宣言した変数へ値の代入をすることができます。

Switch 式と Switch 文

switch (obj) {
  // Matches if 1 == obj.
  case 1:
    print('one');

  // Matches if the value of obj is between the constant values of 'first' and 'last'.
  case >= first && <= last:
    print('in range');

  // Matches if obj is a record with two fields, then assigns the fields to 'a' and 'b'.
  case (var a, var b):
    print('a = $a, b = $b');

  default:
}

switch は式と文どちらとして使っても patterns を使うことができます。 後述するすべての種類の patterns が使えるので、マッチングの使い方としてはこの switch との併用がメインになると思います。

if-case

if (pair case [int x, int y]) return Point(x, y);

if 文にも patterns を使うことのできる if-case という文法が追加されました。一つの patterns にマッチングさせたい時に switch 文よりもシンプルに使えそうです。

同じく collection-if の中でも patterns を使うことができます。

var nav = ['Home', 'Furniture', 'Plants', if (login case 'Manager') 'Inventory'];

ちなみに三項演算子では patterns を使うことができないようです 。

Irrefutable patternRefutable pattern

マッチングが失敗することを許容するかどうかで、 patterns は 2 種類に分けることができます。

変数宣言や変数の代入など、そもそもマッチングが失敗することが文法的に許されない patterns のことを Irrefutableswitchif-case など、マッチングが失敗しても処理を続ける(もしくは単にスキップする)ことのできる patterns のことを Refutable と呼びます。

Irrefutable で無効な patterns を書くと基本的にコンパイルエラーになりますが、 型のキャストや Null チェックなど一部の patterns に関してはランタイムでエラーを投げる仕様になっているので注意が必要です。


patterns の種類

公式のドキュメントでは 15 種類の patternsが紹介されているので、順番に見ていきましょう。

Logical-or

通常の if 文と同様に ||or を表現できます。

var isPrimary = switch (color) {
  Color.red || Color.yellow || Color.blue => true,
  _ => false
};

|| で繋がれているのは patterns なので複雑な組み合わせにも対応しています。。

final isRed = switch (color) {
  Color.red || Color(:final code) when code == '#F00' => true,
  _ => false
};

Logical-and

通常の if 文と同様に &&and を表現できます。 || よりも優先して評価されます。

switch ((1, 2)) {
  // Error, both subpatterns attempt to bind 'b'.
  case (var a, var b) && (var b, var c): // ...
}

Parenthesized

() をつけると ||&& より優先して評価することができます。

// ...
x || y && z => 'matches true',
(x || y) && z => 'matches false',
// ...

Relational

数値などの比較ができます。比較演算子の左辺が省略されたような書き方です。

String asciiCharType(int char) {
  const space = 32;
  const zero = 48;
  const nine = 57;

  return switch (char) {
    < space => 'control',
    == space => 'space',
    > space && < zero => 'punctuation',
    >= zero && <= nine => 'digit',
    _ => ''
  };
}

Cast

指定した型にキャストできるときだけマッチングします。

下のサンプルコードで使われている変数宣言の patternsIrrefutable なので、キャストに失敗した場合はエラーが throw されます。

(num, Object) record = (1, 's');
var (i as int, s as String) = record;

switch 文や if-case 文などのマッチングの場合は後述する Variable patternsWildcard patterns の組み合わせで同様のことが可能です。

switch (record) {
  case (int _, String _):
    print('First field is int and second is String.');
}

Variable

マッチングした場合にだけ使える変数を宣言することができます。

通常の変数宣言と同様に var final などのキーワード以外にも型を使った宣言が可能です。型を使った宣言をしたときに型が合わなかった場合はマッチングしません。

switch ((1, 2)) {
  // 'var a' and 'var b' are variable patterns that bind to 1 and 2, respectively.
  case (var a, var b): // ...
  // 'a' and 'b' are in scope in the case body.
}

when キーワードを使うことで、マッチングした Variable を使ってさらに絞り込みをすることができます。

switch ((1, 2)) {
  case (var a, var b) when a.isOdd: // ...
}

Identifier

patterns の中で最も複雑でややこしいのがこの Identifier です。どこで使われるかによって意味合いが大きく変わります。

  1. 変数宣言の中で使われるとき
var (a, b) = (1, 2);
  1. 変数代入の中で使われるとき
int a;
int b;
(a, b) = (1, 2);
  1. マッチングの中で使われるとき
const c = 1;
switch (2) {
  case c:
    print('match $c');
  default:
    print('no match'); // Prints "no match".
}
  1. ワイルドカードとして使うとき

いづれのタイミングでも _ を使うことができます。


1. の変数宣言の中で使われるときの Identifier patternVariable pattern と混同しがちなので注意が必要です。

例えば

var (a, b) = (1, 2);

このようなコードを書くと、 patterns として扱われるのは (a, b) 部分のみですが、

switch ((1, 2)) {
  case (var a, var b):
    ...
}

こちらの場合は (var a, var b) 全体が patterns として扱われます。

前者のコードは Identififer pattern と通常の変数宣言の組み合わせであり、後者のコードは Variable pattern です。

以下のようなコードは通らないので気をつけましょう

(var a, var b) = (1, 2);
switch ((1, 2)) {
  case var (a, b):
    ...
}

Null-check

Variable patterns でマッチングした変数に ? をつけることで null チェックをすることができます。 Variable patterns でマッチングした変数が null だった場合はマッチングしません。

String? maybeString = 'nullable with base type String';
switch (maybeString) {
  case var s?:
  // 's' has type non-nullable String here.
}

Null-assert

Null-check と同様に null チェックをすることができます。 Variable patterns でマッチングした変数が null だった場合はエラーが throw されます

List<String?> row = ['user', null];
switch (row) {
  case ['user', var name!]:
  // 'name' is a non-nullable string here.
}

Constant

定数を使ったマッチングです。

switch (number) {
  // Matches if 1 == number.
  case 1: // ...
}

List

List の中身を分解してマッチングさせることができます。 List の長さが違う場合はマッチングしませんが、長さが不明な場合でも次の Rest element を使うことで、「0 番目の要素だけ」などにマッチングさせることができます。

switch (obj) {
  // Matches if obj is a list with two elements.
  case [a, b]: // ...
}

Rest element

... を List の patterns に使うことで、明示的に書かなかった残りの部分にマッチングさせることができます。

var [a, b, ..., c, d] = [1, 2, 3, 4, 5, 6, 7];
// Prints "1 2 6 7".
print('$a $b $c $d');

... の前に変数名を入れることで、残りの部分にマッチングした値の List をとることができるようになります。

var [a, b, ...rest, c, d] = [1, 2, 3, 4, 5, 6, 7];
// Prints "1 2 [3, 4, 5] 6 7".
print('$a $b $rest $c $d');

Map

List と同様に Map の中身をマッチングさせる事ができます。 List とは対照的に、すべての要素が明示されている必要はありません。

if (json case {'user': [String name, int age]}) {
  print('User $name is $age years old.');
}

Record

Dart3 から導入された record にも patterns を使う事ができます。

var (name, age) = ('Bob', 20);
var (myString: foo, myNumber: bar) = (myString: 'string', myNumber: 1);

名前付き record にマッチングするときには <マッチングしたいフィールド名>: <マッチングした値を入れる変数名> の形でマッチングさせることができます。

ただし<マッチングしたいフィールド名><マッチングした値を入れる変数名> が同名の場合、前者を省略することが可能です。

注意点として、 文法の都合上 : は省略できません。

var (myString: myString, myNumber: myNumber) = (myString: 'string', myNumber: 1);

// ↓ こう書ける

var (:myString, :myNumber) = (myString: 'string', myNumber: 1);

Variable pattern の場合も同様に省略が可能です。

switch ((myString: 'string', myNumber: 1)) {
  case (myString: final myString, myNumber: final myNumber):
  ...
}

// ↓ こう書ける

switch ((myString: 'string', myNumber: 1)) {
  case (:final myString, :final myNumber):
  ...
}

Object

クラスのインスタンスに対してマッチングすることができます。フィールドに関しても record と同様に変数として取り出すことが可能です。

switch (shape) {
  // Matches if shape is of type Rect, and then against the properties of Rect.
  case Rect(width: var w, height: var h): // ...
}

Wildcard

Variable patternIdentifier pattern で、マッチングはさせたいけど変数として使うわけじゃないものに関しては _ を使うことで任意の値にマッチングさせつつ変数宣言はスキップすることができます。

var list = [1, 2, 3];
var [_, two, _] = list;

実用例

patterns を使うと便利なケースをいくつか紹介します。

Future.wait の書き換え

これまでの Future.wait を使った Future の並列化では各 Future の型情報が抜け落ちてしまっていましたが、

final stringFuture = Future.value('Hello');
final numberFuture = Future.value(1);

final results = await Future.wait([stringFuture, numerFuture]);

final stringResult = results[0]; // ← Object 型
final numberResult = results[1]; // ← Object 型

patternsrecord と組み合わせて使うことで型情報を保ったまま並列化をすることができます。

final stringFuture = Future.value('Hello');
final numberFuture = Future.value(1);

final (stringResult, numberResult) = await (stringFuture, numerFuture).wait;

json のバリデーション


まとめ

patterns は初見だと読み書きに少し戸惑いますが、使いこなせるととても便利なので、実用例に挙げたものを少しずつ使ってみるところからでも試してみてください!