SE-0380: `if` and `switch` expressions

It should also be noted that break x already has an existing meaning of "break out of the statement labeled x", e.g:

x: switch Bool.random() {
case true:
  switch Bool.random() {
  case true:
    break x // Breaks out of the outer switch, so won't print hello.
  case false:
    break
  }
  print("hello")
case false:
  break
}

Personally I think it would be odd to have this syntax mean two different things depending on whether x is a local variable or a label on a parent statement.

12 Likes

Thanks for writing all that up, Ben!

I suppose that was (partially) my point. The current state of affairs forces users to confront this issue early rather than kicking the can down the road only to confront it once the expression becomes marginally more complex. But I find this:

a solid justification for why this would actually end up being a better point to confront the user with the problem.

Invoking SE-0255 here feels somehow inverted to me. There, we were starting from a position of having an existing multi-statement form, for which the progression from the single-expression form was immediately clear. We were also operating with the existing precedent of the single-expression closure to multi-statement closure progression.

Agreed. Which is why I think the answer to this question:

is at the very least "we should have confidence we aren't backing into a corner." Thanks for exploring the various options! This is much more compelling to me than just punting on the multi-statement question. I agree that (5) seems at least plausible and leaves me less concerned that we'd end up in an unhappy place. I share your feels-based discomfort with the fully-fledged last-expression-return rule.

Does the discomfort still not kick in when this potential direction interacts with SE-0255? I suspect it will be not-infrequent for these expressions to appear as top-level expressions in a computed variable/function, and then each branch could grow arbitrarily large with no explicit return to be found anywhere in the body.

2 Likes

Overall I'd say my feeling for adding this feature is +0.3.

For multiline expressions like the following:

let result = if condition {
  3
} else {
  4
}

It seems to me to take away from overall readability since it is harder for me to reason that the output of the expression is bounded to the constant. I think the behaviour of the scoping here becomes a little more confusing. Especially for longer examples where there is a lot going on and the output is further from the declaration of the constant like the following:

let result: Int = switch state {
case .state1: doThing1()
case .state2: doThing2()
case .state3: doThing3()
case .state4: doThing4()
case .state5: doThing5()
case .state6: doThing6()
default: doOtherThing()
}

However, when it is the only item within a particular scope, I think this proposal makes a lot of sense, for example, I think having this behaviour when an if statement or switch is the only expression in the body of a function, the returning behaviour is much more clear and easier to reason about:

func doSomething() -> Int {
    switch state {
    case .state1: doThing1()
    case .state2: doThing2()
    case .state3: doThing3()
    case .state4: doThing4()
    case .state5: doThing5()
    case .state6: doThing6()
    default: doOtherThing()
    }
}

So overall I'd say I'm -1 on allowing these statements to be assignable to values, but in the case where they are the only expression to exist in their scope, in a closure or function, I am +1.

3 Likes

I can think of a lot of examples from my own code, especially when switching over enums, when switch expressions do get long and this feature would still be useful. I’m just not convinced making the exception that these statements can implicitly return the last expression would make sense to users.

Perhaps someone could elaborate why generators and producing an expression are so different, because I don’t think there’s such a fundamental distinction. Namely, we could combine if and switch expressions with loop expressions (like Python’s comprehensions):

Currently, generating a sequence from scratch is quite awkward and requires (IMO) difficult-to-discover global functions. So I think yield could also be used here to set the value for the produced expression.

1 Like

It's a concern, yes, but I think it's mitigated enough. Generally, there is a strong culture in Swift to avoid lengthy indented if blocks, because of guard and early return generally, early distaste for the pyramid of doom etc. And in the case, despite this, of a function containing a single if expression with no return and then a gigantic block, it is at least clear from indentation that is what is going on.

1 Like

Review Extended

Thanks everyone for all of the constructive feedback so far! I've extended the review period to December 30th, as there is still a lot of healthy discussion and new signal. There are no formal revisions to the proposal at this time, so please continue the review discussion in this thread.

In particular, I would like to solicit more discussion on this possible path for supporting multi-statement if/switch expression branches that was brought up here:

Thank you!

Holly Borla
Review Manager

9 Likes

Hi all,

Apologies for the late review. I'm supportive of the direction of this proposal: it's a nice ergonomic improvement that I expect I would use on a near-daily basis. I have a couple of quibbles:

return in an if or switch expression should not be permitted

The discussion here seems to be leaning toward allowing a return inside an if or switch expression, e.g.,

let value = if array.isEmpty { 0 } else { return 3 }

@Paul_Cantrell notes that peer languages allow this, but I think they all got it wrong. As @ktoso notes that this is considered an anti-pattern in Scala, and I think it's worse for Swift. Right now, the only way for control flow to exit out of an expression is via a thrown error, and we consider that to be so important that we require any throwing expression to be marked with try to indicate to readers that this control flow out of the expression can happen. Similar reasoning follows for suspension points with await. Having an expression that has control flow out of itself would undermine this long-held philosophy in Swift.

It's also very easy to make a mistake with this feature; a stray return (say, due to refactoring) would exit the function rather than produce the intended value, and especially with something like return nil the type checker won't save you:

struct X {
  var b: B?
  init?(a: A?) {
    self.b = if let b = a?.getBIfAvailable() { b } else { return nil }
    if /*some other condition*/ {
      return nil
    }
  }
}

I see basically no upsides to the early return; better to ban all non-error-handling control flow in if and switch expressions. This will only become more important if/we go to multi-statement. Speaking of which...

Multi-statement if and else bodies

I am quite certain that, if we accept this proposal, we will immediately want to turn around and discuss a proposal to extend if and switch expressions to support multiple statements. I think that's fine; I'd like to see multi-statement support, but I don't need it in this specific proposal because the gains from this proposal are significant already, and it would be a fine "resting place" for Swift if we find that there are problems with going multi-statement.

Syntax-wise, I'm coming around to option #5 presented here. It's low-ceremony, and by not allowing any control flow outside of the expression (see my previous section!) the chances of being confused are small.

Interlude: making source-generating features expressible in the language

Since I'm working on macros now, one of my overarching goals is to look at how we can take the various source-generating features that we have today and make it so that they are expressible as macros. One important step here is being able to express the semantics of existing source-generation features in the language. For example, can we write a macro that does a result-builder transform on a closure? Or a macro that implements string interpolation by creating an instance of the ExpressibleByStringInterpolation type and forming appendInterpolation calls?

Right now, you can almost do these things, but not completely. If we were to get to the point where we can do them completely, then the overall language (and its implementation) can get simpler, because these features truly do become syntactic sugar. Additionally, it means users can define similarly-powerful sugar through the macro system, so the language can stay simpler.

Type-checking across multiple branches

As a user of Swift, I really want the different branches to be "joined" into a common super type. I'm mostly motivated by two cases, the nil case because I find mapping optionals to often be unwieldy:

let x = if <something> { <produce a value> } else { nil }

and inferring generic arguments from multiple branches:

let e: Either = if <something> { .left(a) } else { .right(b) }   // infers the generic arguments from both sides

The latter case is very similar to what result builders do, because they have a custom implementation. Given this in a result builder body:

if c { thing1 } else { thing2 }

it would turn into something like this:

let __a1 = if c { 
  MyResultBuilder.buildOptional(first: thing1) 
} else { 
  MyResultBuilder.buildOptional(second: thing2) 
}

As an implementer of Swift, I know the challenges in doing the join. We chose not to do the join for opaque result types and for multi-statement closure inference specifically because of those challenges.

So, I agree with this proposal that we should keep the branches type-checked independently so we don't get mired in implementation issues and we don't end up with an inconsistency. I'd love to see a follow-on that introduced "join" behavior across all of the features, if/when we figure out how to do so without causing compile-time performance problems. Again, this proposal points to a reasonable "resting place", even though I hope we can get more in the future.

do as an expression.

I think it's pretty natural to want do as an expression, given this proposal, but it's only really useful if you have multiple statements, because do doesn't... do... much by itself. Given multi-statement if/switch, adding do into the mix is really powerful for macros that want to break down a larger expression into smaller ones.

For example, imagine a simple print macro that takes:

#print(a, b, c)

might want to do something like:

do {
  printSingle(a)
  printSingle(b)
  printSingle(c)
}

and an #assert macro might want to break down an expression so it can report on which subexpression failed, such that #assert(a > b && c > d) can be translated into, e.g.,:

do {
  if !(a > b) {
    fatalError("assertion 'a > b' failed: 'a' = \(a), 'b' = \(b)")
  }
  if !(c > d) {
    fatalError("assertion 'c > d' failed: 'c' = \(c), 'd' = \(d)")
  }
  ()
}

Note that string interpolation is essentially implemented via a compiler-internal version of a do expression (we call it a "tap" expression), which has no corresponding spelling in Swift source code. SE-0228 specifies that string interpolations desugar to something like this:

String(stringInterpolation: {
  var temp = String.StringInterpolation(literalCapacity: 7, interpolationCount: 1)
  temp.appendLiteral("hello ")
  temp.appendInterpolation(name)
  temp.appendLiteral("!")
  return temp
}())

In practice, that doesn't work because an immediately-called closure is not the same as code run directly in the enclosing context. The internal-only "tap" expression addresses those concerns, but it would be less magic---and be available for macros---if we had do expressions with multi-statement support:

do {
  var temp = String.StringInterpolation(literalCapacity: 7, interpolationCount: 1)
  temp.appendLiteral("hello ")
  temp.appendInterpolation(name)
  temp.appendLiteral("!")
  temp
}

Aside from allowing return, this proposal is a reasonable stop along the way to making a number of syntactic-sugar features be truly expressible as syntactic sugar, and put macros on a more-equal footing. Most of the future directions can be just that---something we do in the future to unlock more improvements.

Doug

27 Likes

Considering the next logical step of full expressions and choosing between these options:

1.  let v = (condition ? foo : bar) / 2.71828
2a. let v = iff(condition, then: foo, else: bar) / 2.71828
2b. let v = iff(condition, foo, else: bar) / 2.71828
3a.  let v = (if condition { foo } else { bar }) / 2.71828
3b.  let v = if condition { foo } else { bar } / 2.71828
4.  let v = (if condition then foo else bar) / 2.71828

(where 2 can be implemented today) I'm honestly not sure which one I'd prefer. 1 is concise and nice, but I came from C so may be biased. 2 - not bad, especially given that no compiler change is needed. 3a is somewhat bulky. 3b looks quite confusing. 4 is ok-ish but raises too many questions and confusion (when to use "then", when to use braces).

It feels like multi statement case should be decided upon within the scope of this pitch, otherwise we may end up in a situation considering the multi statement pitch later on and coming to a conclusion we did single-statement case wrong but it would be too late to fix it. If absolutely needed to limit the scope off this pitch I'd rather get rid of "if" and limit the scope to switch statement only (both single and multi-statement cases).

1 Like

I’m all for it. Thumbs up.

Either 3 or 5 from Ben’s list seem like the clearly preferable solution to me, if language implementors feel like it’s workable. 5 seems like a fine solution.

(Option 1 (overload the meaning of return in conditionals) it actively harmful. Option 2 (new keyword) is a possibility I’d been exploring only because it seemed like option 3 might not get off the ground. If option 5 makes the “last expression” rule work, let’s do it!)

This got a bit lost in the noise, but this being an anti-pattern in Scala has little or nothing to do with returns from conditional expressions. If you follow through the puzzler link @ktoso posted (and here’s some supplemental reading), you’ll find that it’s not merely that returning from a conditional expression is an anti-pattern in Scala; it’s that using the return statement at all, in any form, in any context is an anti-pattern in Scala.

Why? Because return in Scala closures is sometimes but not always non-local, and this makes return’s control flow very hard to reason about. That’s unique to Scala, and not relevant to the proposal at hand.


Ruby is the language I’ve used most extensively that has a similar feature, and returns from conditional expressions are certainly not an anti-pattern there — not widespread, but not frowned upon either. It’s really not that confusing from the language user side; the sort of refactoring error Douglas imagines doesn’t really happen much in practice. Not that Ruby ought to dictate Swift, but it is a cleaner precedent for this proposal than Scala.

I can in fact imagine arguments similar to some of what Douglas wrote being applied to allowing return inside of loops if the language didn’t already allow that.

None of that is to say that return inside conditional expressions is a hill I would die on, but artificially limiting it would rule out some desirable patterns, as @Ben_Cohen pointed out upthread.

9 Likes

I pretty much agree with all of this, though I really hope that it’s a ‘resting place’ rather than a ‘final resting place.’ :slightly_smiling_face:

I'd really like to see additional compelling examples other than return nil in failable initializers. This is already a weird special case in the language (since you can't return any other optional value, or even .none) and so if it were deemed essential to support this pattern I wouldn't be too troubled by saying "the only time return can appear in an if/switch expression is as return nil in a failable initializer.

I also think it is, broadly, an interesting position to take that the point at which you ought to refactor an if expression into one of the statement-based forms is the point at which you start wanting to have control flow 'out' of the expression. That's much more satisfying to me than saying we should draw the line directly after the single-expression point. It also mitigates some of my concern about the "function body that's just a gigantic if expression" I raised above. This would only be allowed if the expression really was just a giant if expression that produced a value along each branch (since there would be no explicit returns allowed anywhere).

In any case, if we start out prohibiting return in these positions, we can always enable it later if folks start clamoring for it.

3 Likes

Why not

let x? = if p { nil } else { 2.0 }
let x? = if !p { 2.0 }

Different topic, I know, but still.

I would have thought this would be enough to make this work.

let x: _? = if p { nil } else { 2.0 }

But it seems to be ambiguous, assuming it type checks the same as:

let x: _? = { if p { return nil } else { return 2.0 } }()

Is there a way to implement inference here doesn't have the issues that ternary's inference has? Because this seems like such a natural way to express that you just want to infer the wrapped type of an optional.

PS. I don't like the version without an else. I'm not very fond of optional vars defaulting to nil in the first place, but I absolutely hate the fact that let's do so. I don't think we should add any new code patterns that rely on it.

4 Likes

Just here to say I support the idea in general! I trust all you fine folks to figure out the details.

2 Likes

I just want to chime in to support this sentiment.

7 Likes

I missed this. I have not yet caught up with the discussion. I like to have if/switch expressions but only if they fit properly in the language. I don't think the proposal as it stands is there yet. So for me, it is -1 for the proposal as it stands now. I will try to catch up with the discussion and see if there is anything I can add to the discussion and elaborate on why I think it is not there yet.

6 Likes

+1 overall since I like the idea of aligning Swift closer to ML and Rust. I see the obvious inconsistencies being straightened out in future proposals.

Single-Line Expressions and Tail-Call Recursion

Any decisions here could have an impact on future language features, so hopefully all potential future features are being considered. Tail call recursion is one area that might be impacted since single-line expressions that utilize tail-call recursion are very common in functional programming languages. I think it would be great to see this functional programming style make it to Swift. Particularly if arrays are added to pattern matching in the future.

@recursive func fib(_ val: Int) -> Int {
    switch val {
    case 0: 0
    case 1: 1
    case let n: fib(n-1) + fib(n-2) // Error: Unsafe recursion 
    }
}

@recursive func fib(_ n: Int, _ previous: Int = 0, _ current: Int = 1) -> Int {
    switch n {
    case 0: previous
    case 1: current
    case let n: fib(n - 1, current, previous + current) // Success
    }
}

Haskell-like Pattern Matching on Function Parameters

To reduce nesting, some form of Haskell-like pattern matching on function parameters could make this more ergonomic. Multi-parameter functions could be matched on a tuple. Haskell allows both matching on parameters (typical for recursive functions) and matching as part of an expression (i.e. switch). I think ambiguity between tuples and function parameters would be resolvable.

A potential syntax:

@recursive 
func fib switch (Int, previous: Int = 0, current: Int = 1) -> Int {
case (0, let previous, _): previous
case (1, _, let current): current
default: fib(n - 1, previous: current, current: previous + current)
}

Another example with closures:

let quadrentTwoCoords = coords.map { switch in
case (-1..<0, 0..<1): true
default: false
}

Another syntax supporting internal labels:

@recursive 
func fib(_ n: Int, previous: Int = 0, current: Int = 1) -> Int {
case (0, _, _): previous
case (n: 1): current // OCaml style label matching
default: fib(n - 1, previous: current, current: previous + current)
}

I'm very happy to see this. Examples that are shown in proposal are actually very common in my code.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes, I think that it's significant. I'd love to see some improvements from "Future directions" section as well, but those are very important too.

  • Does this proposal fit well with the feel and direction of Swift?

Yes, especially considering result builder syntax.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I had some Scala experience, enjoyed that feature there.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Quick reading

1 Like

There's another option: allow an annotation on the definition of Log that tells the compiler it doesn't count for purposes of determining whether the return keyword can be skipped. Like this, but probably with better spelling:

@DoesntMakeMeUseAReturn func Log(_ msg: String) { ... }
Off-Topic

Shouldn't Log be log? Is there a Log type that I don't know about? It doesn't really matter for the example, but now I'm wondering if I've missed something.

Just wanted to chip in here as someone who has been working with Rust a lot lately. I am really not a fan of the "last expression" rule - I find it seriously hurts readability. This applies equally to function returns as to if expressions, particularly when the if block is quite long and the last expression is far from the line where it is being assigned to a variable.

I think Swift has made a great choice with functions where the return is implicit for single lines only and I feel we would be making a backwards step to suddenly allow implicit returns for multi-line, regardless of whether it's for functions as well or only for if expressions. Rust at least has one very minor differentiator between the "last expression" and any preceding lines: The preceding lines must end with a semi-colon while the last expression must not. This doesn't help much for readability but it's better than nothing - it won't apply in Swift though, so there will be nothing at all to differentiate the last expression.

So I am not in favour of @Ben_Cohen's proposal #5 for multi-statement expressions for two main reasons:

  1. It would be inconsistent with function returns, which I think would be confusing.
  2. I don't believe the last expression rule is any better for if expressions than it is for functions.

If multi-statement expressions must inevitably be supported, I would very much prefer proposal #2 (an explicit keyword), precisely because it is more consistent with the way functions currently work.
Let's learn from Rust's shortcomings and not repeat its mistakes.

[edit]
I think one of the biggest issues with Rust's implementation is the impact to readability when you start nesting:

fn do_a_thing() -> u32 {
  // <lotsa code>
  ...
  if some_condition { // This `if` statement is the last expression in the function and will implicitly provide the return value
    // <more code>
    ...
    match some_var { // This `match` statement is the last expression in the `if` block and will implicitly provide the result for the `if` statement
      0 => 100, // This value provides the result for the `match` statement and ultimately becomes the return value of the function - is it obvious?
      1 => {
        // <yet mode code>
        ...
        10 // A second return value
      }
      _ => 1, // Another return value
    }
  } else {
    // <probably more code>
    ...
    0 // One last return value
  }
}

The lack of explicit return makes this incredibly confusing to work out where the return values actually are. This would equally be a problem if instead of being return values they were assignments to var x = if some_condition {.
To be fair, the nesting issue still exists even for single-line expressions, but it is far less pronounced. At the end of the day it is up to the user to write readable code but I feel like Rust encourages patterns like this, particularly if you use clippy.

22 Likes

Random thought: What if if/switch expressions could never be an implicit result for an enclosing if/switch expression, even if it's the only statement. So you would always have to explicitly use the keyword, like yield if ... (or whatever the keyword is). This would alleviate readability issues with nesting like the above.
[edit] Retracted this thought also applying to function return values.