[Pitch] if and switch expressions

It used to be that the if/else returns weren't a problem, because their addition to a new line was negligible—and you needed new lines for them all, for debugging.

Now we have column breakpoints, so it's practical to actually write code like

if condition { "breakpoint here" } else { "and another here" }

We're still going to need to break it up for code coverage though—and proposals here can't address that.


I don't think the proposal makes a strong argument against the conditional operator, but the keywords and braces will be helpful with comprehension of nesting.

isRoot && (count == 0 || !willExpand)
? conditionA
  ? conditionB ? "🪺" : "🪆"
  : "▿ "
: count == 0 ? "- "
: maxDepth <= 0 ? "▹ "
: "▿ "
if isRoot && (count == 0 || !willExpand) {
  if conditionA {
    if conditionB { "🪺" } else { "🪆" }
  }
  else { "▿ " }
}
else if count == 0 { "- " }
else if maxDepth <= 0 { "▹ " }
else { "▿ " }
1 Like

Yeah, this make sense, especially since we just accepted SE-0373.

2 Likes

Yeah, I guess I was thinking about this with respect to the alternative of introducing new syntax (rather than the alternative of doing it all at once now).

The worry is that we’d adopt the proposed syntax and then feel constrained to expand it in the future due to the source breaking nature of an extension. Even if it could go in a new language mode, we don’t typically consider ourselves totally unconstrained by any source compatibility concerns just because we’re crossing a language mode boundary.

Of course, adopting a different syntax comes with its own tradeoffs.

I see this as slightly different—in the case of implicit return for function declaration bodies, the ‘migration’ from single-expression to multi-expression bodies is relatively straightforward (just add a return), and so wrapping the body in braces invites that expansion.

But with the proposed if expressions the ‘migration’ path isn’t so straightforward—as the pitch notes, you’d have to fall back to one of the cumbersome workarounds, so wrapping the ‘body’ in braces here feels… misleading.

Of course, if this is a temporary stepping stone along the way to final-expression returns, then that concern is also mitigated. But IMO, and as John notes, the chose syntax here feels like at least a partial endorsement of that future direction. So I think it’s difficult to treat it as entirely orthogonal.

This wasn't my intention, as FWIW I am mildly /persuadably against the "last expression" change – something about it doesn't feel right. The choice of braced syntax was driven by by it matching existing language precedent for implicit return.

4 Likes

Yeah, sorry, ‘endorsement’ ascribes too much intent here. I agree re: mildly against the last expression thing, I just think reusing the brace syntax really starts us down that path since I would expect in the expression:

if condition {
  a
} else {
  b
}

that there be a ‘natural’ way to extend the branches to multiple expressions/statements, and doing ‘last expression as value’ is a common way to accomplish that.

1 Like

Anecdotally, implicit returns have bitten me many times when programming in Ruby. This is why, when first picking up Rust, I had an awful feeling seeing implicit returns again. However, the type system really makes all the difference, and I never had an issue with implicit returns in Rust and now miss it when programming in Swift. (Especially because Swift is already part of the way there, just not fully committed.)

This is already an issue with Swift’s implicit return for single-expression closures. The classic “quickly add a temporary log statement” technique is hindered because you then also have to edit the (unrelated) expression line to add the return keyword for the compilation to work. If this pitch is accepted without last-expression implicit returns, then this hassle would spread further into the language.


Nevertheless, if the consensus ends up being again last-expression implicit returns, then that is also fine as long as this pitch moves forward :)

12 Likes

I think this syntax as-proposed leaves us in a worse position than with the progression from single-statement functions to multi-statement functions (which I don’t really have too much trouble with). When you update a function to add a second statement, you’re required to add return, which, yes, can be a bit annoying, but the end result is overall reasonable and requires only local ‘refactoring’.

But updating an if expression to add a second statement to one branch would require you to either do some contortions like wrap the branch in { ... }() and add return (which IMO isn’t really a reasonable end result even if it’s okay-ish for temporary logging) or do some potentially highly non-local refactoring such as pulling the branch out into a separate function or applying one of the workarounds mentioned in the pitch (which the pitch argues are unsatisfactory for various reasons) to the entire if expression (which may be arbitrarily large).

Now, if the argument is that being unable to easily do the ‘add another statement’ transformation is on-its-face unacceptable, then another syntax which doesn’t so easily suggest an extension of each branch to multiple statements doesn’t help us much. But I think the proposed syntax, as nice as it is to align with the normal syntax for if/switch would leave the lack of last-expression-as-value in Swift feeling like a much more prominent hole in the language rather than just ‘a thing Swift doesn’t do’ as (IMO) it feels today.

3 Likes

Happy to see this pitch!

Would this work with dot-Syntax?

extension Result {
    var color: Color {
        switch self {
        case .success:
            .green
        case .failure:
            .red
        }
    }
}
4 Likes

+1. I’m the last year I’ve been learning and using Kotlin in my day to day job, and this is one of the things I miss when I’m writing Swift. It feels unnatural that this isn’t possible currently

2 Likes

Any reason do-catch statements could not become one of these kinds of expressions, too? Admittedly, I do not feel the desire for them as much as if and switch expressions, but they could make sense.

For example, sometimes I use a do blocks to define very narrow scopes in a method where each defines variables or constants with the same names. Another example could be ensuring both the do and catch blocks return a value to the caller.

2 Likes

I don't see how some cases can be disambiguated from result builders, like this:

@ViewBuilder
var body: some View {
    if condition {
        Text("A")
    } else {
        Text("B")
    }
}

I believe this is an expression which can be analyzed in both strategy, result builders and if expressions. How will the compiler treat this case?

3 Likes

+1 for the pitch.

However, I'd probably use it for "switch" form only most of the time, as ternary notation has grown on me:

var x = 1 + (if foo { y } else { z })   😒

var x = 1 + (foo ? y : z)               😀

Edit: the following brace-less form of if I'd tolerate using instead of a ternary operator:

var x = 1 + (if foo then y else z)
2 Likes

+1 I would be very happy to see this. I write way too many closures that emulate an expression. I’ve always thought Swift should embrace functional programming as much as possible and was disappointed when if/switch expressions were struck down in the past.

I don’t see a problem. If’s in result-builders are already expression-like. No change needed. If you wanted to treat the “if” as an actual expression you would just assign it to a variable to lift it out of the builder. If you really wanted to force it to be evaluated as an expression in the builder, maybe parenthesis could be used around the outside of the if expression.

Something like this:

@ViewBuilder
var body: some View {
    (if condition {
        Text("A")
    } else {
        Text("B")
    })
}
1 Like

Practically speaking, I agree with you. I rather intend to point out potential edge cases.
Currently language allows buildEither function to have some special treatment for if statement, and in such case introduction of if expressions will cause source breakage.

2 Likes

+1, I love the switch change. The if one seems pretty weird to me though. I didn’t know other languages had that. Feels less readable than the tertiary operator IMO but I’m probably just really used to it.

This is something that I've previously thought would be a good idea, but when looking at the examples and considering it as a tangible future direction, I find myself much less enthusiastic.

The proposal claims that its examples demonstrate 'improvements', but when I look at that code, I don't think the use of return is actually a problem. I don't think that any of those examples are made better by if/switch expressions.

And then you consider all of the limitations, and I get the feeling that it's a very minor amount of sugar for a very limited use-case, and the sugar breaks down very quickly while adding a lot of complexity. It isn't even like sugared Array/Dictionary literals which save a lot of typing - an if/switch involves quite a lot of syntax and can easily span multiple lines.

It just doesn't seem like we gain very much. It may even be a regression in readability.

When I encounter this problem, I tend to use definitive initialization. Which the proposal touches on:

It mentions some limitations of DI, but it does not explain whether any other attempts been made to improve on them. For instance, why couldn't we allow using opaque types?

let data: some Collection<UInt8>
if someCondition { 
  data = []
} else {
  data = [1, 2, 3]
}

As with all opaque types, the compiler would need to ensure that data is initialized with the same underlying type on all paths. If the type can vary between branches, there is a straightforward way to make that change - just replace some Collection<UInt8> with any Collection<UInt8>.

Also, why do we have to write out complex generic signatures? We already solved that problem! We did it with placeholder types! So why can't we use the same approach to infer a variable's type with DI?

// Typical usage.
// We don't need to spell out the complex generic type.
let value: AnyPublisher<_, _> = Just(42).setFailureType(to: Error.self).eraseToAnyPublisher()

// So why not also...?
let data: _
if someCondition {
  data = []
} else {
  data = [1, 2, 3]
}
8 Likes

Placeholder types is a fair idea and they deserve their own pitch.

Here you have redundant "data = " parts on several paths, not DRY and quite noisy.

This example itself is not the greatest promoter of the pitch due to existing ternary form:
let data = someCondition ? [] : [1, 2, 3]

1 Like

That is not what DRY means - it is not about never mentioning the same variable on different paths; it is about building abstractions and centralising shared data so you don't keep rewriting the same algorithms or data structures, or have data which goes out of sync. It's a higher-level concept.

Noisy is highly subjective - you might consider it "noise", but at least for me, when I compare this:

let data = if someCondition {
  returnsAValue()
} else {
  returnsAnotherValue()
}

With this:

let data: _
if someCondition {
  data = returnsAValue()
} else {
  data = returnsAnotherValue()
}

I consider the latter more straightforward and readable. At the very least, I don't consider the former to be such a significant improvement that it warrants such a major change to the language.

And yes, it is a simplified example, for the sake of easier discussion.

9 Likes

Here I have to agree that this is subjective, as I'd prefer the former form any day.

I believe it also has code size benefits in cases like these:

switch v {
    case 1: foo[123].bar[baz] = value
    case 2: foo[123].bar[baz] = anotherValue
    ...
}

foo[123].bar[baz] = switch v {
    case 1: value
    case 2: anotherValue
    ...
}

(I'm really struggling to find a killer example for "if" given the ternary operator presence. To me this does NOT mean the pitch should be about switch only, as I admit the ternary operator might not be the thing of choice for people without C background.)

1 Like