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

I'd rather see bidirectional inference gone than ternary operator. At first glance bidirectional inference sounds clever, but it brings more problems than solves IMHO.

1 Like

I think this was briefly discussed in the pitch thread, but I don’t think these types of expressions should work with implicit returns.

func takeClosure<T>(_: (T) -> T) {}

func getId() -> Int {}

takeClosure {
  if .random() {
    getId()
  } else {
    getId()
  }
}

In this case, the closure wouldn’t get an explicit type annotation. This means that to someone not familiar with the code, this closure could expect a specific return type, like Void, or a generic one, like <T> above. Further, without quick access to the definition of getId, which is quite common on platforms like Github, it would be unclear whether this function performs a side effect or/and returns a value. All these issues assume a comprehensive understanding of language features. For someone not entirely familiar with Swift, someone unfamiliar with the code would probably be surprised that a value is indeed returned.

2 Likes

I don't really follow how this is different from takeClosure { getId() } and from my perspective it would be a significant negative to exclude this with no clear motivation other than "if makes the existing issue worse" which, speaking personally, I don't buy.

As noted in the proposal, Swift is really the outlier here. Many modern programming languages treat if as an expression. So the lack of familiarity with Swift doesn't seem like a big factor.

6 Likes

Would this syntax be supported by this proposal?

var body: some View {
    switch selection {
    case .a:
        Text("A")
    case .b:
        Text("B")
    }
    .onAppear {
        doSomething()
    }
}
1 Like

No, switch/if expressions are handled by the result builder transform in a special way and this proposal doesn't change that, see The expression is not part of a result builder expression section for more details.

I’m a big +1 on this general direction. I like this proposal a lot as a first step. I don’t think it’s sufficient as a destination for this design direction (and realize the authors may not necessarily be presenting it as such). I’m skeptical of whether it’s sufficient as a stepping stone.


There’s some skepticism about this whole feature direction. My experience points in favor of it. The review prompts ask:

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I’ve used conditionals as expressions extensively in Ruby, Elm, Scheme, SML, and Python. The middle three are of course functional languages, where there is naturally no other kind of conditional. Ruby was the first time I’d encountered if-as-expression in an imperative language, and it was bizarre to me at first. (Some reactions in this review thread could have been me circa 2006.) That feeling passed quickly! I’ve come to appreciate the feature a great deal, and miss it in Swift.


My concerns about this proposal all stem from the ways in which it stops short of full generality. In particular, I’m concerned about:

  1. Lack of multi-statement support
  2. Lack of support for use in arbitrary expressions
  3. Lack of the same type inference the ternary expression provides

Taken together, I’m concerned about whether this proposal leaves the language in a good intermediate state even if we do plan on future build-out in this space. Some specific thoughts:

Lack of multi-statement support

(Detailed analysis hidden because it's long; key point: Is there *anywhere* else in Swift where a single statement enclosed in braces cannot transform into multiple statements?)

Consider the vexation of someone who has the following perfectly reasonable code:

let message = if let thinger = fetchThinger() {
    "Found \(thinger.name)"
} else {
    "ERROR: no thinger available"
}

…and wants to log the error. They have to do one of the two following awkward (and I think non-obvious) things:

// Option 1
let message: String
if let thinger = fetchThinger() {
    message = "Found \(thinger.name)"
} else {
    log("Thinger fetch failed")
    message = "ERROR: no thinger available"
}
// Option 2
let message = if let thinger = fetchThinger() {
    "Found \(thinger.name)"
} else {
    {
        log("Thinger fetch failed")
        return "ERROR: no thinger available"
    }()
}

Is there anywhere in Swift where a single statement enclosed in braces cannot transform into multiple statements? I don't think so…?

The precedent we've set throughout the language is that any single statement within braces can become many statements, with the addition of a keyword iff the single statement was an expression whose result matters. This holds for closures, properties, and functions. It should hold here too.

It's clear to me that return is the wrong keyword for if-/case-expressions, but for the example above, by the language's own precedents, a solution with the following general shape ought to be possible (using result as a placeholder keyword):

let message = if let thinger = fetchThinger() {
    "Found \(thinger.name)"
} else {
    log("Thinger fetch failed")
    result "ERROR: no thinger available"
}

I'm concerned that this proposal as it stands makes a false promise to language users: what looks like a flexible approach is in fact an underpowered ternary, a dead end that breaks precedent and ends up requiring syntactic backtracking that can't help but feel just incredibly frustrating to a language user.

Maybe this is a good enough stepping stone…but part of me thinks it would be better to just steer people to Option 1 above in the first place until if expressions can pull their weight.

Lack of support for use in arbitrary expressions

(Detailed analysis hidden because it's long)

If I see this code:

let message = "Fetching thinger..."
displayStatus(message)

…my instinct is to consider refactoring away the intermediate variable:

displayStatus("Fetching thinger...")

For this code, shouldn't the same principle apply?

let message = if let thinger = fetchThinger() {
    "Found \(thinger.name)"
} else {
    "ERROR: no thinger available"
}
displayStatus(message)

We ought to be able to transform it in the same way, but the proposal disallows it:

displayStatus(
    if let thinger = fetchThinger() {
        "Found \(thinger.name)"
    } else {
        "ERROR: no thinger available"
    }
)

Again, the language has made a false promise. Normal, basic syntactic rules mysteriously don't apply. ** developer frustration intensifies **

I'm sympathetic to the proposal's concern over extreme cases here. That concern is well-founded.

I proposed allowing if/switch expressions in arbitrary expression position when immediately enclosed in parentheses (further examples here). Could we make that our first stepping stone? It seems safe enough, and does not present the same mysterious barrier as the current proposal.

Future proposals might be able to allow dropping those parens in more situations, for example as @beccadax suggested here:

(…but then presumably still allow them if enclosed in parens.)

Requiring more parentheses now and maybe making some elidable in the future seems far preferable to making common usage patterns illegal now because we might be able to make them parens-optional in the future.

Lack of the same type inference the ternary expression provides

I agree with Becca's remarks on type inference in her post above.

I'm always sympathetic to attempts to mitigate the horrors of multi-directional type unification, but I simply cannot get my head into a place where I'm comfortable with single-statement if expressions with a boolean condition (i.e. not if let) being anything other than completely equivalent to ternary expressions.

Could multi-statement if branches, and perhaps switch vs if, be the bridge where type inference becomes less robust as proposed here?


Again, I like where this proposal is going. And I am tempted to vote +1 as it stands: it hits the most common cases, and doesn't propose anything I'd imagine we'd have to retract in a source-breaking way. (Edit: That last part might not be true.) However, the way it stops short of generality makes me uncomfortable; I suspect its lack of generality is going to cause a lot of frustration, and make developers hate the whole idea when they would have appreciated it in a more fully built-out form. Opinions tend to calcify around those kinds of strong initial reactions.

16 Likes

I don't think we should allow return statements in these branches. This is confusing to read, is inconsistent with other value-producing expressions, and as far as I can see isn't motivated in the proposal or by existing Swift coding practices.

I don't have the same kind of problem with allowing throw statements, probably since value-producing try statements would work as expected.

8 Likes

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.

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).