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

For that purpose, I think break value will also be a possible alternative. In switch, break works like return in functions.

We can reinterpret break in switch as one which stops process and pass the value into outside of switch. And we can do the same thing for if.
Though break value does not reads naturally in English, there is no additional keyword and will be understood easily.

6 Likes

Associating break with if expressions strikes me as bizarre, since break as it exists typically appears inside a conditional but breaks from the outer loop:

while true {
    let foo = readNext()
    if foo < 0 {
        break  // refers to `while`, not `if`
    } else {
        process(foo)
    }
}
while true {
    let foo = readNext()
    let _ = if foo < 0 {
        break  // now refers to `if`, not `while`?!?
    } else {
        process(foo)
    }
}

Still, it's a thought!

2 Likes

+1 to limit the two branch to get same semantics as ternary.

1 Like

I don't see it as a naming/overload issue — it's that if we start using if and switch blocks as expressions, they should consistently act like expressions. We should be able to look at them as replaceable by a function call, just like any other expression you can place on the right-hand side of an assignment.

// this could be an '(Int) -> String' function
let evenOdd = if number.isMultiple(of: 2) { "even" } else { "odd" }

// this could be a '(URL) throws -> FileHandle' function
let file = if let url { try FileHandle(forReadingFrom: url} } else { FileHandle.standardInput }

// no function acts like this
let value = if array.isEmpty { 0 } else { return 3 }

If there are compelling use cases that make this inconsistency worth the potential for confusion, then they should be included in the proposal.

5 Likes

It's not clear that we need to impose that limitation. There’s no precedent for that rule in other imperative languages that support conditional expressions. Swift's neighbors generally either (1) support function returns from condition expressions, (2) only have the ternary operator (or something isomorphic to the ternary), or (3) are functional languages that do not have return statements at all.

Rust allows it:

fn foo(array: &[i32]) -> i32 {
  let value = if array.is_empty() {
    0
  } else {
    return 3  // ✅ returns from function
  };
  // do stuff with value
}

Ruby allows it:

def foo(array)
  value = if array.empty?
    0
  else
    return 3  # ✅ returns from function
  end
  # do stuff with value
end

Kotlin allows it:

fun foo(list: Collection<Int>): Int {
  var value = if (list.isEmpty()) {
    0
  } else {
    return 3  // ✅ returns from function
  }
  // do stuff with value
}

Scala allows it:

def foo(list: List[Int]): Int = {
  var value = if (list.isEmpty) {
    0
  } else {
    return 3  // ✅ returns from function
  }
  // do stuff with value
}

Javascript, C, C++, Dart, and PHP do not have conditional expressions (AFAICT); they only have the ternary operator.

Go does not even have the ternary operator! Assigning all branches to a local variable is the idiomatic approach.

Python at first seems to impose a limitation like Nate’s proposal. However, Python’s ___ if ___ else ___ expressions are really just its version of the ternary operator: its if expressions have an entirely different syntax from if statements, and do not support multiple statements. Python does not have the ? : spelling of the ternary operator. (Ceylon is similar.)

The closest thing I could find to a clear precedent for Nate’s proposed rule is Java’s switch expressions:

int value = switch (list.isEmpty()) {
    case true -> 0;
    case false -> return 3;  // ❌ error: return outside of enclosing switch statement
};

I don't have a convenient C# dev environment handy. It would be interesting to check whether it supports int x = boolValue switch { true => 0, false => return 3 };. It seems likely it follows Java’s precedent.

9 Likes

I agree, but I also don't understand why a multi-statement branch can't be wrapped in a closure. Aren't the following branches all expressions?

var response =
  switch utterance {
  case "thank you": "you’re welcome"
  case "atchoo": "gesundheit"
  case "fire!":
    {
      // Multi-statement branch.
      log.warn("fire detected")
      return "everybody out!"
    }()
  default:
    try {
      // Single-statement branch.
      throw IllegalStateException(utterance)
    }()
  }

or a do statement without catch (perhaps will make the same generated code).

Just to clarify regarding “bizarre” bit - SE-0326 describes why this is happening the way it does - result type is always inferred from first return and there is no join between multiple return statements, print(…) example works because Any is passed down into the closure, so regardless of what each return produces it is always converted to Any in that case.

3 Likes

Yes, thanks Pavel. I figured it was the presence of a contextual type that made the last one compile again.

It's this step in the chain that really surprised me:

// ✅ compiles
choose(.random()) {
    return 0
} else: {
    return 1.0
}

// ❌ error: cannot convert return expression of type 'Double' to return type 'Int'
choose(.random()) {
    print()
    return 0
} else: {
    print()
    return 1.0
}

(And curiously, both closures have to be multi-statement for it to fail; delete either one of the print() statements and it works!)

@hborla wrote up above:

…and I agree, so I was surprised to discover that closures apparently already exhibit this behavior!

1 Like

Fixing one would fix the other because they use the same mechanism under to hood.

I don't believe there are many issues with parsing ambiguities, as the case keyword allows us to know that we don't have a trailing closure, and ditto for the else keyword (which an if expression is required to have). There is an ambiguity for an empty switch with no cases, but that doesn't seem like a common thing to want to write in that position, and we'd continue to parse it as a trailing closure. In general, from an implementation perspective we should be able to allow if/switch expressions in arbitrary expression positions, it's just a question of ensuring that SILGen can correctly handle them in those arbitrary positions (I believe it should basically Just Work in most if not all cases, but we'd need to go through and verify it).

It's a syntactic use restriction, so enabling them in other positions is just a question of removing the diagnostic and ensuring SILGen can handle them okay.

Personally I would prefer if the rules were consistent across if and switches, e.g it would seem odd to me that:

enum E {
  case a, b, c
}

func foo(_ e: E) -> Int? {
  let x = if e == .a {
    0
  } else if e == .b {
    1
  } else {
    nil
  }
  return x
}

would not have the same type-checking behavior as:

func foo(_ e: E) -> Int? {
  let x = switch e {
  case .a:
    0
  case .b:
    1
  default:
    nil
  }
  return x
}

Syntactically, this would be fine to allow (indeed it would be fine to allow even without parentheses in most cases). But, as said in my reply to Becca above, we'd need to go through and ensure code generation handles it okay. if/switch expressions as proposed are novel in that they allow embedding arbitrary statements in an expression position (closures are emitted as separate functions instead).

2 Likes

I'd like to echo this, though I'd refine @avladimirov's example to:

let foo = if bar { 1 } // fails, incomplete expression returns Int
let foo: Int? = if bar { 1 } // produces .none

I expect the more common circumstances people might run into this is when conditionally mutating a variable:

var foo = 5
// …
foo = if bar { 42 } // replaces: if bar { foo = 42 }

This is admittedly starting to look Rubyish (foo = 42 if bar), but I kinda like it. Not enough to suggest that we add unless to the language keywords, but I think incomplete conditionals should probably be allowed when the variable has provably been initialized (or else can be inferred nil)

1 Like

Note that Ruby’s behavior differs from what you're describing:

# Ruby
foo = 5
bar = false

foo = 42 if bar    # Leaves foo unmodified, but...
(foo = 42) if bar  # ...that's only because Ruby parses the previous line like this.
foo = (42 if bar)  # This sets foo to nil.
foo = if bar       # This also sets foo to nil.
  42
end
foo = if bar then 42 end  # This also sets foo to nil.

At this point this seems very targeted to allowing elision of the else specifically when dealing with optionals. And personally, I don't think this is a good bit of sugar to add (but then, I'm a bit skeptical of the fact that we allow implicit nil-initialization of optional variables...).

It feels like this is a different proposal to what is appearing here, and in the form of a single if statement, does not seem worth doing. It's just a different way of ordering the expression, but (unlike with other examples in this thread) no benefits in terms of reducing ceremony – it's purely a reordering. And as such, not useful except as an aesthetic preference.

Now, if you combine it with multiple if else branches too, it starts to become more than just a simple reordering:

foo = 
  if bar { 42 } 
  else if baz { 99 }
  else if boz { 123 }
  // but no else here – don't perform any assignment in that case

then we are maybe talking about some ceremony reduction.

I'm pretty skeptical this is a good direction – it seems just that bit too subtle to notice (with a long chain) that there is a fall through. I would much rather it always be explicit. You can always write else { foo }. Or you can go back to the old way, and put the assignments inside the branches – nothing too bad about that.

(And of course this capability would also introduce a difference between declarations vs assignments)

I suspect the best way to decide if this is a good direction is to accept this proposal as-is (I would say that, of course :) and then gain community experience. If there is a strong feeling that, now that we have expressions, this "only assign a new value when these conditions are met" form now really feels like a gap that comes up often, it is easy to add with a follow-up proposal.

1 Like

Thanks for doing the analysis. I was actually teetering on the brink of agreeing maybe allowing return wasn't such a good idea and we could drop that part – and this post pulled me back to thinking it's a good idea.

Another reason is, there's a particular form in Swift that would benefit from return... failable initializers. Which are often of the form "either assign something to self, or return nil from this function", e.g.

    init?(rawValue: String) {
        self = switch rawValue {
        case "foo": 1
        case "bar": 2
        case "baz": 3
        default: return nil
        }
    }

It'd be a shame to require them to lose the benefit of this proposal.

11 Likes

That is indeed a solid example of where allowing return is good, since you have to return nil in that context! Thanks @Paul_Cantrell and @Ben_Cohen for making the case.

6 Likes

This basically sums up my feelings as well. I've enjoyed using if-as-expression in other languages, and I'd be happy to see it introduced to Swift, but I think it's important for this proposal to leave us in a place that we might be okay being forever. It's not clear to me thus far that we'd be able to come to successful consensus on whatever the next step might be, so IMO it's important to make sure we're okay with the resting place SE-0380 provides.

In particular, I'm not that pleased with the story for multi-statement support, as Paul discusses:

I agree with Paul that an expression enclosed in curly braces strongly suggests the ability to relatively easily expand the contents to multiple statements. I don't really see the response of "you can just wrap that branch in a closure" as a valid resolution, because that's exactly the type of workaround that this proposal aims to tackle!

As I mentioned in the pitch thread, I think the proposal in its current form really starts Swift down the road towards a "statement block evaluates to the final expression" rule supported by many other languages which support if-expressions. I'm not necessarily against that, but I'm also not necessarily for it and I wouldn't really want to see justification for that feature get 'trojan-horsed' into the language with SE-0380, at least not without deliberate consideration.

The proposal discusses this briefly in Future directions but I'm not sure that it sufficiently grapples with its own role in pushing us towards needing to address the issues it raises, and it doesn't fully convince me that we'd ever be able to come to a happy resolution as opposed to being stuck at the SE-0380 state of affairs in the long run.


I'd also more mildly desire arbitrary expression positions, but I think this proposal covers 90% of the cases where I'd want to use the feature, and the potential future extensions aren't as potentially problematic in my mind as for multi-statement branches. If I had to make one further allowance, it would be to additionally permit switch and if expressions to appear recursively as the single expression branch of an outer if/switch expression, e.g.:

return if conditionOne {
  if conditionTwo {
    0
  } else {
    1
  }
} else {
  2
}
6 Likes

This is covered by the proposal:

Within a branch, further if or switch expressions may be nested.

4 Likes

Oop, glossed over that line since it wasn't part of the top-line list of allowed locations for if/switch expressions. Thanks! In that case, no notes on this aspect :slightly_smiling_face:

I am positively surprised after reading through this proposal and catching up with the very thoughtful comments thus far. I think there's some very promising stuff here.

I am, however, unmoved by the following problem statement in the proposal text:

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.

I don't think it should be a goal, simply because we use the word "modern" on an about page, for Swift to be sort of an evergreen collection of the hottest idioms and fashions in programming language design. Indeed, I think we should overtly disclaim this sort of direction. Over "modernity," I'd think it much more essential to prioritize our longstanding proposal evaluation criterion that a feature has to fit well with the feel and direction of the language.

I'd argue that we should be rather ruthless in rejecting designs—even very fashionable, modern ones—that require contortions in order to retrofit into the language as we have it. And, even if it's possible to retrofit without too much trouble, a new feature should enable us in some way to write better, more correct code—not just different code because the existing ways are so last century.


With the benefit of time and the example of other languages, if we were inventing a new language today, I'd agree that the "modern" solution available in Rust and other languages with if and switch expressions is superior to what Swift has today, and I would emulate the former.

I would also agree if someone were to argue that ?: is a bit of an odd duckling in Swift as the only ternary operator in the language, with the otherwise inconsistent meaning of ?, and all the type inference shenanigans already discussed at length.

However, given that we have an established language which has, and will probably forever have, the ?: operator, and given that nothing in this proposal makes possible what is currently impossible to express in the language, I think we have to evaluate this proposal rather differently.


Were if expressions to be a proposal on their own, my overall impression of that proposal would be that the feature doesn't hold its own weight and actually fits very poorly with the feel and direction of Swift:

  • I agree with @beccadax 's feedback (and @Paul_Cantrell, among others) on the oddness of type inference working differently for ?: and for if expressions
  • I'm also troubled, like them, that the latter cannot subsume the former in terms of where it's allowed to be used (and, indeed, in result builders, will never be able to subsume the former)
  • The issue with expressions wrapped in braces suggesting the possibility of multiple statements, just raised by @Jumhyn, is also a distinct drawback of if expressions

I'd also point out that I'm not aware of any languages that have both a ternary operator and if expressions. Overall, I'd say that it doesn't feel like a very satisfactory "resting place" for language evolution.

On the other hand, if switch expressions were their own standalone proposal, I'd be rather delighted with what's been proposed:

  • The limitations on where it's allowed to be used make a lot of sense and have precedent in the current limitations on non-parenthesized trailing closure syntax; the wonkiness of a multi-line expression (which switch expressions undoubtedly will be) being followed by for-loop braces strongly recalls the issues that motivate trailing closure syntax limitations
  • Not having type inference stretch across multiple branches makes very good sense here, and there's no existing language feature here with which it would could be compared which gives different results
  • Keeping each case to a single expression (unless returning or throwing, the justifications for allowing being discussed just above and very convincingly) also makes sense and avoids some of the difficulties seen in Java (which, as I understand, has had to innovate a new yield syntax and distinguish "arrow" cases from "colon" cases)—meanwhile, the lack of any surrounding braces for each case avoids a "closure-like" appearance that may tempt folks to want to write multiple statements

Overall, then, my opinion on this proposal is that the switch expression portion may be a very good addition to the current language, with good fit and an improvement in expressivity. Meanwhile, if expressions do not seem as well motivated and are faced with a slew of unsolved cons, and may be better left out. If that makes Swift a little less "modern," then so be it.


I have used some of the other languages that have switch expressions or their analog (although the bulk of my experience with other languages that have this feature date from when they in turn didn't have the feature), and I have used Python extensively with sufficient love and appreciation for its take on the ternary operator. I have studied this proposal in depth as well as undertaken an examination of how other languages (Rust, C#, Java, Kotlin) implement similar language constructs.

16 Likes