[Pitch] Last expression as return value

let entity = switch validate(post: post) {
case .failure(let e):
  errors.append(e)
  return .failure(errors)
case .success(let x) where x.isEmpty:
  empty
case .success(let x):
  logger.info("ok \(x)")
  x
}

With this I'd give the pitch a +1.

Implicit returns in single statement functions/closures is still something I'm iffy about today. :sweat_smile: Because of code like this

let task = Task {
    await updateDatabase()
}

(An oversimplified example.)

Regardless whether updateDatabase() is marked with @discardableResult or not, the function's returned value is retained in the Task for as long as it lives. But was that the programmer's original intent? task's type is not explicit and the compiler does not prompt an error or even a warning for this code. It's a very, very easy (potential) mistake to overlook.

If we expand the Task with even more functions

let task = Task {
    await updateDatabase()
    await updateUser()
    await clearCache()
}

it becomes even more unclear whether the programmer's intent was to retain the last function's return value in the Task. Especially for other people working in the same project who didn't write the code.

And yes, I know this is all subjective :sweat_smile:

7 Likes

The idea of that rewrite is interesting.

However, what I want to emphasize is that, as a general principle, early exit has merit. Just because one specific piece of code I presented can be rewritten in a different way doesn’t mean the usefulness of early exits disappears.

The return statement is often used outside the end of a function. While it’s possible to structure the code so that return only appears at the end, such a style is unlikely to be mainstream.

In the same way that return allows for a useful early exit from a function, determining the outcome of an expression early using then should also be considered beneficial.

7 Likes

I have a question.
Is the leading dot syntax valid for multiple statements?
Also, does auto completion work?

let result: Result<Int, any Error> = if Bool.random() {
  .success(1)
} else {
  print("failed")
  .failure(SomeError())
}
8 Likes

And if we ever want to consider for or other loops for the same treatment, then even if we adopt a "last expression as return value" rule here, we'd need to invent a keyword for loops if we don't do so here. By contrast, something like then could be that keyword.

So I'm coming around to a yield-like keyword (maybe bind or assign—name can be bikeshedded later), with a subtly different alternative rule: We only support if and switch expressions to declare variables, assign values to already declared variables, and to implicitly or explicitly return values. In the last case, we already have explicit return. So I think we ought to consider a keyword that's only used for assigning to or declaring variables, not returning. Just as return exits the function, such a keyword could support early exit the most immediate containing assignment operation, and I think it's pretty readable:

let x = do {
  blah()
  blah()
  guard foo else { bind "Bah." }
  blah()
  blah()
  bind "Hello, world!"
}
8 Likes

Or we could also avoid the need for a "lesser return" keyword (then or bind) by introducing a modifier that would "catch" the returned value within. I'll call it eval:

let x = eval do {
  blah()
  guard foo else { return "Bah." }
  blah()
  return "Hello, world!" // returns value to the `eval` point
}

Here, the return sends the value to the enclosing eval context, assigning the value to x. This is pretty much the same thing as wrapping the do body in a closure that is evaluated immediately.

It also works with if:

let x = eval if Boo.random {
  blah()
  guard foo else { return "Bah." }
  blah()
  return "Hello, world!"
} else {
  "Bye!" // one statement, so implicit return on this branch
}

Here, the body of the if and else can each return a value to the enclosing eval context. Again, this is pretty much the same as wrapping the if body and else body each in their own closure.

Same for switch:

let x = eval switch Boo.random {
case true:
  blah()
  guard foo else { return "Bah." }
  blah()
  return "Hello, world!"
case false:
  "Bye!" // one statement, so implicit return on this branch
}

I guess this goes a bit against the current modifier-less syntax of existing if and switch expressions. Were they not already in the language, I'd suggest if and switch expressions be required to always be preceded with eval, allowing you to upgrade independently each branches to use control flow whenever needed. As it stands now, you'll need to add eval to your if and switch expression to be able to return within a branch.

2 Likes

I'm happy to see that the discussion seems to be refocusing on alternatives, because the more I think of this (last expression as "implicit" return value) the less I like it.

I strongly disagree with the "familiarity" talk. Swift syntax is designed to be clear, explicit and readable almost like english, and the return keyword at the end of a function has an important role, and conveys some important meaning: the fact that one could get "familiar" with not seeing that at the end of a function doesn't make it a good idea. An important piece of information is still going to be missing, and languages that omit it (without any other syntactic trick) are doing it wrong, and simply employing an inelegant solution to a real problem.

The proposal to omit return in single-line functions clearly and correctly stated that the main issue of using return for one-liners is that it's not needed to understand the code, because the function is so small that the meaning is going to be clear from the context, and that is still true, and it's probably still going to be true for short expressions that are only a few lines long (but it's impossible to arbitrarily decide how many lines is that). But in a longer expression, for example

let y = if x > 42 {
  "yello"
}  else {
  let a = doThis()
  doThat(with: a)
  let b = doThese()
  let c = doThose()
  doAlsoThisButInAFunctionWithAVeryLongName(with: b, and: c)
  "ok"
}

the code is just not going to be clear. Something is missing on the "ok" line to communicate that the value is going to be returned from the expression. One could eventually get "familiar" with this, but doesn't make it good. This is of course even worse in general functions: omitting the return at the end simply removes useful information and makes code less clear, there's no reason to do it per se.

The return keyword has a very specific function and conveys a very specific meaning: it's used to return from the enclosing function, not the enclosing "scope", in fact returning in a for is an often used pattern to design certain algorithms.

We need then something new to communicate the "return" from a scope determined by an if-switch-do expression, and since it's a new requirement with new semantics, I 100% support a new keyword for it (bind seems lovely). Also, I don't see any problem in introducing a new keyword: even in spoken languages we often have neologisms when a new concepts arises that simply cannot be expressed by the existing words. Reusing return just for the sake of not introducing a new keyword would be, to me, a mistake, because as I mentioned, it has a different meaning.

So, overall, I'm -1, and I think I understood why: the proposal solves the issue of multiline expressions with an inelegant solution, that is, just asking devs to remember that the last line is going to be returned from a scope, even if it's not spelled out by the code in any way, thus undermining the relationship between the Swift syntactic choices, that have always been aligned with the idea of an english-like readability, and code clarity. This kind on inelegant solution is also employed in other languages, that to me are worse for it, so I don't see it as a good argument in favor of it.

22 Likes

I'm going to twist this into an argument for the proposal (sorry). The intent of try and await is to help the reader recognize the potential for abnormal control flow at a call site that might throw or suspend. In other languages where implicit return of the last expression is normal, some coding styles encourage the use of explicit return only in cases where the return is an early exit from the function; in particular, Rust encourages this by warning when explicit return is used unnecessarily. A style rule like that gives the return keyword a role that's arguably similar to try and await, alerting the reader that an unusual early exit is occurring rather than normal execution to the end of the function. To me, that philosophy also argues somewhat against using any other keyword to mark an implicit return, under the idea that running to the end of the function is "normal" and shouldn't need marking.

15 Likes

Or instead of bikeshedding a new keyword, you can use an existing keyword that is already used for early exit from blocks like break

2 Likes

I don't really agree with the idea that an early return from a function is any less normal than an ending return. For much of my code, an ending return is the least normal, as it's what happens when all other 'normal' options have been exhausted, so it should be more obvious if anything.

1 Like

Very much this. Common example:

func first<T, S: Sequence>(inSequence: S, thatIs: (T) -> Bool) -> T? where S.Element==T {
  for element in inSequence {
    if thatIs(element) {
      return element
    }
  }
  nil // The "normal" return
}

In no way does this read as more clear than return nil.

10 Likes

To me, this would only work in a language where all functions are single expressions, and subexpressions are not declared in the normal function body, but in a where block, for example:

func frobulate() -> Int throws {
  doThisAndThat(with: a, and: b) where {
    let a = foo()
    if a < 0 { return 0 } // <--- I don't expect to see a `return` here! it stands out
    let b = try bar()
  }
}

If everything is instead in the regular function body, I would expect to see the return keyword in all places that describe what the function returns.

3 Likes

This seems to be very subjective. IMO this reads significantly more clear than return nil, since it doesn't have unnecessary clutter introduced by a redundant keyword. The closing brace after nil makes it perfectly obvious that nil is the return value. There is no ambiguity about it, especially with the fact that Swift requires return types specified explicitly in functions.

Thus, with syntax that is subjective (see implicit self), it seems natural to allow both forms for a developer not to be restricted and to choose their subjective preference as they see more fitting for their project.

(Disclosure: I personally prefer explicit self in my code, but I don't find it necessary to impose this preference on code not written by me, unless a project specifies either preference in its formatter rules for consistency).

6 Likes

Apologies if this has already been asked, I wasn't sure what to search for and the thread is getting too long to skim...

What is the synxtax for guard clauses within an if/else expression, or are we disallowing them? We can't use return, because it would be ambiguous and/or inconsistent, so surely we must allow guard to support implicit return values?

let good = if badness < 1000 {
    true
} else {
    guard badness > 2000 else { false } // what is the syntax to propagate 'false'?    
    isItHonestlyThatBad()
}
2 Likes

I agree with this, but otoh, developers often tend towards the lazy/easy option, so we might end up with a proliferation of returns that are implicit more because of laziness then because of clarity.

2 Likes

There is nothing abnormal about an await. await demarcates points of concurrency, but concurrency is not abnormal. Control flow on the task continues linearly through the function—except in the case of task cancellation, which is abnormal.

There are millions of existing lines of Swift which all use explicit return. Clearly such a warning would not be desirable to retrofit into Swift, so the benefits this warning has brought to the Rust ecosystem do not apply.

Again, there is nothing inherently “unusual” about an early exit. This is the “cyclomatic complexity” idea which I thought had been thoroughly discredited by now.

4 Likes

I don't feel too strongly either way, but I would lean against this pitch. I worry it makes the code in question too magical/spooky. It may make it harder for Swift novices to reason about their code and understand the side effects of particular constructs.

But again, I don't feel too strongly either way, so it's a soft -1.

4 Likes

But then if you change “for” above to “foreach”, or “map”, etc - the change is so subtle, yet the difference is so huge… (although in fairness this is due to Swift using the same brackets for two very different things).

That's why I've mentioned formatters/linters multiple times here.

People preferring to enforce their subjective definition of clarity project-wide can resolve this with automation. Regardless of someone's motivation (laziness or subjective clarity), code becomes consistent with a pre-commit hook that invokes a formatter with preferred rules.

1 Like

This is probably a silly idea but I thought I'd mention it, if only to perhaps spark others.

I'm slightly concerned by the implicitness of omitting the return in if/switch/do expressions but I also find the proposed then and variants a bit clunky.

What if in cases where if/switch/do is used in an assigment it came with a $0 parameter as a standin for the variable being assigned to which you'd use as follows:

let foo = switch bar {
	case a:
		$0 = 1
	case b:
		$0 = 2
}

I'm not sure if this would work or cover all the scenarios but I think I like it because it only uses things we're already familiar with and also mimics the withXXX RAII pattern which is typically used with a $0 as well (i.e. withLock { $0... }).

Not sure if it'd be a stretch but it could perhaps even support a named parameter, either by defaulting to the outer name, i.e.

let foo = switch bar {
	case a:
		foo = 1
	case b:
		foo = 2
}

or as usual by providing it, like in a closure:

let reallyLongButBeautifyllyExpressiveName = switch bar { x in
	case a:
		x = 1
	case b:
		x = 2
}

Does that make sense? Is it nonsense? Has this perhaps been suggested/dismissed already?

8 Likes