Pitch: Multi-statement if/switch/do expressions

I am against statements after „then“. With a „then“, the purpuse of the if-branch is fullfilled, it feels awkward to let this follow by other statements. You could put those statements before the „then“, this is exactly what „then“ makes possible.

I think this distinction is kind of a theoretical one, „then“ feels just like a „return“ but with a different purpose.

If you have to be reminded, then do not hang on to it.

1 Like

I would not use multi-statement if/switch expressions as pitched. I don't want to break the declarative nature of expressions in my code. However, since this is an opt in feature and addresses pain points other people care about I don't oppose.

To add something to the thread, I have an alternative idea for one of the two major use cases I've seen mentioned and a comment on the other.

print debugging

There's a trick from other functional languages that lets you do this without multiple statements and arbitrary side effects (the actual printing is still a side effect of course!). Here's a simple way to write it in Swift and an example:

public func trace<T>(_ s: String, _ value:  T) -> T {
    print(s)
    return value
}

public func usefulCalculation(_ x: Double) -> Double {
    switch x {
    case ...(-0.5): trace("large negative", x * x)
    case    ...0.5: trace("small |x|", 2 * x)
    case         _: x + 3
    }
}

  1> usefulCalculation(-1.5)
large negative
$R0: Double = 2.25
  2> usefulCalculation(-0.25)
small |x|
$R1: Double = -0.5
  3> usefulCalculation(25)
$R2: Double = 28

Using this workaround I don't feel the need in my own code for multi-statement expressions just for print debugging.

Sub-expressions

Swift does provide declarative space for sub-expressions already. You can put them in struct/class properties, global variables, or any functions within scope. In my use of if/switch expressions I get by with these existing options. The issue with the existing options is locality. You can't have your sub-expressions right next to your case statement.

My preferred approach for the locality issue would be something similar to the "where" clauses proposed upthread by @esummers and @jeremyabannister. Those feel like a separate pitch, though, as they'd be useful in many other contexts than this.

do expressions

I don't have enough experience writing Swift do statements to weigh in. I don't know if the multi-statement use cases there are mostly the same as above.

Keywords

I feel strongly that return should continue to mean that you are leaving the current named or anonymous function. I don't have a strong opinion between the other proposed keywords.

I can see how last bare expression could be ergonomic and I could get used to it. I would regret it a bit because currently a conspicuously bare expression is the most visible cue that you are in declarative mode and we'd lose that.

2 Likes

My preference ranking for a keyword name is:

  1. break
  2. return
  3. None

break

break is already our keyword for exiting control flow but not the stack frame. I think we ought to reuse that to enable multiline control-flow-based expressions. I think it makes the most sense when you consider assignment in the context of a large computation

let count = if checked { 1 } else { 0 } 
// would be shorthand for:
let count = if checked { break 1 } else { break 0 }
// do more math…
return count * 42 - 8

There is the edge case with labelled control statements, but I don't think that applies when we're working with expressions. It seems easy enough to make labelled control flow and expression control flow mutually exclusive.

break here obviously exits the control flow — which is obviously an expression — so break <value> naturally implies exiting with a value "in tow". This parallels the ways you can use return to exit functions with or without a value.

return

return feels very weird to me when used for this purpose. As others have said, in my mind return is too tightly coupled with functions & stack frames. With the same example…

let count = if checked { return 1 } else { return 0 }
return count * 42 - 8

Reading this code for the first time, I would expect return to exit the containing function with 1 or 0. I would read count as an unused, unassigned variable.

However, being somewhat familiar with Haskell, I could be convinced of this direction. Since return is a common monad operator, I think you can construct similar-looking Haskell code. "Returning from complex expressions" would be a new concept but I think it could catch on.

Still, I think that direction would make the distinction between break and return murkier. That's why break is preferable to me.

None

I would not be opposed to (as SE-380 put it) "making Swift an expression-oriented language". Essentially, having return be completely optional and implicitly "returning" the last statement.

But I feel like that's a very different proposal than what's been pitched here. I think the motivation would need to be almost rewritten for that broader scope, since that change should also impact functions and other "stack frame types". We can't have "no return multiline expressions" in control flow but omit them from functions. That's too inconsistent.

Also, how would that affect result-builders? Would result-builder functions just opt out of that implicit return?

My preferences are:

  1. Implicit last use.
  2. break
  3. then, use, etc.
  4. Not implementing this pitch at all (even though I really want to)
  5. The sun exploding
  6. return

Hopefully that wasn't too hyperbolic of me. I just really think using return for this is the worst possible solution.

11 Likes

I would personally prefer allowing then in the middle of an expression block if this pitch moves forward. I think that could be valuable. ** EDIT: I changed my mind. I think there is still a way to insert a statement as the expression unwinds for most situations.

It leaves some finer points open though. Does result (in your example) get set before trailing statements or after. If you think of then setting a value to the block then I would say after so it wouldn't be accessible yet. If you think of then passing all the way up the stack first, then the final result might be available to use for those trailing statements.

I might even suggest this:

let result = if condition { x } else {
  then y * z
  // `value` is implicitly bound to block level result
  print("Chose y * z (\(value))") 
}
1 Like

With "value" logically it better be "value = y * z" instead of "then y * z".

2 Likes

Maybe, but typically Swift implicit values tend to not be settable. Thinking more on this, it might not work well for an expression inside a getter. Maybe a different implicit variable like "result".

Could also just keep it simple and explicit.

let result = if condition { x } else {
  let value = y * z
  then value
  print("Chose y * z (\(value))") 
}

EDIT: There would be a pretty simple workaround for adding logging or other side effects after by basically redirecting the flow of the expression to a subexpression. It feels useful to add logging as the expression unwinds, but not sure if it is useful enough to allow statements after the then statement. This is probably good enough to allow inserting something in as the expression unwinds.

let result = if condition { x } else {
  let value = y * z
  print("Chose y * z (\(value))") 
  then value
}

In this example there is no disadvantage of using "then" after print as the last statement. Or some other flavour of "then" we discussed above.

1 Like

I think these two discussions (about if/do/switch expressions and using a “return”/“result” value after the actual statement) are orthogonal and the latter doesn’t really belong in this thread - for instance, there’s some discussion in another evolution post on function body macros about enabling defer to access a return value, which could neatly solve this issue for both the expressions being considered here as well as normal function calls and other applications like macros.

No matter the outcome of this review, I think accessing a “return” value after its actual control-flow statement is a completely separate pitch to handle all the places in the language it might touch, so let’s not derail this thread further.

2 Likes

They are interconnected because the "last expression" rule by its nature doesn't allow "assigning expression values more than once or using statements after it"... Assuming for a second we do need that feature – then we can't use "last expression" rule, otherwise we could.

Before, logically. Both because it makes sense and because consider the situation:

let result = if condition { x } else {
    var tmp = y * z
    then tmp
    tmp *= 2
    print("\(tmp)") 
}

result ends up being y * z. It's just following the existing rules regarding how variables and program state works across a sequence of statements. You can think of it as equivalent to result = tmp in this case.

If the value in question is a reference type, like a class instance, then of course mutations in subsequent statements would be visible, just as they would in any other comparable situation.

Now, I'm not sure why you'd want to write that sort of thing, generally, but maybe there's the occasional case where it just makes sense for various contextual reasons. e.g. maybe for debugging or experimentation purposes you want to short-cut a series of steps and temporarily use the intermediary result.

You could technically do it differently; you could make then 'designate' the return expression such that the expression evaluates to the value of that expression at the end of the block, but I think that's both more complicated to reason about and to implement.

I want to stress that this is somewhat academic - I don't expect folks will use multi-statement expressions for complicated things, or at least I hope they don't. It's merely important that the syntax & rules involved be consistent with the rest of the language and well-defined. Best not compound a dubious design with unusual behaviour.

I concur, because it's not what I was suggesting.

return is very different to then. That's really the whole point.

return is final within a function; you have no need to reference what was returned after it because [within that block] nothing runs after it¹. If you care about what's returned you can capture that externally, as the return value from the function, or refactor your code to name the returned value by storing it into a variable.

then is (as I'm proposing) not a 'final' statement because it's not a control flow statement. It's simply designating the return value of the expression, which can logically be done anywhere in the block. Execution continues normally to the next statement.

It does harken back a little to Pascal, as (I think it was) @tera pointed out earlier, which demonstrated that return values and returning can be implemented as orthogonal features within a programming language. That was in fact the convention in the early days, because when it comes down to it most CPU architectures actually work that way. Even today.

I'm not proposing that for Swift, in the sense of changing how return works. Rather, I'm suggesting there is utility in adopting that (in a limited sense) in this new context of multi-statement expressions.


¹ defer does run after return yet within the same function context, but today can't implicitly reference the returned value. There is indeed that separate discussion about whether defer might be useful for capturing function exit results (whether return values or thrown exceptions), but that's not really relevant here for a variety of reasons:

  1. It's for function results, not expression results.
  2. I'm not sure that's a good idea in general, given defer's purpose.
  3. I don't think it's even the most elegant way to implement decorator macros, the case for which it was invented.
2 Likes

That's interesting. That would make then function a lot more like yield does for accessors.

I think the reason yield works that way is because it's geared around accessing stored properties and as such needs more precise control over the flow of code than what defer allows.

I'm assuming we would be able to use defer from within these control flow expressions. So I'm not sure what the extra precision of yield-like behavior would really offer. My gut reaction is that it would allow things to be too complex.

Tho if we did go that way, I wonder if we could just reuse yield for this purpose? There would be edge cases to consider like conditional expressions used from a _modify accessor. I wonder how robust the compiler's ability to disambiguate that kind of thing would be.

Apparently I'm a big fan of reusing keywords :)

If we go with "the last statement" option, would that apply to only these expressions, or would it apply to functions too? In other words, would I be able to go from:

var body: some View {
    VStack {
        ...
    }
}

to

var body: some View {
    Self._printChanges()
    VStack { // Look ma, no return!
        ...
    }
}

... without having to throw a return on the last line?

In this particular example I believe the answer is no because the implicit @ViewBuilder on a View.body property will build a tuple view which will conflict with Self._printChanges(). Change body to myBody which does not have the result builder applied to it and it should work, IFF we're also going to extend it to the regular return keyword.

2 Likes

Similar, yeah. But yield is an existing control flow keyword, so although it's closer in spirit than return, it still has the same problems.

A matter of perspective, though. I find defer to be a bit dangerous because of its hidden control flow nature, and I avoid it wherever possible in my projects (which is always, actually - I don't think I've ever encountered a need for defer).

1 Like

An idea that would allow keeping current single-expression rules. Just putting "then" on if/do-expressions would allow if/swift/do-expressions to all be single-expression implicit returns.

  func foo() -> String {
    if Bool.random() {
      print("Log this.") 
    } then { 
      bar() // Allow tacking a single expression on to a if/do-statement.
    } else {
      do { try bar(10) } catch { print("error") } then { defaultValue } 
    }
  }

switch Bool.random() {
  case true: do {
    print("Log this.") 
  } then { bar() }
  case false: "false"
}

// above feels busy, so stacking blocks might be a better version
func foo() -> String {
  if Bool.random() {
    print("Log this.") 
  } { bar() } else {
    do { try bar(10) } catch { print("error") } { error.localizedDescription } 
  }
}

EDIT:
This has been on my mind as I have been trying to visualize it in my daily code. I think I can get used to the proposed syntax, but not multi-line implicit results.

One that has really stood out is I see .init being used at the tail end of a lot of multi-line implicit results (usually because the type is more complex then in this case or for easier refactoring) and I wouldn't want to wrap those in parentheses:

let x: MyType = if Bool.random() {
  print("log something")
  (.init(1, 2, 3))
} else { .init(42, 42, 42) }
1 Like

It is no more apparent to me that do { } then { } is an expression, and it comes with the added ambiguity of whether, in the event of an error in the do { } block, the then block executes before the catch block, after the catch block, or never at all.

1 Like

There is no ambiguity. It is an expression, so the catch-block (third block) would supply the result (or escape with return/throw/Never-type) if thrown in either the imperative-block (first block) or expression-block (second block). The then-expression (second block) would not execute since it threw before getting to it.

The catch-block needs to be a single-expression when using do-expressions. Possibly the same then-expression pattern could be used to avoid nesting another do-expression if adding logging there.

  func foo() -> String {
    if Bool.random() {
      do { 
        print("Log this.") 
      } then { 
        try bar() // Allow tacking a single expression on to a do-statement.
      } catch { 
        print("An error occurred.")
      } then {
        defaultValue
      }
    } else {
      "2"
    }
  }

I think this idea makes more sense when used sparingly. It is nice to keep the single-expression return style that Swift already has, but if used too much it might just build pyramids.

If anyone wants to try out bare last expressions in Swift for themselves, I've made a @BareLastExprs macro using the new function body macro role (which is available in the latest toolchain snapshot under the BodyMacros experimental features).

The easiest way to try out the macro is to download the latest toolchain snapshot, clone the swift-bare-last-exprs repo, and run the example with the following command (replacing the toolchain path with your own):

# You don't need `-Xswiftc -enable-experimental-feature -Xswiftc BodyMacros` because I've enabled the feature in the package manifest
/Library/Developer/Toolchains/swift-DEVELOPMENT-SNAPSHOT-2023-12-07-a.xctoolchain/usr/bin/swift run

Please note that Xcode encounters a compiler crash when trying to compile the example for some reason (even though it can compile the expanded code fine if copy-pasted).


Here's an example of the kinds of things the macro lets you write,

@BareLastExprs
func fortune(_ number: Int) -> String {
    print("Requesting fortune for \(number)")
    let fortune = switch number {
        case 1, 3, 5:
            print("Warning: Support for odd numbers is unstable")
            if number == 3 {
                "You have a long and prosperous future"
            } else {
                "Your future looks bleak"
            }
        case 2, 4, 6:
            if number == 6 {
                "You must watch your back tomorrow (good luck...)"
            } else {
                "Your shoes will develop an untimely hole"
            }
        default:
            print("Warning: I've never encountered \(number) before")
            "Spaghetti will fall, meatballs will rise"
    }

    print("Processing...")

    if Int.random(in: 0..<10) == 0 {
        print("Warning: Quantum interference detected in RAM")
        "Fortune got corrupted, please try again"
    } else {
        fortune
    }
}

The macro isn't intended to be bullet-proof, just a proof of concept (there are likely many ways to screw with it).

15 Likes

You can grab a nightly compiler snapshot from Swift.org - Download Swift that has the experimental feature implemented.

1 Like