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

Yes, the proposal explicitly says it is valid, but only with type context; drop the Int? in your example and it breaks. From the proposal:

// invalid:
let x = if p { nil } else { 2.0 }
// valid with required type context:
let x: Double? = if p { nil } else { 2.0 }

Vexing to any who encounter it, no doubt, but not the end of the world.

It is vexing, though. let x = p ? nil : 2.0 works while the if version doesn’t.


Ha, looks like Scala’s closures work like Ruby’s blocks! (Ruby blocks are basically nonescaping closures that make return / break / next act on the enclosing scope instead of the block body, which in turn makes it practical for Ruby to use trailing block syntax as its primary looping mechanism. But Ruby doesn’t ever make blocks look like separate functions; return from a block works as expected because the syntax helps set expectations correctly.) I don’t think that has any bearing on Scala’s handling of return in conditional expressions, however? i.e. return from conditional isn’t what made return a Scala antipattern?

Scala horrors aside, Rust, Ruby, and Kotlin all make return from a conditional expression behave as one would expect.

I'm agreeing that we can/should support it as well -- that seems to be the consensus of the thread as well :slight_smile:

1 Like

I understand your concern however in this particular pattern we need to use deferred assignment to escape or return from where we are I guess. +, within the rhs it'd be an error if we forget to write return in a branch, much like it is in the current stable versions of Swift.
In case that's why multiple statements is being skipped I'm sure there's room for discussion about this first.

I personally don't like the usage of braces for an expression because braces are normally used to introduce a context supporting any number of statements.

Has any thought been given to the idea that we could do a brace-less switch expression? Either with nothing or with parenthesis instead of braces?

let y = switch x case 1: "one" case 2: "two" default: "many"

var description: String {
   switch self
   case 1: "one"
   case 2: "two"
   default: "many"
}
let y = switch x ( case 1: "one" case 2: "two" default: "many" )

var description: String {
   switch self (
   case 1: "one"
   case 2: "two"
   default: "many"
   )
}

Since this switch expression syntax is distinct from the switch statement syntax there should be no ambiguity when used in result builders and the expression could become more general instead of being limited to assignment and returns.

A similar treatment could be done to if, although it's a bit less obvious how to separate the condition from the first branch. Some possibilities:

let y = if let x = value ? x : "no value" // ternary-like
let y = if let x = value : x else "no value" // switch-case like
let y = if let x = value then x else "no value" // new keyword
let y = if let x = value (x) else ("no value") // parens
1 Like

Yep, I suggested something similar above. One way or another - I think it is worth considering making statements and functions/closures more different than they are now. There is one common feature in both (establishing a new scope) but also a significant difference (IRT return / break / throw handling). See this example:

func iff(_ condition: Bool, execute: () -> Void) { // custom if implementation
    if condition {
        execute()
    }
}

func foo() {
    if condition {
        return // returns from foo
    }
    iff (condition) {
        return // oops, this doesn't return from foo!
    }
}

This example is a bit misleading, because you are essentially starting from a position where the workaround is already applied.

There is a big difference, however: if we were to start with this proposal, and the author wrote

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

then, in the case when the user adds an additional statement above the 3, the compiler has what it needs to produce a fixit, rewriting their code in the DI-based form. Without this feature, there is no hook to introduce this to the user. They must just know it.

I disagree with this framing, as I believe even if the final state was that only single expressions in if were supported, a brace would be the correct spelling. A brand new syntax seems entirely unnecessary, just another syntax to learn. This was already the conclusion reached in SE-0255. In that case, return-less functions are limited to a single expression, but a new syntax for that form was rejected.

Choosing a brand new single-expression-only syntax like if p => e1 else e2 or var x: Int => e on the other hand would close off avenues, suggesting that a multi-statement expression syntax was never coming. Or if it did, would land us with an oddly duplicative syntax.

So this leads to the question: must deciding on multi-statement expressions be a precondition of this proposal (despite the precedent of 0255).

Let’s run through the options for multi-statement expressions. I think this list is comprehensive of all the coherent approaches seen so far:

  1. Use return to mean “make this expression the block’s value”. I think we need to rule this one out as definitively a bad direction. It is already a problem that closure-based control flow like forEach { } or 5.times { } lead to confusion between return meaning continue. Repurposing return specifically within if or switch would make that worse. And it conflicts with the goal of allowing explicit function returns.

  2. Introduce a new keyword instead of repurposing return. This is the path Java chose (though their choice – yield – conflicts with another potential use in Swift). This seems too heavyweight a solution to me, to introduce a whole keyword just for this purpose. I admit I don’t have a good argument for this other than that “adding a keyword for syntactic sugar” seems self-defeating.

  3. Adopt the “last expression” rule, the choice of Ruby and Rust. The big win here is it solves not just if but also extends SE-0255. But I see that part as a bug rather than a feature. Again this is a feels-based argument but whenever I see a function in Rust end with a bare true or nil or result I find it very unsettling. I much prefer Swift’s current requirement of an explicit return.

  4. An idea that @Joe_Groff put out there, I believe as a joke that I then started to take seriously, is a variant of the “last expression” rule where you must join the expressions with ;. So if p { log(“true branch”); 3 } else { 4 } would be allowed, as would func f() { log(“f”); 3 . This is more explicit than 3), but re-purposes ;` to give it more meaning, which is maybe not a good idea.

I think 1-4 are all not great solutions to the problem we face today with this proposal. Maybe I am in the minority regarding disliking 3, but it would undeniably be a big change for the language that I’m reluctant to rush into purely for the purposes of solving multi-statement if expressions.

This leads to what I think might be a plausible path forward:

  1. The last expression is the value of the branch, but only within if or switch expression branches. This is essentially option 3), but not for function returns, which would still require an explicit return keyword when longer than 1 expression.

The discomfort I feel about implicit function return doesn’t apply here. Making an if an expression, by putting it on the right-hand side of an assignment or an explicit return, is explicit enough for my liking. I also suspect it will in nearly all cases be used for short expressions where it’s clear the if is an expression, whereas my experience looking at rust code is that it can be common to have relatively long functions that just end in an expression.

This also leaves the option of expanding implicit return of SE-0255 open by implementing 3), without requiring that direction.

There is an implementation, along with a tool chain to try this out, available on this PR.

14 Likes

With "if" getting an expression form, would it make sense to add an optional "then" that allows brackets to be dropped? In the proposal, "->" was mentioned as an alternative that can drop brackets. Simply using "then" feels more swifty to me and breaks up the parts of the "if" statement better when used as an expression.

let x = if p then 0 else 1.0

This would also solve the trailing closure ambiguity in some cases. Possibly "then" could optionally be used in non-expression forms of "if" when this ambiguity exists as an alternative to parentheses.

EDIT:
I think it would work with nesting, but might be hard to read. Parentheses could be added to clear it up.

let x = if p then (if q then 0 else 0.5) else 1.0

It would also be clearer when breaking up long expressions across multiple lines.

// bad example because these lines are short
let x = if p then 
    if q then 
        0 
    else 
        0.5 
else 
    1.0

EDIT 2:
It would work nicely with switch expressions too.

let x = if p then 
    switch q {
    case 1: 0.5
    case 2: 1.0
    default: 0
    }
else 
    1.0

EDIT 3:
Dropping brackets could be considered for guard too.

guard let x else throw MyError.somethingWentWrong

An expression form of guard might look like this, but this might be taking it too far since it is just a reordered "if" at this point.

let y = guard let x else 0.0 then // returns 0.0
    if p then x else 1.0

This is the wrong place to bring this up, so I invite no discussion. I've wished that we could make the error type in throw implicit so something like this would work.

func doSomething(x: Int?) throws MyError -> Bool {
    guard let x else throw .somethingWentWrong 
    x > 0
}
1 Like

If the if and switch keywords become expressions that are assignable then would this set a precedent for other expression conversion; e.g. for in becoming a generator expression... or perhaps more appealing of a future of for await in becoming an async generator expression? Don't take this question as a scope creep to amend this proposal, but instead please interpret it as a question of "is this the right door to open because it has carry-on effects".

7 Likes

I'd like to repeat my argument posted before. I think that supporting multi-line statements in switch expressions can be achieved by using the break value syntax in switch statements. This is similar to how it is used in Rust's loop expressions, and I believe it is a natural way to handle this within the context of a switch statement.

let string = switch value {
case .a:
    print("case a")
    break "a"
case .b:
    print("case b")
    break "b"
default:
    print("default")
    break "default"
}

However, as @Paul_Cantrell suggested, using break value in an if statement would not be possible because break is often used to escape the outer scope of control structures like switch, while, and for.
While it may be inconsistent to allow multi-line statements in switch expressions but not in if expressions, I believe the benefits of allowing this functionality in switch outweigh the inconsistency.

(Reading this thread, I think one another possible solution to the issue of supporting multi-line statements is allowing only switch expressions.)

1 Like

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.