[Pitch] if and switch expressions

Could we also include "do" in the pitch?

To convert:

    let foo: Int
    do {
        foo = try bar()
    } catch {
        foo = anotherValue
    }

into:

    let foo = do {
        try bar()
    } catch {
        anotherValue
    }
12 Likes

Because the first example narrows existing type inference, adding more constraints to a single expression on the rhs (so reducing its search space). Whereas the second does the opposite – it widens the constraint system to need to consider not just one expression, but all expressions that assign to data, in a way that would inevitably lead to far more "expression too complex" problems, as well as very hard-to-reason-about inference behavior.

Yes, you can simulate this today by wrapping the if in a closure, and using type inference. But even then, at least it constrains the problem to code within the closure that must be the rhs of the declaration. Placeholders for later statements imply the type can be determined at any point later in the function, essentially turning the entire function into a constraint system.

6 Likes

I don't think it would cause any breaking changes. Everything that is annotated with a builder won't use the new if and switch expressions and continue to use the builder transformations. It's the same with existing single value expressions and buildBlock. For instance, in this example the builder "wins" over the implicit return and returns 0:

@resultBuilder
enum ZeroBuilder {
    static func buildBlock(_ components: Int) -> Int {
        0
    }
}

@ZeroBuilder
var number: Int {
    1
}

print(number) // -> 0
3 Likes

It's not especially compelling since this is let foo = try? bar() ?? anotherValue. It becomes a lot more compelling if/when we figure out the multi-statement story. So I'd be tempted to put it into the "future directions" bucket.

7 Likes

(more generally, personally I'm a bit skeptical of try?/try! except for throwaway code/samples etc... you should probably do something with errors that are important enough to thrown, and so encouraging another way to just discard the error and sub in a default value, without multi-statement expressions where you could at least log then return a default, seems unwise)

2 Likes

In response to Paul Cantrell's observation on Java

var response = switch (utterance) {
    case "thank you" -> "you’re welcome";
    case "fire!" -> {
        log.warn("fire detected");
        yield "everybody out!"; 
    };
}

To unpack this a bit, Java's switch has two changes (aside from String matching):

  • -> instead of ':' to mean this case statement (like Swift's) requires no break to close the block, and it must return a value (expression).
  • yield to disambiguate multiple statements.

I find the -> very helpful to signal the intent of the case statement, like let in

case let .valid(scalar): return scalar

I would make -> a requirement for any case statement used as an expression, to avoid conflating it with block case statements.

As proposed, the rules for case-expressions are quite restricted (relative to case-block's, for good reason), and I think it's much less confusing for the reader to indicate that overtly.

The -> is like a function return type declaration: the result of anything from this scope.

I also suspect -> will help the compiler know immediately it's in expression context.

e.g.,

public func progress() throws -> Float {
   // return here is optional in single-expression context, but clearer IMO
  return switch state {
    case .starting -> 0.0
    case .running, .finishing -> self.buffer / self.unit
    case .completed: -> 1.0
  }
}
5 Likes

I prefer the ternary form for short expressions too. "if" would only make sense for long expressions where the ternary operator is often abused.

1 Like

I think it would be useful even in under single-expression rules to permit return and throw statements to apply to the surrounding closure or function.

I think that means return should NOT be used to return the value of the expression in an assignment.

From the original proposal (emphasis added):


if and switch statements will be usable as expressions, for the purpose of:

  • Returning values from functions, properties, and closures (either with implicit or explicit return);

The proposal says usually return can be considered ceremony:

public static func width(_ x: Unicode.Scalar) -> Int {
  switch x.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: return 4
  }
}

Permitting throw and return to exit the function scope supports fast-path and fast-fail uses for case legs. e.g.,

public func progress() throws -> Float {
  let buf = switch state {
    case .starting: return 0.0
    case .running, .finishing: self.buffer!
    case .canceling: throw .err("reentrant")
    case .completed: return 1.0
  }
  let unit = ProgressKit.findUnit(buf, self.config)
  return ProgressKit.progress(buf, unit)
}
1 Like

I favor the first line (instead of last line) of multi-line blocks being the implicit return so it doesn't break the flow of the outer expression. Swift could introduce a "where" keyword (or “with” as an alternative) to blocks like the equivalent to where in Haskell. Functional programming languages generally use a style of putting the result expression on top and dependent expressions after. This would be a variation of that, albeit with side-effects. I think this would fit in better when using a functional-style in Swift. Additionally, this avoids surprise since all implicit returns are obvious. This syntax would prevent using implicit returns when using an imparative style (like Swift style guidelines currently recommend) which I think is great.

let bar = switch foo {
   case 0: 1 + 2
   case 1: fallthrough
   case 2: a + b where // maybe “with” instead of “where”
       let a = 1
       let b = 2
   default: a + b where 
       // optionally use break to return explicitly (a labeled break should work too)
       guard Bool.random() else { break 42 }
       someFunction()
       let a = 1
       let b = 2     
}

let bar = if foo > 5 { a + b where
    let a = 1
    let b = 2
} else { a + b where
    someFunction()
    let a = 1
    let b = 2     
}
// Maybe generalize for all expressions
let bar = { a + b where
    let a = 1
    let b = 2
}

var foo = false
let bar = { a + b where
    foo = true
    let a = 1
    let b = 2
}

let bar = do { a + b where
    let a = try fetchValue()
    let b = a * a
} catch {
    print(error.localizedDescription)
}
// Maybe functions and getters too
func f() -> String { 
    if b == true { "true" } else { val.lowercased() where
        let val = "FALSE"
    } where
    
    let b = Bool.random()
    guard b != self.lastB else { return "last" }
} 

func f() -> String { "TRUE".lowercased() where // called second
    someFunction() // called first
}

var foo: String { b == true ? "true" : "false" where
    let b = Bool.random()
} 

// leaving out expression allows avoiding implicit return
let closure = { where someFunction() } 

EDIT: Edited a bunch of times to flesh out example.

1 Like

To be clear, I was not proposing anything more than lifting Java’s idea of using a return-like keyword that is not actually return in multi-statement branches of conditional expressions. But yes, there are some things to be said for -> as well!

Java’s practice of using both : and -> with switch/case can be confusing in the learning process. I wonder whether they chose to introduce -> only out of parsing necessity, or whether it was specifically meant as a “this is an expression!” clarification for code readers.

(And for the record, I’d prefer this direction if it proves workable:)

Unless throwing bar() has an optional return value :slight_smile:, or you want to use an error somehow:

let foo: String = do {
    try bar()
} catch {
    "Error \(error)"
}

Indeed:

    let foo = do {
        try bar()
    } catch {
        print("error: \(error)")
        return anotherValue
    }

Removing the braces from the if statement is one step in the direction of unifying it with switch. In your example, you use -> but switch uses :. If you use : too, you converge on switch even more. Maybe we only need one?

Interesting perspective. I remember back when Swift was new I wondered why use -> instead of a colon:

func foo(): String {
    if isRoot && (count == 0 || !willExpand): ""
    else if count == 0: "- "
    else if maxDepth <= 0: "▹ "
    else: "▿ "
}

typealias T = (Int) : Double
1 Like

I think it comes from the established mathematical notation for functions:

f: U -> V   // U is the domain of the function f, V is its range

I actually prefer the opposite (implicit return on top) so it doesn't break the flow of the outer expression. I put a more detailed example in another post.

let bar = switch foo {
   case 0: 1 + 2
   case 1: a + b where // expression blocks have result at top
       let a = 2
       let b = if Bool.random() { a + c + d where 
            let c = 42
            let d = Int.random(in: 0...a)
            someFunction()
       } else { 9 }
   default: a + b where
       someFunction()
       let a = 1
       let b = 2     
}

I'm very excited to see this proposal! It's been a thing I've continiously missed a lot ever since moving to Swift from Scala! It really helps in writing nice functional code and removes a lot of boilerplate / noise.

It also feels very natural in Swift, which already has set one foot (a whole leg perhaps...) in the door with both var x: Int { 12 } as well as function builders.

People on the thread have already mentioned their experiences with Rust or Ruby which allow for such things, but I think we can learn a lot from Scala here -- and avoid mistakes and pitfalls it's "everything is an expression" has. Especially since Scala has a rich and strongly typed type-system and is on the other side of the spectrum than Swift wrt. the "what is an expression" question.

I'll go with a few examples and discuss the pitfall, and how we address, or could potentially address it:

If without else

:warning: In Scala it is possible to only write the if without else and it still is an expression:

// scala
val x =  // Unit (!)
  if (randomly) 12

:warning: An if without an else has a type of Unit. Which is not wrong but it can be a bit weird. So generally you'll want to have an if/else always if using if as an expression:

// scala

val x = // Int 
  if (randomly) 12 else 42

:white_check_mark: Swift: Good, the current proposal handles this already -- we're saying an if must have an else in order to be an expression! I think that's the right call here :+1: Rather than leave the Void inference, we can make people who want to use an if as an expression always provide the alternative branch.

If/else branch types

In Scala, different branches may have any type they want, and the type of the if expression is the shared common supertype. For example:

// scala 

trait Top
case class Left() extends Top
case class Right() extends Top

def example = // : Top with Product (Product because they're case classes...)
  if (randomly) Left() else Right()

in this example this works nicely, we get the Top type. But you'll notice that we're leaking additional information by accident: the with Product in the type is accidental from the fact that both Left and Right are product types (case classes), so we leaked information here by accident.

So again, being explicit about the type to be returned by an if/else expression is a good pattern anyway -- so if we have more cases where we have to do this in Swift, I think that's no big deal as that's a best practice anyway in other languages.

This often comes in handy, say when I'm returning an existential "any Top" as we'd call it in Swift:

// swift

func make() -> any Top { 
  if randomly { 
    Left()
   } else {
    Right() 
  }
}

I would certainly like the ability to do this. I was not entirely clear if this is proposed to work or not. The proposal states:

// invalid:
let x = if p { nil } else { 2.0 }
// valid with required type context:
let x: Double? = if p { nil } else { 2.0 }

which you may have noticed I believe is good anyway. Specifying the type there is useful, even for local declarations.

Question: Under this proposal, can I write the make() -> any Top function with a single expression, or would I need to as any Top or something in the branches?

To multi-line or not to multi-line, is the question?

I think we need to look at an example of how "last expression is the return" affects programs. In Scala, it is effectively an anti pattern to use return since it can be surprising, so instead code is written in order to always have one "last expression".

Look at this real example from Akka's build:

    @tailrec
    def findHTMLFileWithDiagram(dirs: Seq[File]): Boolean = {
      if (dirs.isEmpty) false
      else {
        val curr = dirs.head
        val (newDirs, files) = curr.listFiles.partition(_.isDirectory)
        val rest = dirs.tail ++ newDirs
        val hasDiagram = files.exists { f =>
          val name = f.getName
          if (name.endsWith(".html") && !name.startsWith("index-") &&
              !name.equals("index.html") && !name.equals("package.html")) {
            val source = scala.io.Source.fromFile(f)(scala.io.Codec.UTF8)
            val hd = try source
              .getLines()
              .exists(
                lines =>
                  lines.contains(
                    "<div class=\"toggleContainer block diagram-container\" id=\"inheritance-diagram-container\">") ||
                  lines.contains("<svg id=\"graph"))
            catch {
              case e: Exception =>
                throw new IllegalStateException("Scaladoc verification failed for file '" + f + "'", e)
            } finally source.close()
            hd
          } else false
        }
        hasDiagram || findHTMLFileWithDiagram(rest)
      }

Ignoring what the code does -- you'll notice it is pretty nested because it strives to follow this expression return style.

The entire method is an if wrapped thing:

if (dirs.isEmpty) false
else { LOTS OF LOGIC HERE }     

Now... this is where things become weird with Swift, since it has a great facility to avoid such nesting -- guard! So in Swift I'd argue the preferred style would be:

// WARNING: weird pseudo-code "translation" to what-if-swift

    func findHTMLFileWithDiagram(dirs: [File]): Boolean = {
      guard dirs.nonEmpty else {
        return false
      } 
      
      let curr = dirs.head
      let (newDirs, files) = curr.listFiles.partition(_.isDirectory)
      let rest = dirs.tail ++ newDirs
      
      let hasDiagram = files.exists { f =>
        let name = f.getName
        if (name.endsWith(".html") ...) { // THIS IF IS RETURNED
          
          let source = scala.io.Source.fromFile(f)(scala.io.Codec.UTF8)
          defer { source.close() }
          
          let hd = // NOTE: removed the try/catch, will discuss below
            source
              .getLines()
              .exists { lines in ... }

          hd // RETURN: this is a bool, and initializes hasDiagram (!) 
        } else false // RETURN: (else branch) initializes hasDiagram
      }
      
      hasDiagram || findHTMLFileWithDiagram(rest) // RETURN: method r
    }

So... here's where things grit a little bit... in such code the styles are mixed and that leads to confusing code. We like Swift and we like guard, so we'll likely be writing guard whenever we can -- but that is the "early return" style, that is in opposition to the "everything is one expression" style. When the styles are mixed, we get confusing code... (as in this "pseudo"-swift translated example.

I'm myself not entirely sure on the complete answer here... but perhaps this is hinting at the fact that Swift should not strive towards this "one huge expression" style, because of the existing language tools are well geared towards early returns. And that perhaps implies that we should limit where and how we use this expression return style...

Summary: All this introduction is to showcase the conflict I'm having with regards to allowing multi-line expressions inside here or not... If we DO allow, then we're leading towards this "everything as an expression" style, which does not mix well with existing patterns. BUT, not allowing it is also incredibly annoying:

For example, disallowing multiple lines of code in the if/else expression may cause the example used in the proposal to be incredibly annoying IMHO:

let x = 
  if randomly { 
    log("yes")
    12
  } else {
    42
  }

I can definitely see myself be really infuriated every time I try to debug some Swift, and have to wrap things into the {}() dance only because I needed to add a log statement somewhere.

Summary summary: I'm a bit worried about mixing styles, but the pain of allowing only single-line expressions I can definitely feel would drive me very annoyed when working in the real world (using mild wording here :wink:).

Methods

:warning: In Scala the return type of a method can be omitted and inferred . This is a pitfall more often than not, a lot of the "bad examples" I'll list below kind of fall back to this issue, so remember that this is legal:

// scala

def example = 12
// is the same as
def example = { 12 }
// is the same as
def example: Int = { 12 }
// is the same as
def example: Int = { return 12 }

// a "void" returning method is:
// def example { 12 }
// or
// // def example: Unit = { 12 }

as you can imagine, this can lead to issues, when declaring APIs. However it is nice for overriding/implementing methods, since you don't need to spell out the type, but the requirement is still there from the being overriden method...

:white_check_mark: Either way, Swift does this right by forcing the return type be written always so we don't run the same risk. Nothing to be done here, we're good from the get-go.

Swich

:white_check_mark: Switching / matching is really just a nicer if as far as rules concerning type inference etc are concerned. Nothing much to add here really.

do/catch

In Scala try/catch (our do/catch) is also an expression:

scala> try 12 catch { case _ => 42 }
val res1: Int = 12

but as the proposal states, this is equivalent to

try? ... ?? alternative

which is a more concise form of the same (though without the power to handle the error). So while it may be nice to add in the future, I don't think we're losing much here.

Arguably though, for consistency's sake it may be a future extension that's worth exploring... Especially since such expression do/catch can also perform Error -> DefaultValue depending on the error` conversions... this isn't super common though.

Summing up

I think the shape and scope of the proposal, as presented is good - we're avoiding problems Scala has with those expressions, and we have more room for growth in the future.

I, personally, would still want to see multi-line support, just because I know it will be incredibly annoying to write nice beautiful code with expression style, and then debugging it and adding logging will be a nightmare otherwise making it all horrible and ugly again. As a community though, we should embrace the multiple return style that guard and other pieces of the language are good about. This means not shunning the return keyword as much as Scala does for example -- it still has its place, I suppose, in Swift.

Hope this was at least a bit helpful to see how we stack up against other languages with this feature :+1:

20 Likes

I am pleasantly surprised to see this pitch! I think up this is a great addition to the language, and I think it would be a good thing for inline if-else to replace ternary expressions.

I share others’ concerns that implicit return of final expressions is a bridge too far. If nothing else, it risks breaking existing Swift sample code in surprising ways. It’s also really dangerous to accidentally leak information from a function. I quite expect that adding this feature would result in at least one CVE.

4 Likes

I’m not sure I believe that CVE-worthy information leaks become drastically more likely with generalized final-expression returns than they are with what’s being proposed here, but okay. If we accept that final-expression returns are evil, but only-expression returns are not, can we at least avoid the code evolution problems of the current proposal by saying that you don’t have to uniformly use only-expression returns in a function? That is, each individual path can decide whether to have an only-expression return or a normal return after multiple statements, so that adding a second statement in one of the blocks only means adding a return keyword to the following expression instead of either completely rewriting the function or surrendering to {}() within what’s already a block.

20 Likes

What kind of breaking changes do you expect? I don't think any code that compiles today would behave differently if implicitly returning of final expressions would be introduced.

I initially thought that maybe overload resolution based on closure return type might be a problem, but there seems to be a precaution in the compiler that prevents implicitly Void returning multi-expression closures from being used for overload resolution.

Example
func launchRockets(_ preconditionsMet: () -> Bool) {
    if preconditionsMet() {
        print("continue")
    } else {
        print("abort")
    }
}

func launchRockets(_ alwaysAbort: () -> Void) {
    print("abort")
}

launchRockets { // error: Ambiguous use of 'launchRockets'
    print("accidentally trigger void overload")
    true
}

I'm seeing it like others in that we have already halfway arrived at implicit returning of final expressions and that the current state feels rather inconsistent. For the subset of Swift syntax that is allowed in result builders it's even possible to make this work in todays Swift:

@ImplicitReturn
func isZero(value: Int) -> Bool {
    print("value: \(value)")

    if value == 0 {
        print("zero")
        true
    } else {
        false
    }
}
@ImplicitReturn
@resultBuilder
enum ImplicitReturn {
    static func buildPartialBlock(first: Void) -> Void {
        ()
    }

    static func buildPartialBlock(accumulated: Void, next: Void) -> Void {
        ()
    }

    static func buildPartialBlock<Value>(first: Value) -> Value {
        first
    }

    static func buildPartialBlock<Value>(accumulated: Void, next: Value) -> Value {
        next
    }

    static func buildEither<Value>(first value: Value) -> Value {
        value
    }

    static func buildEither<Value>(second value: Value) -> Value {
        value
    }

    static func buildArray(_ components: [Void]) -> Void {
        ()
    }

    static func buildArray<Value>(_ values: [Value]) -> Value? {
        values.last
    }

    static func buildOptional(_ component: Void?) -> Void {
        ()
    }

    static func buildOptional<Value>(_ value: Value?) -> Value? {
        value
    }
}
1 Like

I was wondering this to myself while reading this thread because on the one hand it was how I automatically assumed it would work, but then I realized that my assumption was based in the new perspective that this if/else statement is expected to produce a value. Then I remembered that return statements in if/else branches are already a thing with a very different meaning, and that maybe that's why we can't use return in this way. At the moment I'm feeling favorably about the idea of a different keyword for yielding a value towards a composite expression as opposed to returning a value from a function.

5 Likes