Synthesized Comparable conformance for enum types
01 何が問題だったのか
enum には、意味的に自然な順序を持つものが少なくありません。たとえば会員ランクや明るさのレベルなど、ケース同士を比較してソートしたい場面はよくあります。
enum Membership {
case premium // <
case preferred // <
case general
}
enum Brightness {
case low // <
case medium // <
case high
}
しかし、Swift ではこうした enum を Comparable に適合させるのに、利用者が自分で < を実装する必要がありました。SE-0185 で Equatable と Hashable の合成は導入されたものの、Comparable は「どの型をどういう順序で比較すべきか自明でない」という理由で見送られていたためです。
その結果、実際のコードでは次のような書き方が広まっていました。いずれも、ケースを増やすたびに手を入れる必要があり、ミスも起こりやすいものでした。
Int の raw value を経由する
Int を raw value に取って、rawValue 同士で比較する方法です。手軽ではありますが、enum の API に意味のない数値が露出し、さらに init(rawValue:) まで自動で生やしてしまいます。
enum Membership: Int, Comparable {
case premium
case preferred
case general
static func < (lhs: Self, rhs: Self) -> Bool {
return lhs.rawValue < rhs.rawValue
}
}
< を手書きする
最も「正しい」実装は、ペアのパターンマッチで最小値を求めるヘルパーを用意するやり方ですが、非常に冗長で、ケースが増えるほど破綻します。
enum Brightness: Comparable {
case low
case medium
case high
private static func minimum(_ lhs: Self, _ rhs: Self) -> Self {
switch (lhs, rhs) {
case (.low, _), (_, .low ):
return .low
case (.medium, _), (_, .medium):
return .medium
case (.high, _), (_, .high ):
return .high
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
return (lhs != rhs) && (lhs == Self.minimum(lhs, rhs))
}
}
比較用の Int プロパティを手で振る
現実には、各ケースに対応する Int 値を手で割り振る書き方もよく見かけます。ケースを追加するたびに番号を振り直す必要があり、これを避けるために「10 刻みで番号を振る」「Double にして 0.5 刻みで挿入できるようにする」などの回避策が生まれる始末でした。
enum Membership: Comparable {
case premium
case preferred
case general
private var comparisonValue: Int {
switch self {
case .premium:
return 0
case .preferred:
return 1
case .general:
return 2
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
return lhs.comparisonValue < rhs.comparisonValue
}
}
いずれの方法も、本来であれば「宣言順そのものが意味を持つ」という単純な事実を、回りくどい形で書き写しているだけでした。
02 どのように解決されるのか
Equatable や Hashable と同じように、enum に対して Comparable 適合をオプトインで合成できるようにします。合成される比較順序は、ケースの宣言順に従い、後ろに書かれたケースほど大きいものとして扱われます。
enum Membership: Comparable {
case premium
case preferred
case general
}
([.general, .premium, .preferred] as [Membership]).sorted()
// [Membership.premium, Membership.preferred, Membership.general]
Swift は通常「フィールドの並びはコンパイラが自由に並べ替えてよい」という立場ですが、enum のケース順はすでに意味を持つ文脈があります(たとえば Int を raw value に取る enum では、ケースの並びを変えると実行時の値が変わります)。この提案はその延長として、ケースの宣言順に比較順序の意味を与えるものです。
関連値(associated values)がある場合
関連値を持つケースがあっても、その関連値がすべて Comparable であれば合成が使えます。このとき、まずケースの宣言順で比較し、同じケースどうしが並んだときだけ関連値を左から順に辞書式(lexicographic)に比較します。
enum Membership: Comparable {
case premium(Int)
case preferred
case general
}
([.preferred, .premium(1), .general, .premium(0)] as [Membership]).sorted()
// [Membership.premium(0), Membership.premium(1), Membership.preferred, Membership.general]
合成が行われない条件
次のいずれかに当てはまる enum では Comparable は合成されません。
- raw value を持つ
enum(IntやStringを背後に持つもの)。raw value の順序と宣言順のどちらを採用すべきかが自明でなく、Stringの raw value はデバッグやログのために付けられることも多いため、そもそも比較の根拠として不適切です。 Comparableに適合しない関連値を含むケースを持つenum。- すでに自前で
<を実装しているenum(その実装が優先されます)。
この挙動は、既存の Equatable / Hashable / Codable の合成と同じ流儀です。オプトインなので、Comparable を明示的に書かない限り既存コードの挙動は変わりません。