[Pitch] if and switch expressions

Hey all, an early-stage pitch for this feature. No implementation quite yet but hopefully not too far off.

if and switch expressions

This proposal introduces the ability to use if and switch statements as expressions, for the purpose of:

  • Returning values from functions, properties, and closures;
  • Assigning values to variables; and
  • Declaring variables.

Motivation

Swift has always had a terse but readable syntax for closures, which allows the return to be omitted when the body is a single expression. SE-0255: Implicit Returns from Single-Expression Functions extended this to functions and properties with a single expression as their body.

This omission of the return keyword is in keeping with Swift's low-ceremony approach, and is in common with many of Swift's peer "modern" languages. However, Swift differs from its peers in the lack support for if and switch expressions.

In some cases, this causes ceremony to make a return (ahem), for example:

public static func width(_ x: Unicode.Scalar) -> Int {
  switch x.value {
    case 0..<0x80: return 1
    case 0x80..<0x0800: return 2
    case 0x0800..<0x1_0000: return 3
    default: return 4
  }
}

In other cases, a user might be tempted to lean heavily on the harder-to-read ternary syntax:

let bullet = isRoot && (count == 0 || !willExpand) ? ""
    : count == 0    ? "- "
    : maxDepth <= 0 ? "â–ą " : "â–ż "

Opinions vary on this kind of code, from enthusiasm to horror, but it's accepted that it's reasonable to find this syntax too terse.

Another option is to use Swift's definite initialization feature:

let bullet: String
if isRoot && (count == 0 || !willExpand) { bullet = "" }
else if count == 0 { bullet = "- " }
else if maxDepth <= 0 { bullet = "â–ą " }
else { bullet = "â–ż " }

Not only does this add the ceremony of explicit typing and assignment on each branch (perhaps tempting overly terse variable names), it is only practical when the type is easily known. It cannot be used with opaque types, and is very inconvenient and ugly if the type is a complex nested generic.

Programmers less familiar with Swift might not know this technique, so they may be tempted to take the approach of var bullet = "". This is more bug prone where the default value may not be desired in any circumstances, but definitive initialization won't ensure that it's overridden.

Finally, a closure can be used to simulate an if expression:

let bullet = {
    if isRoot && (count == 0 || !willExpand) { return "" }
    else if count == 0 { return "- " }
    else if maxDepth <= 0 { return "â–ą " }
    else { return "â–ż " }
}()

This also requires returns, plus some closure ceremony. But here the returns are more than ceremony – they require extra cognitive load to understand they are returning from a closure, not the outer function.

This proposal introduces a new syntax that avoids all of these problems:

let bullet =
    if isRoot && (count == 0 || !willExpand) { "" }
    else if count == 0 { "- " }
    else if maxDepth <= 0 { "â–ą " }
    else { "â–ż " }

Similarly, the return ceremony could be dropped from the earlier example:

public static func width(_ x: Unicode.Scalar) -> Int {
  switch x.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 4
  }
}

Both these examples come from posts by Nate Cook and Michael Ilseman, documenting many more examples where the standard library code would be much improved by this feature.

Detailed Design

if and switch statements will be usable as expressions, for the purpose of:

  • Returning values from functions, properties, and closures (either with implicit or explicit return);
  • Assigning values to variables; and
  • Declaring variables.

There are of course many other places where an expression can appear, including as a sub-expression, or as an argument to a function. This is not being proposed at this time, and is discussed in the future directions section.

For an if or switch to be used as an expression, it would need to meet these criteria:

Each branch of the if, or each case of the switch, must be a single expression.

Each of these expressions become the value of the overall expression if the branch is chosen.

This does have the downside of requiring fallback to the existing techniques when, for example, a single expression has a log line above it. This is in keeping with the current behavior of return omission.

An exception to this rule is if a branch either returns, throws, or traps, in which case no value for the overall expression need be produced.

Each of those expressions, when type checked independently, must produce the same type.

This has two benefits: it dramatically simplifies the compiler's work in type checking the expression, and it makes it easier to reason about both individual branches and the overall expression.

It has the effect of requiring more type context in ambiguous cases. The following code would not compile:

let x = if p { 0 } else { 1.0 }

since when type checked individually, 0 is of type Int and 1.0. The fix would be to disambiguate each branch. In this case, either by rewriting 0 as 0.0, or by providing type context e.g. 0 as Double.

This can be resolved by providing type context to each of the branches:

  let y: Float = switch x.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2.0
    case 0x0800..<0x1_0000: 3.0
    default: return 4.5
  }

This decision is in keeping with other recent proposals such as SE-0244: Opaque Result Types:

// Error: Function declares an opaque return type 'some Numeric', but the
// return statements in its body do not have matching underlying types
func f() -> some Numeric {
    if Bool.random() {
        return 0
    } else  {
        return 1.0
    }
}

This rule will require explicit type context for declarations in order to determine the type of nil literals:

// invalid:
let x = if p { nil } else { 2.0 }
// valid with required type context:
let x: Double? = if p { nil } else { 2.0 }

Of course, when returning from a function or assigning to an existing variable, this type context is always provided.

It is also in keeping with SE-0326: Enable multi-statement closure parameter/result type inference:

func test<T>(_: (Int?) -> T) {}

// invalid
test { x in
  guard let x { return nil }
  return x
}

// valid with required type context:
test { x -> Int? in
  guard let x { return nil }
  return x
}

It differs from the behavior of the ternary operator (let x = p ? 0 : 1.0 compiles, with x: Double).

However, the impact of bidirectional inference on the performance of the type checker would likely prohibit this feature from being implemented today, even if it were considered preferable. This is especially true in cases where there are many branches. This decision could be revisited in future: switching to full bidirectional type inference may be source breaking in theory, but probably not in practice (the proposal authors can't think of any examples where it would be).

Bidirectional inference also makes it very difficult to reason about each of the branches individuall, leading to sometimes unexpected results:

let x = if p {
  [1]
} else {
  [1].lazy.map(expensiveOperation)
}

With full bidirectional inference, the Array in the if branch would force the .lazy.map in the else branch to be unexpectedly eager.

In the case of if statements, the branches must include an else

This rule is consistent with the current rules for definitive initialization and return statements with if e.g.

func f() -> String {
    let b = Bool.random()
    if b == true {
        return "true"
    } else if b == false { // } else { here would compile
        return "false"
    }
} // Missing return in global function expected to return 'String'

This could be revisited in the future across the board (to DI, return values, and if expressions) if logic similar to that of exhaustive switches were applied, but this would be a separate proposal.

The expression is not part of a result builder

if and switch statements are already expressions when used in the context of a result builder, via the buildEither function. This proposal does not change this feature.

Alternatives Considered and Future Directions

Sticking with the Status Quo

The list of commonly rejected proposals includes the subject of this proposal:

if/else and switch as expressions: These are conceptually interesting things to support, but many of the problems solved by making these into expressions are already solved in Swift in other ways. Making them expressions introduces significant tradeoffs, and on balance, we haven't found a design that is clearly better than what we have so far.

The motivation section above outlines why the alternatives that exist today fall short. One of the reasons this proposal is narrow in scope is to bring the majority of value while avoiding resolving some of these more difficult trade-offs.

The lack of this feature puts Swift's claim to be a modern programming language under some strain. It is one of the few modern languages (Go being the other notable exception) not to support something along these lines.

Full Expressions

This proposal chooses a narrow path of only enabling these expressions in the 3 cases laid out at the start. An alternative would be to make them full-blown expressions everywhere.

A feel for the kind of expressions this could produce can be found in this commit which adds this functionality to the parser.

This includes various fairly conventional examples not proposed here, but also some pretty strange ones such as for b in [true] where switch b { case true: true case false: false } {}.

The strange examples can mostly be considered "weird but harmless" but there are some source breaking edge cases, in particular in result builders:

var body: some View {
    VStack {
        if showButton {
            Button("Click me!", action: clicked)
        } else {
            Text("No button")
        }
        .someStaticProperty
    }
}

In this case, if if expressions were allowed to have postfix member expressions (which they aren't today, even in result builders), it would be ambiguous whether this should be parsed as a modifier on the if expression, or as a new expression. This could only be an issue for result builders, but the parser does not have the ability to specialize behavior for result builders. Note, this issue can happen today (and is why One exists for Regex Builders) but could introduce a new ambiguity for code that works this way today.

This proposal suggests keeping the initial implementation narrow, as assignment and returns are hoped to cover at least 95% of use cases. Further cases could be added in later proposals once the community has had a chance to use this feature in practice – including source breaking versions introduced under a language variant.

Guard

Often enthusiasm for guard leads to requests for guard to have parity with if. Returning a value from a guard's else is very common, and could potentially be sugared as

  guard hasNativeStorage else { nil }

This is appealing, but is really a different proposal, of allowing omission return in guard statements.

Multi-statement branches

The requirement that every branch be just a single expression leads to an unfortunate usability cliff:

let decoded =
  if isFastUTF8 {
    Log("Taking the fast path")
    withFastUTF8 { _decodeScalar($0, startingAt: i) }
  } else
    Log("Running error-correcting slow-path")
    foreignErrorCorrectedScalar(
      startingAt: String.Index(_encodedOffset: i))
  }

This is consistent with other cases, like multi-statement closures. But unlike in that case, where all that is needed is a return from the closure, this requires the user refactor the code back to the old mechanisms.

The trouble is, there is no great solution here. The approach taken by some other languages such as rust is to allow a bare expression at the end of the scope to be the expression value for that scope. There are stylistic preferences for and against this. More importantly, this would be a fairly radical new direction for Swift, and if proposed should probably be considered for all such cases (like function and closure return values too).

Either

As mentioned above, in result builders an if can be used to construct an Either type, which means the expressions in the branches could be of different types.

This could be done with if expressions outside result builders too, and would be a powerful new feature for Swift. However, it is large in scope (including the introduction of a language-integrated Either type) and should be considered in a separate proposal, probably after the community has adjusted to the more vanilla version proposed here.

116 Likes

I'm glad to see this pitch! Shame we can't realistically deprecate ternary ?: once we have something far better. :slight_smile:

I didn't see it mentioned explicitly, but in these expression-based if/switches, are bindings permitted that would be usable within the relevant scopes? For example,

let x = if let foo = something() { foo } else { someDefaultValue }

let y =
  switch bar {
  case .someCase(let value): value
  case .otherCase(let a, let b): a + b
  }

I can't immediately think of a technical/conceptual reason we wouldn't allow this.

10 Likes

@allevato aren't let x = if ... just a syntax sugar of let x = {if ..}()?
This means the scope of expression assignment to an x is limited to the expression itself and it can't leak foo into let y = switch foo. It seems strange to see foo unwrapped in let x assignment used in let y expression.

Exactly, which is why I can't think of a technical reason to disallow it. But for completeness I think we should explicitly call out that they are supported, if they are intended to be.

Those weren't meant to be the same foo; I updated my snippet to use a different dummy variable.

3 Likes

Thanks! This is much clear. I suspected it could be mistyped but was not completely sure.
I agree with you if lets seems like a correct extension of an old use case within this proposal too.

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