Handling Future Enum Cases
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 のレイアウトは引き続きクライアントに公開され、最適化に使われます。
今後の展望
今回のスコープには入りませんが、関連する将来の方向性としていくつかのアイデアが示されています。あくまで speculative なもので、実現が約束されたものではありません。
- ユーザー定義ライブラリへの拡張: 一般のSwiftライブラリでも frozen / non-frozen を選べるようにする。バイナリ互換を気にする配布形態が整った段階で検討される見込みです。
unknownパターン:@unknownはswitchの値全体に対する catch-all にしか使えませんが、タプルの要素など部分パターンに対して#unknownのようなパターンを導入する案も議論されています。- 非公開case: 公開enumの中に非公開のcaseを定義できるようにする機能(Cの世界ですでに行われているパターンに対応するもの)。
- 互換性チェッカ: frozen enumに誤って case を追加してしまうことを、ビルド時に自動検出するツールの整備。
これらはいずれも今回の提案の範囲外ですが、今回導入された frozen / non-frozen の枠組みは、こうした将来の拡張の基盤になります。