Pitch: Multi-statement if/switch/do expressions

I think @tera’s argument would be far more convincing if it used SwiftUI’s ForEach or another function that accepts a trailing closure.

ForEach uses a result builder, so there's all sorts of weird stuff going on. And I'm not sure what you mean about a trailing closure, return within a closure return a value to the closure. Constructing in such a way that you try to return through it just doesn't work. Only bare statements can appear to return through themselves. So I guess the question is, are if / switch expressions more like functions / closures or more like their statement counterparts? I'd argue that, by giving them the current implicit return behavior, they're much more like functions and closures than statements, and so can be treated as such more fully to solve the problem outlined in the pitch.

The second. At least not the first.

4 Likes

Obviously if/switch expressions are more like statements than functions/closures:

  • no "in"
  • no closure / capture list
  • can't assign the "uncalled form" to a variable, pass that variable around and call that variable later on
  • can't have a return (at least in a "single statement" if expression) while there is no problem of having "return" in a single statement function/closure.)
  • no "non trailing closure" form:
    let x = if (true, then: {1}) // 🛑
    let x = if (true, then: {1}, else: {2}) // 🛑

Thanks for the reminder! Reviewing that thread I found that using "return" to mean "expression value" was also regarded as confusing (see discussion for Option #1 linked below).

This, this and this post are particularly interesting. As a compromise solution I like (Option #5), makes sense (bare last expression in if/switch statements, explicit return in functions/closures).

BTW, I found that "try" marker is not required (and not allowed) for an if expression. Is that a bug or a feature?

func foo() throws {
    let x = if condition { throw NSError() } else { 42 } // ✅
    let y = try if condition { throw NSError() } else { 42 } // 🛑 'try' may not be used on 'if' expression
}

It looks like a feature due to the error description but OTOH it feels like a bug at the first and the second glance.

1 Like

If the question is whether it's by design, then the answer is yes, it was designed that way.

1 Like

I'd argue than any closure makes this puzzling to read, not just one disguising itself as a statement or alluding as such, and that this has always been an issue for Swift:

func foo() -> Int {
    let x = 42
    banana(x) {      // a closure!!!
        print("1")
        return 1     // does NOT return from "foo"
    }
    if x == 42 {
        print("2")
        return 2     // returns from "foo"
    }
}

Why are we required to use the same syntax in one vs. another when they clearly produce different results? [edit addition for clarity:] The one vs. other I mean are the closure denoted by braces vs. the control flow clause denoted by braces. A new keyword that's available for use within closures will naturally also solve the issue with if/switch expressions being discussed here.

In this thread the placeholder suggestion for this keyword has been spelled use, can this be spelled yield instead? Would it cause confusion with other (still proposed?) use of yield? :confused:

2 Likes

But they are the same in a sense that those are two closures basically, one just have if {}, I'm not sure about clearly...

1 Like

I wonder if there's any merit in permitting a more verbose but explicit form of return, and if that might sway the decision here (for if/switch/do expressions)? e.g.:

func foo() -> Int {
    let x = 42
    banana(x) {  // a closure!!!
        print("1")
        return 1 from foo  // 🛑 Cannot return from foo from inside a nested closure.
    }
    if x == 42 {
        print("2")
        return 2 from foo  // ✅
    }
}

(Or return from foo with 1 or return foo: 1 or return foo = 1 or whatever syntax you prefer)

There's also the option to make this required whenever there's any potential ambiguity (e.g. when inside a nested function or closure), which itself could be controlled through a compiler flag (in the same vein as SWIFT_STRICT_CONCURRENCY e.g. STRICT_CONTROL_FLOW).

I'm not sure how you'd name an anonymous function (a la closures), though. Maybe something to do with the variable or function they're invoked via, e.g. return 1 to banana in the above example? Semantically a bit incongruent, though.

3 Likes

This is what I thought about with return labels, a function's return label is the function name and a variables return label is the variable name. This mirrors how labels already work with control statements.

func foo() -> Int {
    var a: Int = if bar {
           // label explicit for example,
           // where `return 1` would return 1 to `a`
           return a: 1
        } else {
            // reduced example
            return foo: 2
        }

    // do stuff with `a`
}

This would actually work just but only the first trailing closure cannot have a label, but this seems unpopular. So I could see the introduction of that behavior/syntax leading to first trailing closure labels - therefore filling in another perceived gap of the language and ushering in the return of a desired feature.

// current
foo(...) {
    // n/a
} bar: { // assume type: () -> Int
    let a: Int = if {
        // label explicit for example
        return a: 1
    } else {
        // reduced example
        return bar: 2
    }

    // do stuff with `a`
}

// future
foo(...) bar: { // assume type: () -> Int
    let a: Int = if {
        // label explicit for example
        return a: 1
    } else {
        // reduced example
        return bar: 2
    }

    // do stuff with `a`
} { ... } // assume I have any number of trailing closures

Edit: another example after my morning coffee

// bar is the label for the previously anonymous closure
let foo: () -> Int = bar: {
    let a: Int = if {
        return a: 1
    } else {
        return bar: 2
    }
}()
1 Like

One downside of this is that it couples the contents of the closure (specifically the return 'labels') to the function parameter names. And not in a useful way, as far as I can tell - it shouldn't matter whether it's "bar:" or "troz:" or "zort:" as far as the closure is concerned.

It'd be a relatively minor concern if shadowing weren't a potential issue. e.g. the function parameter "bar:" might be renamed "troz:" and it turns out there's another "bar:" further up, so the closure is now trying to return to a different place than intended. It might get caught by various other constraints, but it seems potentially error-prone (for compiler authors) and fragile.

Frankly, if you're using this feature and you want to return through it, you're explicitly opting into that pattern. Wanting to return a function value within the evaluation syntax of an expression is a huge code smell, but may be desirable in a small chunk of manageable code and we surely won't stop you from doing it.

I was also assuming the current rules for labeled statements would apply in that there cannot be duplicate nested labels - the safety is enforced by the compiler.

Frankly the situation where there may be duplicate function/control statement labels only means that you need to simplify and re-organize your code.

But in this situation it might not be your code - it's controlled by the functions you're calling; they name their parameters.

How is that practically different than when function parameters change today? That conversation can now be shifted to library deprecations and client maintenance responsibilities.

Looks like goto to be honest, think functions/closures should have more deterministic behaviour with return (which they have now).

The difference is you'll get a compiler error if a parameter name changes, forcing you to modify that part of your code, but you won't necessarily get any indication from the compiler that some return statement, an unbounded number of lines further down the file, has now unintentionally changed targets.

That seems contrary. There's not much more deterministic than goto - it's explicitly saying exactly where control flow is going. Unlike return which - as this centithread has stressed - is unclear and ambiguous about what it's doing, if looked at in local isolation. An emphasis on local reasoning is one of the better aspects of Swift.

There's a prevalent anti-goto cargo cult in programming, reinforced by occasional goto fail incidents (which are really more about fundamentally, horribly broken coding practices, orthogonal to goto). I think that's unhelpful. I'm also not advocating for actual goto - everything being discussed here is for more restricted forms of that basic notion, that avoid the genuine pitfalls. e.g. return to <label> / break label / continue label each have very specific control flow semantics with a limited set of label positions they can be used with, making it easier to reason about their behaviour - and ensure intended behaviour.

2 Likes

Current label rules already enforce label existence and since duplicates are already not allowed, this is a non issue. I fail to see where at any point a return point “unintentionally changed targets” - at any point where a parameter changes either by your code or others, it will either cause a conflict with your own labels or will fail because the old label no longer exists. We get that compiler safety at all times.

I thought this would have been implicit, but you would also never be able to return outside of your closure/function. That’s the rule that would further enforce determinism and would make local reasoning about your return labels more … reasonable.

Indeed I was thinking about a more generalised approach, because otherwise - with your proposed restriction - what's the benefit of this over just using then?

That's why I've wrote looks like, of course it's not.
And not sure there is some cargo cult, debate is quite old.

It's just example is small, if you extend it a bit then good luck supporting this code :upside_down_face:

func foo() -> Int {
   let a = switch bar {
     case "1": return a: 1
     case "2": return foo: 0
     case "3": 
       print("Weird")
       return foo: 0
     case "4":
       return a: 5
     case "5": return a: 1
     case "6": return foo: 0
     case "7": 
       print("Weird")
       return foo: 0
     case "8":
       return a: 5
     case "9": return a: 1
     case "10": return foo: 0
     case "11": 
       print("Weird")
       return foo: 0
     case "12":
       return a: 5
     default:
       return foo: -1
   }

    // do stuff with `a`
}

And now we come full circle. I believe there are enough comments in this thread to answer that question.

I find that no different than a contrived confusing example with existing control flow labels - and I have left enough comments why something like that is a code smell.

I will agree that one may think it looks like it, but the return label: value translates to "return a value to this label" and I find that pretty straightforward. Of course that would be explained in something like the Swift Book, but I find it intuitive enough to figure out on one's own.