[Pitch] Support for nil comparisons without Equatable conformance

Dear Swift community,

Please find below a pitch that I’ve developed to address swiftlang/swift-foundation#711. If you have questions, comments, or concerns, feel free to let me know.


Support for nil comparisons without Equatable conformance

Introduction/Motivation

Foundation’s current implementation of build_Equal(lhs:rhs:) requires Equatable output conformance for its left- and right-hand expressions under all circumstances. As such, a declared predicate that compares a non-Equatable optional variable to nil does not compile. For example:

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'

This outcome is inconsistent with the semantics of the Swift standard library, which allows for optionals to be compared with nil regardless of whether the wrapped type conforms to Equatable.

Proposed solution and example

To better align the #Predicate experience with that of Swift itself, I propose a new set of build_Equal(lhs:rhs:) and build_NotEqual(lhs:rhs:) overloads. These overloads cover the special cases of an expansion where either parameter is an instance of NilLiteral. The above example would leverage this one:

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

Detailed design

Each overload works by wrapping the non-Equatable variable expression in an OptionalFlatMap, whose initializer accepts a closure that can evaluate to different outputs based on whether a given input value is present. If a value is present, the closure should discard it and return Value(true). Then, that optional result is compared to nil with the binary operator given by the name of the overload. This approach is analogous to using a branch of an if let conditional binding as a comparand, for which existing API does not constrain the input to Equatable types.

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

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

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

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

With Swift’s method resolution, the compiler can choose the most specific overload available, so these will be preferred over the broader ones in ambiguous cases.

Impact on existing code

Given that the overloads are purely additive and the macro will still generate the same source code as before, there should be no breaking changes.

The new method resolution paths may, however, affect observed compiler performance, particularly for predicate code that already heavily relies on type inference. Below is a summary of compiling sample source files, using an SDK without these new overloads and using an SDK with them.

Number of predicates in file Type check time without new overloads (s) Type check time with new overloads (s)
10 6.073 6.135
20 8.599 8.398
30 11.129 11.466
40 13.802 13.732
50 16.266 16.199
60 18.933 18.866
70 21.265 21.458
80 23.999 23.999
90 26.466 26.599
100 29.065 28.999
110 31.599 31.666
120 34.399 34.466
130 37.266 36.665
140 39.985 39.274
150 42.437 42.099
160 44.547 44.798
170 47.298 47.282
180 49.497 49.577
190 52.414 52.251
200 55.247 54.831

At a glance, an impact on performance is not noticeable.

Alternatives considered

Relaxing requirements for existing build_Equal(lhs:rhs:) and build_NotEqual(lhs:rhs:) methods

This would require relaxing several Equatable requirements elsewhere in the #Predicate infrastructure and public API. Such drastic changes could break existing evaluation functions or lead to other unforeseen consequences.

Introducing a new operator or expression type

This could make the intent behind the API clearer. And with a dedicated operator or expression type, Foundation could support more operations for non-Equatable optionals down the line. However, that abstract prospect—for a use case that is already narrow—is unlikely to outweigh the drawbacks of maintaining a greatly expanded API surface, absent newfound technical justification or input from the open-source community.


For additional information, see the pull request.

3 Likes

Overall this looks like a good direction to me - it’s always great to align the semantics of Predicate contents with those of “normal” Swift syntax! I’m also very glad to see that there is no impact to build times - I was originally worried about introducing new overloads but it looks like build times remain steady so that isn’t a concern.

I also agree with your rejection of the alternatives considered. Finding a way to relax the existing Equal/NotEqual operators seems unattainable and I think the approach of using the FlatMap operator to accomplish this behavior instead of introducing a new operator is acceptable - we already create flat map operators for other language context (like if-let syntax) so it’s not entirely new that we create slightly tangential operators to match the semantics of a language feature.

I think this will be a great improvement to the ergonomics of Predicate!

2 Likes

This seems perfectly reasonable. Swift Testing has some magic around nil literals too, though I don't think any of it ended up in our macros as we handle == very differently from #Predicate today.