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

Hi all,

Apologies for the late review. I'm supportive of the direction of this proposal: it's a nice ergonomic improvement that I expect I would use on a near-daily basis. I have a couple of quibbles:

return in an if or switch expression should not be permitted

The discussion here seems to be leaning toward allowing a return inside an if or switch expression, e.g.,

let value = if array.isEmpty { 0 } else { return 3 }

@Paul_Cantrell notes that peer languages allow this, but I think they all got it wrong. As @ktoso notes that this is considered an anti-pattern in Scala, and I think it's worse for Swift. Right now, the only way for control flow to exit out of an expression is via a thrown error, and we consider that to be so important that we require any throwing expression to be marked with try to indicate to readers that this control flow out of the expression can happen. Similar reasoning follows for suspension points with await. Having an expression that has control flow out of itself would undermine this long-held philosophy in Swift.

It's also very easy to make a mistake with this feature; a stray return (say, due to refactoring) would exit the function rather than produce the intended value, and especially with something like return nil the type checker won't save you:

struct X {
  var b: B?
  init?(a: A?) {
    self.b = if let b = a?.getBIfAvailable() { b } else { return nil }
    if /*some other condition*/ {
      return nil
    }
  }
}

I see basically no upsides to the early return; better to ban all non-error-handling control flow in if and switch expressions. This will only become more important if/we go to multi-statement. Speaking of which...

Multi-statement if and else bodies

I am quite certain that, if we accept this proposal, we will immediately want to turn around and discuss a proposal to extend if and switch expressions to support multiple statements. I think that's fine; I'd like to see multi-statement support, but I don't need it in this specific proposal because the gains from this proposal are significant already, and it would be a fine "resting place" for Swift if we find that there are problems with going multi-statement.

Syntax-wise, I'm coming around to option #5 presented here. It's low-ceremony, and by not allowing any control flow outside of the expression (see my previous section!) the chances of being confused are small.

Interlude: making source-generating features expressible in the language

Since I'm working on macros now, one of my overarching goals is to look at how we can take the various source-generating features that we have today and make it so that they are expressible as macros. One important step here is being able to express the semantics of existing source-generation features in the language. For example, can we write a macro that does a result-builder transform on a closure? Or a macro that implements string interpolation by creating an instance of the ExpressibleByStringInterpolation type and forming appendInterpolation calls?

Right now, you can almost do these things, but not completely. If we were to get to the point where we can do them completely, then the overall language (and its implementation) can get simpler, because these features truly do become syntactic sugar. Additionally, it means users can define similarly-powerful sugar through the macro system, so the language can stay simpler.

Type-checking across multiple branches

As a user of Swift, I really want the different branches to be "joined" into a common super type. I'm mostly motivated by two cases, the nil case because I find mapping optionals to often be unwieldy:

let x = if <something> { <produce a value> } else { nil }

and inferring generic arguments from multiple branches:

let e: Either = if <something> { .left(a) } else { .right(b) }   // infers the generic arguments from both sides

The latter case is very similar to what result builders do, because they have a custom implementation. Given this in a result builder body:

if c { thing1 } else { thing2 }

it would turn into something like this:

let __a1 = if c { 
  MyResultBuilder.buildOptional(first: thing1) 
} else { 
  MyResultBuilder.buildOptional(second: thing2) 
}

As an implementer of Swift, I know the challenges in doing the join. We chose not to do the join for opaque result types and for multi-statement closure inference specifically because of those challenges.

So, I agree with this proposal that we should keep the branches type-checked independently so we don't get mired in implementation issues and we don't end up with an inconsistency. I'd love to see a follow-on that introduced "join" behavior across all of the features, if/when we figure out how to do so without causing compile-time performance problems. Again, this proposal points to a reasonable "resting place", even though I hope we can get more in the future.

do as an expression.

I think it's pretty natural to want do as an expression, given this proposal, but it's only really useful if you have multiple statements, because do doesn't... do... much by itself. Given multi-statement if/switch, adding do into the mix is really powerful for macros that want to break down a larger expression into smaller ones.

For example, imagine a simple print macro that takes:

#print(a, b, c)

might want to do something like:

do {
  printSingle(a)
  printSingle(b)
  printSingle(c)
}

and an #assert macro might want to break down an expression so it can report on which subexpression failed, such that #assert(a > b && c > d) can be translated into, e.g.,:

do {
  if !(a > b) {
    fatalError("assertion 'a > b' failed: 'a' = \(a), 'b' = \(b)")
  }
  if !(c > d) {
    fatalError("assertion 'c > d' failed: 'c' = \(c), 'd' = \(d)")
  }
  ()
}

Note that string interpolation is essentially implemented via a compiler-internal version of a do expression (we call it a "tap" expression), which has no corresponding spelling in Swift source code. SE-0228 specifies that string interpolations desugar to something like this:

String(stringInterpolation: {
  var temp = String.StringInterpolation(literalCapacity: 7, interpolationCount: 1)
  temp.appendLiteral("hello ")
  temp.appendInterpolation(name)
  temp.appendLiteral("!")
  return temp
}())

In practice, that doesn't work because an immediately-called closure is not the same as code run directly in the enclosing context. The internal-only "tap" expression addresses those concerns, but it would be less magic---and be available for macros---if we had do expressions with multi-statement support:

do {
  var temp = String.StringInterpolation(literalCapacity: 7, interpolationCount: 1)
  temp.appendLiteral("hello ")
  temp.appendInterpolation(name)
  temp.appendLiteral("!")
  temp
}

Aside from allowing return, this proposal is a reasonable stop along the way to making a number of syntactic-sugar features be truly expressible as syntactic sugar, and put macros on a more-equal footing. Most of the future directions can be just that---something we do in the future to unlock more improvements.

Doug

27 Likes