Swiftコンパイラで採用されているパターンマッチの網羅性チェックの理論と実装

初めまして、@ukitakaです。主にiOS/Swift界隈に生息していて、型についての理解を深めるべく型システム入門を片手に日々Swiftコンパイラの実装を読んで勉強をしています。
今年も型システムには入門失敗しましたが、コードリーディングで得た知見を元にがんばって言語実装アドカレに投稿してみようと思います。。

Swiftではswitch文を使ってパターンマッチを行うことができ、以下のような少し複雑なパターンでも(defaultケースなしに)パターンが網羅されていることをコンパイラが認識してくれます。

enum MyEnum {
    case a
    case b(Bool)
}

let myEnumOpt: MyEnum? = MyEnum.a

switch myEnumOpt {
case .some(.a):
    print("A")
case .some(.b(true)):
    print("true")
case .some(.b(false)):
    print("false")
case .none:
    print("none")
}

逆に、以下のように足りないパターンがあるとコンパイルエラーにしてくれます。

let myEnum: MyEnum = .a

// error: switch must be exhaustive
// note: add missing case: '.b'
switch myEnum {
case .a: print("A")
}

この記事ではこのチェックがどのように実現されているか、背景にあるSpaceという概念を元にしたパターンマッチの網羅性チェックの理論とその実装について、lib/Sema/TypeCheckSwitchStmt.cppとソースコード中で紹介されている論文を元に紹介してみたいと思います。

もちろんすべてを解説すると長くなりすぎてしまうので、「Swiftコンパイラでの実装が読めるようになる」ことをゴールに、必要な部分をかいつまんで説明します。

Spaceの導入とsubspace関係を使った"網羅"の定式化

説明は後回しにして、先にコアとなる式を書いてしまいます。

let t: T = ...

switch t {
case p1: ...
case p2: ...
...
}

こんなSwiftのプログラムがあったとして、「網羅されている」ということは以下のように定式化できます。

T(T)P(p1)P(p2) ...

直観的にはこんな感じに読めます。

「型Tがカバーしている部分」 より 「各パターンp1, p2,... がカバーしている部分」の方が大きい

これが成り立つとき「網羅している」といえるわけです。シンプルですね!

Space

上では「カバーしている部分」と書きましたがこれをSpaceと呼ぶことにします。
(そのまま"空間"などと訳していいのか自信がなかったのでこの記事ではそのままSpaceでいきます)

Spaceは「型やパターンがカバーしている値の集合」です。
上で出てきた記号について説明すると、

  • T(T)は型TのSpaceを表す。型Tの値の集合。
  • P(p)はパターンpのSpaceを表す。パターンpがカバーしている値の集合。
  • Oは空space
  • はSpaceの和(Disjunction)。和集合のようなイメージ。

subspace関係

上では「大きい」と書きましたが、Space上の二項関係 subspace関係 と呼ぶことにします。

s1s2

subspace関係を定義するために、もう一つだけ (subtraction, minus)を新たに導入します。

s1s2

そしてsubspace関係を以下のように定義します。

s1s2s1s2=O

「s2がs1より大きいとは、s1からs2を引くと空になる」のようなイメージです。

必要な定義を駆け足で紹介してしまいましたが、ここまででわかればSwiftの網羅性チェックのメインの流れを読むことができるので、さっそく実際に見て見ましょう。

Swiftの網羅性チェックの実装を読む

lib/Sema/TypeCheckSwitchStmt.cppSpaceEngine::checkExhaustiveness が網羅性チェックの実装になります。

主要な部分を抜き出すと、

void checkExhaustiveness(bool limitedChecking) {
    // (略)

    // チェックしたい型の space
    Space totalSpace(subjectType, Identifier());

    // 各パターンの space の disjunction
    Space coveredSpace(spaces);

    // totalSpace ⊖ coveredSpace を計算
    auto uncovered = totalSpace.minus(coveredSpace, TC).simplify(TC);

    // totalSpace ⊖ coveredSpace = O であれば網羅されている
    if (uncovered.isEmpty()) {
        return;
    }

   // 網羅されていない場合はエラーにする処理
   // (略)
}

まさに上で紹介した式がそのまま実装されているのが確認できますね!
メインの流れはわかったので次はminusがどのように実装されているかを見ていきます。

⊖によるsubspace関係の定義

は直観的には集合の包含関係・差ですが、もちろん要素一つ一つをチェックしていくなんてことはせず、型の分解(decompose)を定義していったり、サブタイプ関係を使ったりすることでを定義していきます。

例えばAがBのサブタイプであることに基づいて を決めることができます。

T(A)T(B)
T(A)T(B)=O

またSwiftでいうとenum/caseに相当する型、型構築子のようなケース(代数的データ型)についてはsubspcaceの和への分解(decompose)を与えることで対応します。たとえば以下のようなenumがあったとして、分解Dは以下のように定義できます。

enum E<T, S> {
    case a(T)
    case b(S)
    case c
}
D(E)=K(a,T)K(b,S)K(c)

Kは型構築子のSpaceで、K(型構築子の名前, 型 ...)のように表します。

この分解さえ決められれば、以下の例であれば

enum E {
    case a
    case b
    case c
}

let e = E.a

switch e {
case .a: print("a")
case .b: print("b")
case .c: print("c")
}

最初に書いた通り網羅していることは以下のように定式化できるのでした。

T(E)(P(a)P(b)P(c))

そしてそれはによって定義されるのでした。以下の式が空Spaceになることを確認していきます。

T(E)(P(a)P(b)P(c))

各パターンは型構築子(enumのcase)に対応するのでそのままKに置き換えます。

T(E)(K(a)K(b)K(c))

Eを型構築子のSpaceの和に分解します。

(K(a)K(b)K(c))(K(a)K(b)K(c))

あとの細かい計算は省略しますがいくつかの規則にしたがって計算を進めていけば Oになりそうなのがわかるかなと思います。

このようにenumの場合は各caseのSpaceの和に分解していくことで そして を考えていくことができます。

SwiftのSpaceの実装を読む

SpaceについてはそのままSpaceというクラスで表されています。TなどはそれぞれSpaceのコンストラクタに対応していて、そのSpaceが型についてなのか型構築子についてなのかなどはSpaceKindという形で扱われています。

class Space final {
  ...
}
enum class SpaceKind : uint8_t {
  Empty           = 1 << 0,
  Type            = 1 << 1,
  Constructor     = 1 << 2,
  Disjunct        = 1 << 3,
  BooleanConstant = 1 << 4,
};

またBooleanConstantがあるのを見て分かる通り、網羅性チェック時のtrue/falseリテラルに関しては、あたかも以下のようなenumであるかのように扱われています。

enum Bool {
  case true
  case false
}

パターンのSpace PprojectPatternという関数で実装されていて、直接いずれかのSpaceKindを持ったSpaceに変換されます。

// Recursively project a pattern into a Space.
static Space projectPattern(TypeChecker &TC, const Pattern *item,
                            bool &sawDowngradablePattern) { ... }

分解についてはそのままdecomposeという関数で実装されていて、タプル、enum、Boolについてのみ定義されていることがcanDecomposeを見るとわかります。

static bool canDecompose(Type tp) {
  return tp->is<TupleType>() || tp->isBool()
      || tp->getEnumOrBoundGenericEnum();
}

肝心の の実装はすでに見た通りminusという関数で実装されていて、SpaceKindの組み合わせごとに規則が用意されています。

Space minus(const Space &other, TypeChecker &TC) const {
  // ...
  switch (PairSwitch(this->getKind(), other.getKind())) {
  PAIRCASE (SpaceKind::Type, SpaceKind::Type): { ... }
  PAIRCASE (SpaceKind::Type, SpaceKind::Constructor): { ... }
  // 以下すべての組み合わせについて...
  }
}

網羅性チェックが行えないパターン

元の論文でも言われている通り、あくまでも型や型構築子の関係に基づいて定義されるため、値に直接言及するようなパターン(Swiftでいうとwhere付きのcase)は網羅性チェックには使えません。

// 'where'-clauses on cases mean the case does not contribute to
// the exhaustiveness of the pattern.
if (caseItem.getGuardExpr())
    continue;
enum E {
  case a
  case b(Bool)
}

let e = E.a

// 実際には網羅されていてもコンパイラはそれを認識できない
// error: switch must be exhaustive
switch e {
case .a:
  print("a")
case .b(let bool) where bool == true:
  print("b - true")
case .b(let bool) where bool == false:
  print("b - false")
}

また、一意に分解が定義できないようなIntなどの型についてもチェックは行えません。

let i = 1

// error: switch must be exhaustive
switch i {
case (..<0): print("negative")
case (0...): print("zero or positive")
}

まとめ

Swiftコンパイラの網羅性チェックについての理論と実際の実装をざっくりですが確認しました。
「Swiftの」と言ってしまっていますが、元々はScalaのdottyというコンパイラで採用されているものをSwiftに移植してきたものであり、実装をみてみると関数名などほぼそのまま使われていることが確認できます。ありがとうScala先輩。

ほぼ1ファイル1500行くらいで完結していて、珍しく参考文献も示されており、Swiftコンパイラのソースの中ではかなり読みやすい部類に入ると思うので、パターンマッチでなにかおかしなことが起きた際にはぜひソースを追ってみてください!!

参考文献