Pitch: support for case and let when using where within switch and catch

This pitch has not yet been formalised into a proposal. Given enough support, I will convert this into a correctly formatted proposal document and (hopefully) provide an implementation.

Introduction

The switch and catch statements contain an if-like pattern using where. This should support every feature available to if, including binding variables with if let and if case. This proposal aims to include these possibilities within the grammar.

Motivation

In a switch statement, the grammar only allows for expressions evaluating to Bool after where:

enum A {
    case one
    case two
    case three
}

enum B {
	case string(String)
	case number(Int)
}

let a = A.one
let b = B.string("foo")
let s = "foo"

switch a {
case .one where s == "foo":
	print("one")
case .two:
	print("two")
case .three:
	print("three")
}

but it is not possible to check what b is and bind it without another layer of indentation and repeating the code in the default case.

switch a {
case .one:
	if case .string(let s) = b {
		print("one and \(s)")
	} else {
		print("default")
	}
case .two:
	print("two")
default:
	print("default")
}

A similar issue affects error handlers:

do {
// some error-throwing code
} catch ExampleError as e where e.string == "foo" {
// handle the error
} catch ExampleError as e {
// handle a less specific error
}

and it is not possible to check b without:

do {
	do {
	// some error-throwing code
	} catch ExampleError as e where e.string == "foo" {
		if case .string(let s) = b {
				// handle the error
			}
		}
	}
} catch ExampleError as e {
// handle a less specific error
}

Proposed Solution

This proposal aims to expand the case x where y: and catch x where y: syntax to include every feature offered by if and guard, that is, make the y part any condition-list instead (see the grammar for the definition). Thus the following code will become valid:

let b = .string("foo")

switch a {
case .one where case .string(let s) = b: 
	// matches when a is .one, then verifies b is .string
	// and binds the string to s.
	print("one and \(s)")
case .two:
	print("two")
default:
	print("default")
}

instead of the more cumbersome alternative given in the motivation section.

Detailed Design

Grammar changes

From the current grammar:

condition-listcondition | condition , condition-list
conditionexpression | availability-condition | case-condition | optional-binding-condition
case-conditioncase pattern initializer
optional-binding-conditionlet pattern initializer ? | var pattern initializer ?

(from the while grammar, where condition-list is defined)

where-clausewhere where-expression
where-expressionexpression

(from the switch grammar, also applies to where in catch statements)
There are no modifications included for the condition-list section. This proposal would redefine the where-expression section as below:

where-expressioncondition-list

The original behaviour is still captured as condition-list can be replaced by condition which can then be replaced by expression, which is also required to evaluate to a boolean. This allows any condition valid in if, while and guard statements to be used in where.

Behaviour for switch

As the where keyword now uses condition-list, the terms case-condition, optional-binding-condition, availability-condition and expression can be stacked with ,:

let c: Int? = 5
let d: Int? = nil

switch a {
case .one where let c:
	print("one and \(c + 1)")
case .two where let c:
	print("two and \(c + 2)")
case .three where d == nil, let c, case .number(let n) = b:
	// given that:
	// a is .three, d is nil, c exists, b is .number (bind to n)
	// execute the below code.
	print("three, d is nil and \(c + n)")
default:
	print("default")
}

Again, any variables or constants bound by var or let after where are handled following the binding of variables in the case preceding the where. Every part of the case is handled in the direction of writing (in English, left to right).

enum C {
	case maybeInt(Int?)
	case maybeStr(String?)
}

let b2 = C.maybeInt(2)
let b3 = B.number(5)
switch b2 {
case .maybeInt(let n) where case .number(let m) = b3, let realN = n:
	// given that:
	// b2 is .maybeInt (bind n: Int?), b3 is .number (bind m: Int), n exists
	// run the code below.
	// order is important for both sides of the where.
	print(m * real_N)
case .maybeStr(let s) where case .string(let t) = b3, let realS = s:
	print(m + real_S)	
default:
	print("default")
}

Alternatively, using the .maybeInt(let baz?) syntax:

switch b2 {
case .maybeInt(let n?) where case .number(let m) = b3:
	// b2 is .maybeInt (bind n: Int if it exists), b3 is .number (bind m: Int)
	// order is important for both sides of the where.
	print(m * real_N)
case .maybeStr(let s?) where case .string(let t) = b3:
	print(m + real_S)
default:
	print("default")
}

Additions to fallthrough usage rules

Any additions to the list of variables/constants bound by the case can still prevent fallthrough to areas without access to these variables. The code below will therefore not compile:

switch b2 {
case .maybeInt(let n):
	fallthrough 
	// error: fallthrough from a case which doesn't define realN, t
case .maybeInt(let n) where case .string(let t) = b3, let realN = n:
	print(t + String(realN))	
}

Additions to default rules

For this section:

enum E {
	case a
	case b
	case c
}
enum F {
	case a(Int)
	case b(String)
	case c(E)
}
let d = E.a
let f = F.a(1)
let option: X?

The cases where default in a switch is not needed will be extended as follows:

If there is a case .a where let option pattern,

  • either the case case .a where option == nil, (to handle the optional)
  • or case .a (a case without the where clause)
    must be included.

If there is a case .a where case E.c = d pattern:

  • the cases case .a where case E.a = d and case .a where case E.b = d (for all enum cases),
  • or the case case .a (a case without the where clause)
    must be included.

For enumerated types with associated values, this extends like the case for switch itself:

If there is a case .a where case F.a(1) = f pattern:

  • the case case .a where case F.a(let x) = f,
  • or the case case .a (a case without the where clause)
    must be included.

If the specified value is one of an enumerated type, like case .a where case F.c(.a) = f:

  • the case case .a where case F.a(let x) = f,
  • or the cases case .a where case F.a(.b) = f,case .a where case F.a(.c) = f (for all cases of E)
  • or the case case .a (a case without the where clause)
    must be included.

For multiple conditions, there must be one case for each combination possible.
For structure checking, checking every alternative may be too much work in extreme circumstances. For a very large number N:

enum A1 {
	case a(A2)
}
enum A2 {
	case a(A3)
}
//... (from 1 to N)
enum AN {
	case a(B)
}
enum B {
	case one
	case two
}

let x: A1 = .a(.a(.a(... .a(1)))) //... from 1 to N
switch x {
case .a(let a2) where case .a(.a(... .a(.one))) = a2: //... from 2 to N
	print("B is 1")
case .a(.a(let a3)) where case .a(.a(... .a(.two))) = a3: //... from 3 to N
	print("B is 2")
// no default required, takes a long time to show.
}

If showing that no default is required takes too long due to heavily nested case ... where case structures, a default case can be added. If a default case is present, the verification that no default is required does not happen under the current compiler. If a mistake was made and the default is reached, simply include a print statement:

switch a {
// long and confusing case sequence
default:
	print("unexpectedly reached default case at line \(#line)")
}

Behaviour for catch

This is very similar as that of switch, with errors instead of enum cases.
As the where keyword now uses condition-list, the terms case-condition, optional-binding-condition, availability-condition and expression can be stacked with ,:

let c: Int? = 5
let d: Int? = nil

do {
// some error-throwing code
} catch ErrorOne where let c {
	print("one and \(c + 1)")
} catch ErrorTwo where let c {
	print("two and \(c + 2)")
} catch ErrorThree where d == nil, let c, case .number(let n) = b {
	// given that:
	// a is .three, d is nil, c exists, b is .number (bind to n)
	// execute the below code.
	print("three, d is nil and \(c + n)")
} catch _ {
	print("default")
}

As above, any variables or constants bound by var or let after where are handled following the binding of variables in the case preceding the where. Every part of the catch is handled in the direction of writing (in English, left to right).

struct ErrorMaybeInt: Error {
	var maybeInt: Int? = nil
}

struct ErrorMaybeStr: Error {
	var maybeStr: String? = nil
}

let b3 = B.number(5)
do {
// some error-throwing code
} catch ErrorMaybeInt as e where
  case .number(let m) = b3, let realN = e.maybeInt {
	// given that:
	// - thrown error is ErrorMaybeInt (bind n: Int?),
	// - b3 is .number (bind m: Int), n exists
	// run the code below.
	// order is important for both sides of the where.
	print(m * real_N)
} catch ErrorMaybeStr as e where
  case .string(let t) = b3, let realS = e.maybeStr:
	print(m + real_S
} catch _ {
	print("default")
}

Additions for showing a do statement does not leak errors

As in the discussion for switch, the type-checker inferring that a catch sequence does not allow any errors through can be avoided with catch _ {break} or similar when it is clear that any error will be handled by a previous handler. This avoids the type-checker spending time trying to show that no errors can escape the block and can be done with a single line. If a mistake was made and the default is reached, simply include print statements:

do {
// long and confusing error-throwing code
} catch
// long and confusing catch sequence
} catch {
	print("unexpectedly reached default catch statement at line \(#line)")
	print("the error was \(error)")
}

If something truly terrible could happen as a result, fatalError may be appropriate.

Source Stability

The changes are purely additive, so no source code will be broken by this change.
The space of valid programs has been expanded.
Deeply nested structures may cause type-checking to slow, but this can be prevented by adding default: break to any extreme cases. This will not need to happen in existing code as the complications for showing that no default is required are introduced by this proposal's changes.

Interestingly, Swift's syntax highlighting (in Xcode at least) seems to already colour where case and where let statements correctly. The reason for this is unknown.

ABI Stability

This change affects only the type-checking and parsing of the language and makes no changes to the Standard Library. The changes to type-checking and parsing are purely additive, so there is no effect on existing code.

Implications of Adoption

As there is a change to the grammar, code using this feature cannot revert to the current grammar without significant change. The change will not affect existing code. The capacity to exponentially slow the compiler when checking that a default case is not required may cause problems for new code, but at present, default: break may be used to alleviate this.

Future directions

fallthrough

The modifications to fallthrough restrict its use cases when where case statements are used. Therefore a mechanism to avoid fallthrough errors where variables are defined in one case but not in another may be required.

Other condition statements

If any condition (as in the grammar) is added to the language in the future, if, guard, while and as of this proposal, where, will need to support this. A unified system for condition-list statements may be of benefit to the language design.

Similarly, any future conditional expression like where or if may be extended to support the whole condition-list range if this is feasible.

Unexpected default mark and test

An addition to the testing framework could be made to ensure that while tests are running, no specially marked default blocks are reached in either switch or do catch. An annotation to the default or catch _ could be added for this purpose, for example:

switch a {
// lots of cases
@unexpected default:
	fatalError("unexpected default at \(#line) reached due to \(a)")
}

Alternatives Considered

No changes at all

These changes do not add any new expressibility to the language, as any where expression with a condition-list can be rewritten using if:

func defaultBehaviour() {} // the default case behaviour
func action(e: X,h: Y) {} // the action to be performed

// This code:

switch a {
case .one where c == d, let e = f, case .two(let h) = g:
	action(e,h)
default:
	defaultBehaviour()
}

// is equivalent to:

switch a {
case .one where c == d:
	if let e = f, case .two(let h) = g {
		action(e,h)
	} else {
		defaultBehaviour()
	}
default:
	defaultBehaviour()
}

There are some problems; the second sample:

  • is less readable
  • has more indents (3 compared to 2)
  • repeats defaultBehaviour() outside of default.
    This method does, however, shorten the line containing case, but this is not as big of an issue compared to losing readability and repeating part of the code.

The let x? binding syntax

When an enumerated type includes an optional, let x? can be used to bind x when it is not nil. It is already confusing, as the ? gives the appearance of an optional being added, not removed. Extending this to checking where case would be even more confusing:

switch a {
case .one(let x?,let case .two(let z) = y):
	//...
}

instead of the proposed syntax:

switch a {
case .one(let x, let y) where let x, case .two(let z) = y:
	//...
}

The let x? and let case are, however, shorter. Shorter syntax is not preferable to more readable syntax, especially since the shortening is very minor.

Additional/other keywords

There are enough keywords allocated to express these statements, and where case seems to read nicely already. Having a different set for where would be confusing, and contravene the aim of this proposal: to bring parity to where,if,guard and while.

where in switch and not in do catch

Implementing this for only switch and not catch would leave an obvious improvement for another proposal, and would make the grammar unnecessarily complicated with two different where patterns.

Mandating the verification that cases or catch blocks are complete

From the earlier sections, the type-checker may be forced to check an exponentially growing structure to detect the requirement of a default case or catch. This can also be easily remedied by a tiny default block.

Acknowledgements

Thank you to Albert Stark for GitHub-related information.

9 Likes

This is exciting! Did you consider the for-where case too? I believe this is currently invalid:

for item in items where let property = item.property {
    // …
}

This example feels like a bit of an anti-pattern, because it's testing a value that's entirely unrelated to the subject of the switch. When I see that where clause, I'm usually expecting it to be a value derived from the pattern match on the left-hand side, even though nothing requires it to be.

I would write that as follows, which makes it clear from looking at the switch as a whole that both a and b matter to some part of the pattern match but that b is explicitly ignored when matching .two, and it makes it clear that values are potentially being bound that are dependent on both a and b:

let b = .string("foo")

switch (a, b) {
case (.one, .string(let s)): 
	// matches when a is .one, then verifies b is .string
	// and binds the string to s.
	print("one and \(s)")
case (.two, _):
	print("two")
default:
	print("default")
}

That being said, I wouldn't like to see us special case where clauses specifically to add pattern matching support. I'd rather see us add support for generalized pattern matching Boolean expressions outside of control flow statements, something that's been discussed previously on the forums:

let isOddInteger = value is case .integer(let n) where n % 2 != 0

Now, that wouldn't help the case that I quoted from your motivation because my idea of an is case expression is that bindings would only be in scope within the where clause of the expression, not into the body of some control flow statement, so you couldn't write where b is case .string(let s) and then use s inside the case body. But I think that's fine; hitting that limitation is an indication that the pattern is a bit complex and should be refactored, rather than trying to spell it all out as a longer chain of things.

13 Likes

The most common case where I run into this that can't be easily translated is when you effectively want to continue matching on a property of a value bound in the case, like you might in a guard chain:

enum E {
  case e(S)
}

struct S {
  var f: F
}

enum F {
  case f(Int)
}

let e = E.e(.init(f: .f(0)))
switch e {
case .e(let s) where case .f(let val) = s.f, val > 3:
default: print("Other")

This feels 'natural' to me because of how Swift treats if/guard chains, where each comma-separated clause is sequential—but this breaks down in the contexts where comma separated clauses are coequal and 'parallel', as in a switch.

That said, I agree with you that trying to shoehorn pattern-matching into a new position is perhaps not the right fix here. Another option that would satisfy my use cases would be structural matching on property values, so that you could do something like:

case .e(let s { f: .f(let val) }) where val > 3:

I believe there's been discussions about structurally matching on struct values though I think what I really want would be a bit more general and you could match on any property or set of properties.

2 Likes

Yes, this can work with for and where as well. The update to the grammar actually covers this already (but I didn't notice it):

for-in-statementfor case? pattern in expression where-clause? code-block

(the for-in statement grammar)

There is a where-clause in there, so extending where as above would have this effect.

1 Like

This example feels like a bit of an anti-pattern, because it's testing a value that's entirely unrelated to the subject of the switch. When I see that where clause, I'm usually expecting it to be a value derived from the pattern match on the left-hand side, even though nothing requires it to be.

Trying to impose a ‘relatedness‘ restriction on the where pattern would be extremely difficult, as the tie between the two could be arbitrarily complex. I should have more clearly indicated that this style should (not must) be used when there is a relationship between an and b or if the tuple-like syntax is too complicated/long. Under a loose definition of ‘related‘, the mere fact that I am using a and b in the same pattern is a relationship between them. If there are heaps of cases:

switch a {
case .one where case .string(let s) = b:
    print("one and \(s)")
case .two:
	print("two")
case .three:
    print("three")
// and so on...
}

the (.two,_,_//and so on) could become arduous. Further, removing case and allowing let would not allow for parity with if, guard and while.

That being said, I wouldn't like to see us special case where clauses specifically to add pattern matching support. I'd rather see us add support for generalized pattern matching Boolean expressions outside of control flow statements, something that's been discussed previously on the forums:

I also do not intend to make where clauses ‘special’, but to bring them in line with if,guard and while. The pattern matching in any boolean expression could be interesting, but what would be the impact on the compiler? Also, why continue to enforce different grammars for where and if even if the pattern matching in boolean expressions is implemented?

This feels 'natural' to me because of how Swift treats if/guard chains, where each comma-separated clause is sequential—but this breaks down in the contexts where comma separated clauses are coequal and 'parallel', as in a switch.

Could you elaborate on this? What about switch makes the comma-separated clauses order-independent that doesn’t also affect if and guard?

This example syntax seems tortured: where do the { } come from? It also makes even more brackets than the proposed solution. If there are perhaps three layers of struct between the two enums, these can be handled with a sequential a.b.c.f instead of let s { a: { b: {c: {f: .f(let val) }}}}

Comma-separated clauses in a switch are semantically an "or" (since matching any pattern in the list causes the body of the case to execute) but the clauses in an if or guard condition are semantically an (ordered) "and": all the clauses must evaluate to true for the body to execute, and later clauses are evaluated in a context where they can assume that prior clauses have evaluated to true (and bound their patterns, etc.).

Sure, I don't necessarily hold this up as the ideal syntax, only as an illustration of the sort of functionality I would want property-based pattern-matching to support. If we were going to go down this design rabbit hole I think it would also be perfectly appropriate to consider whether there ought to be shorthand for the nested property case as well!

Thank you for clarifying. There could be some confusion where , preceding case means ‘or‘ and , following case means ‘and‘, but there is already strange behaviour without this change:

The compiler itself provides a warning when , as ‘or‘ is combined with where. Making another suggestion, this could be used for where to avoid the confusion:

switch a {
case {.one,.two} where ... :
// either one or two, but always check the where condition
case .one,
    .two where ... :
// one or (two with the where condition)
}

The choice of { } is to avoid looking like a tuple ( ) or like an array [ ]. Consistency with bound variables still applies.

Again, I am aiming to bring parity to where, if, guard and while, that is, make them act in the same way. If the , was ‘or‘ for where and simultaneously ‘and‘ for if, guard and while, this would contravene this aim.

let b = .string("foo")

switch (a, b) {
case (.one, .string(let s)): 
	print("one and \(s)")
case (.two, _):
	print("two")
default:
	print("default")
}
2 Likes

Of course that is one way of replicating the logic, but consider:

enum VeryLongEnum {
    case .one
    case .two
// and so on…
    case .fifty
}
let a = VeryLongEnum.twelve
let b = .string(“hello”)

If b was needed for 10 of the cases, the proposed syntax would allow b to be completely ignored for the 40 cases that do not need it. The current syntax would force the repetition of (.seven,_) at all 40 cases that don’t use b. The problem would only compound if there were more conditions than just b. If there were variables b,c,d, a case that didn’t need any of them would become (.seven,_,_,_).