[Pitch] Last expression as return value

I don't think "in a line above" and "earlier" are quite synonymous. The two branches are at the same point, semantically.

In a sense I think of the else branch as the "early exit" in so much as you might rewrite it like this in your head (and it might be better written this way, too):

func hasSupplementaryViewAfter() -> Bool {
  guard let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell })
  else { return nil } // herein lies the debate about implicit return in guard else

  elements[cellIndex...].contains { $0.elementCategory == .supplementaryView 
}
4 Likes

Thanks for this thoughtful post, Ben. It does help contextualize this pitch quite a bit.

Something that feels worth highlighting, maybe relevant:

In the original code, only the second array search in each method explicitly mentions NSNotFound, but as Ben pointed out, both searches rely on it: cellIndex can be NSNotFound. The code implicitly relies on how NSNotFound happens to compare to actual array indices.

That implicit behavior raises a question: Did the author do this on purpose? Or is the behavior accidental? In the Objective-C version as written, it’s impossible to tell!

In both my very literal Swift rewrite and Ben’s more idiomatic (and, yes, clearly better) version, that question is not present. Optionals force the code’s author to make it clear that the behavior in the “no .cell element” case is a conscious choice.


As I wrote above (and many apologies for my very grouchy first draft, truly), I do think after the initial discomfort, developers would grow accustomed to reading Swift code as pitched. That’s been my experience in other languages. Once you start getting in the habit of paying attention to the last statement, it feels natural and clear — and then, as Ben wrote in the pitch, return can become a useful signal for non-linear code flow.

However, I do worry a bit about the “Did the author do this on purpose?!” problem. That’s the one thing about this proposal that makes me nervous. Per the first half of the post, I can imagine situations where it’s not clear that that a function’s author intended for a statement to be a return value.

I also expect that Swift’s type checking would make this a very rare problem in practice. I would be really, really interesting in some kind of systematic study of whether that’s true. I wonder: could we maybe take a toolchain for this pitch and then run an automated process on a few codebases to ask, say, “What percentage of return statements can be removed (not just the keyword, the whole statement!) and have the code still compile?” Something like that might provide a finger-in-the-wind metric for how common the “accidental return” problem might be in practice.

9 Likes

Thanks for this write up, @Ben_Cohen! It was very thoughtful and I can kind of see where you’re coming from as a code author. I’m a self taught programmer and Swift has been the language I’ve used the most, starting off with Swift 2. I found this set of code difficult to understand. I thought I could walk you through my thought process, as it’s probably different than the more experienced language experts here!

  1. I look at the func declaration. I see that there are no arguments but there is a Bool return type.
  2. I see the let assignment. (I probably would have combined it with the if/let below). Because I’ve seen this before I know there’s a silent where before the expression in the block.
  3. I see the if/else. In the first branch, I see you’re looking for whether the elements array contains something that matches some criteria. I’m unsure where the result of that is used.
  4. I then see the false in the second branch. I’m now tipped off that the if/else returns something! I go back to the first branch and see that we’re returning a bool based on what that contains returns.

I think otherwise, if the return statements had been there, my mental model of this function would have included “the returns are in the if/else” somewhere around step one when I was scanning.

If the issue I’m debugging is this function returning something unexpected, that extra time is kind of annoying. I’m not a language theory expert, so I’m unsure if this is the type of thing people just get used to over time. But as an app developer I would stumble over this.

19 Likes

I agree with you that that's not an early exit because it's two symmetric sides of the same initial condition, nested at the same level.

The rub that I potentially see is that it's a common style choice to eliminate elses when the true clause unconditionally returns, because the else is wholly unnecessary (unless symmetry itself is the desired goal).

Under such styles, adopting the proposed feature would create asymmetry, because it would require keeping the first return:

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

If one of the suggested reasons in favor of this proposal is that it makes early returns stand out, this style sort of does the opposite by privileging the true branch of the if when it otherwise shouldn't be.

Maybe the answer to this is simply "don't adopt the conflicting else-eliding style choice", but the fact that this proposed change doesn't slot neatly into some common styles does give me some pause. On the other hand, that style might be a result of the lack of last-expression implicit return, so that concern might not be terribly relevant.

4 Likes

I appreciate your post but it demonstrates once again the mistake of imagining the solution to a problem that does not exist within the context of EXTREMELY short examples. Which is understandable, because it is (1) difficult to imagine large things and (2) nobody has time to type out real world-examples.

That is true, but the opposition participants aren't confusing verbosity and clarity. We really do mean clarity here.

The following example is NOT clear when return is elided. It is difficult to see, because we're looking at an extremely short example, it is the equivalent of eliding await. Both really are just ceremony as you put it. (Eliding await would be less messy in terms of control flow you just lose your signal to the code reader nothing more)

To better exemplify the issue lets lengthen this a bit to something akin to real world.

//In another library (no idea if this is discardable or not)
@discardableResult someBool(flip: Bool) -> Bool {return !flip}

var doSomething: Bool = false
func hasSupplementaryViewAfter() -> Bool {
	if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
		if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
			elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
		} else if thisThat {
			someBool(flip: false)
		} else {
			if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
				elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
			} else {
				if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
					elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
				} else {
					//doSomething = someBool(flip: false)
					someBool(flip: true)
				}
			}
		}
	} else if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
		return someBool(flip: false)
	} else {
		if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
			elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
		} else {
			if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
				elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
			} else {
				if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
					elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
				} else {
          			//return here?  Okay. I will add return because this is the value, I think they want.
					return someBool(flip: true)
				}
			}
		}
	}
	
//	if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
//		elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
//	} else {
//		if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
//			elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
//		} else {
//			if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
//				elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
//			} else {
//				someBool(flip: true)
//			}
//		}
//	}
// return someBool(flip: false)
// FIX ME: we should possibly return true here.
	
}

This function needs to be fixed. There seem to be multiple ways that we can go about fixing it.

We have an if-statement that can now be thought of as an expression that is the last line of our function. Someone commented out my return line -- accidentally?--, it was just compiling but I added a 'return' statement. I might not ever notice it. The original author who wrote this did or did not intend for someBool function to be the return value always. I'm reading through it to discover intent, but because of where it is placed, and how it is written I don't know. Maybe I just remove return someBool(flip: false)

This is nothing compared to what we witness in a simple one line closure. It is so clear, obvious what will be returned in a one line expression. It is CLEAR in that context.

We require 'await' because it communicates something to the author and those reading their code. It signals how everything is getting executed. Requiring return (and other various keywords) that yield a value, helps us understand when things are intended to exit ESPECIALLY in large functions. This is not an issue that we'd ever have with single line closures/functions.

I am certain that someone could come up with a better example and debug cycle that better demonstrates the issue.

In one line functions, yes (not quite but we already have this as a feature so it matters not, but yes enough). The ending return in multi-line functions (that are not necessarily going to be 5 lines, more like 100) helps readers (not the compiler) immediately know intent. And when removed, the compiler can point you to the issue, so you can be sure that you are returning what you intend (safely with out any assumptions) and future readers of your code and those evolving your code can recognize the same and see clearly where you may be returning early. It is also common to re-arrange code. Accidentally moving what was otherwise an elided return towards the top would neither return nor be alerted to. It might not even be noticed if it was an @discardableResult. This would not happen in a single line function/closure as there is little to distract you. In your example, albeit short, I might not even notice it was returning it is so long.

This is what we mean by 'clarity' and 'safety'. We don't just mean "more or less verbose".

17 Likes

Thank you for the long post, Ben. I now understand better why I don't like this proposal. It is mostly because I read code in a different way.

To me an early exit is, more often than not, a triviality. Usually a range check which returns a trivial result. I don't want to focus there. If there was a button to hide such trivialities, I would use it quite often. I do want to focus on the usual case and the useful result. And I prefer to have a clear market to draw my attention there.

This example is almost unreadable for me:

The first branch looks very similar to a normal call to a Void function. I need to check that this function returns something, then I need to notice that the whole if statement is actually an expression, and it happens to be at the end. Now assume that I'm reading the code of others, so I'm not yet familiar with its logic, and add some more code to make it more realistic, and it becomes totally unreadable.

There are more problems. I cannot easily cut this if statement and paste it somewhere else. I cannot add more code at the end. If I want to return in one of the two branches and do some more processing for the other, I have to add back the missing return. Virtually anything I want to change in this code forces me to restore the missing returns.

24 Likes

This way is best. The trouble with this proposal, versus your old one, is that you can't scope it properly like you can with today's options.

  1. returns within non-expression do:
func hasSupplementaryViewAfter() -> Bool {
  // Code that belongs in the function
  // but does not deal with `cellIndex` or the return value.

  `return`: do {
    let cellIndex = elements.firstIndex { $0.elementCategory == .cell }
    guard let cellIndex else { return false }

    return elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
  }
}
  1. Replaceable with IICE:
return {
  let cellIndex = elements.firstIndex { $0.elementCategory == .cell }
  guard let cellIndex else { return false }

  return elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
} ()
  1. This would be great, but in order to use last expression as return value (if it doesn't come along with a scope-exiting keyword), you need to give up guard, and write it in one of those worse forms.
do {
  let cellIndex = elements.firstIndex { $0.elementCategory == .cell }
  guard let cellIndex else { break/continue/then/return/whatever false }

  elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
}
do {
  let cellIndex = elements.firstIndex { $0.elementCategory == .cell }
  if let cellIndex {
    elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
  } else {
    false
  }
}

Well, yes—if the argument is that "early" here isn't lexical but logical, and thus branches at the same point semantically aren't early-exiting, then by the same token the guard...else version has no early exit either.

Applying the principle you enunciate, then, given that we must exit the function from the else block, the return inside guard...else is strictly ceremony here by the same token.

6 Likes

Both the examples were "real world". One was real code posted from an app. The other was from some graphics code. This feels a bit "no true scotsman" to me.

Plenty of real world code is short, in fact one of the reasons the single-expression rule works adequately well as it does is because single-expression functions are common in Swift. Apps in particular frequently have to implement many simple functions to glue their code together. They include longer functions too for sure. It's a mix.

No matter how long, a function has only one end. That end is generally clear to the user through the closing brace and indentation. The return does not need to punctuate the end. If that ending punctuation was important, people would dislike Swift's existing rule that void-returning functions don't need a return.

The only way you obscure that single end is by building up an implausible tower from it. And the simple answer here is: don't do that. What you have contructed is some terrible code. It would be terrible with explicit or implicit returns. This proves little about this feature other than, like most language features, it is possible to embed them in bad code, and use them inappropriately. This was the point of my bringing up $0 in closures – an important feature that enhances clarity when used appropriately but can be abused.

edit: the code is also invalid under this proposal – it mixes explicit returns from an if with implicit return values in a way that would not be permitted.

13 Likes

Except this isn't what the code would look like.

The code, if written exactly as it would be under this proposal except with explicit returns would be this:

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

Which, under the implicit return of last expression rule would become:

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

In neither case is there more than one return.

1 Like

I think the end of a function is much clearer/easier to identify than the fact that a block inside a function is from the else of a guard statement. Highlighting the return within a function is part of this proposal and that is always the case for guard as you would not need to use one at the end of a function.

1 Like

Exactly—you've re-emphasized my point. This proposal privileges specific style choices and prevents other common ones.

And maybe that's fine, if that's the choice we want to come out of this—Swift is an opinionated language. I just brought it up as an example because I haven't seen that specific style point (omitting elses for unconditionally returning ifs) raised.

2 Likes

My point is that it already isn't what it would look like, because the natural version is already return if if that makes the style privileged, then it is already privileged.

That's only true if people choose to adopt if expressions in every single situation where they are able to, but I don't think we can or should assume that.

1 Like

I agree it privileges those "symmetric" cases where the else is explicitly included. But I think I'm missing how it prevent other cases. Or are you saying that while "no else" code that was valid before remains valid, and doesn't itself get any kind of sugar, and so is lessened?

Personally, I'd write the else form anyway. I'd also say it's fine keeping the return if you're really keen on the "no else" style and think the elided else with it looks a bit icky.

(I have a feeling LLVM developers in particular are keen on this idiom)

I do agree that "come on, is that return/semicolon/paren/longer name really a problem?" is not ever useful feedback. But I don't think that's what we have here. Most people in this thread agree that the problem described in the motivation section is a real problem worth tackling. I surely run into that limitation frequently and would love to see a way around it. But, at the same time, if the solution proposed has broad impact across the language, it's reasonable to question whether those impacts may become a bigger issue than the original problem being fixed.

Otherwise, the same argument can be reused to virtually every idea about cutting down ceremony. We could elide the else { return } clause in a guard part of a function returning Void. Skip the [weak self] in at the beginning of a closure if self is used as an optional inside. Allow implicit throws if the last expression has a type conforming to Error. Or any other idea where it's probably best not to cut down in the ceremony.

I'm not arguing that a solution should be flawless and with no downsides. That's rarely possible. But it should be possible to conclude that for a specific proposal the downsides are greater than the benefits, even if that proposal allows cutting down some ceremony (which granted, is a huge bonus because it composes into clearer code). In fact, there must be a line somewhere, because guard statements in particular have been carved out from this proposal and forbidden from using implicit returns!

I disagree with this statement. I absolutely read this version better than the one where bothreturns are elided. I certainly don't think they interfere with readability. This case is simple enough that one may call those returns redundant, but I don't see how they interfere with readability in the same way as your other example with $0 vs a named i. In that example, the problem with the i is that suddenly one needs to pause for a second and ask "Wait. What is i here?". There's no such problem with return. I don't need ot pause to think what return means. It may be slightly redundant in some cases, but hardly interfere with readability.

And, in the case where you haven't immediately internalized the function signature before reading a snippet (there's many reasons to start reading code in the middle of a function), the returns absolutely help readability.

Ah, but implicit returns at the end (as in last line) of a function are not as controversial. Not to me, at least. The main problem is that when combining implicit returns with large expressions (which can even be nested!), a given "end of a function" may be dozens of lines above the closing brace of the function. It may not be obvious at all that the function ends at that point without reading the entire control flow of the function.

One way to limit that is by mandating one-line expressions. Maybe there are other heuristics (disallow nesting implicit returns?). But I think some guardrail should be in place to avoid writing code where an implicit return happens several lines before the end of the function.

7 Likes

Exactly. Let's imagine that the user had this today, using the elided-else style:

func f() {
  if someConditionWhereEitherOutcomeIsLikely {
    let x = doSomething(a)
    return doSomethingElse(x)
  }
  let x = doSomething(b)
  return doSomeOtherThing(x)
}

That code is already a small bit asymmetric by virtue of eliding the else, but aside from that one concession, it's otherwise symmetric—each branch is a block of statements that ends with a returned value.

If that author wanted to adopt last-expression results—let's say for the sake of argument via a formatter that could automatically remove "unnecessary" returns—then such a rule would introduce more asymmetry into the code by removing the second return but not the first. If the user wanted to improve this (where "improve" means "put back more symmetry"), they would have to introduce an else to remove the other return. This isn't an easily automatable change. If the user wants to benefit from the proposed idea that it draws attention to early returns, then this feature requires more manual intervention to differentiate between what's a real early return and what's just the false branch of an otherwise innocuous function-final conditional, a distinction that doesn't matter/exist when return is required everywhere.

Of course, it's entirely possible that the two style choices—elided else and last-expression return—are fundamentally incompatible with each other and no code base should attempt to do both. If that's the conclusion we reach, it's fine! I'm not necessarily advocating for that style, but I've seen it in plenty of projects I work on day-to-day.

Since the topic of linters does keep coming up, since this proposal does touch on style as much as it does introducing new functionality, it's worth noting that a linter/formatter can diagnose and remove an unnecessary return in a world where this proposal is accepted and the user wanted to enforce a no-return rule, but it cannot insert a necessary return if the user wants to enforce a required-return rule. At least, not without semantic information (it needs to know whether expressions have type Void or not to avoid adding unnecessary returns of those), which is a much less-supported side of Swift's tooling story. (When single-expression implicit returns were added, swift-format was able to add a rule that diagnosed and removed unnecessary returns but it cannot do the opposite.)

So at least in terms of the tooling side of the equation, this would be another situation where a particular style is privileged by a language feature. Given that, I wonder if we're going to be heading in a direction where it makes sense to have the discussion about a more opinionated style and format being enforced by standard tooling, to avoid the "style" part of these discussions from being such a heavy factor. (My biases are certainly showing here.)

3 Likes

ceremony

I don't think that critics of this feature are arguing that less ceremony is necessarily bad. We love Swift's conciseness! We may even use this feature on some places.

However, I repeat an earlier comment that we are somewhat free to choose what reduction in ceremony we may enjoy:

But where will we draw the line for the minimum amount of ceremony necessary? The reduction of ceremony has come through fine iterations like chipping away at marble, removing ever-so-small parts to reveal a fuller form of the language. However, it only takes one bad chip to cause the largest cracks.

There are many other places throughout the language that we can apply this broad "ceremony reduction":

  • remove await
  • remove try if last in do block, or at all
  • remove () for no function arguments
  • remove trailing closure labels
  • remove func, you can tell it's a function from the name [-> type] {
  • remove catch from do/catch. If you have a do, you should know the next block is the catch
  • <insert your removal here that may not be difficult for the compiler to parse, but may be for people>

No, I don't feel like I'm being rhetorical or outlandish with these examples.

teaching beginners

For those unconvinced by the teaching aspect, Swift is marketed as an Accessible language. This is the first line in the description at developer.apple.com/swift:

Swift is a powerful and intuitive programming language for all Apple platforms.

For teaching absolute beginners to programming, absolutely nothing is intuitive. You have to explain what syntax is and why it is the way it is. You have to teach about types and why they matter ("Why can't we mix Int and Double? They're both numbers?"). You have to teach so much from absolutely nothing where absolutely nothing is "intuitive", because now they're undertaking a heavily technical learning path that they may have never done before.

Eliding ceremony is not the best for beginners. The only explanation I can really offer at that level for why it still works when ceremony has been reduced is: "the computer does it for you". You'd be surprised then that it makes them more upset and confused! They keep wondering "Why? Why? Why?". Many beginners don't know what a compiler "is" or is a master in linguistics. Of course we can just "write the ceremony" but these reductions are at the forefront of all syntax, they will run into them "in the real world". No, these are not just "taught once". Having to remember all the time what reductions are allowed is a constant familiarity process that beginners have to fight through.

It has been commented that maybe the frame of beginners isn't the most appropriate to assess this feature. However, I've only seen proponents comment that they enjoy this feature "from other languages".

For those that don't have the expertise of "other languages", the rest of the Swift world are the "beginners". When we borrow features, they should be able to be a natural fit, but we seem to be at an impasse on that front.

13 Likes

The example code I provided is a snapshot into the thought process of someone who is evolving code that either they wrote a long time ago OR is a code repository they are now in charge of evolving. It highlights how difficult it is to reason with expectations.

Which is why I said the following:

So for example when I wrote the comment (on that return in question) as follows:

//return here?  Okay. I will add return because this is the value, I think they want.

It is exemplifying how hard it can be to tell how I am supposed to yield the value. Now that I am revisiting it or taking it over.

That depends on what you mean by 'last line', an if-expression whence the return value is yielded can now be hundreds of lines long. It's the last 'logical' line, I suppose, but it definitely obscures where the return is happening all the way at the top.

This is precisely right, the critique here is not if we could theoretically have this feature work (it will work!) but it is how does it affect the code reader and the cases where a function can assume too much about what your intent is.

I have been programming for ~26 years now. I appreciate what you did here @shantini so much because what you point out is how something as simple as eliding return and having the compiler do more work for us promotes dangerous code style options that can confuse intent. I would have the same problem and have a similar thought process when trying to discover intent.

I think @xwu said it well:

I believe this is hardly a problem in single line closures and functions -- we can come up with examples similar to the ones provided where it obfuscates intent to the reader even in logically single line, but it is less likely. In multi-line functions, it feels more likely and should simply not be allowed.

Often it is okay to sacrifice 'consistency' for 'clarity'.

In theory, eliding return can seem clean, and easy to work with, but in practice it just is not. Eliding return hides what is happening in larger functions and that is feels very dangerous and should not be expanded into multi-lined bodies.

9 Likes

It is equally easy to identify the end of the else block as it is to identify the end of the enclosing function block, and in both cases you have to look at what precedes the paired opening brace to know that it’s a function or a guard.

4 Likes