Swift Digest
SE-0487 | Swift Evolution

Nonexhaustive enums

Proposal
SE-0487
Authors
Pavel Yaskevich, Franz Busch, Cory Benfield
Review Manager
Ben Cohen
Status
Implemented (Swift 6.2.3)

01 何が問題だったのか

Swift の enum は標準では exhaustive(網羅的)です。ライブラリの利用者は switch ですべてのケースを書き切る必要があり、ライブラリ作者がケースを足すとクライアントのビルドが壊れます。

public enum PizzaFlavor {
    case hawaiian
    case pepperoni
    case cheese
}

switch pizzaFlavor {
case .hawaiian:
    throw BadFlavorError()
case .pepperoni:
    try validateNoVegetariansEating()
    return .delicious
case .cheese:
    return .delicious
}

この enum.veggieSupreme を後から追加すると、上の switch はもはや exhaustive ではなくなり、利用者側のコンパイルが通らなくなります。

ABI 安定なライブラリ(library evolution が有効な resilient なライブラリ)向けには SE-0192non-exhaustive enum が導入され、@unknown default を要求することで「将来ケースが増えても再ビルド不要」を実現しています。逆に「もう増えない」と確約できる Optional のような型には @frozen を付けて exhaustive な扱いを取り戻せます。

しかし、resilient ではない通常の(non-resilient な)Swift パッケージでは、この 「将来 case が増えるかもしれない」と利用者に伝える手段がそもそもありません。結果として次のような問題が生じています。

  • Errorenum で表現すると、後から新しいエラー種別を足せません。これを避けるために structstatic let で疑似 enum を作る回避策が広まっていますが、catch のパターンマッチが効かず型キャストが必要になります。
  • 概念として将来拡張されうる集合(HTTP ステータスコードなど)を enum で表現すると、新値が出るたびに deprecate-and-replace が必要になります。SwiftNIO のように .custom ケースに無理やり詰め込むワークアラウンドも見られます。
  • 単なる「現時点で網羅できているケース集合」を enum で表現してしまうと、将来の追加が常に破壊的変更になります。

つまり、non-resilient なパッケージでは、enum を「拡張可能な API」として安全に公開する方法がなく、enum の使い道が大きく制限されていました。

02 どのように解決されるのか

non-resilient なモジュールでも enum を non-exhaustive(拡張可能)として公開できるように、新しい属性 @nonexhaustive を導入します。@nonexhaustive を付けた enum は、resilient ライブラリの非 @frozenenum と同じ振る舞いになります。

/// Module A
@nonexhaustive
public enum PizzaFlavor {
    case hawaiian
    case pepperoni
    case cheese
}

/// Module B
switch pizzaFlavor { // error: Switch covers known cases, but 'PizzaFlavor' may have additional unknown values, possibly added in future versions
case .hawaiian:
    throw BadFlavorError()
case .pepperoni:
    try validateNoVegetariansEating()
    return .delicious
case .cheese:
    return .delicious
}

別モジュール(かつ別パッケージ)から switch する側は、@unknown default を書いて将来ケースが増えた場合の挙動を明示する必要があります。

switch pizzaFlavor {
case .hawaiian, .pepperoni, .cheese:
    return .delicious
@unknown default:
    return .unknown
}

同一モジュール/パッケージ内では exhaustive のまま

同一モジュールや同一パッケージ内のコードは「一緒に開発される単位」とみなされるため、@nonexhaustiveenum であっても @unknown default なしで exhaustive に switch できます。むしろ不要な @unknown default を書くと警告になります。これにより、ライブラリ作者は自分のコード内では従来通りコンパイラの網羅性チェックを最大限活用しつつ、外部 API としては拡張可能性を確保できます。

@frozen との関係

ある enum@nonexhaustive@frozen を同時に付けることはできず、コンパイルエラーになります。両者は exhaustive にするかしないかの真逆の意味を持つ属性なので、混在は禁止です。

resilient モジュールでは元から non-exhaustive が既定なので、@nonexhaustive を付け外ししても挙動は変わりません。

既存の enum を段階的に拡張可能にする @nonexhaustive(warn)

既に公開済みの enum を後から @nonexhaustive 化するのは、利用側の switch をエラーにしてしまうため、API 互換性の観点では破壊的変更 です。これを一段階柔らかく扱うために @nonexhaustive(warn) も用意されます。これは「いずれ拡張可能にしたい」という意思表示で、現時点ではエラーを警告に格下げします。

// Package A の旧バージョン
public enum Foo {
  case foo
}

// Package B
switch foo {
case .foo: break
}

// Package A が将来の拡張に備える
@nonexhaustive(warn)
public enum Foo {
  case foo
}

// Package B 側は警告に格下げされる
switch foo { // warning: Enum might be extended later. Add an @unknown default case.
case .foo: break
}

// 後日 Package A が実際にケースを追加してメジャーアップ
@nonexhaustive(warn)
public enum Foo {
  case foo
  case bar
}

// Package B は @unknown default を未追加のままなのでエラー+警告
switch foo { // error: Unhandled case bar & warning: Enum might be extended later. Add an @unknown default case.
case .foo: break
}

@nonexhaustive(warn) を付けただけではメジャーバージョンを上げる必要を回避できるわけではありませんが、「将来この enum は拡張するつもりだ」という事前告知として機能し、利用側に @unknown default を足してもらう猶予を与えられます。

なお、@nonexhaustive(warn)外す のは、警告で済んでいたものをエラーに昇格させてしまうため API 破壊的変更です。

API 破壊チェッカ

swift package diagnose-api-breaking-changes も新しい属性を理解するように更新され、@nonexhaustive の付与・削除や @nonexhaustive(warn) の削除といった変更が API 破壊として検出されるようになります。

Future Directions(今後の見通し)

提案では、今回のスコープ外として次の方向性が示されています。いずれも将来の検討事項で、実現を約束するものではありません。

  • 言語ダイアレクトの統一:non-resilient な既定が exhaustive であること自体が「気づかぬうちに API を壊す」原因になっているため、将来の言語モードでこの既定を non-exhaustive 側に揃える方向。その際は @preconcurrency のような段階的移行の仕組みが必要になると見込まれています。
  • @unknown catchenumError として typed throws と組み合わせる場合に、switch@unknown default と対をなす @unknown catch を導入する方向。
  • 追加の associated value のサポート:既存ケースに associated value を後から足せるようにする方向。今回の提案はあくまで「ケースの追加」を扱う最小スコープに留めています。
  • パッケージより大きなコンパイル単位:複数の小さなパッケージで構成されたアプリで、それらを一体として exhaustive に扱えるようにビルドツール側で「より大きなコンパイル単位」を定義できるようにする方向。当面は @frozen で同等の効果を得られます。