Pitch: Multi-statement if/switch/do expressions

What about the parsing issues of bare last statement? Unlike in SwiftUI @ViewBuilder contexts, in the contexts we’re discussing here the return type will very often be concrete, meaning that leading-dot syntax will be used often, but with no separating (non-whitespace) character or keyword between multiple expressions this will become ambiguous.

Imagine we start with this code:

var color: Color {
    if isSelected {
        .purple
    } else {
        .gray
    }
}

We want to add a print statement to the else branch, and the options under consideration so far are:

  1. A keyword (e.g., then)
  2. An operator (e.g., <-)
  3. No separator
  4. Semicolons

A keyword

var color: Color {
    if isSelected {
        .purple
    } else {
        print("Not selected")
        // Leading-dot syntax still works, but we had
        // to modify the return value using the keyword,
        // and also there are concerns about parsing a new keyword.
        then .gray
    }
}

An operator

var color: Color {
    if isSelected {
        .purple
    } else {
        print("Not selected")
        // Leading-dot syntax still works, but we had
        // to modify the return value using the operator.
        // At least we avoid issues related to parsing a new keyword.
        <- .gray
    }
}

No separator

var color: Color {
    if isSelected {
        .purple
    } else {
        print("Not selected")
        // Leading-dot syntax becomes ambiguous, so
        // we still end up having to modify
        // the line of the return value.
        Color.gray
    }
}

Semicolons

var color: Color {
    if isSelected {
        .purple
    } else {
        print("Not selected");
        // The only option that allows me to call `print(_:)`
        // without having to also modify the preexisting line.
        .gray
    }
}
3 Likes

It's not unprecedented, we have a similar gotcha in the language:

func foo() {
    return
    // do later
    print("won't be printed, right?") // wrong, it will be printed
}
func foo() {
    return; // fix
    // do later
    print("won't be printed!")
}
To be absolutely precise, you could avoid changing the preexisting line in all your cases

(not that anyone would want to do it, though).

3 Likes

One more idea to consider would be to allow “do” to break out of expression. I think “do” combined with “catch” as an expression still works.

The syntax is just heavy enough that it would probably help discourage stuffing too much in to an expression.

var color: Color {
    if isSelected {
        do { print("Selected") }
        .purple
    } else {
        do { print("Not selected") }
        .gray
    }
}

“let” and “guard” would also be allowed ideally, but “let” could still be problematic without “then”.

// problematic
var color: Color {
    if isSelected {
        let shade = shadeOfPurplePreference()
        .purple(shade) // doesn’t work
    } else {
        do { print("Not selected") }
        .gray
    }
}

// possible fix
var color: Color {
    if isSelected {
        do { print("Selected") }
        guard !failure1 else { throw MyError }
        if failure2 { throw MyError } 
        // Expressions must be the last line. "where" can include if-expressions.
        where // sort of like if/guard with no condition.
            let user = currentUser(),
            let shade = shadeOfPurplePreference(user: user)
        in .purple(shade)
    } else {
        do { print("Not selected") }
        .gray
    }
}
3 Likes

+1 on the comment. I don't think readability is improved so much with the proposed syntax, compared to using an anonymous closure like we're doing today. The current if/switch expressions eliminate the boilerplate when the logic is simple enough, and if the logic is already verbose, why not use a slightly "heavier" closure?

10 Likes

Another +1 for bare last expression for reasons already outlined, the bar for a new keyword should reasonably be higher than for this use case which is largely syntactic sugar. then as a keyword just leads my mind to Pascals if-then-else

9 Likes

I also like the idea of sugar similar to the Swift type system to break up expressions and allow a limited number of statements to appear before the result.

// possible fix
var color: Color {
    if isSelected {
        guard !failure1 else { throw MyError }
        do { print("Selected") }
        .purple(shade) where 
            let user = currentUser(),
            let shade = shadeOfPurplePreference(user: user)
    } else {
        do { print("Not selected") }
        .gray
    }
}

I oppose this entire proposal. Every code example in this thread is less readable than the existing language.

16 Likes

I oppose this proposal. I think it goes against some core design principles in Swift and in designing a language in general.

Using then in this way is unfamiliar to any other language and doesn't read as grammatical English:
To contrast, the word return makes a lot of sense in English when followed by a value that a function returns. It's a verb that takes a value (noun) as a direct object. It also aligns with how we talk about functions and methods as abstract concepts. That's probably why it's so ubiquitously used across languages. then is not a verb, but typically it's used as a conjunction. In other languages that use the word then they use it as a conjunction between the condition and body of the if-statement. It doesn't make sense to use the word then just because other languages use it in the context of if-statements when the grammatical context is actually quite different.

Swift is supposed to be easy to learn the basics, with progressive disclosure of advanced features:
Adding a keyword at the expression level for basic procedural code will give beginners and outsiders trying to comprehend Swift code an unnecessary thing they have to learn as they're trying to read other peoples code. Even though this is partially true for almost any proposal that adds a feature to Swift, I feel that it much more likely for people to make use of multiline if expressions with an unfamiliar keyword, then in even the most basic Swift code.

Alternative considerations outside of using then don't really resolve my concerns:
If yields is off the table, maybe resolve would make sense. Functions return, expressions resolve :woman_shrugging: I am strongly opposed to using the bare last expression. I have experience trying to edit Scala code that took my over fifteen minutes just to determine all of the possible return locations for a method because Scala enforces this concept.

I am in favor of a much smaller step. Allowing single line do-catch expressions seems reasonable. Keeping it to a single line limits it, but I think that keeps to code readable, and it would still be a useful feature and make the language feel more ergonomic after the introduction of if-expressions.

When multiple lines are needed inside an if-expression, I think we already have good options to write the code. You can either refactor one of the blocks as a method, or revert back to if-statements. I much prefer this syntax and find it easier to read top down:

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

I think this fills in an important usability hole in if/switch expressions. Without functionality like this, if/switch expressions feel incomplete imo.

If you have an if/switch expression and need to add more code to one of the branches, this would usually require refactoring the entire if/switch expression, including all of the other branches, to use a different pattern. If the if/switch expression has a lot of cases, this may be a pretty onerous change. Allowing individual branches to evolve into being multi-line without affecting the rest of the expression is important for flexibility and expressivity.


I'm not entirely sure I see the need to introduce a new keyword for this, though, as opposed to using return. We already can't return from an outer scope from inside an if/switch expression (right? you get error: cannot 'return' in 'if' when used as expression), so it doesn't seem like there is any formal ambiguity issue with allowing this:

func foo() -> String {
  let foo =
    if foo {
      return "if branch" // `return` could be omitted
    } else {
      print("Executed the else branch")
      return "else"
    }
  
  return foo
}

To me this doesn't seem any more or less confusing than the following code, which is already allowed:

func foo() -> String {
  let foo = {
    if foo {
      return "if branch"
    } else {
      print("Executed the else branch")
      return "else"
  }()

  return foo
}

A model like this, where an if/switch expression behaves identically to the equivalent code simplify wrapped in a { ... }() closure, seems simple to reason about.

That being said I definitely support introducing a keyword like then for this if we're confident that using return would make the control flow too confusing.

I loosely prefer then over yield -- I think "if foo then 1 else then 2" (etc) reads pretty fluently, compared to "if foo yield 1 else yield 2".

5 Likes

I'm very strongly -1

This is a complication too far that will further increase the mental burden when quickly reading code. My brain now has to understand return, implicit return, resultBuilders and then and that expression return behaviour changes depending on the context. Single expression values are fine IMO as they're easy to visually read and were a nicety for very simple cases that would otherwise require a closure or other property.

A final implicit return would also be non-trivial to identify when reading code. In Rust, you have the semicolons as a visual indicator.

My opinion is that if you want to write such a multi-statement expression, you should probably refactor this anyway into a separate property or similar to encapsulate the logic.

I'm generally not a fan of increasing the number of keywords for increasingly esoteric scenarios unless what's being proposed wasn't already possible in a reasonable way using some other language feature. This doesn't meet that requirement.

28 Likes

I find the use of the keyword then pretty confusing. How about using the keyword emit or some other syntax that does not use a keyword?

It is actually closer to break/continue statements as proposed since it only affects control statements. It does not address why implicit returns are still allowed in functions/closures, so there would likely be something beyond what is proposed...

I'm pretty sure this was done deliberately to delay the decision until multi-statement if is proposed. Not as a deliberate language design. I'm pretty sure this would never be accepted since it would change the way return works significantly and it may still be allowed as a normal return statement in certain parts of a multi-statement block.

1 Like

I can certainly see how it is compelling to do implicit returns since no matter what we are implicitly returning in single-line contexts.

Would it be strange to put the semicolon at the beginning of the line for normal style guidelines when not otherwise using semicolons?

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")
      ; 4
}

Even if then is supported, it might make sense to also support implicit return after a semicolon for cases where you append several short statements on the same line.

could we introduce labeled return?

func foo() -> String {
  let foo =
    if foo {
+     return foo: "if branch"
    } else {
      print("Executed the else branch")
+     return foo: "else"
    }
  
  return foo
}

still, you have to wonder if you’re better off just using deferred initialization…

1 Like

Sorry for a side question, how did you just do the coloured text?

use the diff highlighting mode:

```diff
+
_
3 Likes

Chosing a good keyword:

  • return: The difference between a closure and a “non-closure” (control flow) is an important difference and using return here does not help the understanding, it somehow blurs this distinction, even if formally a distinction could be made for different applications. return should always mean leaving the closure.
  • break: Same here, break is for control transfer, i.e. the control flow expressed by the control flow statements is left, I cannot see this here, we are within the borders of control so to speak.
  • use which was already mentioned or choose could be good alternatives, as then is so widely used in a different sense in other languages. (I personally like then nevertheless, but this is certainly a bit nerdy, see my argument below.)

Keyword or “last line” rule:

  • Please: No return of the semicolon! Not needing semicolons (except where you want to put several statements in one line, which is fine) is a very nice feature of Swift, and having to use semicolons in certain cases feels like a confession that this was a bad idea…
  • I think the use case of choosing an enum value shows that a keyword would make sense.
  • As a stronger argument, the “last line” rule looks too implicit to me and does not help the understanding, to speak frankly it seems too “nerdy”: Swift is a language where a non-expert should be able to understand and change and add code on a higher application level (this was one of the main reasons I chose Swift for a large cross-platform project and out-ruled Rust and Scala).
  • Concerning the diff views for according changes: I see this as a weakness of the usual line-centric diff views, of course one could do better (see how SmartGit does it, and maybe Xcode could make a better job here too – and yes, I know the line-centric thing is what Git gives you).

Comparison to result builders:

  • The result builder syntax is for defining content within braces and not defining a sequence of statements. It is a different use case, using the same syntax (curly braces). (In practice this syntax overloading is not a problem, as the result builder syntax is headed by something where you know you want to define some content for. On some mental level, you always define content with curly braces, but in one case this content is statements.) So I would not follow the comparisons made here.

Is the feature “needed”?

  • In my opinion, the let value = { …calculate the value… }() formulation introduces a closure because of an obvious weakness of the language, the introduction of a closure should have more profound reasons than that, as with a closure you make a step outside the current control flow. (Another such “weakness” — for-in-loops not allowing optional chains — leads me to the use of “forEach” instead of for-in-loops, introducing a lot of closures where I actually would not like to have them.)
  • I like the while do { counter -= step; then counter >= 0 } { … } use case — this is maybe also a bit nerdy :wink:, but not in an implicit way.
8 Likes

I believe you are talking about this example. Au contraire, it just illustrates the point that in some cases you'll need to do something extra (e.g. be more creative on the previous line or use a full Color.gray notation on the line that exposes the issue). It is very easy to do for the users and does not justify a full new keyword (IMHO).

I realized that this particular gripe of mine about the last-expression rule doesn't actually make sense:

  1. If my last expression does not use leading-dot syntax I have no problem.
  2. If it does, I can choose to put a semicolon at the end of the previous line to fix the parsing ambiguity without needing the semicolon to be obligatory in all cases.
2 Likes

Here's another line of argumentation + solution that I think I would personally be happy with:

We're trying to solve for the situation where one branch of an if/switch expression needs to be composed of multiple statements. Perhaps it is useful to separately consider the situations where the extra statements will influence the resulting value and the situations where they will not.

When the extra statements influence the resulting value...

... then to me an immediately executed closure seems like the perfect tool. A closure is a series of statements that evaluate to a single result, which is exactly what we need here.

When the extra statements do not influence the resulting value...

... then maybe we could introduce something like what @esummers was suggesting, where a do { } block is given the special behavior of not disrupting the surrounding declarative code.


So if all I want to do is insert myself into a specific spot in my declarative code and print a message, I can create a little imperative bubble using do (which I think is perfectly named for this job) which will not revert the surrounding code to "imperative mode" (i.e., neither in a function nor in an if/switch expression will I need to add return or any other keyword to the result-generating expression).

However, if what I need is actually to compute this particular value in an imperative way, then in a function things are already great the way they are (I mark the final result with return), and in an if/switch expression I can embed my imperative subsection into the surrounding declarative context using an immediately executed closure, which, again, feels to me like a perfect use of closures because they are specifically the language construct that allows us to bundle up messy imperative details and focus on the resulting value.

Example

let backgroundColor =
    switch colorScheme {
    case .light: .white
    case .dark:
        {
            var (r, g, b) = (0.0, 0.0, 0.0)
            for value in myCollection {
                // ... update rgb values
            }
            return Color(r: r, g: g, b: b)
        }()
    @unknown default:
        do { print("Unknown `colorScheme`: \(colorScheme)") }
        .white
    }

Caveat: I guess this could pose a problem for allowing do-catch-expressions in the future...
Counter-argument to caveat: do-catch could be evaluated as an expression while just do serves as a non-disrupting imperative interjection.

3 Likes