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

Ruby does, FWIW. That said, its excessive love of having multiple syntactic options don't make it a clear precedent-setter for Swift.

(Java does not have if expressions, but does have both switch expressions and the ? : ternary operator.)

You have a point: it certainly it is notable that Rust, Kotlin, and Scala all opted to support multi-statement if expressions and also opted not to include ? :. I'm not convinced that implies that Swift should avoid supporting this feature, however. While this is technically true:

…in the sense that Swift is already Turing-complete, I've found the expressive power of multi-statement conditional expressions to be well worth the syntactic weight in languages that offer them.

It's like string interpolation: it looks excessive and unnecessary until you've lived with it for a while, and then you wonder how you lived without it.

4 Likes

IMHO nobody actually evaluated ternary from type-checking performance perspective unlike in this case where splitting expression into multiple doesn't make any sense.

2 Likes

This seems a bit spurious to me. The fact is you can write multiple statements in a switch case in Swift. So when writing expressions, you would assume you still could, same as you can in the brace of an if. I don't think it's likely that many developers, when finding their multi-statement case in a switch expression doesn't compile, will think "huh, I guess there are no braces in cases so that makes sense".

Of course, from my perspective, the solution here is it's fine: if you write a multi-statement case in a switch expression you'll get an error that sorry you can't do that. And you'll get that same error with a multi-statement if.

I see your "braces imply multiple statements will work, so we shouldn't do if" and raise you a "switch expressions will imply if expressions will work, so we should do them too" :)

In particular, if expressions will give users the ability to unwrap multiple optional values using a familiar syntax, and then combine them into a new value, while also providing a default in the else. Can you write this things today? Yes, but not easily – this is something that is pretty hard to do well, involving good knowledge of how to combine various optional sugar like ?? and map together to get the result you want. if let expressions unlock a new concise yet expressive way to do this that I suspect most Swift developers will find very easy to grasp and benefit from immensely.

4 Likes

Basically happy to see this kind of flexibility considered in Swift, welcome feature.

However a point I did not understand much is a future direction: Multi-statement branches, because without this feature I don't really see the big benefit or differences, as you already can express them with closures just like presented in the proposal:

let bullet = {
    if isRoot && (count == 0 || !willExpand) { return "" }
    else if count == 0 { return "- " }
    else if maxDepth <= 0 { return "▹ " }
    else { return "▿ " }
}()

I think I'm missing a point how all of this feature is designed, however can't help stop think like, why not force use of return just like it does in getters:

let decoded =
  if isFastUTF8 {
    Log("Taking the fast path")
    return withFastUTF8 { _decodeScalar($0, startingAt: i) }
  } else
    Log("Running error-correcting slow-path")
    return foreignErrorCorrectedScalar(
      startingAt: String.Index(_encodedOffset: i))
  }

edit: I mean just like computed variables or functions with return values:

var value: Int { 0 } // OK
var value: Int { print(); return 0 } // Even with multiple statements, with explicit return, it's OK
3 Likes

Such returns would be confusable with returns from the function itself.

Somewhat related: To avoid confusion about where return returns from I'd use two different types of brackets: one for functions / blocks and another for statements:

func foo() [
    if condition {
        return // from function
    }
    for item in items {
        ...
        return // from function
    }
    foo.bar.baz [ // aha, square brackets! so this is a closure (block), not a statement.
        ...
        return // from closure
        if condition { return } // from closure
    ]
    DipatchQueue.main.async [ // ditto
        return // from closure
    ]
]

It doesn't look too odd or radical, although I didn't find precedents in other languages (other than the opposite: using { } for array constants in C). This would make different things slightly more (visually) different and could help during debugging (how many times I was stepping though a line ".... {" expecting to be on the next line but ending up elsewhere because that was a closure.)

1 Like

So I think I'm a +0.9 on this. The general idea of if and switch statements as expressions does seem good, though I'm not a big fan of them being purely inline with nothing really signifying the difference. For example, I don't believe

let bullet =
    if isRoot && (count == 0 || !willExpand) { "" }
    else if count == 0 { "- " }
    else if maxDepth <= 0 { "▹ " }
    else { "▿ " }

is inherently better than

let bullet = {
    if isRoot && (count == 0 || !willExpand) { return "" }
    else if count == 0 { return "- " }
    else if maxDepth <= 0 { return "▹ " }
    else { return "▿ " }
}()

The former does remove the return but I feel like the closure syntax gives a LOT of clarity. I understand that you can still use the closure syntax, but I'd still find it nice for Swift to enforce better readability over the small writability gain with some sort of "expression" syntax. I think what Tera suggests above is certainly something worth considering. I'm not sure if [] is the best thing to use, but I feel like we could do with something

2 Likes

I read through this with growing joy at the thought that one day a switch expression could return a value, without the actual, ahem, return.
I am a big fan of this. If dropping the return ceremony in functions was considered a good thing, then dropping them in switch statements should be too. It's consistent with the practice in functions, and it just makes sense.
So many times I have wanted to do this and been annoyed at the lack of it.

For "if" statements, I'm less clear on the benefit, and have never found the lack of it a problem or even an inconvenience. I haven't read other reviewer comments, and am only reporting my own experience here.

2 Likes

This strikes me as being a 'solution' only from the standpoint of the compiler—for the user who attempts to write the multi-statement if branch, the fact that the compiler emits an error is itself the problem, not the solution!

The workarounds that exist today for the lack of if expressions at least have the benefit that they evolve relatively gracefully. If I've written:

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

then it is straightforward to update this code to add some intermediate computation:

let result: Int
if condition {
  let intermediateResult = someOtherComputation()
  result = intermediateResult * 3
} else {
  result = 4
}

But with if expressions as-proposed, and as the proposal notes, the available options as a branch evolves aren't so satisfying.

It would be one thing if this proposal took the position that multi-statement if expression branches are actively undesirable, and that any more complex branches ought to, say, be refactored into a separate function so that they could be called as a single expression. In this case I'd say we should definitely choose a different syntax to make it clear that these expressions are fairly different from their statement counterparts.

But AFAICT this proposal remains fairly agnostic on the merit of supporting multi-statement branches while simultaneously choosing a syntax that, IMO, actively suggests that they should be supported.

IMO it is more surprising for a brace-enclosed region to support only a single expression than it is for if and switch to support different feature sets. Notably, switch out-of-the-box supports exhaustive pattern matching, which this proposal relies on. We of course have the else escape hatch for if expressions as a practical matter, but IMO it's not unreasonable that if and switch support different things. I think PHP is like this—there's a match expression, but no equivalent if expression (other than the ternary operator).

That said, I'm very sympathetic to the ergonomic/familiarity points you note about if vs. switch.

8 Likes

I would be surprised by that behavior. My understanding is that if bar { 42 } is an expression of type Int?, which you couldn't assign to foo because foo isn't optional.

3 Likes

Circling back to this thread to express my excitement about this feature! I look forward primarily to using it to streamline computed properties in enumerations and to eliminate ternary operator usage:

enum Direction {
   case up, down, left, right

   var xDelta: Int {
      switch self {
      case .up, .down: 0
      case .left: -1
      case .right: 1
      }
   }
}

struct CollectionWrapper<Base: Collection> {
   // ...
   
   var startIndex: Index {
      if base.startIndex == base.endIndex {
          endIndex
      } else {
          .element(base.startIndex)
      }
   }
}

I've looked through the standard library codebase as well as packages that I've worked on, and adopting this feature will make the code more readable and accessible.

The ability to extend existing types with small functions and computed properties has always been one of my favorite parts about Swift, since it encourages encapsulation and makes my code clearer. This continuing evolution to simplify those kinds of single-expression extractions only improves that feature of the language.

I particularly like that the expressions use existing if/switch syntax,
since it means there's no new version of them to learn, and provides consistency with the code we already write. Adding new syntax or using something like Python's x = y if condition else z would lose that benefit.

10 Likes

Coming back to the review thread after the pitch thread a while ago :slight_smile:

Overall +1, this is a good step. I'm hopeful for the future directions to materialize as this feels like only a partial step, but in the right direction, so I'm in support of this small step in the right direction.

Independent branch typecheck

This is honestly fine. I've worked with if/switch expressions that unify the types of branches in Scala and it definitely is true that it works very well there, it also has weird edge cases, unifying to things like Serializable, Product or Any... I don't think this is a big of a deal being a bit strict here should be fine -- and it is a decision that can be revisited in the future if we had to.

It is definitely true that both branches having the same type is the most common case to deal with here.

As far as I understand, the following is possible, right?

let x: Int? = 
  if p { 
    nil 
  } else {
     2 
  }

if so, that addresses a lot of the cases one may have to deal with types which aren't exactly the same on both branches, but end up as the same when the whole expression is assigned to x.

Requiring else

This is good. The alternatives are confusing, and overall lead to more ? everywhere where you might not really need it. I think it is good to force the if to always have an else so that if I want an optional, I'll spell it out.

Overall, personally, if without else always is a bit of a worrying thing I need to double-check, so this seems good to enforce.

:yellow_circle: Single line -> multi-line

I'm not a huge fan of the "single line" special cases of things for reasons that the proposal itself already acknowledges with the "unfortunate usability cliff" explored in Multi-statement branches.

The reality is that a lot of code uses logging, tracing, metrics everywhere and by limiting this feature to single lines, we're adding a lot of friction to doing the right thing (yes, do add that log statement; do add that metric, do add that coverage probe).

So if there's a single piece of the proposal I'd push harder on, it's this. But the proposal authors requested to scope this out into a future discussion so that may have to wait.

Alternate syntax, deprecating ternary etc

I agree with the authors in that I don't think new syntax is viable or advisable here; Swift can perfectly well accommodate this pretty "normal" by nowadays modern language standards feature in existing syntax.

Ternary is fine to let be, it'd just cause needless crunch to deprecate it; but one would hope to see it much less in future Swift code being written :wink:

Nitpicks

While this is possible in Scala, it's an anti pattern. return has some horrible meanings in Scala and is best avoided entirely. See here why return is terrifying in Scala: Scala Puzzlers

Scala puzzler: return is horrible
def value: Int = {
  def one(x: Int): Int = { return x; 1 }
  val two = (x: Int) => { return x; 2 }
  1 + one(2) + two(3)
}

this returns 3 :scream: Though since recently this causes a warning: Non local returns are no longer supported; use scala.util.control.NonLocalReturns instead

Anyway, with Swift's current return semantics I believe this is fine, let's support this:

    init?(rawValue: String) {
        self = switch rawValue {
        case "foo": 1
        case "bar": 2
        case "baz": 3
        default: return nil
        }
    }

as @Ben_Cohen mentioned in SE-0380: `if` and `switch` expressions - #55 by Ben_Cohen

Overall

As we further explore this space and fix the remaining issues in future proposals, I suggest we lean harder on languages like Scala, which have gotten fleshed out a long time ago. The proposal has been leaning hard on Java as example cases, but I think that's not a good source of inspiration because everything it did with expressions has been inspired by Scala and adapted to "how do we make it feel JAVA" which isn't a goal for us, so instead we should be learning from more expressive languages as sources of inspiration, and not less expressive ones that adopted these things in their modified ways.

5 Likes

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