Swift Digest

#Predicate 内で Equatable 適合なしに nil 比較を行う PredicateExpressions API

PredicateExpressions API for nil comparisons without Equatable conformance within #Predicates

Proposal
SF-0035
Authors
Matthew Turk
Review Manager
Jeremy Schonfeld
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Foundation の #Predicate マクロでは、Swift のクロージャ構文でフィルタ条件を書きながら、SendableCodable、かつ実行時にも構造を取り出せる述語を組み立てられます。

しかし、#Predicate の中でオプショナルな値を nil と比較するとき、その Wrapped 型が Equatable に適合していないとコンパイルエラーになっていました。これは == / != を構築する PredicateExpressions.build_Equal(lhs:rhs:) / build_NotEqual(lhs:rhs:) が、左右の式の出力型に常に Equatable 適合を要求していたためです。

struct Message {
    struct Subject {
        let value: String
    }

    let subject: Subject?
}

let predicate = #Predicate<Message> { $0.subject == nil }
// Referencing static method 'build_Equal(lhs:rhs:)' on 'Optional' requires that 'Message.Subject' conform to 'Equatable'

この挙動は Swift 標準ライブラリのセマンティクスとは食い違っています。標準ライブラリでは、WrappedEquatable に適合していなくてもオプショナルを nil と比較できるからです。#Predicate の中でも同じように書けることが期待されます。

02 どのように解決されるのか

PredicateExpressions に、nilNilLiteral)との比較専用の新しいビルダー関数を追加し、NilLiteral を相手にする比較については WrappedEquatable 適合を要求しないようにします。これにより、冒頭の例のように non-Equatable なオプショナルを nil と比較する #Predicate がそのままコンパイルできるようになります。

let predicate = #Predicate<Message> { $0.subject == nil } // OK

追加されるビルダー関数

左右どちらが nil 側でも書けるよう、== / != のそれぞれに NilLiteral を一方に取る2種類、合計4つのビルダー関数が追加されます。nil 側の引数ラベルは nilLiteral になっており、既存の build_Equal(lhs:rhs:) / build_NotEqual(lhs:rhs:) とはシグネチャで区別されます。FoundationPreview 6.5 以降で利用できます。

@available(FoundationPreview 6.5, *)
extension PredicateExpressions {
    public static func build_Equal<LHS, Wrapped>(
        lhs: LHS,
        nilLiteral: NilLiteral<Wrapped>
    ) -> Equal<OptionalFlatMap<LHS, Wrapped, Value<Bool>, Bool>, Value<Bool?>>

    public static func build_Equal<Wrapped, RHS>(
        nilLiteral: NilLiteral<Wrapped>,
        rhs: RHS
    ) -> Equal<Value<Bool?>, OptionalFlatMap<RHS, Wrapped, Value<Bool>, Bool>>

    public static func build_NotEqual<LHS, Wrapped>(
        lhs: LHS,
        nilLiteral: NilLiteral<Wrapped>
    ) -> NotEqual<OptionalFlatMap<LHS, Wrapped, Value<Bool>, Bool>, Value<Bool?>>

    public static func build_NotEqual<Wrapped, RHS>(
        nilLiteral: NilLiteral<Wrapped>,
        rhs: RHS
    ) -> NotEqual<Value<Bool?>, OptionalFlatMap<RHS, Wrapped, Value<Bool>, Bool>>
}

仕組み

各ビルダーは、Equatable でないかもしれない式を OptionalFlatMap で包み、値があるかどうかだけを Bool? に写してから nil と比較します。値がある側のクロージャは中身を捨てて Value(true) を返し、無ければそのまま nil が伝播します。これは if let で値の有無だけを見る書き方と同じ発想で、Wrapped 自体の値どうしを比較する必要が無いため、Equatable 適合は不要になります。

どのビルダーを選ぶかは #Predicate マクロが担います。マクロは == / != の両辺を調べ、片方が nil リテラルであれば、その辺に nilLiteral ラベルを、もう片方に lhs または rhs ラベルを割り当てて対応するビルダーを呼び出します。両辺がともに nil の場合は既存の build_Equal(lhs:rhs:) にフォールバックしますが、このときは型注釈が必要になることがあります。WrappedEquatable に適合している場合の挙動は変わりません。

ラベルで区別することで、method resolution を型検査に委ねずにマクロ側で解決できます。これは初期の案(既存の lhs:rhs: ラベルのまま NilLiteral を受けるオーバーロードを追加する方式)が、一部のプロジェクトで型検査のタイムアウトを引き起こしたことへの対応です。追加されるビルダー関数は純粋に additive で、SDK 内の既存シンボルと衝突しないため、破壊的な変更はありません。