Pitch: Multi-statement if/switch/do expressions

This new keyword seems to carry very little weight. If this kind of expression is important; wouldn’t the natural choice be to use return?

17 Likes

I'd love to see this, but I am firmly in camp "bare last expression" here.

then seems to be the best keyword choice, but even it feels out of place and does not read well in context (at least to my brain). when looking at the parsing complexities/usage rules it would bring with it I am even more skeptical.

to the point of "bare last expressions" not being swifty:
I would have agreed a few years ago, but with return-less single-expression getters and functions being widely used, and the introduction of if/guard/switch expressions, I would say swift is already halfway there. (Even result builders work in this direction - explicit returns have been fading away).

To me it feels only natural to be able to turn this:

var computed: Something { x + y }

into

var computed: Something { 
print("hi mom")
x + y
}

without a lot of extra song and dance. So I am all for bare expressions all the way ; )

Bonus thought: At least to me it feels that "modern" software development employs more and more functional patterns in everyday code. Swift is quite good at that, but the discrepancy between single-expression blocks and handful-of-lines blocks has always been a bit cumbersome. Bare last expressions would unify this experience.

13 Likes

This statement is surprising to me. I haven't encountered any formal introduction of yield keyword in Swift. I believe it shouldn't be disregarded as a potential keyword, especially when it aligns well with the context.

As far as I remember, it was expected to be introduced through the modify accessor pitch, but it didn't proceed through the Swift evolution process. After researching other Swift evolution proposals, I found no mentions of yield. Additionally, it's not documented in The Swift Programming Language (TSPL) book.

8 Likes

I suspect that the general sentiment weighs against me in this, but I oppose the proposal as a whole.

I think allowing multi-statement branches within a compound expression is an invitation to write very hard to read code. Specifically, the kind of code that makes sense as you're actively working on it, but later becomes a tarpit for your eyes and brain.

The most compelling use-case to me was this one:

let width = switch scalar.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 
      log("this is unexpected, investigate this")
      then 4
}

We've all been there – it's a bit frustrating when you need to add a logging statement to a branch. But in this case, the option of turning the just default branch into an immediately executed closure sufficiently ameliorates the problem. (And also places the more complex syntax only in the more complex branch.) If you just need the log for debugging while working on the code (that is, if you're not planning on committing the logging), then that's fine. But if I were reviewing a PR that had a random function call with side-effects in the middle of an expression to assign a value to a variable, I'd ask the author to refactor. It's unexpected that the right-hand side of a statement that begins with let width = would have side-effects unrelated to determining the value.

The thing that this proposal brings most immediately to mind is C's comma operator, which is used for similar shenanigans. For those not familiar, in C, the comma operator allows the user to provide two expressions. It evaluates the first and discards its result, then evaluates the second, and that result becomes the value (and type) of the overall expression. For example, in int x = (puts("foo"), 5);, x has the value 5. Or sillier, more terrible things:

int x = 5;
int y = (x*=2, x+=1, x-3);
printf("x: %i\n", x); // x: 11
printf("y: %i\n", y); // y: 8

For those saying "any feature can be used to write hard-to-read code", that's certainly correct, but language design is at least partly about steering users into making better decisions, such as writing clearer code. That's why the try and await keywords exist – purely for human readers (and sometimes for overload disambiguation).

This forum is probably mostly frequented by very experienced Swift developers, which can blind us to the silly things less experienced devs would do with a new feature. If the benefits are high (such as async/await or the forthcoming typed throws), that's an good tradeoff, but in this case, I think the benefit is so minimal that the cost isn't worth it.


On the matter of then versus bare last expression, I think requiring the keyword makes it clearer that that particular if/switch/do statement is "weird" in the sense of also being an expression. When users inevitably write 300-line-long switch expressions, I think we'll all be thankful for the thens, even though they're ugly.

19 Likes

Just a quick alternative option. One could turn then into use.

if ... {
  use ...
} else {
  use ...
}

It seems also to word better with the switch.

11 Likes

In previous discussions on this topic I provided what is for me the most compelling argument against the "last expression" rule:

Basically it boils down to that result builders already gave dangling expressions a different meaning (namely, that all of them get used, not just the last one), and since there is no visual indicator that a result builder is in effect in a given context, giving dangling expressions a fundamentally different meaning in non-result-builder contexts feels to me like it could be disastrous for readability.

15 Likes

What about simply allowing other expressions as long as they evaluate to ()?

let backgroundColor: Color =
    switch colorScheme {
    case .light: .white
    case .dark: .charcoal
    @unknown default:
    // Upon writing this I realized that it
    // would parse as `Void.white`, and that
    // therefore we should have some keyword or sigil...
        log("Unrecognized `colorScheme`: \(colorScheme)")
        .white
    }

I'll throw -> into the ring for consideration, even though leaving aside the stylistic question it might be completely untenable just in terms of parsing complexity:

let backgroundColor: Color =
    switch colorScheme {
    case .light: .white
    case .dark: .charcoal
    @unknown default:
        log("Unrecognized `colorScheme`: \(colorScheme)")
        -> .white
    }

I'm wary of overloading the meaning of ->, both from a parsing perspective but also from a learning perspective: we need something that explicitly doesn't mean return, and using the sigil for the return type of a function seems to defeat that purpose.

That said, an operator may allow us to avoid the various issues with parsing then described in the pitch. One option may be to flip the direction of the arrow—i.e., <-.

That's not bad, I think: another option along these lines would be evaluate, which emphasizes the expression-ness aspect.

7 Likes

I would like to see a more serious exploration of the alternative with no keyword in the proposal. Currently the only downside mentioned seems to be:

This seems weak at best, given the existing rules for single statement expressions, etc. Are there more serious objections to this, because it seems like the obvious choice (improves language consistency, doesn't require a new keyword with bizarre parsing rules for backwards compatibility, etc). If then is the proposed alternative then I would personally prefer to just stick to the status quo, because the immediately-executed closures are both easier to understand and less ugly.

10 Likes

:point_up_2:

1 Like

Sure, I saw that. Result builders already behave differently from normal Swift code in so many respects that you fundamentally have to understand where they're being used, even though they're not marked in any way. This issue was well known at the time they were implemented and introduced, and it was discussed extensively at the time. I don't much care if this is yet another subtle aspect of result builders that you just have to know.

1 Like

If to use a keyword at all – it's better not be "then": would be very confusing to see "then" in the "else" block.


I was thinking about using "then" as a way of having brace-less if expression:

if condition then 1 else 2
8 Likes

I'm fan of this! Overall this pitch seems really thought out, so +1 from me, no notes.

I always thought do statements should be expressions just like if & switch now are. It's a very common pattern in ruby to use the similar begin blocks to compress multiple lines into an expression. I like that do would now supplant the weird immediate closure nonsense.

I think the use of some keyword is a good call and feels very in keeping with Swift's overall style. I think it's a readability enhancement compared to Ruby, as mentioned.

On the bike-shedding question

I just wanted to note that Ruby also has an interesting (if underused) feature where break can also "take" a value just like return statements. So, you can do either of these:

value = if true 
  break 42
else
  0 
end

Taking that inspiration for Swift: we could consider reusing break statements for the same purpose as pitched for then. The up side is it's an existing keyword so most of the contextual keyword parsing difficulties become a non-issue. Currently break usage is only permitted in if or do when one of the statements is labelled, so I think the parsing would be easy enough.

here: if .random() {
  // labeled statements weren't even mentioned in the pitch
  // so I figure this is a non-issue
  break here
}
return if .random() {
  let here = 42
  break here // clear enough to me!
} else {
  break 0
}

I don't have a strong opinion on this. I think then is a fine choice. But I'm curious what others think about using break as an alternative spelling.

5 Likes

return seems better than then to me and also allows the sub if usage that was brought up in a way that is clearer.

“Return the following value as the value of this expression.”

However, does that suggest that this feature of the language is trying to be an excuse to not write the function that ought to be written for clarity and maintainability?
¯_(ツ)_/¯

3 Likes

I feel like return is not an option because it would be ambiguous whether it means "use this as the value of the expression" or "return this as the value of the outer function".

15 Likes

Nested expressions seem largely unreadable, so personally I don't think the language should be encouraging them. If this has been decided, I would suggest something other than then, as a plain verb would be more clear. Adding then also encourages confusion in whether it can be used with if as part of if then else rather than using braces. We can avoid that by using different word.

26 Likes

:ok_hand:

(meaning: ok, I can see the utility of this, but I don't feel strongly about its need / merit)

I do like the base ability to write declarative decision trees using expressions (as opposed to having to write them imperatively). It reminds me fondly of some of the better parts of VHDL. And I can see why it's of interest to allow easier mixing of declarative and imperative code, as this proposal adds, in the same vein that you can mix functional styles with imperative styles e.g. data.filter(\.enabled).map { /* imperative code here */ }.reduce(…).

Exactly; it's supposed to be different, because it's a declarative style, not an imperative one. I think that distinction is not just conceptually clean but practically important in making it clearer to readers which mode the code is in.

Is it necessary?

I concur.

I'm not certain that should entirely preclude the ability to do this - for better or worse printf-debugging is important in Swift and having to do refactors just to add a print statement is annoying - but at the very least it means the proposal should meet a high bar in terms of not introducing complexity, ambiguity, or other negative consequences.

I'm inclined to concur with this too. I'm not really sure why immediately-executed closures get quite so much hate from so many people.

I want to agree as some kind of matter of principle, but I think this just isn't true for any non-trivial expression.

let width = parentView.frame.width

That might be literally just a memory load, or either or both of frame and width might actually be computed properties which log something, or have (technically) any side-effect.

Heck, even the following has unexpected side-effects (in the lay-person sense of the term):

let width = proposedWidth + 5

Can you see what it is? Hint: the above code is not safe and can crash!

Answer

The implementation is slightly more subtle, but essentially the + operator is defined not as a pure expression but as essentially the actual addition plus a precondition(!overflowed && !underflowed).

Granted this is a subtle and relatively innocuous side-effect, but it's still a side-effect. And a broadly undesirable one, for most people (in the sense that most people expect such code to be guaranteed to work, because in their happy-path mindset there's surely no way a trivial addition could fail, right?!).

Bare last expression returns

I've found the random, unmarked appearance of "ResultBuilder" syntax to be frustrating.

It's fine if you're e.g. writing SwiftUI view methods where it's a well-known convention that you're in that special language mode, but when result builders are used in almost any other place, they are surprising and cause a lot of annoyance because of their different behaviours and rules versus normal Swift.

I suspect a lot of this could have been avoided if some sort of sigil were required to indicate actual 'return' values, whether that be a keyword (return / yield / whatever) or an operator (<- / whatever) or somesuch. Or if there were at least some keyword at the top of the block to clearly indicate the special, unusual language mode, e.g.:

libraryFunction(normalArg) { @builder
    …
}

I view this a big design flaw in result builders, in retrospect. It was discussed heavily at the time of their proposal & implementation, and the concerns were dismissed, but time & experience has proven that the concerns were well-founded.

And so…

Note that if bare last expression became the rule for if and do, it raises the question of whether this also be applied to closure returns also, and perhaps even function returns, which would be a major and pervasive change to Swift.

Absolutely. It would be a nightmare of inconsistency and confusion to [further] implement bare last expression returns only in some contexts but not others.

Thus, I don't think a "bare" return is a good idea. It's yet more context-sensitive complexity and ambiguity, that we've already seen to be a problem in its current [ab]use by result builders.

I find that uncompelling. I for one have never used [in a significant way] any language which does this, that I recall, in over thirty years of programming.

Well, I say that… I have written a bunch of Spark code which is technically Scala. Nonetheless, I don't recall ever encountering this grammar rule in real-world code. I don't think it's sound to assume most people coming to Swift have ever encountered it. Which doesn't mean it cannot be considered, but does mean it can't side-step the normal bars for intuitiveness and simplicity.

This would be inconsistent with what comma means in Swift, which is essentially as a synonym for || in if expressions.

Also, as someone who really loved utilising the comma operator in C/C++ in clever ways, I can assure you my colleagues were unimpressed. Even in such long-lived languages as C & C++ it's apparently still a very esoteric and misunderstood language feature.

Alternatively…

If we did lean into the 'bare' returns idea, by making everything surrounded by curly braces follow that rule, so that it's consistent at least, then I can see a potential sensible avenue. It'd require further changes to allow curly braces to be omitted in more places, so that you can write:

let foo = if x then y else z

…as a purely declarative form, or you can access the embedded imperative mode with:

let foo = if x then y else { print("z"); z }

The appeal here is that there's now mostly consistent syntax rules across the language, including to things like foo.map { print("x"); x } in a way that's very close to the existing syntax (just sans return statements).

Result builders would still remain weird, unfortunately, with their multiple returns.

Inner then expressions

I'm inclined to agree with the pitch on this aspect, because the alternative is inconsistent and overly complicated - allowing then in inner expressions means the outer expressions implicitly follow a "bare" last return pattern. I like both the simplicity of requiring properly-placed "then" and how it better maintains the simple model of a declarative condition tree.

However, @ellie20 raises a good point:

I agree that it's logical and important that key syntax like guard statements be supported well. guard doesn't really make sense as an expression, and writing then guard … is just weird since the "else else" of the guard is implicitly the rest of the block, which would technically mean you write:

let x = if .random() {
  then guard .random() else {
    0
  }
  1 // No 'then' because it's covered by the `then` on guard.
} else {
  0
}

I'm not sure what the solution is here. It might be that Swift has unwittingly backed us into a corner, through the existing patterns (re. return et al) and unusual (but useful) syntax like guard statements.

Load-bearing semicolons.

Nope. Nope nope nope nope nope.

:slightly_smiling_face:

Naming

I think that use of any control-flow keywords - return, yield, break, etc - is a bad idea. For all the reasons that have been discussed in depth before, such as the obvious and significant room for confusion over, well, control flow.

But, result: might be an interesting option:

let width = switch scalar.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 
      log("this is unexpected, investigate this")
      result: 4
}
let icon: IconImage = do {
    let image = NSImage(
                    systemSymbolName: "something", 
                    accessibilityDescription: nil)!
    let preferredColor = NSColor(named: "AccentColor")!

    result: IconImage(
            image, 
            isSymbol: true, 
            isBackgroundSupressed: true, 
            preferredColor: preferredColor.cgColor)
}

It's technically distinct from return - it's not an existing keyword nor intuitively a control flow statement - so arguably it avoids confusion over the scope being exited, but it's linguistically close enough to return to maintain a useful conceptual parallel.

6 Likes

I'm inclined to disagree because the whole point of this feature is to let people use imperative features within an expression for ergonomics, like mutating intermediate values or setting variables. The do/switch/if expressions would function almost exactly like existing imperative blocks in Swift except for control flow. A similar but truly declarative feature would be result builders, which aren't powerful enough to subsume the use cases of do/switch/if expressions.

One of the places I think that sequencing multiple statements within a single expression would be most useful would be within a conditional, in order to perform an update and test a condition in one step. This is particularly useful in while loops, like in C where you can do:

while (counter -= step, counter >= 0) {
  update()
}

Recently, the topic of introducing non-unwrapping bindings within a compound conditional also came up. Although one can use case conditionals with a discard pattern to do this, like if let x = optional(), case let _ = update(x), condition(), that's nonobvious and awkward.

The proposal dismisses using explicit semicolons as an alternative, but to me that's much more appealing than a new keyword, and serves many of the justifications for the keyword just as well. As proposed, where a do block and then keyword are necessary to form a multi-statement expression, the loop conditional case becomes pretty bulky and awkward, both from the extra keyword noise and the extra brace block:

while do { counter -= step; then counter >= 0 } {
  update()
}

if let x = optional, do { update(x); then condition() } {
}

compared to:

while (counter -= step; counter >= 0) {
}

if let x = optional(), (update(x); condition()) {
}

(Maybe we don't even need the parens, though removing their need might require deeper changes to the grammar. if let x = foo(), let y = bar(); baz { } would be difficult to read without them since one let is an optional unwrapping condition and the other is a regular statement appearing as part of a compound expression.)

Since explicit semicolons are rare in idiomatic Swift today, and aren't yet otherwise valid inside of expression contexts, their presence also seems like it would serve the "something is going on here" role used to justify for the new keyword, and they also would serve the disambiguating separator so that things like foo(); .bar parse properly.

5 Likes

Right, that's the crux of my concern. I don't want any confusion over control flow.

Reviewing history briefly: the choice was made that imperative code (statements) uses control flow statements - return et al - while declarative code (expressions) does not. Remembering that e.g.:

func test(arg: Bool) -> Int { 
    let x = if arg { print("Disabled."); return 0 } else { 1 }
    print("x: \(x)")
    return x
}

:stop_sign: error: cannot 'return' in 'if' when used as expression

This was a carefully-considered choice to prevent ambiguity as to what that return actually means; would it skip the second print statement or not?

Having distinct syntax between these modes is important for aiding understanding and readability.

What you're proposing (re. how then should behave and be located) is essentially a straight synonym for return. It would help avoid the control flow confusion, by using any word other than an existing control flow keyword, but it still blurs the lines a little bit conceptually.

To be clear, I don't think it's particularly problematic - either approach works, technically. I just like the additional distance that the proposal's grammar provides.

I think I'd be more inclined to drop then entirely rather than make it merely a synonym for return.

3 Likes