Derived Collection of Enum Cases
01 何が問題だったのか
Swiftの enum(特に、関連値を持たない「シンプルな enum」)は、有限かつ固定された case を持ちます。ところが、それらの case を動的に列挙したり、個数を数えたり、Collection として扱ったりする安全で公式な手段がコンパイラにもランタイムにもありませんでした。
使いたくなる典型的なケース
実務では、全 case を列挙する処理はしばしば登場します。例として、トランプのスートと数字の組み合わせからデッキを作る場合、
let deck = Suit.magicListOfAllCases.flatMap { suit in
(1...13).map { rank in
PlayingCard(suit: suit, rank: rank)
}
}
のように「全 case の一覧」が欲しくなります。UITableView のデータソースで、行数に case 数を使い、indexPath.row から対応する case を取り出す、といったランダムアクセスも典型です。
これまでの回避策とその問題
公式な仕組みがないため、ユーザーはさまざまな workaround を使ってきました。
- すべての case を手書きで並べた配列を用意する。
RawRepresentableな enum で、想定されるrawValueを 0 から順に試し、init?(rawValue:)がnilを返すまで集める。unsafeBitCastでhashValueを case に読み替えるなど、メモリ表現を前提とするハック。switch文の網羅チェックを利用して、手動で「次の case」を返すジェネレータを書く。
これらの方法には共通の問題があります。
- enum ごとに同じようなコードを重複して書く必要があります。
- 標準的な形で提供されていないため、ライブラリごとに流儀が異なります。
- case の追加・削除に追従し忘れるとバグになりやすく、
unsafeBitCastを使うものは型の内部表現に依存していて危険です。
ライブラリのレジリエンスにも関わる
将来的にライブラリ作者がバイナリ互換性を壊さずに public な case を追加・非推奨化できるようにしていくことを考えると、クライアント側で「全 case の配列」を手動で組み立てるのはますます不適切になります。クライアントがコンパイル時に見ていた case 一覧と、実行時にリンクしているライブラリの case 一覧がずれてしまうからです。
同時に、「全 case を列挙できる」という性質はライブラリ側が明示的に約束すべきもので、あらゆる enum に暗黙に付けるべきではありません。opt-in でありつつ、ボイラープレートなしで宣言できる仕組みが求められていました。
02 どのように解決されるのか
標準ライブラリに CaseIterable プロトコルを追加し、シンプルな enum に対しては、宣言で適合を表明するだけでコンパイラが実装を自動生成するようにします。
CaseIterable プロトコル
CaseIterable は次のように定義されます。全 case を要素とする Collection 型 AllCases と、それを返す静的プロパティ allCases を要求します。
public protocol CaseIterable {
associatedtype AllCases: Collection where AllCases.Element == Self
static var allCases: AllCases { get }
}
AllCases は Collection に適合していればよく、具体的な型は実装側の自由です。合成される実装がどの型を使うかは実装詳細として扱えるため、将来的に戻り値の具体的な Collection を差し替えても API としての互換性は保てます。
自動合成のルール
宣言に CaseIterable への適合を書くと、次の条件をすべて満たす enum についてはコンパイラが allCases を自動的に合成します。
- 関連値(associated value)を持つ case が一つも含まれない。
CaseIterableへの適合が元のenum宣言に書かれている(extensionで付けた適合には合成されない)。allCasesをユーザー自身が実装していない(実装していればそちらが優先される)。
使い方は次のとおりです。適合を書くだけで、allCases から全 case を順序どおりに取り出せます。
enum Ma: CaseIterable { case 马, 吗, 妈, 码, 骂, 麻, 🐎, 🐴 }
Ma.allCases // Ma を要素とする Collection
Ma.allCases.count // 8
Array(Ma.allCases) // [Ma.马, .吗, .妈, .码, .骂, .麻, .🐎, .🐴]
合成された allCases に含まれる case の順序は、ソース上の宣言順と一致します。
細かな仕様
自動合成の対象外となるケースや、含まれない case について、いくつかの取り決めがあります。
- C/Objective-C のヘッダからインポートされた enum では自動合成は行われません。必要であれば Swift 側の extension で手動実装します。
unavailableが付けられた case はallCasesに含まれません。利用できない case を返してしまわないようにするためです。- 関連値を持つ enum や struct でも、
CaseIterableに手動で適合させれば、自分で用意した Collection をallCasesとして返すことができます。自動合成の対象ではないだけで、プロトコル自体は任意の型で使えます。
どんなときに便利か
自動合成が効くため、設定項目の列挙や UI のピッカー表示、テストで全 case を網羅する処理など、これまで手書きの配列で済ませていた箇所を置き換えられます。
enum Attribute: CaseIterable {
case date, name, author
}
for attribute in Attribute.allCases {
print(attribute)
}
case を追加したときに allCases の更新を忘れる、といったバグが構造的に起きないのが最大の利点です。rawValue を頼りにした workaround や unsafeBitCast を使ったハックに頼らず、安全に全 case を扱えるようになります。