Switch exhaustivity checking doesn't understand ranges

e.g.:

func bestMatch(forInputBitDepth bitDepth: Int) -> Int {
    switch bitDepth { // ❌ Switch must be exhaustive
        case ...8:    8
        case 9...10: 10
        case 11...:  12
    }
}

It's amazing how often I hit this. Maybe it's just me, but this rudimentary reasoning about ranges pops up frequently.

This has been noted before. What I couldn't find in prior discussions is a great workaround for this limitation. The best I've seen mentioned so far is default: fatalError("This should be impossible but obviously isn't. 😔"). @scanon promises that fatalError will not actually make it out into the compiled binary. But, I'm a suspicious bugger, so I want to ensure it doesn't. Is there some similar construct I can use that will actually emit a compiler error if the default clause somehow makes it all the way through the optimiser and is about to be emitted into the final binary?


P.S. The phrasing of the compiler error in these cases is a bit antagonistic. The switch obviously is exhaustive, but the compiler is mistaking its own limitations for user error. Changing it to e.g. "Unable to tell if this switch is exhaustive - please rephrase it or add a default clause" would at least not raise my hackles as much every time I see it. :slightly_smiling_face:

4 Likes

In this particular case:

extension ClosedRange {
  func categorize(_ input: Bound) -> ComparisonResult {
    switch (input >= lowerBound, input <= upperBound) {
    case (true, true):
      return .orderedSame 
    case (true, false):
      return .orderedAscending
    case (false, true):
      return .orderedDescending
    case (false, false):
      preconditionFailure("cannot order incomparable values (like NaN)")
    }
  }
}

switch (9...10).categorize(bitDepth) {
// …
}

In the general case, though, there’s not a good way to do this (to my knowledge) because the optimizations that run early enough to still produce diagnostics don’t include integer range reasoning.

You can poorly do this, however, by declaring a function that doesn’t exist in a C header, and then calling it in your default case; if it’s not optimized away, you’ll get a linker error. Just be sure to only use that trick in optimized builds; in a debug build you should stick to fatalError (or preconditionFailure) instead.


EDIT: This should in no way be taken as “Swift can’t ever add explicit support for Ranges to the switch exhaustivity checker”; that’s just been discussed plenty in the previous threads.

5 Likes

I liked and understood Joe’s explanation of why this is problematic to implement. We can only cover a finite number of special cases.

Interesting approach, but upon a closer inspection you've just changed one switch with the unreachable default statement with two nested switches with the unreachable (false, false) condition. :wink:

Wonderful workaround! Do you say "poorly" just because this would compile/link in debug and fail to link in release? Expanding:

@inline(never) func foo(_ v: Bool) -> Int {
    // this switch requiters default statament
    switch v {
        case false: 0x1234
        case !false: 0x5678
        default: unreachable()
    }
}
//->  0x100002468 <+0>:  tst    w0, #0x1
//    0x10000246c <+4>:  mov    w8, #0x1234
//    0x100002470 <+8>:  mov    w9, #0x5678
//    0x100002474 <+12>: csel   x0, x9, x8, ne
//    0x100002478 <+16>: ret

func unreachable() -> Never {
    #if DEBUG
    preconditionFailure("unreachable code!")
    #else
    nonExistingFunction()
    // void nonExistingFunction(void); in BridgingHeader
    unreachable() // to make it truly unreachable
    #endif
}

There could be pathological cases when DEBUG is defined with -O but other than those this looks perfect. Could we have it in the standard library? :wink:

It's reachable for floats! And it's a different sort of uncovered case, too, in that you can't reach it via typo. But yes, if you want the guarantee you'd have to do the same sort of trick (and also make the function inline-always).

No, I say "poorly" because you get a linker error, which, if you're lucky, tells you the filename of the uncovered switch, but not a line number or function name. (Okay, sometimes the "referenced from" line includes the mangled name of the function, which is slightly better.)

2 Likes

Also for RockPaperScissors kind of types and anything else that has a bogus Comparable conformance, but I was talking about the specific integer "func bestMatch(forInputBitDepth bitDepth: Int) -> Int" example.

Yep :smiling_face_with_tear:. I guess in that case you'd want to provide the missing symbol (with a trap in it), run the app and figure out where it traps but this is not good.

In this case, why not just do

func bestMatch(forInputBitDepth bitDepth: Int) -> Int {
    switch bitDepth {
        case ...8:    8
        case 9...10: 10
        default:     12
    }
}
2 Likes

Indeed that's what I sometimes resort to. The downside is that it's less clear what the intent was, and that it opens the door to errors (in the face of future changes to the switch statement - not a big concern in a simple case like this, but as switch statements get larger the concern increases superlinearly).

Since Swift introduced me to exhaustivity checking, I've increasingly come to think of default clauses as bad. In my experience they often indicate laziness, lack of proper strong typing, or some other such code smell. They are of course unavoidable in some cases (e.g. open enums from 3rd party modules) - and genuinely warranted in some too - but I find it's wise to avoid them if possible. A conceptually trivial situation like this, just working with a number line, is not something that (in principle) should require default, IMO.

2 Likes

This is the sort of thing that makes me wish we had an assume and assumeUnchecked magic function. Or at the very least, unreachable and unreachableUnchecked like Rust does, so we can make such a function ourselves.

1 Like