Pitch: Multi-statement if/switch/do expressions

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

Makes some sense, but it still feels more like C than Swift.

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

Better (IMO) but still a bit weird since there's two blocks that are repeated, with a somewhat subtle semantic distinction between them (the first is executed before the first conditional check, the other only after it; the first is always executed at least once, the second might never be executed).

Today you could write e.g.:

for counter in stride(from: topValue, to: 0, by: -step) {
    update()
}

if let x = optional {
    update(x)

    if condition() {
        …
    }
}

I don't really see anything wrong with the existing syntax. On the if case, if you need an overall 'else' behaviour there's various existing options (e.g. var allGood: Bool, refactoring into a standalone function, etc).

Incidentally if Swift had better arithmetic behaviour you could potentially do:

while try? counter -= step {
    …
}

(where counter is an unsigned integer)

1 Like

I just wanted to mention that this pitch is inherently about a functional feature. Therefore, it gives more credibility to a “bare” return as it is also a functional feature. Just something to think about.

Even if yield is already in use, would overloading it for this feature result in breakage of the existing uses? If not, it seems like it’s a common keyword in other languages, limits fancy heuristics to maintain source compatibility, and prevents having to introduce a new keyword that will be hard to search for - all things that may work in favor of yield over then.

2 Likes

Use of yield would conflict with its current use in _modify accessors and also preclude its possible future use for generators if those are ever considered to be added to the language. For mainstream languages with generators (Perl, Python, JavaScript, PHP, C#, F#) yield has an unambiguous meaning of yielding a value from the generator function it's used in.

13 Likes

i don’t think anything would break, but it would be confusing to me because yield means that control will be transferred to some implicit coroutine determined by the enclosing scope (_read, _modify), and will only return if the coroutine doesn’t throw an error. this yield borrows the value, the other yield would consume it instead, like a return.

2 Likes

I'm +1 about proposal, but also as others concerned about adding then keyword.

Just wanted also to point that today in Swift a default behaviour where you can define function or variable like:

func some() -> Int { 1 }
var another: Int { 1 }

and it will know you're returning an Int. But as soon as you adding something—compiler will ask you to provide a return in a function:

func some() -> Int { 
   print("hello")
   return 1
 }

And feels like behaviour should be the same across language, so you just need to write return when it's needed.

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")
      return 4  // would now be allowed
}