[Pitch] Last expression as return value

To add a data point, as a recent beginner to Swift, I ran into this same confusion.
It seems this pitch would help with consistency, seeing as omitting return is already in the language.

After all, when omitting return, all one needs to search for is the final block of a function. Or, in the case of Swift, an early return which would be highlighted/boosted by the fact the default end-body return would no longer be needed.

4 Likes

Upon seeing @Ben_Cohen’s example, my mind immediately translated it into something more declarative:

func hasSupplementaryViewAfter() -> Bool {
  elements.firstIndex { $0.elementCategory == .cell }.map { cellIndex in
    elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
  } ?? false
}

This of course has the added benefit of being a single statement, avoiding the issue, but I wouldn’t say that avoiding the return is motivation itself using this style.

You're not, but each one has an answer. The goal here is to remove ceremony where it delivers insufficient value, or is otherwise causing problems, inconsistencies etc.

So for example, "remove () for no function arguments" is, I think a very reasonable proposition (if you're talking about the call site). Ruby, for example, doesn't require parens for function calls. The challenges though would be considerable. Mainly, the syntax for unapplied methods is to leave off the parens. If we went back in time to Swift 1.0, added the \ syntax for keypaths, and then extended that to also be the sigil for unapplied methods, then I could totally see an alternate reality where () aren't necessary for function calls. These days, however, that's almost certainly an unrealistic change to make to Swift. Unlike the change pitched here, it could not be adopted as an incremental change, and so probably not worth discussing further. But that doesn't mean it isn't interesting to consider.

As for removing await or try. There is every reason to look for ways to minimize these keywords further. They exist for a specific purpose, which is to mark a point where reentrancy could happen, or a scope can exit. Those markers are vital – but also sometimes redundant, like return can be.

Swift already allows you to skip stating the try twice on one line. Sometimes. But not in this case:

try values.map { try $0.failable() }

The second try is entirely redundant. It would be nice to have a rule to avoid having to restate it.

This kind of thing is important to look at in future. If you look at some well-written modern structured concurrency code in Swift, it is a cascade of disheartening return try await ... return try await ... return try await. We can do better there, so long as it's done with care.

8 Likes

Great! As presented by the naysayers, it adds sufficient value to keep it in multi-lined functions and removing it causes problems.

I was afraid that you'd try to make the case.

Requiring it does it with care, it is also elegant, clean, safe and gives us the right information that we need.

A tiny bit of redundancy for the sake of safety and clarity is the Swifty way to go!

6 Likes

I think this is a great example of the differences how people parse code. While Ben’s examples (all, even the one with the omitted return) where completely easy to read for me, my brain cannot easily comprehend your code. I have to go to the innermost part first and then switch between the different closures, check what you're mapping, go back to the contains, etc.

So while one person may find it easy to 'determine' what a method will return, others have to think more and go through the function line by line as some people can 'unwrap' closures easily, I cannot.

2 Likes

Whenever I use an “ugly” combination as return if (which happens to be a lot in my code at the end of functions) I have the suspicion that there should be something better. And having to type return twice instead does not feel much better.

And after seeing some examples without any return at the end this does not feel so “bold” any more.

Yes, so I think if you have to use return or not at those places will not decide if you write code that is easy to read.

Arguably easier, as it's unusual to find large guard bodies in practice. So the end of a guard's else block is usually much closer to the guard keyword, and has simpler control flow compared to the end of a function.

I can't think of a reason to avoid extending implicit returns to guards, other than enforcing the idea of using return only for "unusual control flow". But I'm not sure that's even achievable. Sometimes the "unusual" flow happens at the end, this is a relatively common pattern:

func handleURL() -> Bool {
    if handleWithFeatureA() {
        return true
    } else if handleWithFeatureB {
        return true
    }
    // No feature can handle this URL!
    false
}

Anyhow, I feel like the pitch as-is would have the effect of discouraging early returns using guard:

  • guard statements can't use this new syntax to return values.
  • In a if/switch/do expression, one must avoid using guard, as there's no keyword to assign the variable in the else block and there are no implicit returns there. This may build an 'inertia' of not using guard even in contexts where it's supported.

To this point, the version of @Ben_Cohen's final example using guard instead of an if/else block reads best to me, even with an elided return:

(Of course, this opens the door of what to do with guards in functions that return Void. The logical conclusion would be to allow eliding the else there entirely).

2 Likes

Does not Swift need a keyword to differentiate class vs instance methods too?

Could you expand more on how in this particular example the returns actively “hurts” readability please? It is not clear to me it does.

4 Likes

As @xwu was mentioning before, reading your reply made me think about it even more, it is a concern where having lots of smaller ceremonies to pick from and the subtle effects each would have or they would have when combined that creates a ceremony of its own.

For all its faults, block syntax was a late addition and not always great (although it could be side stepped), Obj-C was a relatively simple language easy to compose. Actually quite readable and beyond the scare of [ brackets and message passing, the verbose syntax was a bit of a difficulty bump but after that bump the language gotchas mostly disappeared (it was a great language to make apps with
).

The reduction in ceremony in Swift and the additional features it gained vs its predecessor (generics, result builders, structured concurrency, etc
) and how it gained them have a cost in complexity (early Swift did not need famous C++ language lawyers, jury is a bit out on modern Swift
).

@Ben_Cohen the paragraph where you mentioned removing parentheses from methods that had no arguments gave me flashbacks of Obj-C 2.0 when properties were introduced. Few changes I was so excited about that in practice confused many people blurring the line between properties and methods :smiley: .

5 Likes

For the pitched multiline expressions this is not holding true. Even as of right now it is possible to write the code where last line isn’t clearly denoting returned value.

Considering the initial example of this function,

  1. If we treat this from standpoint that both branches return a value, then this function has two ends that here presented as “equal”.
  2. If we treat this as an expression, then whole if denotes the end of a function.

Both cases can be (subjectively easy) deduced in the example. Neither of them, however, follow that function has either one end or it is clearly denoted by the closing brace.

It also can be rewritten with only one return given currently available expressions, and that reads nicely:

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

I would still prefer version with guard in the first place, but that’s question of preference.


I also agree with @austintatious that problematic cases tend to be more complex and less expressive in the first place, they quite often present much more critical parts of the program as well. Large if-else-if or switch is reality for many apps, more than I wish I end up working with code that looks like this one, with me not being an author of this code, so I have to guess what was intent of the author.


There is also another issue brought by implicit returns. Modifying example with rays to match proposed behaviour, plus one more replacement to get this code:

func hit(_ ray: Ray, _ bounds: Range<Double>) -> Hit? {
  // snip...

  // Find the nearest root that lies in the acceptable range.
  var root = (h - sqrtd) / a
  if !bounds.surrounds(root) {
    root = (h + sqrtd) / a
    if !bounds.surrounds(root) {
      return nil
    }
  }
  
  let point = ray.at(root)
  hit(with: point)  // <- changed
}

It is now harder to reason just by the bare last line what’s happening: does this function returns a value or not? I might be brought there via debugger, so I am not yet aware of the signature, and explicit return can help understand more from this narrow context.

Comparing to Rust, for instance, it is a bit better by at least signalling via missing semicolon:

fn hit(_ ray: Ray, _ bounds: Range<Double>) -> Option<Hit> {
  // snip...

  // Find the nearest root that lies in the acceptable range.
  let mut root = (h - sqrtd) / a;
  if !bounds.surrounds(root) {
    root = (h + sqrtd) / a;
    if !bounds.surrounds(root) {
      return None;
    }
  }
  
  let point = ray.at(root);
  hit(point)
}

IMHO it is still harder to get compared to explicit return, but there is some hint for better understanding.


This all can be justified that it is not the reason to block the change, because someone always be able to abuse feature to the point of non-readable code. I don’t find this argument relative to this change: the question for me not that it can be abused, but that it promotes certain way to write code to the point where long if/switch expressions with side effects are extremely easy to create and language has explicitly been adjusted for that.

6 Likes

Another big thank you for that post, it's definitely shifted my opinion a bit, although I'm still very much -1.

I've been thinking hard about just why I dislike this so much, because I want to make sure it's not a rationalization for laziness or stubbornness or something else. I think my main reason is that returning a value from a function is kind of a side effect, and when you don't have the return keyword, the fact you are causing this side effect becomes invisible. Consider this PR for the tail end of a long public function inside a class...

    // complicated cool stuff
    
    // other things happen
    
    // generate profits etc
    
    --- calculateCatAgeInYears(...)
    +++ calculateCatToyCount(...)
}

With the code seen as is, without implicit returns, we know that this function cannot directly affect the behavior of a caller just by returning, otherwise there would be a return statement. But with implicit returns, this could directly affect the functionality of callers, too, maybe even causing breaking changes to contracts if this was a public API...but that affect is invisible at the site of the code change that caused it.

9 Likes

My two cents are not at all original, but as a person who loves Swift, I would like to chime in:

I would absolutely love this!
I love single-statement closures without returns. I love single statement functions without returns.

I love switch-expressions allowing me to leave out returns in my many, many functions that simple map over an enum value.
They read so much better to me, because they are very obviously just mapping values.

I do not like realizing that I can’t eliminate returns from a switch statement because of a single log statement or extra check in one of the branches.
Hitting these situations actually annoy me and I wish that I didn’t have to keep all the returns around.

Similar for single statement functions, it feels hacky to me that I have to add a return because of an extra log statement.

I very much like that this pitch does not introduce new keywords. I really dislike the idea of adding something like ‘then’.

Knowing myself to prefer brevity - and actually believing that brevity most often helps readability, I’m certain that I will also start returns for ‘longer’ functions.

+1 from me.

8 Likes

Yes, but – and this is a key point of my post – you only have to specify when a method is a type method, not that it is an instance method, because instance method is the default. This is where Swift differs from Objective-C, in this and many other regards.

You don't have to write "-" in Swift, but you have to write "func". I wouldn't say either one is less or more ceremony, just a different ceremony.

And in Swift the return type is harder to see since it's at the end of the function signature (possibly also sandwiched with a where clause) whereas in Objective-C it comes first so it's easier to spot. Seeing that return type could be an important clue that we're returning something, but it's harder to spot in Swift than in Objective-C.

The comparison with Objective-C is interesting, but depending on what particularities you pick and how you interpret them the argument can swing both ways. That's how I see it.

13 Likes

Having read through many of the posts here, I'd say it's a clear -1 from me.

My full time job is working with Swift, and I have more than 25 years of experience as a software developer, and IMHO this change just introduces too many foot guns, as some people have so eloquently put it.

Defects that stem from the compiler not producing the code you think it does, often take lots of time to discover and to understand, and I think there is value in minimizing the risk of producing such code.

I also believe that it's more difficult to read and understand code than what it is to write and produce code, so I also find there is value in prioritizing code readability, and some might say that omitting returns are less verbose, thus more readable, and I can agree but only to a certain point.

I think it can improve readability of smaller functions that still require multiline paths, but I don't think it improves readability of larger functions while it also increases the mental load to get an understanding of the code itself.

I can appreciate the arguments about consistency, and I also like the single line functions where a return can be omitted, but only when I feel they read more easy than a function with explicit return statements.

For most of the examples in this thread, I don't think the functions without explicit returns are easier to read and understand than what their current counterpart would be.

To counter the argument about solving the case when big if- or switch-statements with implicit returns suddenly requires explicit returns, just because you want to add an extra log line somewhere, can already be circumvented by existing compiler functionality, like using an immediately executed closure or just creating a wrapper function that does the same thing...

@inlinable func wrap<T>(do block: () throws -> T) rethrows -> T {
    try block()
}

func SingleLineImplicitReturns() -> Int {
    switch Bool.random() {
    case true:
        1
    case false:
        // print("The random value was false - can't be added here") ❌
        2
    }
}

func MultiLineImplicitReturns() -> Int {
    switch Bool.random() {
    case true:
        1
    case false:
        wrap {
            print("The random value was false")
            return 2
        }
    }
}

Weighing the positives against the negatives with this proposal, I just don't think it's worth the risk of introducing more ways to write code that behaves in unintended ways, and also the extra mental load it takes to interpret functions that may or may not use implicit returns.

16 Likes

If it weren't for catches, I would actually like them about equally, and think they were probably good enough to abandon these pitches.

@inlinable public func `do`<T, Error>(
  _ body: () throws(Error) -> T
) throws(Error) -> T {
  try body()
}
switch Bool.random() {
case true: 1
case false: `do` {
  print("The random value was false")
  return 2
}
}
switch Bool.random() {
case true: 1
case false: {
  print("The random value was false")
  return 2
} ()
}

But there are catches. And as typed throws eventually become significantly less broken than they have been over the past year, people will want to use them more. I don't think last expression as return value is a great solution, but it is a solution.

Today's compiling code:

throws(Failure) currently being necessary in a closure is a result of the feature not getting enough dev time. Pretend it isn't there.

func f() throws(Failure) { }
enum Failure: Error { case a, b, c }
let doValue = `do` {
  do throws(Failure) {
    try f()
    return 1
  }
  catch .a { return 2 }
  catch { return 3 }
}
let valueParentheses = {
  do throws(Failure) {
    try f()
    return 1
  }
  catch .a { return 2 }
  catch { return 3 }
} ()

I think necessitating returns for those single lines is now inconsistent with how Swift has evolved. And because of that, something relevant to these pitches needs to be done. I prefer this, but, whatever gets the job done.

let value = do {
  try f()
  break 1
}
catch .a { 2 }
catch { 3 }
1 Like

An argument against the pitch was that beginners might have more effort to learn the last expression rule. But the constructs above invent closures where they should not be necessary. There are places where closures are a good fit, but here they certainly do no good at all to beginners, and it also invents much ceremony and has all the shortcomings of closures, e.g. you cannot return from the outer context. Whatever speaks against the pitch, this is not a good alternative and so I do not see it as a good argument against the pitch. (And I think all this has already been said somewhere above.)

4 Likes

This pitch doesn't offer a way to enable expressions to exit an outer context. (Good!) As I've gone over, what it represents is functionally a proper subset of closures. Not as useful as them, but maybe better-looking. It's all a wash until do-catch comes into play, which I think is really the main interesting part of the discussion. Figure that out and the rest will follow.

Currently my main concern with this pitch is the potential for source breaking[1] changes in a wide variety of areas, that at times can be subtle and easy to miss[2]. But FWIW, to me, these concerns are less concerning if this feature were added in a major Swift release rather than a point release (say Swift 7 for example). Users expect source breaking changes on major releases, and we can make migration tools and preview flags to ease the transition.


  1. To be clear, when I'm talking about "source-breaking changes" I'm not talking about the source-code for the Swift compiler/tools, but I mean the source-code written by users in Swift. ↩

  2. In particular functions with Void return values and @discardableResult. Haven't read the entire thread, so perhaps we've made progress on coming up with solutions to these problems. ↩