Pitch: Multi-statement if/switch/do expressions

Multi-statement if/switch/do expressions

This is a very long thread but given my personal interest in where this proposal goes, I went through and read all of the first posts and most of the follow up posts, thought I did skim much of the later back and forth discussion that happened later in the thread.

I want to summarize what I’ve read so far so that people entering the discussion don’t feel like they necessarily have to read through everything to add to the discussion. As fair warning, my own opinion is intertwined heavily in this summary.

Out of the people who posted something, over a third of people are firmly against the proposal. Their main arguments against are:

  • Multiline if expressions will generally encourage less readable code. Having multiline if expressions seems to facilitate the convenience of writing code, but since code is read more often then it’s written, I find this a compelling argument
  • Side effects within expressions seem unintuitive.
    • Counter arguments were given such as side-effects in function call expressions, getters, or even arithmetic operators.
  • Adding a keyword to the language should have a higher standard and stronger motivation.
  • There are already suitable alternatives such as imperative style variable declaration and immediately executed closure.

Adding to the people who out-right opposed, there were several who state that they were opposed to the pitch in it’s current form, and offered various proposals for what they would be in favor of, although each of their opinions is different from another.

Most of the arguments about multiline if expressions leading to poor and difficult to read code are mostly ignored. Instead, must of thread is suggesting alternatives to using “then” as a keyword. Of the people who expressed strong support or neutrality, almost all of them suggested an alternative to the use of “then”. I only found six people that became convinced that using “then” as a keyword was an acceptable solution, though I may have missed some.

Other than the use of “then” the three main alternatives discussed seem to be:

  • Introducing a different keyword or operator/symbol. Dozen’s were suggested, but none seems to be more popular than “then”
  • Using “return” contextually within if-expressions
  • Using the bare last expression of the block.

Although each of these ideas has its supporters each of them met opposition as well.

Using “then” or an alternative keyword

No matter which keyword or symbol you use, they all seem to have the same problem. They all introduce the language feature in a way that is not familiar to any other programming language. As far as I’m familiar, no language that allows if-blocks as expressions utilizes a keyword.

Using “return” contextually

In a strong sense this breaks the expected meaning of return which historically and across almost all languages means that you are going to pop the call stack and return to the caller. However, we could fudge the way we think about that a bit by waving our hands and saying that if-expressions are syntactic sugar for immediately-executed closures. Although it would be unfamiliar to other languages, I think the meaning could be inferred fairly quickly by looking at surrounding code that would imply that the return was meant for the expression and not the containing function. In many cases it will be obvious when the expression type is different than the enclosing function type.

Using the bare last expression

This is one of the alternatives mentioned in the pitch. In the thread it’s the alternative that got the most traction from supporters, but it also has the strongest arguments against it in the context of Swift. I assume support from this comes from its simplicity and familiarity from other languages. However, it breaks what I believe is to this point a long-decided design decision in the language’s syntax. It has been decided that single line closures, functions, and getter do not need to use a “return” keyword, but longer blocks do. I think a lot of thought has already been put into that decision and using bare last expressions creates glaring inconsistencies within Swift. Other’s gave several reasons that it would be problematic. There are a few contexts where it adds additional ambiguity to the language. First, result build where bare expressions already have a meaning. And second, ambiguity with implicit static member look up on the last expression. The final point that a few people emphasized is the added difficulty for humans to read the code alluding to experience working with Scala which relies heavily on this feature.

My conclusion

There seems to be a fair amount of opposition so far to this pitch, although that is somewhat clouded by the lengthy discussion of alternative. Of the people the support the direction of this proposal, there doesn’t seem to be a settled direction for how to implement it in the context of Swift.

Counter proposals

There where two counter proposals of which I would be grateful to hear direct responses by the pitch’s author @Ben_Cohen

My own:

We should take a much smaller step by allowing single line do-catch-expressions as a way to make the language feel more consistent with was was added in SE-0380

Ethan Pippen's: @Pippin

I can’t do justice in explaining it, but here’s a link too his comment. Pitch: Multi-statement if/switch/do expressions - #208 by Pippin

12 Likes

-1 from me. My subjective opinion is that this evolution adds complexity where it is not truly improving code legibility. The language is not objectively improved with the adoption of this pitch.

I read through many of the comments, but it seems like some of the back and forth conversation derailed into tangents and was hard to follow.

7 Likes

My subjective opinion is that this evolution reduces complexity and is truly improving code legibility.

1 Like

Can you provide a concrete example to sway the minds of opponents in the opposite direction?

Here you are. I'd prefer this:

extension UIInterfaceOrientation {
    var name: String {
        switch self {
        case .portrait: "portrait"
        case .portraitUpsideDown: "portrait upside down"
        case .landscapeLeft: "landscape left"
        case .landscapeRight: "landscape right"
        case .unknown:
            print("investigate unknown \(self)")
            "unknown"
        @unknown default:
            print("investigate unknown default \(self)")
            "unknown default"
        }
    }
}

to either that:

extension UIInterfaceOrientation {
    var name: String {
        switch self {
        case .portrait: return "portrait"
        case .portraitUpsideDown: return "portrait upside down"
        case .landscapeLeft: return "landscape left"
        case .landscapeRight: return "landscape right"
        case .unknown:
            print("investigate unknown \(self)")
            return "unknown"
        @unknown default:
            print("investigate unknown default \(self)")
            return "unknown default"
        }
    }
}

or that:

extension UIInterfaceOrientation {
    var name: String {
        switch self {
        case .portrait: "portrait"
        case .portraitUpsideDown: "portrait upside down"
        case .landscapeLeft: "landscape left"
        case .landscapeRight: "landscape right"
        case .unknown:
            {
                print("investigate unknown default \(self)")
                return "unknown"
            }()
        @unknown default:
            {
                print("investigate unknown default \(self)")
                return "unknown default"
            }()
        }
    }
}

Version 2 is quite noisy because of all those return's. Which I'd of course want to refactor once I remove the "print" lines. And then have to reintroduce back when I return the print lines back. And then toggle between the two forms until tired at which point I'd just settle with the "return" form and tolerate the noise.

Version 3 is visually awkward (extra nesting, extra parens after the closing brace cause me to feel nauseous).


This is probably outside the scope of this pitch: I'd also want to have the non-handicapped version if/switch/do expressions one day:

42 + if a { 1 } else { 2 } // 🛑 currently error

PS. For me any version of the expression value "marker" (be it "the bare last expression", "then", "use", "out", "<-", "->", "value = ", or anything else but "return") is better than not having this feature at all.

6 Likes

I would have thought that all examples were already given...

Conventional:

let result: Int
if condition {
    let twice = n + n
    result = twice + 1
} else {
    result = n + 1
}

Using a current if expression:

let result =
    if condition {
        {
            let twice = n + n
            return twice + 1
        }()
    } else {
        n + 1
    }

Using just one big closure:

let result =
    {
        if condition {
            let twice = n + n
            return twice + 1
        } else {
            return n + 1
        }
    }()

Using the new keyword use:

let result =
    if condition {
        let twice = n + n
        use twice + 1
    } else {
        n + 1
    }

And see how the last one is easily changed from:

let result =
    if condition {
        n + n + 1
    } else {
        n + 1
    }

...using a change that is very analogous from having to add a return when you add a statement to a one-expression function body.

From my definition of complexity (see my respective comment above) the complexity of the code that uses use is less than the others, this reduced "complexity" also meaning better legibility.

My problem with lots of examples in thread that they're focusing on simple if else. Just going a bit more complex even with simple switch—things are not so obvious. Even with example which was in proposal not sure if this:

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
}

is truly improving this:

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 
   }()
}
2 Likes

I strongly agree with Ethan’s arguments and am in favor of his counter proposal.

1 Like

Then consider the same with "if":

let g = if condition {
    4
}
else {
    {
        print("log false")
        return 5
    }()
}

:nauseated_face:

You give examples but no argument as to why they are more or less readable.

The is the example you give of how I would do it in Swift today. (I wouldn't consider using an expression with closures if I need multiple lines)

Example 1:

let result: Int
if condition {
    let twice = n + n
    result = twice + 1
} else {
    result = n + 1
}

And this is what your example would have looked like using the proposal, although all of your example assumed alternatives:

Example 2:

let result =
    if condition {
        let twice = n + n
        then twice + 1
    } else {
        n + 1
    }

My argument:

The first example is already readable. So it doesn't merit the addition of a keyword to the language for an alternative way of writing. Moreover, I argue that it is more readable than the second example, not only because of the new generally unfamiliar keyword usage in the second example, but because it's literally less strait forward to read. When talking about readability I think it's instrumentive to try and verbalize the code and see what sounds more natural. So here we go:

Example 1:
Result is and integer. If condition, then twice equals two n and result equals twice plus one, else result equals n plus one.

Example 2:
Result equals, if condition, then twice equals two n and then twice plus 1, else n + 1.

My intent was to keep the words in the same order as the operations in the code. I assume it's obvious that example 1 translated better to natural language. But, if you want to argue that my example is invalid, either explain to my example is not instrumentive to the discussion, or come up with a better translation of example 2 that I missed.

4 Likes

People can look at the examples and judge for themselves.

I won't elaborate this any further, partly because I think all arguments and relevant examples should now be given in this topic.

Yeah, no doubts, but still it's like not truly improving if you compare.

let g = if condition { 4 } else {
    {
        print("log false")
        return 5
    }()
}

and

let g = if condition { 4 } else {
     print("log false")
     then 5
}

The thing is with if else you can also do thing like this:

let g = if condition { 4 } else { 5 }
if g == 5 { print("log false") }

and it's closer mentally to first example—explicitly firing a side effect.

Just in case—I'm actually ok with both variants. Probably would be even interesting to play with do { then } as kinda monadic stuff. :sweat_smile:

1 Like

It is. However, this does not make it preferable. There are many reasons why using DI to initialize variables like this is good, but not great.

Specifying the type manually is not something Swift encourages. In general, types for local variables are a distraction, and this is why Swift has type inference. I think I need to stress this point: type inference and low-ceremony code is an important aspect of Swift. This is the motivation behind features like if expressions, implicit returns, avoidance of punctuation like semicolons and parens in if conditions. In addition to the clarity that comes from reduced ceremony, it's also just more enjoyable to write code in a low ceremony language. There's a reason ruby programmers love ruby so much, and why some people are so keen on DSLs. Low-ceremony code is more enjoyable to write. And we want to make writing Swift enjoyable.

So, if expressions have the goal of reducing ceremony in code. This is why the examples cited as improvements are good. That switch statement that eliminates all the returns is a good example of the improvements switch expressions bring. The returns are clutter, distracting the eye from the goal of being able to easily read what the switch is doing – mapping cases to values that are returned from the function. return return return detracts from that. So does the manually specified : Int.

Now, sometimes this low-ceremony approach is in tension with another goal, which is that code must be clear. When the clarity argument wins is particularly in cases where it's important to draw attention to threats to correctness. A good example here is try. You are forced to write try in front of any throwing function. This can be pretty annoying (especially when you have to repeat it multiple times e.g. try map { try f($0) }. But this is really important because try marks points where your function may exit. Whole books have been written on the dangers to correctness of C++ exiting unexpectedly from functions leaving half-initialized state that violates preconditions. So despite the added ceremony of the try, marking "your function might exit on this line!" explicit is worth it.

So these two goals – low ceremony, but clear code – can sometimes conflict. Where we sometimes take a misstep is confusing verbosely explicit code with clear code. Many folks will look at a switch expression and feel it would be more clear to put in explicit returns, or they might look at the DI version of the assignment with it's explicit type, and say "this is more explicit, therefore it is more clear". But this is not correct. The extra verbosity, while more explicit, reduces clarity because it introduces noise that doesn't especially help with understanding. I often wonder, if we could go into a parallel universe where Swift didn't have type inference, what the evolution pitch thread for a proposal to add type inference for local variables would look like. I would expect it to contain a lot of "-1, not Swifty, this makes code less clear".

With all that throat clearing out of the way, the key questions are:

  1. should we introduce multi-statement expressions at all, or just stick with limiting them to single expressions
  2. if so, how should that be achieved

Not up for discussion is whether features like type inference, implicit returns from single-expression functions, or single-expression if/switch expressions are even a good feature in the first place. These features are not to some folk's taste. That's fine. But they are a settled part of the Swift language.

For this reason, if your argument against multi-statement expressions is, essentially, "you shouldn't use single-expression if statements either", then this is not a compelling argument. This is one reason why "I prefer the DI version of the if" is not a compelling argument against – because you could say the same of a single-expression version.

The main argument for why we need multi-statement expressions is spelled out in the proposal, and also came up during SE-0380. They solve a cliff where you did have a nice single-expression version, but now you want to break one branch of it up, and you have to go back and factor your code back to the old ways – to stick returns or assignments on each of the branches. That is a pain, and enough of a pain (IMO) to clearly merit solving.

So then how do we solve it? There are two reasonable approaches, and personally I'm very much on the fence about which is better: introduce a keyword, or go with implicit last expression. I do worry that implicit last expression might be that little bit too implicit, and so actually start to impact clarity. But the ergonomics and ceremony reduction are also pretty compelling.

As to, if the keyword is preferable, what that keyword should be, I have yet to see any pitched here that are better than then, though some (like use) are about the same.

In my view, redefining what return means, to instead yield a value from the if/switch/do expression, is an idea so flawed IMO that I find it hard to argue against. It is clearly going to cause immense confusion. I think the counter-arguments already made upthread make this case well.

30 Likes

I would also put “consistency with single-statement if expressions” on the list of reasons to consider implicit-last-line.

6 Likes

That is a good point. Though on the counter side, "initiating the slippery slope to last-expression for closures and functions" is a reason against. Of course, many might consider that a feature not a bug.

2 Likes

Sorry, but I can't see it, so can you try to explain? From my perspective there is no redefinition and no source of confusion, it's just one additional context in which return can, you know, return a value. Given the fact that then is likely to be explained as "returning a value from an expression", it seems we're 90% of the way to return anyway. And since it seems likely there will need to be a fixit in the compiler for when users naturally use return to disambiguate, or when they translate an immediate closure to an expression, it doesn't seem to be a parsing or other compiler issue. Frankly, these all seem to be strong points in favor of return. I can certainly see the confusion in returning through an expression, as that gets very hard to track, but returning from the expression seems very natural.

4 Likes

To be clear, are you referring to the version where return from within an if expression behaves differently from return within an if statement? Surely it is evident how confusing this could be to the reader.

1 Like

Not really, given we've already accepted expressions in the first place. Once they're familiar with those, use of return becomes rather obvious. I don't see how you can argue implicit return isn't confusing but explicit return is.

1 Like

Because it looks like your returning out of the function. That's the ambiguity.

let x = if condition {
    42
} else {
    print("log message.")
    return 69_105
}
// Gee, it sure looks like x can only be 42 here.

This is especially nasty, because sometimes one wants to write code that short curcuits like this appears to.

6 Likes

It explicitly doesn't look like that because you're assigning to a value. That's the whole point of expressions! It only looks like that if you aren't familiar with expressions at all. Most things are confusing if you aren't familiar with the language. This is like saying a nested closure return looks like a return from the enclosing function because you're not familiar with closures and how they work.

3 Likes