KabologHomeXGitHub

Dart 3のリリースに備えて - sealed型と進化したswitch

Dart 3 では多くの新機能が加わって進化しています。
その全てをすぐに使いこなすのは難しそうなので準備として数ヶ月前に仕様をひととおり読んだのですが、もう忘れてきているのでできる範囲で記事にすることにしました。

この記事は sealed 型の仕様書 をベースにし、他のいくつかのページのコード例も取り入れています。
コード例は少し改変しています。

sealed 型で可能になること

sealed を付けると、サブタイプを利用するときに網羅性をチェックできるようになります。

従来の方法

まず今までの書き方を見ましょう。
別のページ にあった例を持ってきました。

abstract class Shape {  
  double calculateArea();  
}  
  
class Square implements Shape {  
  final double length;  
  Square(this.length);  
  
  double calculateArea() => length * length;  
}  
  
class Circle implements Shape {  
  final double radius;  
  Circle(this.radius);  
  
  double calculateArea() => math.pi * radius * radius;  
}  

Shape のサブタイプである SquareCircle のそれぞれで calculateArea() を実装しています。
これはこれでいいのですが、実装が各クラスに分散して一ヶ所で管理できません。

下記のように Shape 側でサブタイプの分岐を行うこともできますが、記述が少し長くなります。
また、すべてを網羅している保証はないので、漏れたときのために throw する等の対策が必要です。

abstract class Shape {  
  double calculateArea() {  
    final self = this;  
    if (self is Square) {  
      return self.length + self.length;  
    }  
    if (self is Circle) {  
      return math.pi * self.radius * self.radius;  
    }  
    throw ArgumentError("Unexpected shape.");  
  }  
}  
  
class Square extends Shape {  
  final double length;  
  Square(this.length);  
}  
  
class Circle extends Shape {  
  final double radius;  
  Circle(this.radius);  
}  

sealed 型を使った方法

クラスの定義に sealed という修飾子を付けると、switch などでサブタイプの網羅をチェックできるようになるため、漏れがあればコンパイルエラーになって気づくことができます。

sealed class Shape {  
  double calculateArea() {  
    final self = this;  
    switch (self) {  
      case Square():  
        return self.length * self.length;  
      case Circle():  
        return math.pi * self.radius * self.radius;  
    }  
  }  
}  
  
class Square extends Shape {  
  final double length;  
  Square(this.length);  
}  
  
class Circle extends Shape {  
  final double radius;  
  Circle(this.radius);  
}  

switch の進化

switch はパターンマッチングや使い勝手のための改善が多く加えられて大きく進化しました。

patterns

これまでは case で指定するのは定数でなければなりませんでしたが、Dart 3 では様々な値(パターン)でマッチングを行うことが可能になりました。
パターンは 仕様書 によると従来の「式」と「文」に次ぐ第三の分類です。

"Expression" and "statement" are both syntactic categories in the grammar. Patterns form a third category. Like expressions and statements, patterns are often composed of other subpatterns.

かなり複雑なのでこの記事には詳しく書きません。
今までのような定数だけでなく(式と異なるけれど式のような)複雑な表現でパターンを示すことができると捉えておけばいいのではないかと思います。

先ほどの sealed 型を使った例では Square()Circle() は定数ではないのに case で使用できています。

destructuring

マッチングを行いながら destructure して値を取り出すことまでできるようになっています。

switch (shape) {  
  case Square(length: final l):  
    return l * l;  
  case Circle(radius: final r):  
    return math.pi * r * r;  
}  

取り出した値を入れる変数がコロンの前の名前(識別子)と同じなら識別子を省略できます。

switch (shape) {  
  case Square(:final length):  
    return length * length;  
  case Circle(:final radius):  
    return math.pi * radius * radius;  
}  

なお、この例は様々な patterns のうちの object pattern に該当します。

An object pattern matches values of a given named type and then extracts values from it by calling getters on the value. Object patterns let users destructure data from arbitrary objects using the getters the object's class already exposes.

他のパターン

この記事では他に relational pattern について後述します。

switch 式

switch を式として使うこともできるようになりました。
式では case のキーワードは使わずに パターン => 返す値 の形の簡潔な記述になります。
式として使わないときはこれまで通りの case を使った書き方です。

double calculateArea() {  
  return switch (this) {  
    Square(length: final l) => l * l,  
    Circle(radius: final r) => math.pi * r * r,  
  };  
}  

switch 文を式に変えるのは、IntelliJ IDEA では quick fix でできることを確認しました。
VS Code では確認していません。

break が不要に

sealed 型と直接的には関係しない余談ですが、switch の話になったのでついでに書きます。
Dart 3 では switch の case は fall through しなくなったため break; と書くのを省略できます。

sealed class Animals {  
  void speak() {  
    switch (this) {  
      case Cat():  
        print('Mew');  
      case Dog():  
        print('Bowwow');  
    }  
  }  
}  
  
class Cat extends Animals {}  
class Dog extends Animals {}  
void main() {  
  Cat().speak(); // Mew  
}  

一見すると互換性がなくなって困りそうですが、何の処理もない case は変わらず fall through するので、大抵の場合は影響がないと思います。

void speak() {  
  switch (this) {  
    case Cat():  
    case Dog():  
      print('Bowwow'); // CatでもDogでもここに到達  
  }  
}  
void main() {  
  Cat().speak(); // Bowwow  
}  

break の記述はオプショナルですが、unnecessary_breaks の lint ルールを有効にすると不要な場合に警告されるようになります。

本記事のこの先のコード例でも break は使われません。

比較演算子によるマッチング

==<= 等の比較演算子を使ったパターンでマッチングできるようになるのも大きな改善です。
これは様々な patterns のうちの relational pattern です。

relational pattern を sealed 型に対して活用する例があると良いのですが、思いつかないのでリンク先の例をそのまま貼ります。

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',  
    // Etc...  
  }  
}  

when

パターンにマッチした後に when による ガード節 で追加の評価を行うことができます。
そこが false なら次の case に移るので、複数の同じ case を書いて異なる when によって分岐させることが可能です。

switch (shape) {  
  case Square(length: var size) || Circle(radius: var size) when size > 0:  
    print('Non-empty symmetric shape (size: $size)');  
  case Square() || Circle():  
    print('Empty symmetric shape');  
  default:  
    print('Asymmetric shape');  
}  

when を使わずに次のように relational pattern によるマッチングと destructuring を同時に行うこともできます。

case Square(length: > 0 && final size) || Circle(radius: > 0 && final size):  

if-case

if も進化しているのでついでに書きます。

switch を使うまでもない場合に if-case で switch と同様の case でパターンを使うことができます。

if (shape case Circle(radius: final r)) {  
  final area = math.pi * r * r;  
}  

sealed 型の詳細

さて余談が多くなりましたが、sealed 型の詳細を見ましょう。

制限

  • 暗黙的に抽象クラスになるのでインスタンスを直接生成できない
  • 直接のサブタイプは同じ library 内で定義しなければならない

これらの制限によって網羅の把握が可能になったわけです。

同じ library とは

Dart では library は基本的に一つのファイルのことです。
part で分けている場合は分けたファイルも同じ library になります。
また、検討が進んでいる Augmentation Libraries が実際に導入されれば、それによって分割した部分も同一 library 内として扱われます。

sealed を付けたクラスが定義された library 以外でそれを継承、実装しようとするとコンパイルエラーになります。

library が異なる例

library1.dart

sealed class Either {}  

library2.dart

import 'library1.dart';  
  
class Left extends Either {} // The class 'Either' can't be extended, implemented, or mixed in outside of its library because it's a sealed class.  

library を part で分けた場合

library1.dart

part 'library2.dart';  
  
sealed class Either {}  

library2.dart

part of 'library1.dart';  
  
class Left extends Either {}  

これは同一ライブラリになるので OK です。

直接のサブタイプとは

sealed class Either {}  
  
class Left extends Either {}  
class Right extends Either {}  

LeftRight は sealed 型である Either を直に継承しているので直接のサブタイプです。
switch でマッチングのパターンとして Left()Right() を書けば網羅していることになります。

String leftOrRight(Either either) {  
  return switch (either) {  
    Left() => 'Left',  
    Right() => 'Right',  
  };  
}  

もし Left を継承したクラスを作って switch で Left() の代わりに使うと、Either に対して網羅していないことになります。

class LeftOut extends Left {}  
return switch (either) { // The type 'Either' is not exhaustively matched by the switch cases since it doesn't match 'Left()'.  
  LeftOut() => 'Left',  
  Right() => 'Right',  
};  

つまり、網羅性のチェックを機能させるには sealed 型を直に継承/実装した型を使ったパターンでなければならないということです。

階層構造

sealed 型の Either の子である Left も sealed 型にすることができます。
そうすると LeftOut も sealed 型の直接のサブタイプになります。

sealed class Either {}  
  
sealed class Left extends Either {}  
class Right extends Either {}  
  
class LeftOut extends Left {}  

階層構造は次のようになり、EitherLeft が sealed 型なので LeftOutRight によるパターンマッチングでも網羅したことになります。

Either --+-- Left ----- LeftOut  
         |  
         +-- Right  

この例はあまり意味がなくてわかりにくいので、sealed の仕様書に書かれている例 を紹介します。

sealed class UnitedKingdom {}  
  
class NorthernIreland extends UnitedKingdom {}  
sealed class GreatBritain extends UnitedKingdom {}  
  
class England extends GreatBritain {}  
class Scotland extends GreatBritain {}  
class Wales extends GreatBritain {}  

階層構造:

UnitedKingdom --+-- NorthernIreland  
                |  
                +-- GreatBritain --+-- England  
                                   |  
                                   +-- Scotland  
                                   |  
                                   +-- Wales  

UnitedKingdomGreatBritain が sealed 型なので、UnitedKingdom の子である NorthernIrelandGreatBritain だけでなく GreatBritain の子である EnglandScotlandWales を組み合わせたパターンでも網羅性チェックが効きます。

String name1(UnitedKingdom uk) {  
  return switch (uk) {  
    NorthernIreland() => 'Northern Ireland',  
    GreatBritain() => 'Great Britain'  
  };  
}  
String name2(UnitedKingdom uk) {  
  return switch (uk) {  
    NorthernIreland() => 'Northern Ireland',  
    England() => 'England',  
    Scotland() => 'Scotland',  
    Wales() => 'Wales',  
  };  
}  
String name3(GreatBritain britain) {  
  return switch (britain) {  
    England() => 'England',  
    Scotland() => 'Scotland',  
    Wales() => 'Wales',  
  };  
}  

クラス修飾子

新しいクラス修飾子sealed の他にもいくつかあります。
仕様書の Syntax のところにある sealed も含めた修飾子のわかりやすい表をそのまま貼ります。

Declaration Construct? Extend? Implement? Mix in? Exhaustive?
class Yes Yes Yes No No
base class Yes Yes No No No
interface class Yes No Yes No No
final class Yes No No No No
sealed class No No No No Yes
abstract class No Yes Yes No No
abstract base class No Yes No No No
abstract interface class No No Yes No No
abstract final class No No No No No
mixin class Yes Yes Yes Yes No
base mixin class Yes Yes No Yes No
abstract mixin class No Yes Yes Yes No
abstract base mixin class No Yes No Yes No
mixin No No Yes Yes No
base mixin No No No Yes No

sealed class は Construct、Extend、Implement、Mix in が No になっていて Exhaustive だけが Yes です。
Extend 等が No なのが変に思えますが、他の修飾子の組み合わせに関して次のように書かれているので、その意味でできないということかもしれません。

sealed types already cannot be mixed in, extended or implemented from another library.

基本の型も sealed?

bool, double, int, Null, num, String といった dart:core で定義されている型はもともと特殊なケースの制約によって sealed と同様の挙動になっていたようです。

sealed 型の導入によってそのような特別な扱いをせずに sealed 型として取り扱うことができるようになるそうですが、ソースコードを見るとまだほとんどそうなっていなくて num にのみ sealed が付いていました。

sealed class num implements Comparable<num> {  
Xに投稿するこのエントリーをはてなブックマークに追加