Getting in trouble with mixed comparisons

on under c++
5 minute read

Andrzej has a great new post about the difficulty that arises when the language or the library tries to make assumptions about programmer intent. He brings up two examples of this difficulty:

  • should CTAD wrap or copy?
  • how should optional’s mixed comparison behave?

I’ve touched on the first topic in an earlier post on CTAD quirks, and I wanted to write some words about the latter topic here - since I think it’s really interesting.

Mixed comparisons are very closely related to implicit conversions, which themselves are always going to be a difficult topic. Let’s just start on the low end and work our way up, with just the ints:

bool f1(int a, long b) {
  return a == b;
}

Unless you take the view that implicit conversions are always wrong, and thus that mixed conversions should never happen (which is an extreme view in terms of the range of possible opinions to have, but isn’t totally outrageous), this is a perfectly reasonable function. a and b have different types, but represent the Same Platonic Thing. We can convert from int to long both cheaply and without any information loss. There aren’t any weird caveats with comparing an int to a long. It just works and does the sensible thing.

Let’s go one step outwards:

bool f2(optional<int> a, optional<int> b) {
    return a == b;
}

The only reason to reject this example would be if you think that optional<T> shouldn’t have any comparisons at all under any circumstances. I don’t know of anybody who has expressed that view. This function also has a clear and sensible meaning, a == b if either a and b are both disengaged or both are engaged with the same underlying int value.

Let’s go one step further:

bool f3(optional<int> a, optional<long> b) {
    return a == b;
}

This one is more interesting. The underlying comparison here is equivalent to f1(): either both optionals are disengaged or both are engaged and we do the mixed-integer comparison. If we think that comparison is sensible, surely this one should be as well? We’ve added a wrapping layer here, but we haven’t actually added any kind of additional semantics that would break our notion of Same Platonic Thing.

Now what about this one:

bool f4(optional<int> a, int b) {
    return a == b;
}

This is a different kind of mixed comparison than the ones in f1() and f3(). But I think you can still make the argument that this comparison has an obvious meaning: a is engaged and its underlying value is the same as b. This is a perfectly reasonable semantic, and there really is no other meaning this comparison could take on. Outside of being ill-formed, that is. Since optional<int> is implicitly constructible from int, if optional<T>’s comparison operators as declared as non-member friends, then you get this comparison even without asking for it.

And what about this one:

bool f5(optional<int> a, long b) {
    return a == b;
}

If you allow f3() (a mixed-optional comparison) and f4() (an optional-value comparison), then you must allow this one right? Arguably, we’re still making perfectly sensible decisions with each step here. Every comparison presented thus far does have one, clear semantic meaning - with no weird quirks, caveats, or edge cases.

So far, so good right?


But what happens when we do this:

bool f6(optional<optional<int>> a, optional<int> b) {
    return a == b;
}

Think about this for a minute. In particular, what should the value of f6(nullopt, nullopt) actually be? This is basically the case that Andrzej brought up at the end of his post. It’s tempting to say the answer is obvious, but it’s surprisingly not. There are two different ways of thinking about this:

  1. This is a special case of f3(): comparing optional<T> to optional<U> for types T and U that are comparable (in this case T=optional<int> and U=int, which is the f4() comparison). If we think about it in these terms, then the result of f6(nullopt, nullopt) should be true because we have two disengaged optionals, so they are equal.
  2. This is a special case of f4(): comparing optional<T> to T for type T that is comparable (in his case T=optional<int>). If we think about it in these terms, then the result of f6(nullopt, nullopt) is false because the a is disengaged - so it cannot compare equal to a value.

Which is the correct answer?

The Standard Library picks option 1 by way of allowing mixed-optional comparisons. Boost does not support mixed-optional comparisons (f3() does not compile using boost::optional), so it picks option 2. I’m not sure either of these options is more defensible than the other.

A third different, arguably defensible implementation strategy would be to allow mixed-optional comparisons (like f3()) but disallow mixed-category comparisons (like f4()), in which case f6() wouldn’t compile. I don’t know of an open source implementation that makes this choice - and this choice would have its own inconsistency: f4() would not compile, but if the body of f4() were used f2(a,b) instead of a == b, it would have? But if you disallowed implicit conversions (that is, optional<int>’s value constructor is explicit), then allowing f3() but disallowing both f4() and f6() would be both reasonable and consistent.

What’s the moral of this post? I am not sure. Even making a couple of really sensibly and easily defensible decisions (allowing comparing optional<int> to both long and optional<long>) still leads us to a situation where there just is no right answer. What did the programmer actually want f6() to mean? In this case, the programmer may not even know what they wanted! Is that a reason in of itself to view this as a library defect and try to prevent this particular kind of comparisons? I do not know. Ultimately, I just thought this was an interesting dilemma and wanted to share.