Pitch: Multi-statement if/switch/do expressions

There are two camps of people who have literally the opposite meaning of "return everywhere" ("the classic" return from the outer function/closure even when used in expression context and "the new" return we didn't have before to mean "expression value"). The two camps have irreconcilable differences and the way to not piss one camp off to the benefit of another is the judgement of Solomon approach – not allow "return" in those contexts at all – allowing it to mean the either option would be too controversial.

Consider again my response from above:

And consider this way that it was put above:

3 Likes

This topic has generated the greatest number of replies ever on this forum!

2 Likes

Something like I suggested here sure seems to me like it could have come in handy in a situation like this

OK, so you are saying that normally when you see a return, you need to check if that's in a local function/closure or in the main context, but in this case it's not enough to check if it's inside an if or switch, you need to specifically check if it's an if/switch expression. And your point is that the difference between a normal if and an if expression is more subtle than that between a local function and the main context?

That's a valid point, but I don't think this difference in level of subtlety really warrants a new keyword for exactly the same thing. Also, wouldn't that argument apply even stronger to the currently existing implicit returns for single rows in if expressions?

My counter argument is simply that whenever you see a return, you always have to figure out what the context is, and I don't find it significantly harder to do that if it's found inside

let x = if numberIsEven { 
    let result = 2 * y + 4
    return result
} else { 
    12   
}

than inside, say

let xFunc = {
     if numberIsEven { 
        let result = 2 * y + 4
        return result
    } else { 
        return 12   
    }
}
1 Like

You may have convinced me that return would be fine from a code-comprehension standpoint. I think though that this was not the only gripe about using return. One other factor is the fact that there would be no way to return from the outer scope (although no one seems really enthused about that ability). Additionally, I think I saw some discussion above about the way that these expressions interact with the error propagation mechanisms, and that using return may be extra weird/non-intuitive based on that.

That's a slippery slope.

func foo() -> Bool {
    let x: Int = {
        if condition {
            print("here")
            return 42 // the closure value!
        } else {
            print("there")
            return false // returns from the outer scope!
        }
    }
    print("finally: \(x)")
    return x == y
}

"What could possibly be ambiguous here: the types are different, so it is obvious what goes where." Right?!

4 Likes

I didn’t mean that this should be the factor that determines what return means, I presumed (and hope) that returning from the outer context will not be allowed, since it seems like very bad practice. I don’t think anyone should perform any kind of side effect inside an if expression, is that something that we actually want to support?

2 Likes

This looks like a weak and subjective reasoning to me, because:

  • other languages (the above mentioned Rust, Ruby, Kotlin and Scala have no problem with it
  • throw is allowed, why make return an exception
  • having side effects is one of the features we'd have to allow (and sometimes even desire) with multi-statement if/switch/do expressions. A mere "print()" quoted throughout this thread is a side effect already.
4 Likes

I am of course arguing from my own subjective standpoint. But if returning from the outer scope will in fact be allowed, then I guess using return for the expression result is not an option.

The issue isn’t side effects so much as control flow. I would expect that if we went with return, a return in a conditional assignment would make the assignment 100% of the time and jump to some other part of the code never.

When I first read the proposal I was somewhat undecided whether i preferred a keyword (although the interaction of then with regex literals in particular seemed unfortunate), but after at least skim-reading most of this thread I'm firmly in the bare last expression camp.

First if all, I do concede that for this to give a coherent overall picture, the same syntax needs to work for functions/closures as well, but like the proposal suggests, I do not see this as a drawback but as a win.

With that out of the way, here are my main reasons for preferring bare last expression:

It's a direct extension of the single expression syntax that is already part of the language. You can just start sticking extra statements before the expression, no "but you also need to throw in a $keyword as well".

The one drawback I am aware of in Ruby (leaking implementation detail by unknowingly returning the return value of your last called function) doesn't apply because static types.

Explicitly disagreeing with other posts calling this variant "hard to read", "not swifty" (oh how I dislike this phrase :D), or "subtle", I think this is a super simple rule that is easy to learn/teach/read. I suspect some of these opinions are affected by thinking about this as a new thing that needs a spotlight shone on it. Imo, we should give more consideration to the long term, where this is would be a normal Swift feature, probably one programmers encounter and learn early on.

A keyword seems to me like it makes people expect return/break/continue-like behaviour, something affecting the flow of execution. This thread has a number of posts that expect execution to jump somewhere when using this feature, while the proposal suggest nothing like that, execution flows just as if it was a normal if/switch/... (I guess with the exception of a possible computed setter being run, but I'd say that is obvious enough). I believe this expectation is best avoided by avoiding the keyword altogether.

The proposal has a few paragraphs on how the feature only applies to the directly enclosing expression. This would fall out naturally from using the last expression, because there is only a single place for the last expression of any branch of an if/switch/whatever expression to go.

The fact that opening parens beginning a line cant parse as function call means that if there are ambiguities, there are two ways to address them (semicolon and parens) that are both used in other places in the language for similar-ish purposes already, so that slots in nicely as well.

And lets round it out with some further ramblings:

Whether using a keyword or bare last expression, this feature still feels a bit like something that the language deems somewhat weird and that it only lets you touch with a properly dimensioned stick. You cannot use these expressions everywhere you can use normal expressions, which breaks the expectation that you can freely refactor between passing an expression somewhere directly or using intermediate variables. You also can't use return etc like you can in "normal" Swift code.

To remedy this and make these expressions feel like a fully integrated coherent part of the language, I would prefer if one could, eventually (but I do not think holding this proposal up over it would be the right move):
(a) Use return and friends inside such expressions (retaining their current behaviour of course)
(b) Allow them to be used anywhere normal any other expression can (maybe with some small restrictions if source compat turns out to be an actual issue as mentioned in SE-0380)

11 Likes

That‘s fine, there are different opinions in that.

I disagree with this, I would value those opinions higher, see e.g. the comments from some people knowing the last line feature from Rust, and many people do like it that we need a “return” when a function body has several statements.

Like “return” references the inner closure, “then” or “use” would reference the inner if/switch/do expression, nothing new here so to speak. Some people might want more, and indeed this is less simple, but should not serve as an argument against the simple case.

I did not really understand what follows, but I am comment no. 412. Wow.

@ahti brought something that wasn't stressed enough before:
With "then" we have to make decisions upon of a whole lot of issues:

  • do we allow statements after then
  • are those statements warnings or errors
  • or if "then" acts like an assignment to an "expression value" which could be reassigned later?
  • if expressions like these allowed?
let x = if condition {
    if subcondition { then 42 }
    print("hello")
    then 24
} else { ... }

With a "last expression" rule all of these are effectively no-issue...

However I would consider separately (and will oppose to) using the bare last expression in multi-statement functions/closures.

5 Likes

I'd argue this feature is not worth it in the first place. The transition from expression to normal control flow, such as when introducing a print statement, is annoying, but that's about it. It's only a minor annoyance for a brief moment. And "minor annoyance" is not a good motivation for introducing a new keyword, or changing language precedent (bare last expression rule). If this feature were a seamless extension that integrated well with the language, then it would be a no-brainer. However, the 400+ posts in this thread show there is no ideal solution.

15 Likes

I do think it is seamless, making the language more consistent, applying an idea that is established somewhere else in the language already (“return” statements).

The many posts show that people have different opinions about it, and that it is worth to be discussed. There is never an “ideal” solution, and even changing nothing would be a decision in this space, who says changing nothing is better?

(BTW to be careful when changing a programming language and even to be a bit hesitant is a good strategy, but in the case of Swift to my personal opinion there is a bit too much reluctance when it comes to smoothing out the edges and making the language more consistent — I am thinking e.g. of my beloved topic “Extending optional chains to include for loops” which — for me — would also be about making the language more consistent, but did not gain as much traction as the change in this topic — so much about the question if a proposed change is invalidated if the discussion is intense :wink:.)

1 Like

Can we make it such that then is a contextual keyword in Swift 5 but in Swift 6 it’s a “proper” keyword? I think this part of the proposal is a bit ugly and confusing.

Also, I don’t really like “then” nor “use” as the name of this operation. While I think it’s nice that “then” is already associated with if statements, it’s not really as closely associated with switch and do statements. It’s also a bit awkward in some places (“if condition then x else then y”) and it isn’t consistent with Swift’s other control flow transfer keywords (break, continue, fallthrough, return, throw) which are all verbs.

“Use” feels a bit vague (what are we using the value for?) and it feels like a misnomer (we’re not really “using” the value for anything, we’re passing the value as the result of the expression). Additionally, “use x” seems too similar to ownership-related expressions like “consume x” and “copy x” even though they’re not related.

IMO “produce” or “pass” would make better keywords.

4 Likes

You can omit then in such cases, though, just as you essentially already can today.

let result = if something { x } else { y }

It's only when you do more than present an rvalue in a condition block that you need then, because otherwise it's e.g. "if condition print x, else do something y". Adding then improves readability, "if condition print then x, else do something then y".

Of course in English it'd generally be clearer to say "if condition then x and print, else y and do something". However, there may be ordering dependencies between the imperative statements and the resulting value, so you can't always write then x; print("debug info…").

But it does suggest there might be merit in permitting - and executing - statements after the then, e.g.:

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

This might at first confuse people because they think of then as just a synonym for return, but once you realise that it's not this actually becomes a valuable distinguishing feature. Every time you see statements after a then line you are reminded that then is not a control flow keyword.

This is also another argument in favour of an explicit marker over any kind of implicit-last-line-result, because it gives you more flexibility in how you arrange your statements.

Perhaps that distinction (from the existing verbs) is valuable, since those are all control flow keywords, which then is not.

2 Likes

IIUC the pitch in the current form doesn't support statements after "then".

I'd say this is the argument against an explicit marker because, as you wrote "it gives you more flexibility in how you arrange your statements", too much flexibility. Here less is more, IMHO.

Is defer usable and what is its behavior when used in this feature ? Does it defer to when the variable is set or when the parent scope finishes ?