Optional comparison revisited

Why can't we compare optionals?

var x: Int?
x == 0 // ok
x > 0  // error

I found some explanation here (0121-remove-optional-comparison-operators):

but I fail to see why this example:

struct Pet {
  let age: Int
}

struct Person {
  let name: String
  let pet: Pet?
}

let peeps = [
  Person(name: "Fred", pet: Pet(age: 5)),
  Person(name: "Jill", pet: .None), // no pet here
  Person(name: "Burt", pet: Pet(age: 10)),
]

let ps = peeps.filter { $0.pet?.age < 6 }

ps == [Fred, Jill] // if you don’t own a pet, your non-existent pet is considered to be younger than any actual pet  🐶

says "Fred, Jill" is returned (was it some gotcha of the previous implementation?). I would suppose that if $0.pet?.age < 6 was allowed it should have returned "false" for Jill, as if it was written:

 `$0.pet?.age != nil && $0.pet!.age < 6` // or the better "if let" form

so the expected result would be [Fred].

Obviously instead of the currently disallowed x > 0 I can do:

x != nil && x! > 0

or its better "if let..." form. Or, at least conceptually, I can do "the equivalent":

x == 1 && x == 2 && x == 3 ..... && x == .max

It just looks odd that == and != is allowed but < > not. Perhaps I am missing some good example where optional comparison would be really surprising?

Note that the motivation section of the quoted article refers to some limitations in generics system which might be no longer applicable today.

The problem with making Optional Comparable is not so much in a single comparison, but the cascading effects of sorting algorithms. As an exercise, make Optional Comparable and see if you can get something that looks correct out of this:

[2, nil, 1].sorted()

Here's == for Optional, for inspiration.

public static func ==(lhs: Wrapped?, rhs: Wrapped?) -> Bool {
  switch (lhs, rhs) {
  case let (l?, r?):
    return l == r
  case (nil, nil):
    return true
  default:
    return false
  }
}

How does .none compare against .some?

3 Likes

If nil < 42 was false but 42 < nil was also false, sorting an array like [3, 2, nil, 7, 1] would result in [2, 3, nil, 1, 7], which doesn't make much sense. Consider also that Equatable and Comparable are completely different protocols, with different semantic requirements, which assume a different underlying mathematical structure: Comparable "includes" Equatable in the definition of its semantics (so the former implies the existence of the latter), but the inverse is not true, so there's nothing particularly weird in allowing 42 != nil but not 42 < nil (while if we allowed 42 < nil we would be forced to also allow 42 != nil).

3 Likes

This is already how NaN works. The documentation for Comparable explicitly states that "a conforming type may contain a subset of values which are treated as exceptional" that don’t take part in the strict total order. Personally, I think this is paradoxical to the point of nonsense, but given that it is how the protocol is defined, I don’t see why treating FloatingPoint.nan is really that different from Optional.none. They represent the same concept — the absence of a value.

2 Likes

.none isn't an exceptional value for an Optional. It's arguably the entire point of the type.

10 Likes

My take here is that .nan is an exception that should not be repeated nor followed as a foundational example: twice the nonsense doesn't produce "sense" :smile:

I don’t know. Given the carve-out for .nan, Comparable is already useless in practice for anyone who needs an actual strict total order on an arbitrary T.

It’s perfectly reasonable to give an arbitrary ordering for .some and .none. Swift does this automatically for other enums.

Swift used to have a < implementation for Optional, but it was removed in SE-0121.

The motivation in that proposal is mainly driven by Swift’s implicit optional wrapping, causing surprising behavior. It also mentions that these operators could be re-introduced once conditional conformance is available (though I find the actual rationale for this elusive… I am not really clear why lack of Comparable conformance makes or breaks these operators).

Conditional conformance is now available, but the implicit wrapping issue remains. Disallowing it was proposed as SE-0123. I suspect that this topic is worth revisiting – in particular, the similar implicit conversion to Any remains a blocker for defining < for any Comparable. Perhaps some opt-in to forbidding conversion would be worthwhile.

3 Likes

.nan probably exists because computation on real machines is not like ideal mathematics: it's a necessary evil, that one could still avoid with careful design. But the fact that it exists doesn't mean that we should then encourage non-strict semantics in any other arbitrary case. .some and .none are perfectly valid values for an instance of type Optional, without exceptions, and if the type conformed to Comparable it would mean that we should come up with a sensible definition of an ordering for them: for example, I'd expect that if nil is lower than 42, then 42 is greater than nil.

In almost all project that I work on I have the old Standard Library implementation.

The problem with this is the conflation of concepts that occurs because of < being a symbol instead of a name, and only returning a Bool.

If < means "is less than", then all four Comparable requirements should return false, when one of the operands is nil. nil is neither less than, greater than, nor equal to a value.

But if < means "is ordered before", then nil could be defined to come before or after all values.

The problem is, < means both of those things. So it's only appropriate to apply it to types where both wordings hold. Optional can only mean the latter.

I don't think your distinction holds for String. Character ordering is defined as "comes before" or "comes after". There is no inherent value to a character such that it is "less than" or "greater than" some other character.

It seems that your requirement could only ever apply to ordinals. Any other type is using the "before/after" semantic.

2 Likes

Yeah, seems like the idea needs work. What about, "< cannot apply to types that are synonymous with their primary associated type (that's not the correct terminology for the generic placeholder but I don't know if there's another wording that matches the protocol equivalent), and do not always represent a value of that type"? What else but Optional fits that description? Property wrappers are what come to mind, but do those cause any related problems?

This table presents all sane possibilities how comparing some to none can work:

some>none   some<none   none>none   none<none   comment
false       false       false       false       // as Float.nan
true        false       false       false       // "something is more than nothing"
false       true        false       false       // "something is less than nothing"
nil         nil         nil         nil         // Optional<Boolean>

Float.nan would be "compatible" with Float's nan, "something is more/less and than nothing" :arrow_down: would give some reasonable sort order, although strictly speaking it's not necessary. Optional<Boolean> (returning nil) is perhaps the most "safe" option (although it won't meet Comparable conformance that needs bare Bool).

⮕ As Ben has pointed out above, "something is more/less than nothing" is the behaviour we have with "normal" enum types:

enum MyOptional1: Comparable {
    case none
    case some(Int)
}

enum MyOptional2: Comparable {
    case some(Int)
    case none
}

MyOptional1.some(0) > .none // true
MyOptional1.some(0) < .none // false
MyOptional1.none > .none    // false
MyOptional1.none < .none    // false

MyOptional2.some(0) > .none // false
MyOptional2.some(0) < .none // true
MyOptional2.none > .none    // false
MyOptional2.none < .none    // false

I would agree, except for that synonymousness I mentioned. none compared to some has different semantics than .none being compared against a Wrapped, and there's not enough baked into the four Comparable operators to deal with that.

let i = 1
i is Int // true
i is Optional<Int> // true

I think the problem may be unique to Optional, at least until property wrappers support get throws or something else that could allow them to mimic a similar effect.

Key to how Optional contributes to the correctness of your code is that you are prompted to consider the nil case. When there’s both implicit optional wrapping and comparison operators for Optional, you can sort or ask for the minimum and, not realizing that one operand is optional, get a nil result. This is not desired.

As @Ben_Cohen writes above, an alternative would be to disable implicit optional wrapping in this scenario, which was considered and rejected in SE-0123. Another alternative is to have a comparison operator that makes allowance for an unordered result (i.e., partial ordering—e.g., a spaceship operator <=> that returns an optional ternary Optional<ComparisonResult>), which has the nice property that it wouldn’t require an exception for NaN.

These (and variations on each, such as returning Bool? for <, etc.) are pretty much the only possibilities in this solution space. Note that in all of these cases, you would be required to handle the comparison-to-nil case explicitly in some way: currently, by unwrapping the optional value; in the case of Ben’s alternative, by wrapping the non-optional value; and in the case of a comparison operator that allows for partial order, handling the unordered case. This is actively desirable.

It is semantically the case that two values that may be equal or unequal can have no defined order relative to each other. That’s not odd, and there is no alternative design that can make this fact go away any more than you can make π = 3.

4 Likes

If I recall, it was because a type T that had no Comparable conformance would nonetheless have values that were apparently comparable by implicit promotion to T?—although in the moment I can’t recall how the comparison of two .some cases was handled back then.

SE-123’s rejection rationale suggests the path forward:

In the post-Swift 3 timeframe, we can investigate other broader options for solving this problem more generally (e.g. a new parameter attribute).

So you could then define e.g.:

func < (lhs: @explicit any Comparable, rhs: @explicit any Comparable) -> Bool {
    // define total ordering of all Comparable types
}

But without the current problem, which is that if you define this, it could implicitly compare String to Int without you realizing, which would be pretty terrible.

(Vaguely relatedly, we should consider revisiting that f(arg: any Comparable) is more favored than f(arg: some Comparable), which makes the above problem even worse)

With this functionality, it should hopefully be OK to reintroduce < for optionals and also make them conditionally Comparable.

1 Like

@tera I have a pretty decent answer on StackOverflow about precisely this. I'll cross-post it here:


It makes perfect sense for the equality operator to support optionals, because it's absolutely clear that for any integer valued variable i:

  • nil == nil
  • nil != i
  • i != nil
  • i == i if and only if their values are the same

On the other hand, it's not clear how comparison to nil should act:

Is i less than nil?

  • If I want to sort an array so that all the nils come out at the end, then I would want i to be less than nil.
  • But if I want to sort an array so that all the nils come out at the start, then I would want i to be greater than nil.

Since either of these are equally valid, it wouldn't make sense for the standard library to favor one over the other. It's left to the programmer to implement whichever comparison makes sense for their use-case.

Here's a toy implementation that generates a comparison operator to suite either case:

func nilComparator<T: Comparable>(nilIsLess: Bool) -> (T?, T?) -> Bool {
	return {
		switch ($0, $1) {
			case (nil, nil): return false
			case (nil, _?): return nilIsLess
			case (_?, nil): return !nilIsLess
			case let (a?, b?): return a < b
		}
	}
}

let input = (0...10).enumerated().map {
	$0.offset.isMultiple(of: 2) ? Optional($0.element) : nil
}

func concisePrint<T>(_ optionals: [T?]) -> String {
	return "[" + optionals.map { $0.map{ "\($0)?" } ?? "nil" }.joined(separator: ", ") + "]"
}

print("Input:", concisePrint(input))
print("nil is less:", concisePrint(input.sorted(by: nilComparator(nilIsLess: true))))
print("nil is more:", concisePrint(input.sorted(by: nilComparator(nilIsLess: false))))

Output:

Input: [0?, nil, 2?, nil, 4?, nil, 6?, nil, 8?, nil, 10?]

nil is less: [nil, nil, nil, nil, nil, 0?, 2?, 4?, 6?, 8?, 10?]

nil is more: [0?, 2?, 4?, 6?, 8?, 10?, nil, nil, nil, nil, nil]