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

Better not to overload the meaning of the return.

How about yield instead?

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

To clarify, are you suggesting that if expressions with multiple-statement branches should have more limited type inference than single-expression branches? This seems to go against your earlier arguments that single-expressions should easily be evolvable into multiple statements:

Changing type inference when you evolve an if expression with single-expression branches into multiple-statement branches seems like that would be another source of frustration. We certainly found that to be the case for single-expression closures versus multi-statement closures - having to annotate the return type of multi-statement closures was a huge pain point until SE-0326 was implemented.

Could you please elaborate on why you believe changing type inference behavior between single-expression and multiple-statement branches would not lead to the same sorts of frustration/confusion? Or please correct me if I've misunderstood your suggestion!

Will if and switch expressions be allowed for property initializers?

struct T {
  var x: Int = Bool.random() ? 1 : 0
  var y: Int = if Bool.random() { 1 } else { 0 }
  var z: Int = switch Bool.random() { case true: 1 case false: 0 }
}

Swift 5.7.1 allows property x (with the ternary expression).

Yes — though not advocating so much as wondering out loud. I’m casting about for some policy that preserves parity with ternaries without creating a type inference performance explosion.

Yes, that certainly is a flaw (likely a fatal flaw) in my suggestion!


Your mention of SE-0326 might be useful guidance. @jrose mentioned a hypothetical choose function upstream; let’s spell it out:

func choose<Result>(
    _ condition: Bool,
    _ trueBranch: () -> Result,
    else falseBranch: () -> Result
) -> Result {
    return condition ? trueBranch() : falseBranch()
}

choose(x < y) {
    print("true branch")
} else: {   // (Swift lets us get away with this! Heh)
    print("false branch")
}

SE-0326 is released in Swift 5.7.1, right? Experimenting with what I think (?) is SE-0326 behavior gives fascinating results:

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

// ✅ 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
}

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

Would achieving parity with whatever's happening here be acceptable for multi-statement if expressions?

Edit to add: While the 0 / 1.0 example above does show parity between choose and the ternary expression, the other example from the proposal at hand does not:

let x = .random() ? nil : 2.0                   // ✅ compiles
let x = choose(.random()) { nil } else: { 2.0 } // ❌ error: 'nil' is not compatible with closure result type 'Double'

…so ¯\_(ツ)_/¯

3 Likes

What does this code mean? In any case I think this usage of yiled will make it difficult to co-use if expression in _read and _modify.

1 Like

IMO, yield is also the wrong keyword to use in case the language ever wants to support coroutines. However, I would support exactly this approach using some other keyword.

I suggested result as a placeholder up above, which has its own obvious problems:

…but I do think a resolution is lurking in this design space. And we could (I think?) use a word like result that isn't currently a reserved word, since the context in which this word would exist is not currently syntactically valid.

Although…hmm, accepting this proposal with only single-statement if expressions would negate what I just wrote: result or whatever keyword we ultimately want would appear as an identifier inside legal single-statement if expressions, making the multi-statement migration a breaking change. Yikes. Perhaps another strike against accepting the single-statement flavor as a stepping stone.

2 Likes

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.

5 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