将来のenumケースを扱う
Handling Future Enum Cases
このダイジェストは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 の UIKeyboardType に asciiCapableNumberPad case が追加されるといったことが過去に実際に起きています。大きなエラー enum も、ライブラリが新しい機能をサポートするにつれて case が増えていくのが普通です。
網羅的なcaseと拡張可能なcaseの両立
実際にFoundationの公開ヘッダを調査すると、60ほどの NS_ENUM のうち exhaustive に扱うべきなのは ComparisonResult や NSKeyValueChange など 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 するときは、default や case _ のような 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 にのみ書けます。
@unknown は fallthrough と組み合わせると便利です。既存の 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 としてマークされます。代表的なものは次の通りです。
OptionalNeverFloatingPointSign/FloatingPointClassificationClosedRange.IndexUnicodeDecodingResult/Unicode.ParseResult
一方、将来 case が追加される可能性のあるものは non-frozen として残されます(DecodingError / EncodingError、FloatingPointRoundingRule、Mirror.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 パターン
現状の @unknown は switch 全体の 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 ケースと組み合わせる
現状の @unknown は default: または 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 値(RawRepresentable の rawValue)を持つ enum については、実体を 32bit 整数で表現するなどの最適化も考えられます。しかし、これを採用すると enum に raw 型を付け足したり外したりするのが ABI を壊す変更になり、enum HTTPMethod: String の形と enum HTTPMethod: RawRepresentable を手書きする形が等価でなくなってしまいます。互換性への影響が大きいため、今回のスコープからは外されています。