Why Bool doesn't conform to Comparable?

Hmmm… that feels like unnecessary redundancy. They already have a specified order - their declaration order.

I've seen this "the order of stored properties should have no effect" assertion pop up a few times, but I don't understand where it's coming from - what's the rationale behind that? And what's the use case that requires rearranging stored properties arbitrarily?

I don't think that presumption is true today, anyway - in principle if not also in practice. e.g. Codable auto-conformance has to pick an order for attempting to encode and decode the stored properties - I haven't checked but I assume it just does it in declaration order. I suppose it could choose some arbitrary other order - e.g. sort the properties by name - but I hope not, because that then implies that it's impossible to define that order myself (short of manually writing Codable conformance).

For better or worse, changing the declaration order of stored properties in a non-frozen struct is an ABI-stable change, and so must also not change the semantics of any generated protocol witnesses.

3 Likes

Good point. The implications of this include that people might invert their boolean properties just to suit the needs of sorting, which is a bad compulsion with bad results.

One of the very earliest rules I remember from Apple's HIG is that checkboxes (i.e. booleans) should not contain negatives. "Enable feature", not "Disable feature". That principle applies to all booleans, not just GUI elements. It's harder for humans to parse labels that contain negation.

Right, but is that not orthogonal to things like Comparable? If I manually implement Comparable, I can change the implementation arbitrarily, and that's ABI-stable. I see no difference whether I do it manually by writing methods vs rearranging stored properties. Both are explicit, unambiguous actions.

And if someone wants to implement Comparable with a different order than the declared order in the source code, they can write their own implementation. That should be the exception, though. Why penalise the more straightforward cases?

1 Like

I don't think anyone in this thread is arguing that true is naturally understood to be greater than false.

  • By "naturally" I mean in the language-level mental model for Swift. Obviously, if the implementation represents false as all-zero bits, and true as … not that, then there's a sort of "natural" order at the implementation level, but that's not what I'm talking about here.

Therefore, in order to understand a Comparable-conforming Bool, we have to assign it some sort of magnitude, conceptually.

Therefore, I'm arguing, Comparable conformance would involve what is essentially an autoconversion from Bool to (say) UInt8.

Therefore, I'm further arguing, this should be ruled out by Swift's general reluctance to autoconvert between types.

Yes, it's a small pain point that Swift doesn't autoconvert between types, even when it seems trivial to do so, but that is the philosophy of the language, and I think we'd need a stronger reason (than anything I've seen here) to transgress it.

Put another way, if Bool conformed to Comparable it would be just one more random factoid about Swift (say, true > false) that newcomers would have to memorize. I think it would degrade the language, a little bit.

2 Likes

It's not uncommon for types to grow new properties over time, and in doing so, it's also not uncommon to rearrange properties for grouping and documentation purposes.

Having those changes silently and implicitly affect the semantic behavior of the type can be incredibly subtle and unexpected.

It does so in the order of the enum cases in the CodingKeys enum (which itself derives the order from the stored property unless you define the keys yourself), but the key is that this doesn't make a semantic difference to the type: if you have a value of type T that decodes successfully with a synthesized Codable conformance from a given data blob, it should decode successfully regardless of property order (because it's decoding from a keyed container); if T would not decode successfully from the data blob, the order of the properties shouldn't matter because it would fail to decode regardless. (The specific error you might get could be different — e.g., you fail to decode property B instead of A — but the semantic guarantees of the type haven't changed.)

It can matter both from a "convenience" perspective (e.g. wanting as much decoded as possible, to aid in debugging) and a performance perspective (e.g. wanting encoding to fail fast if it fails at all).

It could also hypothetically matter if the encoder or decoder have 'side effects' from each encode / decode action. Although, to my knowledge I've never encountered that in practice; I, like most people, only really use Codable for JSON.

I recognise the desire there to decouple things - which is why I concur that it should be possible to separate them - but I don't share your concern about tying them together by default. As you noted, this is already how it works for Codable with the CodingKeys enum, and it doesn't seem like anybody takes issue with that?

A lot of Comparable things - in my code, at least - are pretty simple structs. The kind of boilerplate that's proposed, of requiring specification of order for automatic Comparable conformance, would substantially increase the size of the whole struct declaration in many cases. It'd also partially defeat the point of having automatic synthesis (to avoid writing more code).

P.S. Note also that Codable conformance doesn't require specification of CodingKeys (thank goodness!), so we already have an implicit reliance on declaration order of stored properties.

I can see where you're coming from, but in practice,

I don't think these are compatible with auto-synthesized conformance — if you have specific requirements around decoding specific properties that you want fulfilled, you should be implementing the protocol requirements yourself to satisfy them.

This is theoretically possible, but a correctly-written Encoder/Decoder shouldn't have any visible side-effects from encoding or decoding. (This is theoretically intended to be communicated via mutating/nonmutating requirements on the protocols)

I don't think anyone takes issue with it because there's intentionally no discernible difference in the results when you reorder properties in either the success or failure case.

For Comparable, where the ordering of comparisons by definition affects the outcome, having the intentional, semantic ordering of comparisons tied to source-level artifacts which are not necessarily semantically meaningful (the ordering of properties) leaves too much room for spooky action-at-a-distance outcomes from seemingly-unrelated changes.

3 Likes

I don't disagree that having to write boilerplate is unfortunate, but I think that having to specify a consistent, stable, intentional order of comparison of a type's properties is the lesser of two evils, because that's the semantic requirement of Comparable ("protocols are more than a bag of syntax"). I don't think it should be intentionally easy to say "eh, let the compiler pick something for me" — if you don't care about the order, then why conform to Comparable?

Now, if we get rid of the boilerplate but still require the author of the type to make a conscious decision about how properties are compared, I think that's a win.

1 Like

But I do care about the order - that's why I chose it in my struct declaration. With the order of the stored properties. :slightly_smiling_face:

I get where you're coming from, I think we just disagree on the judgement call here.

Admittedly the conservative option here would be to require an explicit order be specified initially, then gather real-world experience and consider removing that requirement later. The converse would be problematic because it'd be source-breaking.

Still it remains to be seen what the syntax would look like.

Also, in their current state, I think using macros for this is non-viable. Macros introduce far too much build-time overhead. But some new built-in syntax could be viable, e.g.:

struct Humanoid: Comparable(using: [\.race, \.age]) {
    var age: Int
    var race: Race
}

…still, even as I write that, I now immediately start to worry about that list getting out of sync with the actual stored properties, because it's also immediately obvious that it'd be useful to be able to omit some stored properties from use for Comparable. e.g. if a gender field were later added to this example but it's considered irrelevant for ordering. But, how do you know (as a reader of the code after the fact) that omitting it was intentional and not an oversight?

A page could be borrowed from Observable's book with e.g. @NotUsedForComparison property decorators, but now we're starting to mix-and-match syntax in increasingly complex ways (e.g. what if you then list a @NotUsedForComparison property in your ordering? Sure, it can be caught by the compiler, but it's not ideal that it's possible to begin with).

I know that you did :smile: But I also know that many codebases, including ones I work on, might not be as disciplined. For coordinating work across developers, too, I'd prefer for the contract to be explicit in code, rather than having every developer on my team watch every PR for the insertion/reordering of a property, lest it change semantics.

Agreed :smile:


And I think this is a fantastic summary of at least partially why Comparable conformance isn't synthesized by default :sweat_smile: The source of truth needs to live somewhere, and when you want to make it explicit and consistent, finding a succinct way to write it out can be really tough.

Finding a balance between implicit and explicit can get exceedingly complicated until... you come full circle to realize that just centralizing that source of truth to an single implementation of < can be easier than littering the same information all over the place.

(That's not to say that not synthesizing Comparable conformance has necessarily been an intentional decision — it's just that these are the challenges that need to be solved in order to do it. It's also why Codable went with the CodingKeys approach, for the many drawbacks that has too)

2 Likes

I think the discussion about whether or not Comparable should be synthesised is separate from whether Bool should be comparable.

Let's imagine, for the sake of argument, that we're talking about a way to implement Comparable explicitly. This works on nightly:

enum SortOrder {
    case ascending
    case descending
}

func isLessThan<Root, each Value: Comparable>(
    _ leftRoot: Root,
    _ rightRoot: Root,
    comparing properties: repeat (KeyPath<Root, each Value>, SortOrder)
) -> Bool {

    for (kp, sortMode) in repeat each properties {
        let (left, right) = (leftRoot[keyPath: kp], rightRoot[keyPath: kp])
        switch sortMode {
        case .ascending:
            if left < right { return true }
            if left != right { return false /* left > right */ }
        case .descending:
           if left > right { return true }
           if left != right { return false /* left < right */ }
        }
    }
    return false /* left == right */
}

And you can use this to conveniently compose comparable properties:

struct Account: Comparable {
  var name: String
  var isActive: Bool

  static func < (lhs: Self, rhs: Self) -> Bool {
    isLessThan(
      lhs, rhs,
      comparing: (\.isActive, .descending), (\.name, .ascending)
    )
  }
}

let accounts = [
    Account(name:"Albert", isActive: false),
    Account(name:"Alice", isActive: true),
    Account(name:"Becka", isActive: false),
    Account(name:"Bob", isActive: true)
]

for a in accounts.sorted() {
    print(a)
}

// Prints:
// Account(name: "Alice", isActive: true)
// Account(name: "Bob", isActive: true)
// Account(name: "Albert", isActive: false)
// Account(name: "Becka", isActive: false)

(Godbolt lets you execute this in the browser)

This is certainly much more convenient than writing the comparison function yourself. It's explicit about which properties are compared in which order, and can allow for customisation if our default way of ordering booleans doesn't suit your use-case.

But it does rely on booleans being comparable. Otherwise this gets a lot more awkward, for no really good reason.

1 Like

I think this is a very good argument against automatic comparable conformance.

Should the same reasoning apply to tuples?

Before refactoring:

typealias S = (
    x: Int,
    y: Float
)
let a = S(x: 1, y: 2.0)
let b = S(x: 2, y: 1.0)
...
print(a < b) // true

After refactoring:

typealias S = (
    y: Float,
    x: Int
)
let a = S(y: 2.0, x: 1)
let b = S(y: 1.0, x: 2)
...
print(a < b) // false. Oops. Surprise!! 🤔
2 Likes

This seems like an extreme example, for a couple reasons:

  • Your refactoring has also reversed the order of the tuple arguments at the usage site, which isn't the same situation as stored properties in a struct (synthesized memberwise inits notwithstanding).
  • If that tuple is important enough that it should be given a name, it probably should be a struct. As a client, it would be frustrating to find that something you're using that looks like a struct doesn't actually work that way (e.g., it can't be extended) because it's just a typealias hiding a tuple.

The surprise here is because the example is intentionally confusing for reasons that extend beyond just tuple comparability.

3 Likes

In fact I was as objective as possible when compared the two... As was the case with a struct with a memberwise initialiser, first I changed just the fields in the tuple, then got the compilation warnings for "let a = S(x: 1, y: 2.0)", etc lines: "Expression shuffles the elements of this tuple; this behavior is deprecated" (in case of struct those were compilation errors), after which I changed the order of the arguments.

I am! In actual, by hand, boolean arithmetic false is 0, true is 1, and is *, and or is +. true is naturally, and trivially, greater than false.

Well, xor is +, not or.¹ And only when you're using + on ℤ/2ℤ rather than ℤ, which means you don't have a classical notion of "greater than" any longer. When you lift back to ℤ, –1 is just as good of a choice of a representative as 1 is. There's no strong mathematical argument for choosing between +1 and –1 for true, and indeed many languages and systems have made each choice.


¹ or is ab + a + b

6 Likes

And here I thought Forth only chose -1 as true because it unified bitwise and logical operations, not because there was an actual mathematical justification for it.

In C for example or is similar to +: anything not equal to 0 is true:

  if (1 + 1) printf("true\n"); else printf("false\n"); // true

Xor would not mean "or":

  if (1 ^ 1) printf("true\n"); else printf("false\n"); // false

† The anomaly would happen when you add two non 0 values and the result is 0 due to overflow.

I like thinking about "and" being "*" and "or" being "+" for this reason: if you multiply 0 by anything the result will be 0, meaning you don't have to calculate the right hand side:

    if (0*foo()) { ... } // 0 (false) anyway, why bother calling foo() 🤔

(although calling "foo()" in this case might still be required by the C standard).

Similarly "1 + foo()" when treated as bool expression:

if (1 + foo()) { ... }

would give "true" regardless of foo() result (the overflow anomaly † aside).

These are very similar to Bool's short-circuiting && and || operations:

if (false && foo()) { ... } // foo() won't be called!
if (true || foo()) { ... } // foo() won't be called!

The other (completely non-scientific, I admit) "proof" of why Bool's false and true are 0 and 1 is this: Bool's && and || are "obviously" similar to bitwise & and | and to give consistent results false should be 0 and true should be 1:

0 & 0 == 0  // false && false == false
0 & 1 == 0  // false && true  == false
1 & 1 == 0  // true  && true  == true
0 | 0 == 0  // false || false == false
0 | 1 == 1  // false || true  == true
1 | 1 == 1  // true  || true  == true

Similarly (and equally non-scientific): "UInt8 is isomorphic to (Bool, Bool, Bool, Bool, Bool, Bool, Bool, Bool). UInt8 is Comparable. So should be the tuple of bools. So should be the Bool itself."

... unless foo() returns –1.

Also, everything you wrote applies equally to +1 and –1.

The reason why false is 0 in C and true is 1 in C is that the standard defines them that way. Other languages (Forth, o.g. BASIC dialects, ...) have true be –1. Everything works out just about the same either way.

1 Like