Pitch: Multi-statement if/switch/do expressions

Slightly more general example made out of yours, which AFAIK is how it is suggested in the pitch:

extension Result {
    var isSuccess: Bool {
        let (ok, err) = switch self {
            case .success: (true, 0)
            case .failure(err):
                print("failure")
                then if let err = err as? NSError {
                    (false, err.code)
                } else {
                    print("this is not NSError")
                    then (false, 123)
                }
        }
        print("err: \(err)")
        return ok
    }
}

Will the following form be also allowed? cc @Ben_Cohen

extension Result {
    var isSuccess: Bool {
        let (ok, err) = switch self {
            case .success: (true, 0)
            case .failure(err):
                print("failure")
                if let err = err as? NSError {
                    then (false, err.code)
                }
                print("this is not NSError")
                then (false, 123)
        }
        print("err: \(err)")
        return ok
    }
}

I'm personally ok with return here. It is pretty clear that:

    var isSuccess: Bool {
        let (ok, err) = switch self {
            case .success: (true, 0)
            case .failure(err):
                print("failure")
                if let err = err as? NSError {
                    return (false, err.code) // `return` here is tied to switch expression
                }
                print("this is not NSError")
                return (false, 123) // `return` here is tied to switch expression
        }
        print("err: \(err)")
        return ok  // `return` here is tied to computed var
    }

In practice it is common situation that inside expression the returned value is tied to enclosing expression.
If someone need to return from body of computed var he can use deferred initialization pattern:

    var isSuccess: Bool {
        let (ok, err)
        switch self {
            case .success: (ok, err) = (true, 0)
            case .failure(err):
                print("failure")
                if let err = err as? NSError {
                    return false  // `return` here is tied to computed var
                }
                print("this is not NSError")
                (ok, err) = (false, 123)
        }
        print("err: \(err)")
        return ok  // `return` here is tied to computed var
    }

and

    var isSuccess: Bool {
        switch self { // implicit return of `switch expression value` tied to computed var body
            case .success: true // implicit return tied to switch expression
            case .failure(err):
                print("failure")
                if let err = err as? NSError {
                    return false // `return` here is tied to switch expression
                }
                print("this is not NSError")
                return false // `return` here is tied to switch expression
        }
    }

It is unserstandable according to current swift rules. What problem then keyword will solve in these cases?

1 Like

As was brought up several times upthread, quite many people consider return should only return from closures / functions, but not be the value of if/switch/do expressions – because "it would be confusing otherwise". (Personally I won't see a problem of return returning from the "outer" function closure like in Rust, Ruby, Kotlin and Scala, but, again, many people consider that equally confusing, so we won't have that either.)

    var isSuccess: Bool {
        let x = switch self {
            case .success: true
            case .failure(err):
                print("failure")
                return false // THE CONFUSING PART
        }
        print("x")
        return x
    }
2 Likes

Wouldn't it be better to describe this possibly confusing part in documentation? There are also many other language constructs that can be confusing for some people and not confusing for others: multiple defer blocks, double and tripple optional values, subscript call on dictionary with optional values, min / max functions giving incorrect result for FloatingPoint values and structured concurrency features.
We can explain to people that in your example THE CONFUSING PART won't return the computed var body, it returns value for switch expression / let binding. The rule is simple – as every nonthrowable function, expression must return a value. It can not return the body of outer scope.

1 Like

The "confusing part" in this case isn't that people won't know how return works in that context, it's that readers will have to take in a lot more context for every return they see when reading a function and reasoning about it. That cognitive overhead can't be fixed with documentation.

32 Likes

return returning from the "outer" function closure like in Rust, Ruby, Kotlin and Scala

Hmm, this is a really valuable look at how this works in peer languages. I had previously been supportive of using return over then, but seeing how this works in so many other languages makes me reconsider. While we aren't beholden to the design choices of other languages, it seems like important signal -- this probably tells us at least a little on how people will intuitively expect this to work.

So I'm definitely seeing the appeal of using then instead, to avoid the potential confusion and diverging from how this works elsewhere.

18 Likes

I’d also add that this is, to me, a much stronger argument against ‘return should mean something different in this position than it does in other languages’ than it is against ‘we should support the mid-expression return pattern.’

I still think we should be cautious about going down that road and I don’t think this feature would in any sense feel incomplete without that ability.

4 Likes

I wish someone could explain roughly why it would be
difficult or undesirable to make something like this possible:

var someInt: Int = random()

x = if condition {
    use someInt
    someInt = 0
} else {
    use 5
}

Agreed — and that’s from someone who’s not necessarily opposed to the mid-expression function return. (Aside: I’ve used it in those other langs and it’s really not so bad, but it’s also not particularly essential.) It’s both reasonable and precedented for people to think that return means “go to the end of the function/closure” in any context. People lament Swift’s proliferation of keywords, but I lament even more languages’ habit of giving the same keyword multiple meanings. Good names are the heart of clarity.

There’s a related argument here: overloading the meaning of return is confusing within Swift, even if we ignore precedent from other languages. Consider this code:

func f() -> String {
  if .random() {
    return "verdigris"
  } else {
    if .random() {
      "viridian"
    } else {
      return "zaffre"
    }
    return "eburnean"
  }
  return "gamboge"
}

Now what happens if we add _ = at the top? The outer conditional becomes an expression, and suddenly, despite having 4 return statements, the function can only return "gamboge":

func f() -> String {
  _ =
  if .random() {
    return "verdigris"
  } else {
    if .random() {
      "viridian"
    } else {
      return "zaffre"
    }
    return "eburnean"
  }
  return "gamboge"
}

Figuring out whether a return exists inside a (possibly deeply nested, possibly distantly created) expression context in order to divine its meaning creates an “action at a distance” effect that I don’t think is ideal.

Edit: I’d missed @allevato’s post making the same point.

11 Likes

Personally, I think the only way return could ever work within expressions is via (implicit) labels as was suggested previously. I'd be interested to see more discussion around this, exploring any potential problems/concerns/edge cases and whether they can be resolved. E.g. what would happen with a bare (no label) return x within an expression? Would it be disallowed or would it always return from the function?

My belief is it should only be permitted if there's only one possible interpretation - return from the named function. Otherwise a labelled return (or some other syntax, like then / use / whatever) should be necessary in order to avoid ambiguity.

Following Swift's sort of general principle that the compiler doesn't like to make assumptions in the face of ambiguity (because that means humans reading the code would also have to, and - unlike compilers - humans are bad at making assumptions consistently).

That said, I'm less sure how well that works for existing functional patterns like using map / filter / etc. I generally write their closure arguments as pure expressions wherever possible (and proper named functions otherwise), to avoid the return keyword and any potential confusion, but you certainly can use return in them today and it does not mean return from the "parent", named function. Conceptually changing that existing use of return to e.g. then is simple, but it'd be a big change mechanically and socially.

1 Like

I don't think that would cause anything but a disservice: there are local functions which are very similar to nested closures.. You are not suggesting that "return" could be used only at the top level function/closure, right?

let globalVar = {
    func foo() -> Int {
        func baz() -> Int {
            return 3 // and not here?!
        }
        baz()
        return 2 // but not here?!
    }
    foo()
    return 1 // allowed here
}
1 Like

It seems like at least one big reason people want this is to avoid reformatting during print debugging, etc. I use the following custom ~> operator based on ideas from the "With functions" pitch.:

@inlinable @discardableResult public func ~> <T>(_ value: T, modifications: (inout T) throws -> Void) rethrows -> T {
	var value = value
	try modifications(&value)
	return value
}

// Typical usage
let rect: CGRect = self.parentRect ~> { $0.size.width = 320.0 }

Besides allowing values to be modified in place, it can also be used inside of expressions to ensure they're still a single statement and avoid the parenthesis on an anonymous closure. You can simply add it to the end of the line without adding an extra return.

func doSomething(value: Int) -> Bool {
	switch value {
	case .min..<0: true
	case 0..<1000: false ~> { _ in
		print("Why did this happen?? \(value)") // Set breakpoint here 
	}
	default: true
	}
}

var goodGetter: Bool {
	self.value ~> { _ in print("Using someGetter \(self.value") }
}
var grossGetter: Bool {
	print("Using someGetter \(self.value)")
	return self.value
}
6 Likes

I like the idea of return being available anywhere, but I don’t have much experience with languages which permit that, to know if it has downsides.

I was thinking it could be considered unambiguous for a plain return to return from the nearest named function, but thinking about it more and looking at your example, perhaps that would still permit too much ambiguity… technically what really matters is whether the context is escaping or not, I suspect. It’s the non-escaping cases that cause the most ambiguity (and they are perhaps the only cases where you could implement a ‘far’ return?).

It’s the same amount of context they already have to take into account though. No different from a closure. It seems to me that it behaves like a closure in some other respects, so I personally wouldn’t be confused at all that returns also behave as in closures.

Perhaps the issue is that these are technically expressions, which is different from closures, but I would guess that most users aren’t philosophers, they will just see something that behaves like a closure and expect returns to work the same.

Also, I don’t see how return could possibly mean “return from the outer context” here, since there is an assignment, and since the return type typically won’t match. So how could anyone really be confused?

1 Like

I am baffled by how many people seem to be fine with "return everywhere".

The biggest issue I have with this part of the discussion is how it disregards the fundamental differences between expressions and closures (or "anonymous functions" if you will). Those are very different things that just happen to both come wrapped in curly braces.

I believe we are ill-advised if we muddy the waters in this regard: closures are functions (+ captures), expressions are not.

You can't pass around expressions and call them later, you can't have "input arguments", and they sure do not "return" anything (in the OG meaning of returning).

As for the "labels to the rescue" idea:
To me the point of this pitch is to be able to spell expressions that evaluate to some value more concisely and with less drama. Requiring labels feels like going in the opposite direction.

The whole idea is that a chunk of code "evaluates" to some value, throwing in control flow-y things like labels (or then in the middle of the block? what are y'all doing?) does not make sense to me.

7 Likes

The fact that return in Swift means unambiguously "return from the enclosing function, whether the function is named or not (that is, a closure), wherever you're calling it from" is one of the key features and principles that I appreciate about the language. Using return for anything other than that will be very, very confusing to me, and will basically destroy one key principle that allows to reduce complexity in Swift code, and increase clarity.

I'm still not seeing any strong argument against a new keyword here, other than "too many keywords", which seems arbitrary ("too many" compared to what?).

12 Likes

Fundamentally, why does that fact that expressions are not closures have to imply that the keyword for returning a result to the outer context has to be different?

Yes, they are different things, but return would mean the same thing in both places, so why insist on having different keywords?

By that logic, shouldn’t we have different return keywords in closures, functions, local functions, variable getters etc? Those are also fundamentally different things.

There are two camps of people who have literally the opposite meaning of "return everywhere" ("the classic" return from the outer function/closure even when used in expression context and "the new" return we didn't have before to mean "expression value"). The two camps have irreconcilable differences and the way to not piss one camp off to the benefit of another is the judgement of Solomon approach – not allow "return" in those contexts at all – allowing it to mean the either option would be too controversial.

Consider again my response from above:

And consider this way that it was put above:

3 Likes