[Pitch] Last expression as return value

If we limited the multi-statement branches to do blocks like this, it could also open up another possibility for an (optional) keyword for yielding the value, including guard statements/early exit:

let value: Int = if condition do {
  print("branch 1")
  done 1
}
else do {
  print("branch 2")
  guard otherCondition else {
    done 2
  }

  3
}
5 Likes

I share similar concerns as the others this may end up making logic complex.

+1 from me.

Introducing new keywords makes the language more difficult to understand (in this context, return is needed, in that context implicit return is available, and in this other context this new keyword is needed – very difficult to explain and remember).

The main use case I see for myself is for if/switch expressions. Adding a log line is a common pain point right now, as is having branches that need just a bit more logic than what would naturally fit onto a single line, so not having to cram everything together to meet today’s expectations is also great.

The fact that functions also get this new behavior is not my main motivation, but it does make the syntax easy to teach and remember: “last expression as return” – no “… well, in these specific contexts”.

I disliked last expression as return value very much in Ruby, but like it in Rust where the type system takes care of the common pitfalls. That fact that Rust has the ; as a signal and Swift will not does not bother me. It’s such a small syntactic signal such that for reading purposes it might as well not be there. The fact that an expression is at the end of a block is the much stronger signal.

This proposal will simplify the syntax, making it easier to teach and remember than today’s syntax, and remove pain points when using if/switch expressions.

3 Likes

Doesn’t it make harder to teach when there are gotchas / exceptions over exceptions? This is like saying pronunciation is easy in the English language thanks to the “rules” and “exceptions” ;).

2 Likes

Isn’t return affected the same way with multiline expressions? Does the proposal mention how this use of labelled break is compatible or not having other kind of sharp edges?

Considering Joe’s answer here ( [Pitch] Last expression as return value - #300 by Joe_Groff ) I am bit confused about how these last two points apply. Also how expression statements and multiline closures do not suffer from the same problem.

do expressions would prohibit returns, just like if and switch. I'll clarify in the proposal.

Consider this example:

func foo() -> Int {
    guard x else { return 1 } // ok here
    let v = do {
        …
        guard y else { return 2 } // not ok here
        // to fix:
        // - either get rid of this guard which might me not easy.
        // - or reshuffle the code to not be do expression, which might be painful.
        print(e)
        e
    }
    print(v)
    v
}

Does the above look ok to you? It does feel a bit inconsistent but maybe it’s just me; I’ll support the pitch even in this form.

BTW, is it guard itself or “guard with return” that’s going to be disallowed in “do” expressions?

3 Likes

The Language Steering Group has talked about this some, but I don't think we've ever taken a strong stance against immediately-executed closures, and it's definitely not an officially accepted position that we should minimize them.

Personally, I think they're sometimes fine and sometimes pretty awkward. In particular, having to make a case block an immediately-executed closure just to stay within the restrictions of the switch expression grammar feels to me like a pretty embarrassing place for the language to end up.

16 Likes

My summary for the last expression as a return value would be:

  • There are cases that many people seem to be in favor of.
  • In other cases, many people object to it — as far as I can see because of the lack of clarity or because type inference may not produce the expected result.
  • I don't think there are any "real" showstoppers, i.e. situations that absolutely speak against it.
  • As @FlorianPircher noted, simple rules for where the "return" statement can be omitted might be a good thing, although this might not be the perfect solution in some cases.
  • My own conclusion: Since even today you can't avoid some people writing "bad" code, let's see how this is used and what best practices emerge. In some teams, a linter might enforce certain code styles, as is the case today.
5 Likes

My summary: eliding return is not necessary, it produces no new features, adds nothing of value to the language, solves no show stopping problem and is really just something that a few people think would be nice. It removes clarity, causes some debug friction, puts more guesswork on the compiler all of which is more headache than the “oh shoot how much I hate typing return” headache experienced by the few promoting the pitch.

There is no real objection to eliding return in single line closures/functions.

There seems to be a major need for a new keyword in these sort or pseudo-closure as “return” would be ambiguous in control flow statements as expressions.

12 Likes

Accepting this proposal would simplify/consolidate the existing rules and exceptions, yielding a simpler syntax for newcomers. Currently, you have to say: Last expression as result is possible, but only in this context, that other context (unless you want to also do this additional thing, then you have to use a different syntax altogether), and that context.

if/switch already work without any additional keyword, but with only a single expression. Introduction a new keyword would be in addition to the current situation, making the syntax more complicated.

1 Like

having to make a case block an immediately-executed closure just to stay within the restrictions of the switch expression grammar feels to me like a pretty embarrassing place for the language to end up.

It is pretty awkward. However, before switch expressions, this would be expressible as:

let value = {
  switch someEnum { 
    case .someCase:
      /* complex logic */
      return thisCasesResult
  }
}()

So this inconsistency is, at least in part, due to the switch expression rule itself, as without it, the closure could alternatively wrap the entire switch statement.

2 Likes

Yes, but we’re not going to revert that proposal, so this is the language we have now.

4 Likes

The fact that it "doesn't allocate a closure" doesn't change an async function to a sync one, nor does it remove the need to capture any local variables used inside it. By contrast, a block inherently has the same async/sync context and access to the same variables as its containing function.

"That ship has sailed" is a bit different from "no real objection".

And introduce a bunch of new ones, like

At least with one line, you don't even need to explain why you can't use guard, since there's no way to use guard and return a value in a single line anyway.

Overall, I think it adds up as more rules, not less.

That is an existing rule.

1 Like

this looks very good, but only because each branch is either one or 2 lines

if a branch had many lines instead, it just wouldn’t read clearly, but if there was a keyword (like bind) on the expression returning the value that is bound to goForWalk it would be much better; also, it would allow for guard or if with no else to be used in the branches… it would basically allow for the usual control flow structure we know and love

in terms of raw added complexity, adding a keyword is going to complicate the language less than just “remembering” that last expression is bound to the variable: the latter will simply not read well for long branches

3 Likes

As I've said

So, it's not a "rule", it's just that there's no way to write the code in which this "rule" is "violated". This "rule" is simply a side effect of the "only a single line" rule, and therefore needs no extra explanation.

Background: I have been teaching programing for 13 years now. It is possible that you have been doing the same, and you just don't see the issues that I have. I accept that we can have different experiences when it comes to 'newcomers' and 'new programmers'

That is simply not true. The existing rule comes out of an affordance given to @autoclosure that felt right because it simplified single line entires that were really easy to debug, albeit sometimes confusing especially when all you were writing was <. (This is NOT easy for newcomers btw)

Yet, these contexts are so very simple, you would never have pages of code to scroll through to find the return value. You would have exactly ONE line to deal with that that line alone could be the only culprit. This affordance was allowed because it sort of matched what was already happening inside parameters.

It ways always a ay of making closures within parameters kinda behave like the expressions.

You would, for example, never have to say

doThisThing(x: return 1, y: return 2, z: return 3).

Again, these would always be single lines anyway.

So then it was asked that we extend this to all single line closure (I believe).

I kinda objected to this, I think, because it served a sort of philosophical niche in parameters that didn't NEED exist outside of that. I think if Swift was made today, I would have never invented autoclosures as it hides too much of what is actually happening (this is unsafe in my opinion).

But it wasn't a big deal. It was ONE line. There really could be no major confusion about what was being returned.

Single line, allowed. Multi lined, not allowed. Very simple to understand, very simple to keep straight for newcomers.

But not always, because newcomers and new learners of programming often confuse that a body of a function and closure are different than a body in if-statement (this is about to get mudded even more). Why for example, in the body of an if expression can I not simply state the return value and it would early exit? It LOOKS kinda like a closure -- but it's not.

What if it were allowed in if-expressions? Do now all of my @discardableResults become a potential return value?!

That is absolutely dangerous.

What if it were allowed in multi-lined closures/functions and in the course of development I comment out my return statement and now a unsuspecting discardableResult becomes the return value? In the past, the compiler would warn me that I didn't write 'return '. Giving me clear notice that I need to pay attention to what comes out of this function.

What happens if I continue to develop a function, not notice that I had already returned, and I continue to type code.

I have been teaching Swift for 13 years now to young adults. I am honest when I say this, Swift is starting to become a programming language for experts. This is another step in the wrong direction of creating a language that is SAFE, explicit and easy to read.

When I teach programming now, there are things I have found are better to NEVER mention until much later in the year. 1) implicit types 2) trailing closures (all my example code puts the closure inside the (). 3) eliding return in single line closures (with some exception). They very frequently leave the students confused. It is always very difficult to explain why result builders do the funny things they do with if statements. But each of those are far less dangerous that was is being proposed here. This actually causes the program to yield values in functions that can be passed down through a call chain. In functions that are hundreds of lines long, this is unacceptable.

So please, I beg people to listen to the naysayers who are advocating for something to really be thought about in practice. Not with the brains that we have. We read code all day. We see these discussions and participate fully in the debate of how a new idea will be implemented. Even when we dislike it we will easily understand how it works.

This pitch is a decoration (not a feature) that would only exist because it feels a particular way. Not because it is needed. Not because it is safer. Not because it makes for a measurably better language that enhances the final result (the built program).

For those who feel that this pitch creates consistency despite at the expense of clarity, okay, than I prefer to be inconsistent. Some inconsistencies deserve to remain.

This pitch creates MORE confusion and more rules not less especially when we considered all the other side effects and exceptions that may need to be carved out. And the very fact this is decoration is not needed. We need features for the language that encourage safety not decorations for the language that encourage being less explicit.

25 Likes

I think there would be serious downsides to omitting any version of the then construct entirely - we probably want users to be able to return early from block expressions, such as in guard statements, as they do in immediately-executed closures. If the then construct is indeed necessary, the question becomes whether or not we should permit last-expression returns in addition to the then construct. It is worth noting that if last-expression returns are permitted, then the then construct can probably afford a more cumbersome syntax that avoids a new keyword, such as something similar to the Rust syntax: 'label: { break 'label value } .

I think the claim that permitting last-expression returns results in easier refactoring is overblown. In the general case, adding a side effect or variable binding before an expression would still require cumbersome refactoring: a bare expression would need to be wrapped in a do block. It is only when the expression in question is already the sole expression of an if branch, switch case, or function that the addition of a do block would become unnecessary; in that case, I think the lesser cost of adding a then keyword is acceptable.

In contrast, in C, adding a side effect before any expression is lightweight, only requiring the addition of a comma (sideEffect(), value in C is equivalent to do { sideEffect(); value } in this proposal). I think the approach taken in this proposal and in Rust is better, because (in addition to allowing for variable bindings) it makes the code easier to read, despite requiring more syntax and more refactoring work. I think an explicit then construct is better for similar reasons; generally, sacrificing some refactorability for explicitness is a good tradeoff here.

1 Like

The last-expression rule, as proposed, would also apply to functions. So I don't see how any additional refactoring could be required under this proposal. And without this proposal, the addition of a do block would only be required if a catch is needed.

Having said that, I don't oppose the addition of break {label}: expression, but I don't want it's use to be mandatory.

What I mean is that, for example,

let x = expression()

would need to be refactored into

let x = do {
    sideEffect()
    then expression()
}

So in general, adding a side effect or variable binding before an expression requires the addition of a do keyword and braces. It is only when the expression is directly situated within an if branch, switch statement, or function that that level of refactoring would be unnecessary:

func f() -> Int {
    // No need to add a `do` block here
    sideEffect()
    return expression()
}

I think if adding a do keyword and braces is acceptable in the former case, then adding a then or return keyword is acceptable in the latter case.

2 Likes