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

To clarify, as currently proposed, if/switch expressions do not have to be marked with try or await if the branches may throw or suspend, as those actions will be explicitly called out within the branches themselves with try/throw/await. For example, you can write:

func foo() throws -> Int {
  let x = if .random() {
    throw SomeError()
  } else {
    5
  }
  return x
}

and:

func foo() throws -> Int {
  let x = if .random() {
    try someThrowingFunction()
  } else {
    5
  }
  return x
}

without writing try if.

I thought this was mentioned in the proposal, but it seems like it isn't. It did however come up in the pitch thread.

If we were to enforce try if/await if, then I agree it might make sense to ban return, as there's no equivalent keyword to mark the expression. However I'm not convinced that we should enforce try if/await if, as IMO it would be unnecessary noise, especially for the cases currently being proposed (bindings, implicit returns, etc.). As such, banning return while allowing throw and try would seem odd to me, as they are all able to exit the function, and are just as explicit as each other.

I mean, this is true of any refactoring where you're taking code that was previously in a closure and bringing it into a parent function, I'm not convinced it's sufficient grounds to outright ban return in if/switch expressions.

That being said, I'm not against banning return for now and re-examining it when we consider allowing if/switch expressions in arbitrary positions (where IMO return may be more contentious).

4 Likes

I'm leaning towards requiring try and await to mark that if or switch is being used as an expression. Almost makes me wish there were a way to also mark it for non-throwing/async cases.

doesn’t that make it so it appears that the if is its own ‘returning scope” like a closure? I dunoo most of the code in this thread is already confusing enough l this may make it even worse.

I don’t want to say that I’m strongly against, but is there a real need to add this feature to the language? Do people struggle forced to write something like the following?

let value: Int
if true {
    value = 10
} else {
    value = 15
}

Or just extract this logic into a separate method?

3 Likes

Yes, in fact many people are unaware that Swift allows you to write something like the code example you give and that it will ensure that value is initialized exactly once along every code path. Instead, they change let to var and insert a dummy initial value such as 0 or "". This is not a great situation, so if we can find more intuitive ways to allow users to write the code that they actually mean to write, then we should.

Another observation here is that, even with the code example you've written, you'll notice that it's required to spell out the type of value. While it it is understandable and not onerous, it is one more thing that doesn't have to happen if the value is assigned unconditionally, which currently you do have to write if you want to initialize the value in different branches. This is made largely unnecessary with if expressions, thereby reducing the ceremony of expressing oneself as intended.

13 Likes

Sometimes it can be quite onerous if the value is of a deeply nested generic type, like a long chain of Combine publishers or an hierarchy of SwiftUI views.

3 Likes

Should we really be optimizing the language for people who don't know how to use it? The concept of declaring a variable then assigning its initial value in a subsequent statement is introduced in the first page of the language guide.

Why shouldn't this be handled by a compiler warning instead? The compiler could easily detect cases where a variable's initial value is immediately replaced by another statement and tell the programmer that the initial value may be unnecessary. Fix-its could then be used to either (a) remove the initial value, or (b) silence the warning by explicitly dropping the initial value before each reassignment (_ = move x).

There are plenty of cases where a type annotation is still required with this proposal, though. Even something as simple as

let number: Int? = if condition { 42 } else { nil }

would require a type annotation to work.

How often is it that one needs to assign multiple potential values of the same deeply-nested generic type to a variable without using similar procedures to create those values?

I don't think that's very common. In most cases, I believe you'll be able to replace something like this:

let view = if condition {
    NavigationStack {
        Text("condition met")
    }
} else {
    NavigationStack {
        Text("condition not met")
    }
}

with something like this:

let text: LocalizedStringKey = condition
    ? "condition met"
    : "condition not met"
let view = NavigationStack {
    Text(text)
}

which is much more readable and easy to understand.

4 Likes

I agree that there is a tendency to over-cater towards absolute beginners in evolution discussions, often to the detriment of experienced users (for example, by favoring verbose names that might help understand their meaning on first sight – but that then harm reading and writing for the numerous subsequent uses).

However, knowing that you can use definite initialization to avoid needing to use a default value with a mutable variable is a fairly advanced technique – one I'd be surprised if more than half of Swift developers were aware of if.

Nevertheless, this is just a minor byproduct of the main goal, which is to allow users to initialize variables directly from the values produced from an if, without having to explicitly type them. As @Max_Desiatov points out, maybe the type is non-trivial. But even when it isn't, Swift has type inference and it's very reasonable to want to be able to extend that inference to these cases too.

6 Likes

Really? Given Swift's emphasis on value types and immutability, it's one of the first techniques you need, and it's one of the first things we teach, especially when we were transitioning Obj-C developers who were used to being able to declare a pointer and set it later.

4 Likes

This raises a question of its own: could we make this type not spelled out?

let value // type will be inferred
if true {
    value = 10
} else {
    value = 15
}
2 Likes

You can use type placeholders for that purpose, as this. Therefore, it doesn't require any type annotations.

let number: _? = if condition { 42 } else { nil }

I believe this is convincing, and I sometimes feel needs for it. But it does not solve type annotation problem.

1 Like

There's a warning in fact in this case:

func fugazi(expr: Bool) {
    var value = 0 // 🟠 Variable 'value' was written to, but never read
    if expr {
        value = 10
    } else {
        value = 15
    }
}

Overall, given the ternary operation that's not going away anytime soon, it feels to me that the switch statement part of this pitch is less controversial than the if statement part. Perhaps we should split the two apart (the same way we do not consider do statements as part of this pitch).

3 Likes

Since this works in Rust, it should definitely be doable.

But I'd raise the counter question: should we do it and if so, to which extent should we go with this?

In Rust, you can really hide the initialization of an untyped variable in complex control flow and the compiler will figure the type out regardless of that. I don't know how they do it but I would imagine that this could be too much for the already rather slow Swift type checker. I'm no expert on the compiler though, so maybe it would be ok to do the same.

However, even if we had this feature, I'd still be in favor of control flow expressions.

Consider the following two snippets:

let valueWithALongName
switch otherValue {
case 0: valueWithALongName = "Foo"
case 1: valueWithALongName = "Bar"
default: valueWithALongName = "Baz"
}

// vs.

let valueWithALongName = switch {
case 0: "Foo"
case 1: "Bar"
default: "Baz"
}

To me, the second snippet looks way clearer and it is easier to write and read. I would only use control flow expressions in such cases where the benefit is so obvious (IMHO at least). Of course, if you have large nested structures of ifs and switches, it isn't as clear anymore, but every language feature can be abused in some way and I don't really see this as a compelling argument against this one.


So, in conclusion, I'm definitely +1 on this change and hope that there will be follow-up proposals extending this to other control flow (like do or maybe even loops in some way).

5 Likes

But the first snippet has clear advantage that it by definition accepts multiple line branches. I'm not a fan of last-line rule proposed in this thread, and I prefer the first one to the second with the last-line rule.

And is this worth making the language even more complex and filled with little specific exceptions from the rules?
Solving one problem, we add, as it seems for me, more new problems:

  1. More unexpected type inference cases. We already can call a method returning nothing and get () or even ()? in some cases. We’re adding more such tricks.
  2. More questions about @discardableResult, result builders, other elements of the language (and for future elements too).
  3. More subtleties when working with if and switch, when they can return a value, when not, when you need to specify the type, when can avoid, etc.
  4. More work for the type checker. It has tendency to break on certain simple expressions, and now this feature (imho) is going to add much more “complex” cases.

It doesn’t look like the language will become simpler for beginners, I would say the opposite…

1 Like

Minor point: all methods return something. Swift just allows a shortcut for Void methods by allowing them to omit the return statement at the end of the body.

Can you elaborate on how @discardableResult could ever break code? The annotation only silences the compiler's warning that the return value is being ignored. It never has any impact on the behavior of code, to my knowledge.

Yes, return, but not exactly "return".

func a() -> Int
func b()

a() // Unused result
b()

Although both methods return something, the result of b() is ignored as if returning nothing. Even if a method must return something and generic type is Void. But written in a closure, it changes the type, for instance:

let completion = { [weak self] in self?.doSomething() } // () -> ()?

What if you create if like the following?

let completion = {
    if something {
        A().a()
    } else {
        B().b()
    }
}

Is it returning anything? What exactly? What if a and b return values, but you don't need them and have marked them @discardableResult? What should the compiler think about this expression? I don't say it's unpredictable, but for a beginner it could be quite a puzzle (or maybe even a bug)

Again, the compiler simply doesn't warn about ignored Void return values. That doesn't mean there's no return value, or that the type is somehow unknown to the compiler.

In your closure example, there is no change of type. The expression in the closure can return nil because self could be nil. This means the return from the closure is either () (the single possible value of the Void type) or it's nil. This means the expression returns Optional<Void>. However, the return type of doSomething() is unchanged.

There's nothing special going on here. If doSomething() returned an Int, the closure's return type would be Optional<Int>.

If your A().a() and B().b() are referring to your earlier functions, this would be malformed because the branches don't return the same type. Even if you marked a() with @discardableResult, it still returns an Int, not a Void.

Read, please, my comments again. I’m not saying that I can’t understand what I write, but beginners will have troubles learning and using Swift. Easing one difficult moment for a new developer, imho, the proposed feature adds a few new difficult moments for them.

What then about removing … ? … : … in favor of if … else … ?