[Pitch] Last expression as return value

goto fail says hello.

3 Likes

No, it means that the function returns whatever you declare it to return, and untyped closures will default to assuming that you are returning the last expression just like they currently do with single expression closures. The assumed to be a statement idea was only meant for functions and closures that have a declared return type to check against.

Although I just realized that if the inferred type of the last expression expression doesn't match the return type, then it wouldn't have compiles in the current version of Swift anyway, because it runs off the end without a return.

1 Like

Things brings up a diagnostics quality-of-implementation question—

Currently, if you leave off an appropriate return statement in an early-exit branch, the diagnostic will highlight your error at the correct spot, while the rest of the code on the happy path will return as intended.

But recall as discussed above that, to avoid backtracking, if expressions use only the first branch for type inference (unlike ternary a ? b : c).

With this proposal, will there be scenarios (maybe only in closures?) where the first early-exit branch that "runs off the end" will be taken as correctly written, and the rest of the code will have unexpected errors or (worse) compile with unintended return types?

I'm thinking in particular of what would happen if later branches end returning the result of calling a function f<T>(..., _: T.Type = T.self) -> T, and T ends up inferred to be whatever arbitrary thing was left as the unintended last-statement-as-return-expression in the first branch.

3 Likes

That’s assuming the reader is correctly counting nested braces, and has already determined the scope of the block. I don’t think this is how everyone scans code.

Another criteria is the likelihood of introducing errors that are syntactically correct, which I believe is higher and significant since it’s only a one-character shift across braces. By hypothesis, these errors require testing to be discovered, so they are also expensive. That risk is not presented in one-liners.

I say this reluctantly because the proposal would be my personal preference, but I find it concerning for disparate teams and application developers.

4 Likes

This is where I lost the Swift code. i.e. where I could no longer figure out what it's doing and how. Part of the problem is that I don't have a clue what the type of cellIndex is supposed to be, so I need to do like 3 or 4 documentation dives before figuring out which type is either an enum with a .max case or a type with a .max static constant of the same type.

By contrast, the only thing confusing about the Obj-C code was that the extremely long closure type made the line too long to parse. A typedef would likely take care of that. Inferred closure type is the only thing you need in order to render the Obj-C code clearer than the Swift one.

So I don't think you've shown a case of multiple steps of ceremony removal making the code more readable. What you've shown was one big and necessary step forward, and then several small steps backwards, and then showed that cumulatively they're still sum up to a step forward. Then coming to the incorrect conclusion that each step adds, even though only one of the steps was actually forward.

I would honestly make this a guard, thereby removing the weird dangling false:

func hasSupplementaryViewAfter() -> Bool {
  guard let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) else {
    return false
  }
  return elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
}

The "ceremony" returned, except now it looks more meaningful. More importantly: Happy path at the bottom. This better delivers the intent of the code, rather than simply how it works. It also makes it easier to expand the function to verify a sequence of 3 or more items, instead of just 2. Done with happy path at the top, that would require nested ifs.

It's a perfectly good fit here. The condition starts with !. That would be reason enough. The issue comes from having a nested condition, which, in turn, is only necessary because we need the root which "matched". If we didn't need the root, this could have been written much simpler:

guard bounds.surrounds((h - sqrtd) / a) || bounds.surrounds((h + sqrtd) / a) else {
  return nil
}

Mind you, the nested form is unclear anyway. The two roots are equivalent, but they look like they aren't. What if there were 3 or 4 possible roots? It's clearer if flattened:

let root
let candidate1 = (h - sqrtd) / a
let candidate2 = (h + sqrtd) / a
if bounds.surrounds(candidate1) {
  root = candidate1
}
else if bounds.surrounds(candidate2) {
  root = candidate 2
}
else {
  return nil
}

The issue here is that candidate2 went from being calculated only in the else to being calculated unconditionally. The problem is that you can't bind together the definition and use of candidate1 with the if in the way that lets you else both at the same time.

For comparison, here's a version with IIFE that better expresses the intent

guard let root = { () -> Double? in
  do {
    let root = (h - sqrtd) / a
    if bounds.surrounds(root) {
      return root
    }
  }
  do {
    let root = (h + sqrtd) / a
    if bounds.surrounds(root) {
      return root
    }
  }
  return nil // Unfortunate case of happy path at the top
}() else {
  return nil
}

Which could be farther made clearer using iterator operations

guard let root = [(h - sqrtd) / a, (h + sqrtd) / a].first(where: { bounds.surrounds($0) }) else {
  return nil
}

However, this goes back to unconditionally calculating the second root. Additionally, the literal array may cause an allocation that should be optimized away. If Swift had proper generators, both issues could be resolved [1]:

guard let root = (generator {
  yield (h - sqrtd) / a
  yield (h + sqrtd) / a
}).first(where: { bounds.surrounds($0) }) else {
  return nil
}

Another form possible today is if bounds.surrounds was replaced with a function that, instead of returning true or false, returned root or nil:

guard let root = bounds.nilUnless(surrounds: (h - sqrtd) / a) ?? bounds.nilUnless(surrounds: (h + sqrtd) / a) else {
  return nil
}

More generic option: Create an extension T.nilUnless((T) -> Bool) -> T? and use as ((h - sqrtd) / a).nilUnless({ bounds.surrounds($0) })

This is less pretty than a generator if the root count increases. But as a bonus point, it doesn't need a new language feature.

The return here tells me that this line is creating an object of type Hit and returning it. Swift's lack of new would, without return make this line indistinguishable from a call to a function called Hit that has Void return.

Notable, this would be the first line in the entire example that doesn't start with a keyword. Look at all the lines above it: let, if, var. With the function leaning in such a way, it would be hard to tell there's even a statement starting there, as it, at a glance, might be confused as a continuation of the previous statement.


  1. Like a non-escaping closure, a non-escaping generator should be optimized away to 0 allocations. ↩︎

12 Likes

More simply: The difference is between code that describes how something is done, and code that describes what is done.

The "positive" examples for removing return seem to start with code that already shows how something is done. So removing the return raises no issues. It's unclear before, and unclear [1] after.

By contrast, if you refactor that same code to show what is being done, suddenly the return becomes meaningful. It expresses intent. And removing it reduces that expression, and obfuscates the intent.

So the question that should be asked is: Does removing return make it easier or harder to express the intent of code? And is it worth introducing ambiguity in closure type inference for it?


  1. but a bit shorter ↩︎

16 Likes

True, I have never seen code with block syntax not typedeffed away to simplify it (http://goshdarnblocksyntax.com/), so it is a bit cheating showing it without typeset being used. Still it was a bit of an ugly corner of the language, but it barks far louder than it actually bites ;).

2 Likes

I would welcome this change if only because using single statement returns is already very practical in contexts of properties and using a switch or chain of if statements to determine a value instead of using an inline closure, and breaking down complex lines to achieve this would be a good thing. Most importantly though, as the pitch outlines, adding any single line to these constructions after the fact is tremendously disruptive, and holds the potential to introduce bugs in the re-write rather than augment or fix an issue. I'd imagine those that are against this style could use a linter to avoid it (and the existing instances of it) in their own code bases, as I don't see how lacking a return at the end of such statements could cause actual problems.

The only argument against that I could think of is forgetting to mark the return type of a function, and not getting the diagnostic that the return statement disagrees with the value actually being returned, but since Swift enforces the return type, and would warn if you tried to use a function and assign it to something, I can see disaster being avoided (well, at least in most situations… async let returnValue = functionMissingReturnValueButMeantToReturnSomething() may lead to a few surprises, at least until await returnValue is used?)

To those arguing that this will end up just like goto fail, do note that there are different ways of solving that problem as well, than just saying {} braces are always needed. Consider a same-line requirement, for instance:

if (someCondition) return; // Would succeed

if (someCondition)
    return; // Would fail to compile

I've similarly argued simplifying the language not for if statements, but for guard else statements, that are almost always two word return statements that read better without the {} getting in the way. I similarly don't think leaving the return statements out gets in the way of reading familiar, or even unfamiliar code — we are poor readers of code to begin with, and removing extra keywords will likely help parse code more quickly as our brains were skipping the ceremony already without most of us realizing it.

It's been almost a decade and a half since I learned of this, so forgive me for being unable to quote the study, but from a human language point of view, redundancy in speech is a good thing because it helps us catch phonemes that are dropped or hard to hear — this is why we conjugate verbs despite other words surrounding them giving us the same information. In written language though, redundancy tends to complicate things quite a bit — via eye tracking, it is largely skipped over (though different groups from different languages focus on the different redundant part), and many learners of a language, typically children, second language learners, and people with reading difficulties like dyslexia often misapply the redundant rules. The argument that written language should have settled by now to be most optimal falls through when we look at the largest literate group we have ever seen: people on the internet — I'm sure everyone here knows someone who speaks properly, but leaves almost everything out when texting, no matter their age :sweat_smile:

Point being, I see dropping the return statement when it can be safely inferred to be more welcoming to all involved that will be coming to Swift. Those that wish to include redundancy when writing the code still can, and will get the additional benefits of the compiler checking their work, but when the redundancy isn't necessary, I will surely welcome the lowered mental overhead.

I do love a good study on language!

I had some trouble sourcing the one you mentioned, though… and I do like to read original source material if someone thinks it’s worth citing.

Could this be it?

(Searches on redundancy and language turns up a variety of info, any extra key terms you could provide would be great!)

1 Like

I couldn't for the life of me tell you the exact studies were being discussed when I was in college some 15 years ago, but this relatively new one summarizes the point decently well. From what I remember, long story short: when you know a language well, redundancy helps a bunch (but mostly for speech, generally it'll be glossed over when typed out), and when you don't, it tends to be dropped when producing new utterances.

I am in no way suggesting that something like return complicates someone learning swift all that much (if anything, the lack of the keyword is likely more surprising to programers who know literally any other programing language), but leaving it out when unnecessary and relying on specifying a return type instead would simplify the amount of effort a newcomer needs to pick up the language, as it's one less thing they need to remember to do.

Since we are dealing with an artificial language where partial fluency results in a compiler error, the more we can simplify to one required mode, and other optional redundant modes, the better it is for everyone: proficient coders can write with redundancy built in (specifying types proactively, skipping syntactic sugar, documenting code), while novices can get their feet wet with a simplified grammar that also works, with many of those redundancies dropped.

7 Likes

I was skimming through this and the "old" pitch thread and figured maybe I should provide some clarification for some of the Scala callouts made throughout the threads. Since it's a prominent example of the last-expression-as-return feature and some have mentioned it causing unexpected problems; I've spent close to a decade with Scala code and was consulting developers of various skill level back then in their Scala usage.

I'll introduce the "known problems with this" in Scala and compare if we'd have the same problem in Swift or not:

--

The "gotcha's" in Scala:

The implicit return had two (or three I guess) very specific problems:

  1. unexpected return from outer scope from closures

Which produces:

WARNING: * Non local returns are no longer supported; use boundary and boundary.break in scala.util instead

  1. unexpected inferred function return type

Scala 2 (old):

Which leads to unexpected inferred types. The problem honestly only shows up as real issue at function boundaries because when you write smaller functions and ifs you rarely end up with weird types inferred. (Scala 3 solves this by introducing union types: in the above example the return type in Scala 3 is Int | String.

  1. (a) is a similar problem where randomly throwing in an early return can make inferred return types confusing... So nowadays doing this is even triggered as an error:

ERROR: method fun has return statement; needs result type

Nowadays both are either resolved or banned in Scala.


So... does this proposal lead to the same issues in Swift?

IMHO: No, it does not.

  1. return in Swift behaves in expected ways, returning from whatever scope it was in;
  2. Swift methods cannot omit return types and they must be spelled out.

I believe this proposal is a good positive addition, and solves issues that we have today:

  • early returns look easy to spot because the return keyword is a clear "things happening here!"
  • early returns are often paired with guard - I think it is good to keep guard+return pair. we signal that it's an early return.
  • unexpected return types getting inferred in methods is not a problem because Swift requires explicit return type annotation (this is good)
    • one might argue that it happens in closures; however closures are supposed to be small pieces of code -- if your closure is getting "huuuuuge" this is another good reason to make a method for it.

Overall I'm in favor of this because it rounds out the language.

Currently we're in a messy state and the "single line branch body can be used as expression but multiple not" is a confusing cliff in the language which breaks normal programming practices like introducing a temporary variable or even just breaking up a few calls that could be mashed into one horrible too-many-characters long line, and encourages writing clear code.

At least in my experience: In my 6~7 years of Scala use but also lots of teaching and consulting newcomers to Scala way back then before my days with Swift this specific part of the language wasn't really the difficult part and except those gotchas (which Swift won't suffer from), it has made code much less ceremonial and clear.

So, at least on a personal note, I think this is a very valuable improvement that makes Swift more clear to read and I personally look forward to it! I've been hitting the "eh, I need another line in this if" far too often recently... :sweat_smile:

31 Likes

Thank you for the Scala context.

I agree that the surface area of potential issues is much smaller in Swift vs. Scala because Swift requires explicitly declaring return types for methods.

While you acknowledge the issue still exists with closures, I believe you downplay the problem.

We can debate about if closures are “supposed” to be small. It is still the case that they often are not small in practice.

Furthermore, Result Builders and SwiftUI-like APIs strongly encourage large deeply nested closures. I and others have raised this issue extensively here and elsewhere in this thread. (Sorry I don’t want to beat a dead horse.)

Granted, we’ve discussed earlier in the thread that after this proposal, result builders will not behave in the same way as non-result builders. And so many of the ambiguities I raised earlier in this thread would not be much of an issue.

However, Result Builders are often implicitly applied and are invisible to the call site. So that increases the confusion of why last expressions appear to behave one way in some contexts (a non-Result Builder) but another way in other contexts (an implicit Result Builder).

4 Likes

I very much agree with the above sentiment and therefore I am a strong -1 on this proposal.

I already find the currently-allowed self and return omissions to be occasionally confusing. There are times when I see a variable and think to myself, “Where does this come from?” only to find it belongs to self. I already find myself looking through code sometimes and having to parse it very carefully to figure out where the return value is coming from. This proposal feels very much like saying, “Let’s take the more confusing parts of the language and double-down on those!”

I can't help but think of the axiom, “Things should be kept as simple as possible, but no simpler.” Also, ”readability counts.”

14 Likes

By using a keyword to return from an expression we would be able to use control flow statements like guard in a do block (or any other multiline expression), which would make it consistent with functions and closures: I'd argue that this would round out the language, while accepting last expression as return value will instead introduce another corner of specific limitations.

Also, any argument like "things should be small" is, to me, just plain wrong, because replacing a long expression with a separate function call will make code harder to follow and understand: a specific keyword will also help in crafting and understanding longer multiline expressions.

I quickly surveyed some of my code to check for opportunities of using do expressions, and I found some cases that would look very good with this pitch, but many others simply wouldn't work because of the need of guard, so I would need to use the plain old immediately executed closure, that as we know makes things slightly more confusing due to the return used inside. Having the possibility to use a specific keyword to return from a do block will guarantee better clarity thanks to the distinction between that keyword (return just from the enclosing expression) and return (return from the whole function).

2 Likes

Personally, against this change. The return keyword makes the code clearly explicit on where and when code blocks exit. One of the goals of Swift was to reduce bugs, having code the automatically exit at the last line/expression creates enormous opportunities for incomplete code the correctly compiles.

For example, I routinely break long calculations up into smaller blocks of code that are easier to read/check, but I don't want to return anything until I'm done with my calculation. Having to specify the specific value I'm returning means that that my code won't compile until I've completed my calculations with the correct, designated output.

Additionally, Swift was designed to be easy to interpret and read. The return keyword makes it explicitly clear what the code will do and what it is intended to do. The goal wasn't to make the smallest possible language with tons of must-learn rules before someone can interpret the code.

I think this pitch is mistaking brevity for clarity and needlessly introduces new opportunities for buggy code.

14 Likes

Personally, I believe we should not implement this change. Using "return" makes perfect sense when looking at unfamiliar code, as it allows for a quicker understanding of what’s happening. The same goes for omitting 'self'; I still think it was a mistake to allow this. I often find myself needing to double-check which variable I’m currently working with.

15 Likes

Sorry, that I didn't read whole 449 posts, but a couple of questions.
What is the status of this pitch? Will it become an SE proposal, or the conclusion was against it?

In general, if Swift was developed from scratch, or if we would be willing to break existing source code more willingly, last expression as a return value would be excellent idea. A problem is that it might break some existing code.
For example - an often used pattern for a function with side effects to return a value (which is often discarded) and to be called without caring for the return value. I do believe it is not a good idea in general, but doesn't change the fact that it is happens often. And it pretty common pattern when calling C code for example.

func produceSideEffects() -> Something { ... } 

Now, every closure of type

{ ( // parameters) -> () }
// do stuff
produceSideEffect()
}

will suddenly change it's type from (parameters) -> () to (parameters) -> something.
All such code is expected to break.

However, I do believe that it would make sense to limit this to if/else and switch expressions. At the moment, if/else expression is just a ?: operator with much more verbose syntax that can't be used everywhere ?: is used, so it is kinda useless (except is someone prefers if/else to ?: for aesthetic purposes). Allowing several statements within if/else expression would make it a much more powerful and useful construct. Same for swtich expression - it is slightly more useful that if/else as it allows avoiding nesting ?: but again - allowing several statements within it would make it much more useful.
It could make sense for functions to make return word optional as well - as the type of the function is known and is not inferred, but would cause mess and confuse people, and make the code less readable (if same pieces of code do the same thing and equivalent, why make some words optional)?
For closures - I believe it would be a huge headache.

But if possible - please make it available for if/else and switch expressions. (Or remove if/else expression, it is almost useless in it's current form)

As the migration could be easily automated by the compiler and this feature has no ABI concerns, if this pitch were proposed and accepted then compatibility concerns could be addressed by limiting it to if, switch, and do expressions and leaving the full version behind an upcoming feature flag.

Having used this feature in both Ruby and Rust, I can say that the only problems with I've ever had with implicit return have been solved by static typing.

I am sure this has been asked before, but wouldn't do expressions on their own solve this almost as well, but with none of the downsides? At least it seems that way to me, looking at the motivating examples.

2 Likes

This is actually one of the features I hate most about Rust. From my experience with Swift the main place this is a problem is with switch/if expressions, so can we just focus the solution on the actual problem? Also agree that do expressions could further help (but would need a similar solution), but the overall clarity that explicit return statements provide would be really sad to lose

7 Likes