[Pitch] if and switch expressions

I am absolutely for this.

I do sometimes wonder if Going all the way with explicit return like ruby does (the last statement is returned) would benefit Swift. I’ve seen that abused in Ruby, but it often feels like Swift arbitrarily cuts off implicit returns.

1 Like

In the olden days, we were wanting to avoid dealing with the subtle distinction Rust makes between x; y; z (evaluate and discard x and y, then return z) vs. x; y; z; (evaluate and discard x, y, and z, then return nothing), as well as conversely avoiding the common issue you see in Ruby of methods accidentally returning stuff they don't intend to, and that accidental return evolving into an API contract. That is maybe less of a concern for Swift now, since we have a type system (so you can't accidentally return the wrong type of thing) and we usually require result discards to be explicit like _ = z, an expression which itself evaluates to Void. So the remaining hazard would be that your last statement is a call to a discardableResult method that happens to have a return type lining up with the function's return type.

19 Likes

I was so excited to see this proposal that forget to say I am for this. This matches the style of Swift, there is also a significant amount of similar applications prior art across languages communities. It also will reduce my jealousy each time where I look over the shoulder of Kotlin engineers.

3 Likes

How about continue or break outer? Would it work to have the same rules as guard instead? (“must not fall through”) I realize that there’s still a syntactic restriction that happens because some of these forms are not expressions, but besides that I can’t think of a reason not to allow continue and break (which I think are the only “imperative” statements missing).

(For completeness, fallthrough also seems valid, but redundant with just specifying two patterns, so I could see that going either way.)

3 Likes

More generally, could we filter out expressions that return uninhabited types like fatalError(), in addition to statements that have nonlocal exits?

4 Likes

I am delighted to see this much-needed proposal seeing the light of day at last! It is indeed a conspicuous gap.

I have two suggestions, which I’ll post in separate messages to avoid reply confusion. First suggestion:

Consider allowing conditionals in arbitrary expression positions when enclosed in parentheses.

The lack of full generality of the proposal as it stands is bothersome to me.

What if we took this proposal as is for the 3 allowed cases — returns, assignments, and declaration initializers — but then allowed if / switch expressions in arbitrary other positions if and only if enclosed in parentheses? Thus:

var x = 1 + if foo { y } else { z }    // not allowed

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

Thus the potentially ambiguous example in the proposal’s Future Directions section retains its original, current meaning:

VStack {
    if showButton {
        Button("Click me!", action: clicked)
    } else {
        Text("No button")
    }
    .someProperty
}

…because parentheses (currently illegal, thus no breakage!) would be necessary to get someProperty of the Button / Text values from the conditional:

VStack {
    (if showButton {
        Button("Click me!", action: clicked)
    } else {
        Text("No button")
    })
    .someProperty  // One would probably format this on the same
                   // line as `)`; just making the point that
                   // the ambiguity the proposal mentions is resolved.
}
12 Likes

I agree in principle but the consequences on things like DI, and the potential that this increases the implementation time significantly, make me nervous and it would be a real shame if these niche cases ended up deferring this feature significantly.

This pitch is particularly targeting a most-useful subset, with the intention of continuing to expand the scope in later proposals. So it's tempting to put these in this bucket.

8 Likes

Second suggestion: Consider stealing Java’s solution to multi-statement branches.

Java’s switch expressions introduce a new keyword that essentially means “return from enclosing switch,” and it’s…not too bad:

var response = switch (utterance) {
    case "thank you" -> "you’re welcome";
    case "atchoo" -> "gesundheit";
    case "fire!" -> {
        log.warn("fire detected");
        yield "everybody out!";  // yield = value of multi-statement branch
    };
    default -> "who knows";
}

I’m not sure yield is a good keyword choice for Swift, since it smells like a language that could one day have coroutines, but the introduction of a new keyword that is not return for this purpose seems like a promising direction.

3 Likes

This sounds great to me. Switch expressions cover most of my use cases by themselves, if expressions are just icing on the cake. +1

I'm also interested in the future direction of treating the last statement of a block as the value of an expression .

Would be super nice. Would there be support for inline expressions? Since people seem to like complicated ternary expression stuff in SwiftUI views. Also, any ideas on how the formatting would look?

/// Current
struct ContentView: View {
    @State var isOn = false

    var body: some View {
        Toggle(
            isOn ? "ON" : "OFF",
            isOn: $isOn
        )
    }
}

/// New
struct ContentView: View {
    @State var isOn = false

    var body: some View {
        Toggle(
            if isOn { /// would this work?
                "ON"
            } else {
                "OFF"
            },
            isOn: $isOn
        )
    }
}
3 Likes

Glad to see some motion on this, it's definitely felt like a missing feature to me for some time. I agree existing alternatives are unsatisfactory.

I don't love some of the proposed limitations, though. In order:

I appreciate the reasoning discussed in the Future directions section, but I worry that this is a sign that we're painting ourselves into a corner. I agree that the contrived examples are probably not worth worrying too much about, but if we adopt the pitch as written and then later find ourselves wanting to expand if/switch expressions to more positions, such a proposal will have to rise past the bar of a source break.

The alternative I see would be to adopt a different syntax for if/switch expressions. Along with the single-expression requirement, this might actually be good (since it could give a way to drop the braces, which IMO naturally suggest 'multi-statement'. E.g.,

if isRoot && (count == 0 || !willExpand) -> ""
else if count == 0 -> "- "
else if maxDepth <= 0 -> "▹ "
else -> "▿ "

At the very least, could we additionally allow if/switch expressions as the recursive single-expression of an outer if/switch expression branch?

Totally on board, this feels like the right design for this in Swift, at least for the initial introduction of this feature.

This feels a bit overly restrictive to me, and I expect will be annoying in practice. The performance issues are a bummer. I'd really want this to type-check just like a ternary. I'm fine with leaving this as a future possibility though.

Yeah, I get why this has to be prohibited, but it's sort of unfortunate that this would also break with the ternary operator. If we used a different syntax for the expression forms, this wouldn't be necessary.

2 Likes

Is there a reason the result-builder restriction isn't just that we'll prefer the statement interpretation (and thus apply the result-builder transform) over a possible expression interpretation when if and switch appear in statement positions? It feels super weird to say that let x = if b { "Hello" } else { "World" } is disallowed in result builders.

Also, I agree with others that this is so far down the path of final expressions being returned as a general rule that we might as well just do that.

16 Likes

Is that true? I'm not sure if adding the ability to have these cases today makes adding e.g. subexpressions later more source breaking.

Not just performance. Honestly, our expression inference rules lead to some truly astonishing results sometimes, and I feel like bringing that baggage to larger expressions with control flow would be a highly questionable move even if performance were not an issue.

With the one case I'm aware of (the result builder one – maybe we'll find more but they'll be obscure), the source breaks are not really the main motivator for keeping the scope narrow. We could always hold back just the source breaking cases under a language variant. The narrow scope is more about timelines and feeling our way towards the right landing point incrementally.

So I don't see "give this a new syntax" as an alternative to the limitations. An alternative to this proposal full stop, sure. My feeling is there is no real merit to complicating the language by introducing a new syntax. This is to some extent already something that was already litigated during SE-0255, where an alternative returnless function syntax of func f() -> Int = 42 was proposed, but ultimately rejected as not an improvement over the existing implicit return syntax.

5 Likes

Like I say in the pitch, maybe that's the right move, but it feels orthogonal to this proposal since the same situation occurs with implicit returns from functions etc. and so should be proposed independently. This would just increase the case for it.

6 Likes

Same.

3 Likes

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