Swift Digest
SE-0192 | Swift Evolution

将来のenumケースを扱う

Handling Future Enum Cases

Proposal
SE-0192
Authors
Jordan Rose
Review Manager
Ted Kremenek
Status
Implemented (Swift 5.0)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Swift では enum のすべての case を switch で exhaustive(網羅的)に書けるのが大きな魅力の一つです。default を書かなくても、コンパイラが漏れなく case を処理していることをチェックしてくれます。

しかし、ライブラリ側から見るとこの仕様には深刻な問題がありました。enumに新しいcaseを追加することが、常にソース互換性を壊す変更になってしまう のです。既存のクライアントコードが書いた exhaustive な switch は、新しい case に対応していないため、ライブラリのバージョンアップによってコンパイルエラーになってしまいます。

一方で、現実のフレームワークでは enum に case を追加していくことは避けられません。たとえば Apple のSDKでも、Foundation の DateComponentsFormatter.UnitsStyle に新しい brief case が、UIKit の UIKeyboardTypeasciiCapableNumberPad case が追加されるといったことが過去に実際に起きています。大きなエラー enum も、ライブラリが新しい機能をサポートするにつれて case が増えていくのが普通です。

網羅的なcaseと拡張可能なcaseの両立

実際にFoundationの公開ヘッダを調査すると、60ほどの NS_ENUM のうち exhaustive に扱うべきなのは ComparisonResultNSKeyValueChange など 6 個ほどに過ぎず、大多数は将来的に case が増え得るものでした。

つまり、「将来絶対に case が増えない enum」と「case が増え得る enum」を区別し、後者に対しては switch 側に catch-all な case を要求する仕組みが必要になります。そうすれば、ライブラリは case を追加してもクライアントのコードを壊さずに済み、クライアント側も新しい case の存在を明示的に意識できます。

加えて、default を書いてしまうと「既知の case を書き忘れていてもコンパイラが指摘してくれない」という exhaustiveness check のメリットが失われてしまいます。この弱点もカバーできる仕組みが望まれていました。

C enumのインポート

Cから輸入される NS_ENUM も同じ問題を抱えます。Apple のSDKの NS_ENUM のように .m ファイル内にプライベートな case が定義されていることすらあり、Swift側からはすべての case が見えているとは限りません。こうした C enum をどう扱うかも整理が必要でした。

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

enumを frozen(凍結済み)non-frozen(非凍結) の 2 種類に区別します。frozen な enum は「今後 case が追加されない」ことが保証され、従来通り exhaustive な switch が書けます。non-frozen な enum は「将来 case が増え得る」ものとして扱われ、switch には catch-all な case が必須になります。

ただし Swift 4.2 時点でこの区別が適用されるのは、Cからインポートされたenum、および 標準ライブラリとOSオーバーレイで定義されたenum に限られます。ユーザー定義のSwift enumは引き続きすべて暗黙的にfrozen扱いです(他ライブラリへの適用は将来の課題として残されています)。

non-frozen enumに対するswitch

non-frozen な enum を switch するときは、defaultcase _ のような catch-all な case を書かなければなりません。Swift 5 モードで書いていないと警告になり、実行時に未知の case に出会うとトラップします。

switch excuse {
case .eatenByPet:
    // …
case .thoughtItWasDueNextWeek:
    // …
default:
    // …
}

if case や値の生成など、switch の exhaustiveness 以外の使い方は影響を受けません。また、frozen な enum や Bool に対する非網羅的な switch は従来通りコンパイルエラーのままです。

@unknown 属性

単に default を書いてしまうと、既知の case を書き忘れてもコンパイラが警告してくれません。これを解消するのが @unknown 属性です。default または case _ に付けることができ、既知のcaseのうち扱われていないものがあれば警告 してくれます。

switch excuse {
case .eatenByPet:
    // …
case .thoughtItWasDueNextWeek:
    // …
@unknown default:
    // …
}

ここで .thoughtItWasDueNextWeek を書き忘れると警告が出ますが、ライブラリ側に新しい case が追加されたときもそれを検知できます。警告に留めるのは、case の追加自体はソース互換な変更として許容したいからです。

@unknown は catch-all としての挙動も兼ねるため、実行時に未知の値が来ても安全に受け止められます。また switch の最後の case にのみ書けます。

@unknownfallthrough と組み合わせると便利です。既存の case と同じ処理を流用しつつ、新しい case への警告も受け取れます。

switch excuse {
case .eatenByPet:
    showCutePicturesOfPet()

case .thoughtItWasDueNextWeek:
    fallthrough
@unknown default:
    askForDueDateExtension()
}

なお、パターンに登場する enum がすべて frozen(あるいはそもそも enum が含まれない)場合、@unknown を書くと警告されます。ユーザー定義のSwift enumは暗黙的にfrozen扱いですが、こちらは新しい case に備えて @unknown を書けるようにしてあります(将来 case が増えた際の対応が楽になるため)。

C enumの扱い

Cからインポートされる enum は、デフォルトで non-frozen として扱われます。NS_ENUM でもそれ単独では frozen にはなりません。

明示的に frozen としたい場合は、新しく導入された Clang 属性 enum_extensibility(closed) を C 側で付けます。

typedef NS_ENUM(NSInteger, GregorianMonth) {
    GregorianMonthJanuary = 1,
    // …
    GregorianMonthDecember,
} __attribute__((enum_extensibility(closed)));

frozen な C enum では、init(rawValue:) もコンパイル時に既知の case かどうかをチェックするようになります。non-frozen な C enum では従来通りチェックは行われません。

また、enum_extensibility(open) または (closed) のどちらかが付いていると、Swift はその enum を「本物のenum」として扱います(付いていない場合は option set や整数定数として扱われることがあります)。オプションセットを示すための flag_enum 属性も同時期に追加されています。

標準ライブラリ/オーバーレイでの適用

標準ライブラリの enum の多くは frozen としてマークされます。代表的なものは次の通りです。

  • Optional
  • Never
  • FloatingPointSign / FloatingPointClassification
  • ClosedRange.Index
  • UnicodeDecodingResult / Unicode.ParseResult

一方、将来 case が追加される可能性のあるものは non-frozen として残されます(DecodingError / EncodingErrorFloatingPointRoundingRuleMirror.DisplayStyle など)。オーバーレイ側でも、例えば DispatchTimeoutResult は frozen、Calendar.Component などは non-frozen といった形で分類されます。

契約を破ったときの挙動

frozen と non-frozen の区別は、ライブラリ作者とクライアントのあいだの「契約」として機能します。

  • non-frozen enum に case を追加するのはソース互換・バイナリ互換な変更になる。
  • frozen enum に case を追加すると、コンパイル時には従来の非網羅的 switch と同じエラーになる。実行時(= バイナリ互換の観点)では未定義動作となり、クラッシュや意図しない分岐の実行を引き起こし得る。
  • ただし @objc な enum に対する未知の値は(frozen であっても)未定義動作ではなくトラップする。
  • non-frozen enum を frozen に変更するのはソース互換だが、その逆(frozen → non-frozen)は許されない。

ABIへの影響

non-frozen な enum のメモリレイアウトはクライアントに公開されません。将来追加される case がそのレイアウトに収まらない可能性があるためで、公開APIに現れる際には追加の間接参照が入ります。frozen な enum のレイアウトは引き続きクライアントに公開され、最適化に使われます。

03 今後の見通し

今回の提案では、frozen / non-frozen の区別を C enum と標準ライブラリ・オーバーレイの enum に限定するなど、スコープを絞っています。関連する将来の方向性として、以下のようなアイデアが示されていますが、いずれも構想の段階であり、実現を約束するものではありません。

ユーザー定義ライブラリの enum への拡張

初期の提案では、ユーザー定義のSwiftライブラリの enum にも frozen / non-frozen の区別を導入する案が含まれていました。今回はバイナリ互換を気にする配布形態(標準ライブラリやオーバーレイのように複数のクライアントから利用される、決まった場所にインストールされるライブラリ)に限定されていますが、将来は一般のSwiftライブラリにも広げることが想定されています。「バイナリ互換を気にするライブラリ」とは何かを定義することも含めて、それ自体が大きな議論となるため、別の Proposal で扱われる見込みです。

unknown パターン

現状の @unknownswitch 全体の catch-all にしか使えず、タプルの要素や別の enum の付随値の中の enum を部分的に扱うことはできません。将来的に、パターンの一部として未知のケースに一致させる新しいパターン(仮に #unknown と表記)を導入することが考えられています。

switch (excuse, notifiedTeacherBeforeDeadline) {
case (.eatenByPet, true):
    // …
case (.thoughtItWasDueNextWeek, true):
    // …
case (#unknown, true):
    // …
case (_, false):
    // …
}

ただし、@unknown を catch-all 以外の位置で使えるようにすると、既知のケースに一致するパターンと併存したときにマッチ順の挙動が分かりにくくなる、@unknown を警告ではなくエラーにすると case の追加がソース互換でなくなる、といった課題があります。これらの整理が必要なため、今回は見送られています。

@unknown を他の catch-all ケースと組み合わせる

現状の @unknowndefault: または case _: にしか付けられません。case let value:case (_, let b): といった他の形の catch-all にも @unknown を付けられるようにすることが考えられています。実装上の障害は特に知られておらず、提案のスコープを広げすぎないために今回は外されたものです。

非公開のケース

non-frozen enum を実現する仕組みは、公開 enum の中に非公開のケースを持たせる機能の基盤にもなります。Apple のSDKでは、ヘッダに公開している enum の値以外に .m ファイル内でプライベートな case を定義しているような例が実際にあり、こうしたパターンを Swift で素直に表現できるようになる可能性があります。なお、frozen enum には非公開ケースを許さない設計が想定されています。

互換性チェッカ

frozen enum に誤って case を追加してしまうと、コンパイル時にも実行時にも互換性が壊れます。これを防ぐ仕組みとして、ライブラリのバージョン間で API を比較するチェッカや、型のレイアウトをシンボル名にエンコードしてリンク時にずれを検出する仕組みなどが検討されています。

raw 値を持つ enum の効率的な表現

raw 値(RawRepresentablerawValue)を持つ enum については、実体を 32bit 整数で表現するなどの最適化も考えられます。しかし、これを採用すると enum に raw 型を付け足したり外したりするのが ABI を壊す変更になり、enum HTTPMethod: String の形と enum HTTPMethod: RawRepresentable を手書きする形が等価でなくなってしまいます。互換性への影響が大きいため、今回のスコープからは外されています。