Swift Digest
SE-0266 | Swift Evolution

Synthesized Comparable conformance for enum types

Proposal
SE-0266
Authors
Diana Ma (taylorswift)
Review Manager
Ben Cohen
Status
Implemented (Swift 5.3)

01 何が問題だったのか

enum には、意味的に自然な順序を持つものが少なくありません。たとえば会員ランクや明るさのレベルなど、ケース同士を比較してソートしたい場面はよくあります。

enum Membership {
    case premium    // <
    case preferred  // <
    case general
}

enum Brightness {
   case low         // <
   case medium      // <
   case high
}

しかし、Swift ではこうした enumComparable に適合させるのに、利用者が自分で < を実装する必要がありました。SE-0185 で EquatableHashable の合成は導入されたものの、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 どのように解決されるのか

EquatableHashable と同じように、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 を持つ enumIntString を背後に持つもの)。raw value の順序と宣言順のどちらを採用すべきかが自明でなく、String の raw value はデバッグやログのために付けられることも多いため、そもそも比較の根拠として不適切です。
  • Comparable に適合しない関連値を含むケースを持つ enum
  • すでに自前で < を実装している enum(その実装が優先されます)。

この挙動は、既存の Equatable / Hashable / Codable の合成と同じ流儀です。オプトインなので、Comparable を明示的に書かない限り既存コードの挙動は変わりません。