Pitch: Multi-statement if/switch/do expressions

I think there are some cases where return is the best choice, like this. There's no ambiguity, and perhaps no confusion. I will not get surprised if this works today.

var description: String {
    switch self {
    case .a: "a"
    case .b: "b"
    @unknown default:
        print("unknown case found \(self)")
        return "unknown \(self)"
    }
}

If I must use then here, that's more surprising for me. And if so, extending this behavior and using return in multi-statement if expression seems straightforward to me.

1 Like

I do not agree with this. Firstly, the only expressions within which return can occur today are closure literals, in which return means returning from the closure. Since closures are anonymous functions, return means the same thing.

In fact, that’s what makes it so confusing, and why anyone wants to come up with an alternative in the first place! Scanning the body of a function, when you see a return keyword, your immediate and best guess is that it is a place where the function can terminate. It is only upon close enough inspection that one notices the curly braces that denote a closure literal, and mean that one must recontextualize the return.

And you are suggesting that bringing that level of mandatory scrutiny to every return within the arms of an if or switch is somehow less confusing? I am not sure how you are arriving at that conclusion. It doesn’t seem consistent with any instincts I have developed from 20+ years of reading C-family source code.

13 Likes

This is one of the major reasons why (in my own opinion, not speaking for the language steering group) return is a non-starter. It's already a frustration to many that closure-based APIs like forEach don't handle control flow the same way as their statement-based counterparts. But at least in that case, the reasoning is straightforward to explain—the body of forEach is a closure, so it operates as its own functional unit. There's a clear definition of what a "function" is, and some of them just happen to be written inline as closures.

To then go and say "in addition to functional units, some non-functions—but only in expression context and not statement context—also treat return differently" would be such an awkward special case that I can't see how it would make things easier to understand.

19 Likes

Well, as I said above, this is the exact motivation for if expressions in the first place. If you really don’t find it more difficult to read a function that uses closure literals compared to one that doesn’t, you must read source code very differently from how I do, as well as everyone I have ever pair-programmed with.

We didn't allow return in the single statement if/switch expression to not confuse readers (one will think that this is the return from the outer function, another will think this is the value of the if expression). Expanding expressions to be multi-statement must follow the same approach using exactly the same line of reasoning: to avoid potential confusion for the reader and make the code easier to follow.

unless you make this change to do something after the expression:

var description: String {
    let x = switch self {
    case .a: "a"
    case .b: "b"
    @unknown default:
        print("unknown case found \(self)")
        return "unknown \(self)"
    }
    print("will this be called always?") // 🤔
    return x
}

BTW, this is not only "return"... "throw" behaves the same way: when you see "throw" in a deeply nested code you'd have to follow it and see where it is called from (is it a closure or not) to see where the control goes to.

:100:

5 Likes

I find this to be a blanket statement that is incorrect - type inference has never been a discouragement for anything. For local variables specifically, I can see almost immediately what type it is because:

1 - if an expression, it's right there after =
2 - if a function, I can trivially navigate to the function to see the return (explicit!) type

In an if/switch expression without an explicit type, that type comes after I read any number of lines - this is not easy to read or write with.

I find this again to be another blanket statement for the justification of almost anything. We might as well never have to specify a function return type /pedantic.

I feel like all of the arguments for the DI version are descriptive enough for why it is objectively better instead of a "preference" - it is more than a fair argument.

6 Likes

It seems evident at this point (maybe even long since) that there will be no consensus. Either someone authoritative will have to choose an alternative and be done, or we need to take a few steps back and try to find something else we can agree on more.

Otherwise we're just going in circles with various valid opinions being expressed and very little convincing actually happening.

12 Likes

Given the evolution of the expression feature (it's a replacement for a closure), I'd argue that expressions aren't really different and won't be seen as different by most Swift developers. Besides, numerous examples of the same confusion in existing constructs have already been given, yet we expect developers to deal with them, and they do, by learning the rules of the language. In fact, using another keyword removes only a single consideration: it can't return from a function or closure. You still have all the same confusion in nesting and whether execution stops at any given point. How is this:

func grabStrnig() -> String {
  if value == 1 {
    then "1"
  } else if value == 2 {
    then "2"
  } else if value == 3 {
    then if other == 1 {
      then "1"
    } else if other == 2 {
      then "2"
    } else if other == 3 {
      then "3"
    }
  }
}

better than this:

func grabStrnig() -> String {
  if value == 1 {
    return "1"
  } else if value == 2 {
    return "2"
  } else if value == 3 {
    return if other == 1 {
      return "1"
    } else if other == 2 {
      return "2"
    } else if other == 3 {
      return "3"
    }
  }
}

It doesn't seem any better to me. So really the only argument for another keyword is it to make it visible that something can't return from a function or closure, just an expression. I don't really see a distinction there, but that's really the only advantage I can see.

If we're set on a keyword, perhaps we can make the distinction clear by using express rather than then? It's longer, but it makes it explicit it's only used in expression contexts. This version actually provides clarity to me:

func grabStrnig() -> String {
  if value == 1 {
    express "1"
  } else if value == 2 {
    express "2"
  } else if value == 3 {
    express if other == 1 {
      express "1"
    } else if other == 2 {
      express "2"
    } else if other == 3 {
      express "3"
    }
  }
}
2 Likes

Indeed. This thread has been largely repeating itself for a hundred posts now.

I don't envy the proposal's authors nor the Swift team reviewing it, as this is clearly an important design question that people are passionate about yet strongly divided on. I appreciate the difficult situation they're in, and I appreciate their work.

It does feel like it's decision time, though. I respect the authors & reviewers, and honestly I'm mostly just eager now to hear the decision and move on from this topic.

14 Likes

Note that this is not "if expression"... it is an "if statement". Interestingly with the current rules you either have return on all branches (then it is a statement) or on no branch (then it is an expression). If you try to have a return on some branches but not others you'll get a compilation error.

if "return" was allowed to mean "if expression" value (which is a crime IMHO, but let's consider it for a second), and you have this:

    let x = if value == 1 {
        print("log")
        return "1"
    } else {
        "2"
    }

then whenever you want to comment out the print you'd also have to remove "return" from the next line. And whenever you uncomment the print – you'd need to reintroduce "return" back - not nice.

Very true.

The pitch isn't completely clear on this. I assumed that then would be usable, if optional, for single statement expressions, like return is for single expression returns. It says:

If needed, a then must be the last expression in a branch.

It also has this example:

let x = if .random() {
   print("hello")
   if .random() {
     then 1 // this `then` is intended to apply to the outer `if`
   } else {
     then 2
   }
 } else {
   3
 }

which is then later optimized to remove the inner thens. So it looks like you can use then even for single line expressions to me. With the same rules for return, I don't think your point follows.

The pitch says that this mentioned example should not compile!

Ah, right, I was just looking for the example. So far as I can tell there's no other statement whether we can use then outside of multiline expressions.

Or both, right? I thought the keyword would be required to use something other than the last expression, or for clarity even in the last expression.

Beyond then another keyword proposed from generators was yield:
https://forums.swift.org/t/pitch-multi-statement-if-switch-do-expressions/68443/17

As for indicia beside a keyword, since this feature hinges on rules of definite assignment:

  • a new block-assignment operator :=
  • assigning to the target variable in the outer scope
    • and consider naming function results for this purpose? (oh no it's Go!)

These alternatives were not popular and are not complete, but might be reasonable.

https://forums.swift.org/t/pitch-multi-statement-if-switch-do-expressions/68443/188

Overall I'm a little concerned that the focus on syntax and ceremony obscures the question of the programmer's mental model.

In Python developers have dealt with variable argument splatting, context managers, and a whole host of meta-programming, and the mental model of an interpreter feeds into the notion of the last-expression as function result.

Swift for error-handling purposes has guards and early returns that make the explicit return statement for the function pretty essential, so ambiguating return is more problematic.

Swift's closure support and its high-traffic combinations with async and systems API's make it pretty common in Swift to have code in nests that are not only deep, but non-sequential. So things like block-relative interpretation or (throw-like) jumps to outer contexts from expressions pile complexity on complexity.

i.e., a bias against the return keyword in Swift or for the last-expression in Python comes not really the economy or readability of syntax but what the programmer has to understand (and has understood) in the overall language context.

From my perspective, expressions are relatively safe; they're almost by definition simple and local. That's worth preserving.

In medical education they distinguish between system-1 and system-2 thinking. System 1 is quick, high-confidence associations to simply memorize and apply efficiently. System 2 involves working through complex issues from first principles. Yes, all doctors do both, but doctors differ generally by expertise in one or the other. In schooling or practice it's unwise to simply assume a volume or depth profile, and impractical to ask for depth at volume or volume at depth. It makes things easier when the workflow has boundaries between the different systems so switching has clear signals.

Given that distinction, one design heuristic could be to keep expressions in system-1 distinct from closures requiring system-2.

If there really are cases where the multi-statement expression is simpler to think about than the alternatives, the clearest rule would be to only support a result in the last statement, and to require some syntactic indicia for multiple statements

For that indicia, the obligatory bikeshed:

  • not return, for reasons above
  • not then, because of if, and lack of precedent
  • not yield if Swift is ever to have generators
  • not : label, for ambiguity and obscurity

Some assignment-reminiscent prefix operators:

  • := - (more definitional),
  • => - mirrors the function result indicator ->
  • (but both typically are used as infix operators)

To birth this difficult decision, it could help have an open repository with a robust suite of canonical examples written in Swift today, with people contributing branches or folders for the different approaches. That could be also be a starting-point for compiler test suite.

6 Likes

re: ceremony vs clarity.

This pitch would reduce the ceremony (or scourge) of immediately executed closures.

However, I think it also reduces clarity of if / switch expressions. Today, they're just plain ol' muxes. In future, they'd be more capable, but thus non-trivial to reason about.

Specifically, developers may be tempted to stuff unrelated statements into their switch expressions, leaving readers confused – wait, what does this have to do with deciding the value of bar? e.g.

.onChange(of: colorScheme, { _, scheme in 
  self.textColor = switch scheme { 
    case .light: 
      if initialRun { showSillyDarkModeOnboarding() }
      then .black
    case .dark: 
      then .white
  }
})

That tangles a ternary with an unrelated side-effect. But the code is perfectly understandable, and wow, it's even terser than the broken-apart version! This is how I envision some very strange switch expressions could begin. Today, they simply can't.

Not a blocker, but I think this pitch is a clarity trade-off, not a straight win.

6 Likes

I think I tend to the former. While implicit return is cool, it must be applied consistently across the entire language, which to me means multi-statement implicit return should also be a thing. The new Verse programming language teaches that approach from the very start. It requires some getting used to, but it's not thad bad. However personally in Swift I would rather prefer an explicit keyword for that functionality.

As for the keyword. I advocate for use, not because I pitched it, but because to me a non-native English speaker it reads with the most sense.

  • if this condition applies, then do this and that and finally use value X else do this and that and use another value.
  • (if we had labeled if/switch/do expressions): from some deeply nested expression expression_label use value seems to fit very well

The then keyword seems to dance out of place to me personally. Sure one can potentially rephrase things, but it doesn't seem to always fit.

  • if this condition applies, do this and that and then finally use / return value ...

The then keyword does not signal the final operation, but is more like a task description filler.

I hope that's somewhat understandable.

4 Likes

My opinion on this pitch for the record:

  • I think that if, switch, and do not being expressions from the beginning, in the first version of Swift, was a mistake. I'd be glad to see this finally fixed. For me Swift is predominantly an FP language with imperative sugar on top where needed, and in FP languages "everything is an expression" is often a core tenet.
  • We already have implicit return for single-expression closures, functions, and property bodies. Not having it (with or without a special keyword) for multi-line closures/functions feels quite inconsistent.
  • I'm strongly against return in multi-line expressions meaning "evaluate to this value" instead of "return from this function". Always keeping track of the current context to understand what return means on a specific line is not worth it for me personally.
  • For the reason above, I don't think that immediately evaluated closures are a good workaround, I find that they obscure control flow quite a lot, where again return has a different meaning depending on the context. Also see the notorious "forEach problem"[1].
  • I initially didn't have a strong opinion on then keyword vs implicit last expression, but I would prefer use instead of then if it's decided that we must use an explicit keyword.
  • Overall, now I'm leaning towards "implicit evaluation to last expression in code block, with no keyword", seeing how much bikeshedding effort was put into it in this thread already just to pick a keyword. Ruby, Kotlin, and Rust have established a precedent here for us, I don't see a problem with Swift following that. I also don't see a good reason to deviate from that precedent.

As said above, it would be great to have this matter finally settled, whatever the outcome is.


  1. I personally would also like to see forEach method deprecated and eventually removed from the language, it's the odd one out in the list of "canonical FP" methods like map, filter, and reduce. Swift should encourage the use of for ... in instead, so that control statements like return and break make more sense when reading this type of code. Going one step further, I'd love to see some kind of "list comprehension" feature eventually, so that for ... in could also become an expression. ↩︎

23 Likes

I agree with nearly all of this, so rather than restate those points, thanks to @Max_Desiatov for summing it up so nicely.

On the issue of explicit keyword or implicit final expression, I lean more toward the explicit keyword, solely because the ambiguous cases described above I think are too egregious—the inability to use an implicit member reference without inserting a semicolon/parentheses being the big one. Rust doesn't have this problem because it requires semicolons; I don't know Kotlin well enough to talk about it but I believe that their grammar is roughly on the same level as Swift's in terms of weird edge cases.

So I might be biased, but as someone who primarily writes source tooling (some using SwiftSyntax, and some not), I shed a slight tear whenever we introduce new bits of highly-context-sensitive syntax, since it not only increases the complexity of the parser, but of other code that uses the parse tree to analyze or transform code. Of course it's necessary to introduce these exceptions to maintain source compatibility, but in cases where we have the option to choose between something that's less ambiguous and one that's more ambiguous and also prohibits common Swift patterns, I hope we'd choose the less ambiguous one—whether it's then, use, or some other option not considered yet.

10 Likes

Same, though I might be inclined to adopt a coding style that uses a semicolon at the end of an implicitly returned expression to effectively make it explicit:

var sigNum: Int {
  if self == 0 {
    0;
  } else if self > 0 {
    1;
  } else {
    -1;
  }
}

Being completely optional, it’s something people who prefer the extra obviousness of a keyword could just do for themselves.

My personal opinion that isn't going to sway anyone

It was an utter mistake to add SE-0380 to the language. I have yet to find a single case where it improves my ability to read and understand code. One of the things these proposals make me look forward to doing is writing a linting rule that forbids statement expression from being used at all.

I find all the arguments about adding this to "make writing Swift enjoyable" wholly unpersuasive, because Swift code gets read orders of magnitude more often than it gets written. Code is written once, but it is read dozens or hundreds of times. IMO, the emphasis should be on readability, not writability. People already complain about the readability of a simple ternary operator, but statement expressions have gone and made that far worse by throwing additional ceremony involving curly braces and keywords into the mix.

But, I know that fighting this is a losing battle, so I won't die on this hill.

Here's my actual review:

  1. should we introduce multi-statement expressions at all, or just stick with limiting them to single expressions

For the people who choose to use these expressions in code… I can see the value in being able to add a print() call into the middle of one of these expressions and not have to rewrite the entire thing because of a limitation around only allowing single expressions. So, from that point of view, I'm in favor.

  1. if so, how should that be achieved

Under no circumstances should we add a new keyword.

Swift already has over 120 public keywords.

(Compiled from Documentation)

  • #available
  • #colorLiteral
  • #else
  • #elseif
  • #endif
  • #fileLiteral
  • #if
  • #imageLiteral
  • #selector
  • #sourceLocation
  • #unavailable
  • actor
  • any
  • arch
  • arm
  • arm64
  • as
  • assignment
  • associatedtype
  • associativity
  • async
  • await
  • borrowing
  • break
  • canImport
  • case
  • catch
  • class
  • compiler
  • consuming
  • continue
  • convenience
  • default
  • defer
  • deinit
  • didSet
  • do
  • dynamic
  • else
  • enum
  • extension
  • fallthrough
  • false
  • fileprivate
  • final
  • for
  • func
  • get
  • guard
  • higherThan
  • i386
  • if
  • import
  • in
  • indirect
  • infix
  • init
  • inout
  • internal
  • iOS
  • iOSApplicationExtension
  • is
  • lazy
  • left
  • let
  • Linux
  • lowerThan
  • macCatalyst
  • macCatalystApplicationExtension
  • macOS
  • macOSApplicationExtension
  • macro
  • mutating
  • nil
  • none
  • nonisolated
  • nonmutating
  • open
  • operator
  • optional
  • os
  • override
  • postfix
  • precedencegroup
  • prefix
  • private
  • protocol
  • public
  • repeat
  • required
  • rethrows
  • return
  • right
  • safe
  • self
  • set
  • simulator
  • some
  • static
  • struct
  • subscript
  • super
  • swift
  • switch
  • targetEnvironment
  • throw
  • throws
  • true
  • try
  • tvOS
  • tvOSApplicationExtension
  • typealias
  • unowned
  • unsafe
  • var
  • visionOS
  • watchOS
  • watchOSApplicationExtension
  • weak
  • where
  • while
  • willSet
  • Windows
  • x86_64

There are another 90 or so "private"/underscored keywords, bringing the total of unique Swift keywords to well over 200. This is more than twice as many keywords as C++ (97).

I understand the good intentions of wanting yet another keyword for this proposal, but look at the road we are paving and where it is leading. Adding a new keyword should be reserved for entirely new language features, like how we added async and await for concurrency, or borrowing and consuming for ownership. These were ideas that were not expressible in the language at the time.

What is being pitched here is not an entirely new language feature. We are not debating some previously-impossible construct. This thread is full of examples about how everything being proposed is already accomplishable using existing syntax, and therefore does not rise to the bar of needing a new keyword.

The keyword we should use is return, because that would have parity with existing single-statement-versus-multi-statement inference:

var foo: String {
    "hello, world" // single statement, no keyword necessary
}

var foo: String {
    print(#function)
    return "hello, world" // multi statement, `return` keyword necessary
}

That pattern should apply to expressions as well:

let foo = if someCondition {
    "hello" // single statement, no keyword necessary
} else {
    "world" // single statement, no keyword necessary
}

let bar = if someCondition {
    "hello" // single statement, no keyword necessary
} else {
    print("else branch")
    return "world" // multi statement, `return` keyword necessary
}

Direct review: if we adopt this, we should require a keyword and the keyword should be return

--- EDIT---

Alternatively: if we adopt this, we should NOT require a keyword and we should remove the requirement to have return in the computed property examples I used above

Consistency above all else should be the goal.

18 Likes