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

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.

What do you think the problem is in this example? Looks straightforward: if bool parameter is true then 43 is returned, otherwise 21 is returned.

I don't have any strong opposition on this. It will be useful in some cases like this. It can enable guard like usage of if expression.

let x_root = if x >= 0 { sqrt(x) } else { return }

If I'm forced to say, I can imagine cases where this expression can be misunderstood as closure like behavior (return is just returning value for outside of the if expression).

1 Like