SE-0380: `if` and `switch` expressions

Hello, Swift community!

The review of SE-0380: if and switch expressions begins now and runs through December 21st, 2022.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager by email or DM. When contacting the review manager directly, please keep the proposal link at the top of the message and put "SE-0380" in the subject line.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/main/process.md

Thank you,

Holly Borla
Review Manager

39 Likes

(Is this the right place to post? 2 hours later and no comments?)

I appreciated the discussion of the alternate syntax of -> for switch expressions (0380-alternative-syntax)

case n -> m was rejected as unhelpful to learn for switch, and as presenting numerous problems for if.

I think it would be very helpful to use -> for switch expressions (and not for if). There's no precedent or even reason to use -> for if, and wanting to combine the two syntaxes may be harmful.

A distinct switch syntax is actually easier to learn and reflects the new usage. More importantly, the -> form makes it much easier to read and understand switch expressions. And developers can/should prefer switch expressions to if or to switch statements because the semantic invariants make code easier to read and reason about.

It's tempting to conflate switch and if because switch is gaining so much power, and you can replace switch with if. But switch is intentionally a simpler, more constrained assertion about cases, and switch expressions would constrain them even further to a single result of a single type. It's these restrictions that make developers prefer switch to if: they're easier to reason about than if and offer more guarantees. The switch expression in particular has the nice allure of something like f(x) -> y, single input and output.

It's a good idea to treat switch expressions differently than if expressions or switch statements. Switch is already unique in creating an implicit scope context without {} (unique aside from the top-level scope?). Swift differs here from other languages which require break and use a common namespace.

Using -> means the reader can see only the switch case and know immediately that it is an expression. It confuses developers when they need to know the outer context to understand the local code. Swift forums everywhere are littered with questions about code "not working" inside implicit view builders.

That's made worse in this case when switches are used as implicit returns; without ->, there will be NO indication in the text why the compiler is complaining, when the code "works fine" elsewhere. Indeed, the confusion will only increase if, or when, multiple expressions are permitted in the case scope.

With ->, both the reader and the learner know to apply any special restrictions (listed in this proposal). But even if the restrictions are eventually lifted, the user should really consider the expression form differently than the switch form, as a functional result instead of a procedural list of operations.

Although any switch can be converted to an if statement, it really means something else: that there are (only) these enumerated cases to consider. While the "case" has evolved from a single int to permit binding and pattern matching, but it still is mostly restricted to something like staticly-determinable code "matching" (in some way) as single value type. (Until you venture into the where clause...).

Switch vs if is a bit like the difference between truth tables and boolean logic: they are logically interchangable, but we consider tables via simpler discrete/pigeon-hole principles, while with if expression logic we have to consider unbounded evaluation. I believe code is usually clearer when it uses mechanisms of lesser power that say more about the underlying logic. Replacing if with switch usually makes things clearer, and it will be even more so for the more-restricted form of switch expressions. While where clauses and multiple expressions muddy this distinction, code not using those forms should read as cleanly as f(x) -> y

Indeed, while this proposal applies to both if and switch, I'd bet it results in a bigger increase in the use of switch, and that the vast majority are simple f(x)->y without side effects or where delegation. It's the restricted form of expressions that's most helpful.

But higher prevelance means even if you believe the chance of confusion is small, it still creates a huge drag on developer time, forum traffic, and perceptions of Swift. Using -> signals immediately that this is the new form of switch, introduced with this proposal, and people can search and learn and read and write accordingly.

(Sorry for the length/duplication; and adding code samples didn't really help with clarity.)

3 Likes

I like this proposal in general--it definitely adds value to terse cases like this:

let foo = if bar { 1 } else { 2 }

But I find this claim baffling:

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

Being able to clearly distinguish actual return points in a closure from other statements reduces cognitive load. But I guess that's a style question that each team can grapple with as they wish.

6 Likes

The issue the proposal calls out isn't distinguishing return points in the closure from other statements in the closure, it's distinguishing return points of the closure from return points of the outer function, especially for a closure that is 'just' being used as a little control flow stub in the outer function.

3 Likes

I like this proposal a lot. It definitely seems in line with the recent direction the language is taking and does address several common, if subtle, patterns without an apparent tangible downside.

I'm a little surprised to see a specific requirement for else spelled out in the proposal. It does seem consistent with definitive initialization, etc, but also causes a lot of seemingly unnecessary burden because in cases where an optional value is expected the developer must now specify both the type and the nil case of the clause.

let foo = if bar { 1 } // produces an Optional<Int>

vs

let foo: Int? = if bar { 1 } else { nil }

The spelling out of the type in particular is specifically brought up in this proposal as a negative

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.

So shouldn't we strive to avoid it?
There's also a precedent for elseless if that works similarly in result builders as well, so there's a precedent for treating if expressions in this way already and a regular old if as a statement has no else requirement, so it feels like a stumbling point. In the contexts where the type is inferred to be non-optional if can still require else, which would still satisfy cases like function return values. I realize that the proposal points this out a possible separate refinement across the board, but it feels like this case is sufficiently different from DI and function return cases to be considered as apart of this proposal.

This could, but in my opinion shouldn't, be extrapolated to switch statements with inferred Optional resulting types. The reason I believe these are different is that switch statements already have a requirement to handle all cases whereas if statements do not, so keeping the same requirements on switch and if would be inline with how the users expect the language to behave.

7 Likes

I understand that. I just think it has the potential to create a new problem of distinguishing return points in the closure from other statements.

I think I can reconcile myself to the basic concept, but reading the details of this proposal, I keep finding myself wishing it were better. I would like a feature that's strong enough that we could at least consider deprecating the ternary operator, but if expressions are much weaker on at least two fronts: their type inference is much less powerful, and their use in subexpressions is forbidden.

This example seems really awkward, and although you don't show it, I assume this restriction would also affect empty array and dictionary literals.

I have questions:

  • How certain are we that, at least for if statements, stronger inference is not workable? It's not immediately obvious to me why we can handle bidirectional inference for ternary operators but not for if expressions.

  • Have we experimented—either here or in the work on SE-0362—with a very limited set of rules to help with the most common type ambiguities or mismatches? For instance:

    1. Separately type-check each expression, skipping expressions that are just a nil literal, empty array literal, or empty dictionary literal. (If all expressions are skipped, fail.)

    2. If all remaining expressions type-check and their result types differ only in optionality, lift each one to the most-optional type. (If there are other differences in their types, fail.)

    3. If a nil literal was skipped and the result type is not optional, lift all expressions to a type that is one level more optional.

    4. Type check expressions skipped in step 1 with the contextual type determined by the previous steps.

  • Failing that, should we at least allow a trailing as cast to provide a contextual type in addition to a type annotation on the variable? This reads a little more fluidly to my eye:

    let x = if p { nil } else { 2.0 } as Double?
    

Are many of the "strange ones" associated with isExprBasic == true (that is, the places where the grammar doesn't allow expressions to have trailing closures)? If so, we could ban if and switch expressions whenever trailing closures are not allowed.

For what it's worth, I'd love to see statement-level implicit member expressions in result builders deprecated in Swift 5 and eliminated in Swift 6; they're not available consistently enough to actually be useful anyway. (In other words, I'm suggesting a syntactic use restriction in the result builder transform that covered implicit member expressions at the top level of a statement.)

If we did that, I think we could make a dot after an if or switch form an expression in Swift 6 mode, while requiring people to use parentheses or something in Swift 5 mode if they want to use it there:

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

This review has been pretty negative, so I'd like to clarify that I'm currently undecided on this proposal, not firmly opposed. But gosh, I wish it would give me more reasons to say "yes".


Housekeeping note:

How is this rule implemented? Is it part of the grammar, such that the parser will reject if and switch in other positions, or is it more of a syntactic use restriction?

If it is a grammatical restriction, I think I'd like to see how it would be implemented in the grammar. (This wouldn't be grounds for rejection, just a request for the proposal to be more explicit.)

20 Likes

I’ll add that choose(coinToss, ifTrue: 1.0, ifFalse: nil) has exactly as much as inference as coinToss ? 1.0 : nil, just from our rules unifying parameter types. If we accept that today (not at a computer right now) I think it would be fair to say if expressions can also do some multi-expression unification. switch does seem like a step too far to me though.

6 Likes

Should do statement be considered in this pitch as well (mentioned in the "alternatives considered" section for starters) or do you think it is totally out of scope and deserves its own pitch?

let x = do { // type inferred
    return try foo()
} catch {
    return bar(error)
}

// ditto with return statement syntax optimization:

let x = do { // type inferred
    try foo()
} catch {
    bar(error)
}

instead of:

let x: T // explicit type annotation

do {
    x = try foo()
} catch {
    x = bar(error)
}
5 Likes

They are in the future directions section.

I've never been a huge fan of removing keywords/delimiters my brain compiler uses to more quickly parse and understand code.. (return, commas etc..) but sadly this is the march we're on so whatever.. the only thing I strongly dislike about this is return jumping out of the expression and enclosing function. I can't wait to resolve the barrage of bugs that is going to introduce :ok_hand: :joy:

7 Likes

It's workable but performance is the main issue at the moment because we'd have to solve all of the branches of if/switch together to enable that, any use of i.e. operators inside would effectively yield this feature useless even simple things like if { 1 + 2 } else { 4 + 1.0 } would be potentially exponential and the impact would be even worse for incorrect code just like what we currently experiencing with result builders but worse because they still take advantage of piecemeal solving across elements in the body.

Unfortunately the approach you have described is not easily expressible in the constraint system. I have experimented with joining return statements in closures which is exactly the same issue.

I think it should be possible to translate a coercion at the end into a contextual type without any major changes to approach taken by the implementation.

1 Like

I'm usually a proponent of "expressions" everywhere because they do really simplify some code.

But I got to admit that some examples on the proposal don't seem more readable, quite the opposite. In fact it reminds me of other modern languages, that people compares to Swift quite often, where I like them a less than Swift because they lack what I call "adult supervision".

But I have to admit that I don't have enough experience to have a strong argument against this change, and with recent purely syntactical changes in Swift it seems that is where the language is going anyway.

Call me curious to see where this eventually ends up including the future directions.

5 Likes

Would this be worse than it is with the ternary operator, or is the ternary operator already pretty bad, or what?

2 Likes

Ternary is pretty bad already as is ?? but it's only two branches too where if/switch could have a large number of branches.

4 Likes

Allow inference for only two branches, produce an error for the rest? There's some precedent in the old closure return type inference behavior. With a good error message it should be simple to work around.

FWIW, I think it’d be reasonable to decide we won’t even try it with switch, or to put something like a two-branch limit on both of them.

3 Likes

Without actually weighing in on the proposal, I just want to mention that this is valid code:

let y = (x < 10) ? "tiny"
      : (x < 20) ? "small"
      : (x < 30) ? "normal"
      : (x < 40) ? "large"
      : /* else */ "huge"
12 Likes

This seems a bit too arbitrary to me personally.

Sure, the point I was trying to make is that sometimes performance is bad with two branches no need to write an unreadable code to illustrate that :)

How is the next code compiled?

func foo(_ bool: Bool) -> Int {
    let value = if bool { 
        42
    } else {
        return 21
    }
    return value + 1
}

I think it's possible like fatalError() example, but I don't know whether else branch returns Never.