[Pitch] Last expression as return value

Happy New Year, Swift Evolution!

This is a new pitch for a rule that would allow multi-statement if and switch expressions, as well as introduce do expressions. It does this via a new rule that the last expression in a function or branch is the return value, without a keyword (either return for functions/closures, or the previously pitched then for if or switch). It follows from feedback from the original pitch for a then keyword.

The PR can be found here. Please direct typos and other minor fixes there.

To add a personal note on the evolution of my thinking on this: I used to be pretty opposed to a "bare last expression is the return value" rule, but have come around to it over time. What pushed me over the edge was the observation (I wish I could remember from whom, as I am grateful to them for it) that when applied pervasively, this rule allows return to become "unusual control flow" that stands out more when seen mid-function, bringing similar benefits to that of guard.

Interested to hear what others thing.


Last-value rule for return values and expressions

  • Proposal: SE-NNNN
  • Authors: Ben Cohen, Hamish Knight
  • Review Manager: TBD
  • Status: Awaiting Review
  • Implementation: available on main via -enable-experimental-feature ImplicitLastExprResults

Introduction

This proposal introduces a last value rule, for the purpose of determining the return value of a function, and of the value of an if or switch expression that contains multiple statements in a single branch. It also introduces do expressions.

Motivation

SE-0380 introduced the ability to use if and switch statements as expressions. As that proposal lays out, this allows for much improved syntax for example when initializing variables:

let width = switch scalar.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 4
}

where otherwise techniques such as an immediately-executed closure, or explicitly-typed definitive initialization would be needed.

However, the proposal left as a future direction the ability to have a branch of the switch contain multiple statements:

let width = switch scalar.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 
      log("this is unexpected, investigate this")
      4  // error: Non-expression branch of 'switch' expression may only end with a 'throw'
}

When such branches are necessary, currently users must fall back to the old techniques.

This proposal introduces a new rule that the bare last value would be the value of a branch, which allows a switch to remain an expression:

let width = switch scalar.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 
      log("this is unexpected, investigate this")
      4
}

It can similarly be used to allow multi-statement branches in if expressions.

Swift has always had a shorthand for closures that allowed for the omission of the return keyword for single-expression bodies. SE-0255 extended this rule to all functions. However, when functions required multiple expressions, the return must be used.

This has some negative affects on how code is written. It becomes tempting to avoid breaking expressions up into sub-expressions and variables, because this will require the addition of a return keyword. The addition of a log line similarly introduces a need to insert an additional return. Whilst these shortcomings are not not as severe as the forced refactorings from having to add a multi-statement line to a branch of a switch, they are still an ergonomic papercut.

Introducing a last expression rule would also cut down on aftifacts such as the slightly awkward return if that must used if you want to make use of an if expression to return a value:

static func randomOnHemisphere(with normal: Vec3) -> Vec3 {
  let onUnitSphere: Vec3 = .randomUnitVector
  // how does one best indent this?
  return 
    if onUnitSphere • normal > 0 {
      onUnitSphere
    } else {
      -onUnitSphere
    }
}

Additionally, the introduction of a last value rule allows for a style where explicit an return in mid-function now stands out as unusual, drawing the eye in a way that it does not when it's fully expected for there to be at least one return in every function. For example, there are many examples in the Swift standard library that check for a fast path, return quickly, then follow "normal" flow:

func foreignHasNormalizationBoundary(
  before index: String.Index
) -> Bool {
  // early bail out
  if index == range.lowerBound || index == range.upperBound {
    return true
  }
  
  // "normal" path, no return
  _guts.foreignHasNormalizationBoundary(before: index)
}

The desire to use a keyword to draw attention to early exit is popular in the Swift community, as evidenced by enthusiasm for the guard keyword to check for these conditions. However, sometimes an if is more natural for the condition being checked than having to negate the condition. if plus explicit return, with no return needed for the "normal" path (which may be multiple statements) serves a similar function.

Finally, the introduction of this rule also makes stand-alone do expressions more viable. These have two use cases:

  1. To produce a value from both the success and failure paths of a do/catch block:

    let foo: String = do {
        try bar()
    } catch {
        "Error \(error)"
    }
    
  2. The ability to initialize a variable when this cannot easily be done with a single expression:

    let icon: IconImage = do {
        let image = NSImage(
                        systemSymbolName: "something", 
                        accessibilityDescription: nil)!
        let preferredColor = NSColor(named: "AccentColor")!
        
        IconImage(
                image, 
                isSymbol: true, 
                isBackgroundSupressed: true, 
                preferredColor: preferredColor.cgColor)
    }
    

While the above can be composed as a single expression, declaring separate variables and then using them is much clearer.

In other cases, this cannot be done because an API is structured to require you first create a value, then mutate part of it:

let motionManager: CMMotionManager = {
    let manager = CMMotionManager()
    manager.deviceMotionUpdateInterval = 0.05
    return manager
}()

This immediately-executed closure pattern is commonly seen in Swift code. So much so that in some cases, users assume that even single expressions must be surrounded in a closure. do expressions would provide a clearer idiom for grouping these.

Detailed Design

If a function returns a non-Void value, the last expression in the function will be used as an implied return value if it is of that type. For closures, the last expression will be used to infer the type of the outer closure.

if and switch expressions will no longer be limited to a single expression per branch. Instead, they can execute multiple statements, and then end with an expression, which becomes the value of that branch of the expression.

Additionally do statements will become expressions, with rules matching those of if and switch expressions from SE-0380:

  • They can be used to return vales from functions, to assign values to variables, and to declare variables.
  • They will not be usable more generally as sub-expressions, arguments to functions etc
  • Both the do branch, and each catch branch if present, must either be a single expression, or have a last expression, of the appropriate type.
  • Further if, switch, and do expressions may be nested inside the do or catch branches, and do expressions can be nested inside if and switch expressions.
  • The do and any catch branches must all produce the same type, when type checked independently (see SE-0380 for justification of this).
  • If a block either explicitly throws, or terminates the program (e.g. with fatalError), it does not need to produce a value and can have multiple statements before terminating.

Implicit returns inside guard statements are not proposed:

func f() -> Int {
  // error: 'guard' body must not fall through, consider using a 'return' or 'throw' to exit the scope
  guard .random() else { 0 }
  1
}

Guard statements can contain more than just returns – they can continue/break, abort, or throw, and so an explicit return is still required.

Impact on existing code

This change is largely source compatible. As with SE-0380 there are edge cases where closures can lead to a source break. Within the Swift source compatability suite, there is only one instance of this, which can be reduced as:

@discardableResult
func f() -> Int { 1 }

let outer = { () -> (() -> ()) in
    // return value of inner closure is left inferred
    let inner = { () in
      // statements that prevented this closure
      // from being single-expression, inferring ()->Int
      print("do something")
      // discardable result, so no warning when
      // this closure previously was inferred as ()->()
      f()
    }
    // With -enable-experimental-feature ImplicitLastExprResults this now fails with
    // Cannot convert value of type '() -> Int' to closure result type '() -> ()'
    return inner
}

In keeping with Swift's compatibility guarantee, this feature should be introduced under an upcoming feature flag. However, it may be worth considering enabling it by default if breaks such as these are deemed exceptionally rare (i.e. if this example from the compatability suite remains the only known instance).

Alternatives Considered

Many of the alternatives considered and future directions in SE-0380 remain applicable to this proposal.

The convention that the last expression in a block is the value of the outer expression, without any keyword, is well preccedented in multiple languages such as Ruby. In these communities, the rule is generally thought to be highly desirable.

Rust has a slight variant of this: a semicolon is required at the end of each line except for the line representing the expression of the outer expression. This option likely works better in Rust, where semicolons are otherwise required. In Swift, they are only optional for uses such as placing multiple statements on one line, making this solution less appealing.

A previous version of this proposal introduced a then keyword to return a value from if/switch/do expressions:

let width = switch scalar.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 
      log("this is unexpected, investigate this")
      then 4
}

This approach was taken by Java (with the keyword yield) when it introduced switch expressions.

This had the benefit of making the value of the overall expression more explicit, at the downside of a whole new contextual keyword. It also required a number of parsing rules to resolve ambiguities, and could lead to confusion when e.g. a newline separated then and a leading dot expression. It also required clarification that a then inside a nested if expression only applied to the value of the inner expression. A keywordless "last expression" rule has no possible ambiguity of interpretation.

It can be argued that the last expression without any indicator to mark the expression value explicitly in multi-statement expressions is subtle and can make code harder to read, as a user must examine branches closely to understand the exact location type of the expression value. On the other hand, this is lessened by the requirement that the if expression be used to either assign or return a value, and not found in arbitrary positions.

The introduction of a then keyword would set if expressions apart from functions. The presence of a then keyword in an if expression that was part of a single-expression function body would act like a return but not exactly, causing cognitive load on the part of the reader.

Overall, the composable consistency between if/switch/do expressions, and function bodies, along with the "return means exiting early" benefits, weighs in favor of a last expression rule and against a then (or similar) keyword.

Source compatibility

As discussed in detailed design, there are rare edge cases where this new rule may break source, but none have been found in the compatibility test suite. Where they do occur, backticks can be applied, and this fix will back deploy to earlier compiler versions.

Effect on ABI stability

This proposal has no impact on ABI stability.

62 Likes

Having used this in Rust and Ruby, I think… in a brand new language, I wouldn't necessarily be in favor of this. I think there are benefits to explicitly signaling what's being returned, and I find it's sometimes easy to forget that the last expression is contributing a return value, particularly when it has side effects as well.

That said, Swift already has many cases where the last expression is implicitly returned already, and I find the inconsistency difficult to deal with. I do now, frequently find myself surprised to have to add a return keyword.

So I'll count myself in favor — if not of the concept — then at least of making it consistent.

23 Likes

This is extremely exciting! This feature is something I deeply miss from other languages (especially with everything-is-an-expression rules), and when combined with a powerful type system to prevent silly mistakes, really makes a language feel like it's getting out of my way to let me express what I want.

The source compatibility break feels niche enough that, on the face of it at least, it shouldn't be much of a concern.

Big +1 from me.

17 Likes

strong -1.

This is one of the most confusing parts of reading Rust code, and the very narrow use cases it solves are not worth the pain applied elsewhere.

34 Likes

Can you share some examples that illustrate this in Rust code?

2 Likes

I’m mostly going to stay out of this one, but I’ll note that my reason for disliking this in Rust is that both { foo(); bar() } and { foo(); bar(); } are valid but mean different things. This still isn’t much of a problem in practice because the two forms have different result types (or they don’t and it doesn’t matter), and therefore they usually will result in a compiler error if you get it wrong. But it’s still annoying to me.

Swift won’t have that problem because the two forms will be equivalent, though I also expect linters to complain about a final semicolon if the value is used or returned. Swift also warns on unused values by default, which Rust does not, so if you meant to supply a result and somehow screw it up you’re more likely to be told.

34 Likes

In Rust, every single line of code ends with ;, when you do not use it, you are explicitly saying: "I will return this value".

i.e:

fn foo() -> String {
  let message = String::from("Hello, world!");
  let call_another_method_just_for_illustrate = bar();
  
  message // <- this is going to be returned for this method
}

5 Likes

Oh I have very, very mixed feelings about this. On the one hand it will allow us to get rid of some minor language annoyances. On the other hand... I'm a little scared of unforeseen consequences. I would prefer to have this somehow present as a "beta" in the language for, like, a year, and see if it ends up working out well enough (I know there's a lot of complications around that, though).

15 Likes

I really like that feature in Rust; however, I do know it is better understood because of the use of semicolons everywhere. In Swift, semicolons are optional, and I guess people will face problems until they realize the issue could be in their code.

For just a single line of code returning a value, it seems a little less complicated to understand while you are just reading it in a code review. Possibly, the implementation of it in the compiler could be problematic; however, I have no experience to tread on this hot ground.

Personally, I do think it would be amazing to have such a feature; however, I believe it needs to be configurable. Otherwise, I guess a hell could be started.

3 Likes

I've loved this feature since I first saw this in Ruby while playing around with RPG Maker ages & ages ago. I am 100% for this change for all of the reasons in the pitch.

2 Likes

I'm +1 on this too, mostly because I finally can add that let without needing an explicit return. Additionally there is now a nice symmetry with result builders. Since Swift already encourages a functional style and other languages like Rust and Ruby do this too, I believe it neatly generalizes the existing single-expression function syntax

9 Likes

I am -1 on this.

I have spent a great deal of time in very large Kotlin project this year and I find the lack of clarity of returns far more confusing than the frustration when I start to write a multi-line expression in Swift and wind up needing to immediately refactor it.

25 Likes

Yes I understand—my question to @8675309 (or others) is specifically about examples that demonstrate their point that it is confusing in Rust code.

4 Likes

I also work with Kotlin and fully agree with this. If you use IntelliJ or Android Studio, you can enable inlay hints to reduce the ambiguity, but those are not available elsewhere (like GitHub pull requests).

9 Likes

I'm a huge -1 on this. In my experience mentoring junior engineers, they're terribly confused by implicit returns of single-statement functions, and this would only make the problem massively worse.

This is a superficially nice feature for a lone developer working on a their own code, but it will cause nothing but headaches for teams trying to agree on a style for clarity, consistency, and easy of onboarding, and it will make the language even more confusing for new (and seasoned!) Swift developers.

The language is complex enough as-is, and we've got more complexity we're staring down the barrel of adding. Let's not make things needlessly complex by adding this.

42 Likes

+1. This unifies the language and improves at least two specific things: one is using an immediately-evaluated closure when needing to turn multiple statements into a logical expression, and the other is that when you use an implicit return in a single-line context adding literally any other code in it (even a print statement) breaks it and you have to stick a return on it just to make it compile. I suspect the source breakage is actually going to be larger than the suite foretells, but then again I have never really had a positive opinion of its ability to track these down. But even then I think we should do it, because the source changes are minimal and fairly rare.

10 Likes

I was pretty into this idea, until I saw this snippet:

let icon: IconImage = do {
    let image = NSImage(
                    systemSymbolName: "something", 
                    accessibilityDescription: nil)!
    let preferredColor = NSColor(named: "AccentColor")!
    
    IconImage(
            image, 
            isSymbol: true, 
            isBackgroundSupressed: true, 
            preferredColor: preferredColor.cgColor)
}

After seeing that, I am now really into this idea.

+1

18 Likes

How does this interact with result builders?

e.g. this code:

var body: some View {
    Text("Foo")
    Text("Bar")
}

is currently interpreted something like

var body: some View {
    Group {
        Text("Foo")
        Text("Bar")
    }
}

However, this is also valid:

var body: some View {
    Text("Foo")
    return Text("Bar")
}

The latter interpretation is obviously less common, but still out in the wild, and would still require an explicit final return.

9 Likes

Strong +1 having used this in multiple other languages. It’s always a bit of an eye-roll coming back to a language that requires explicit return. “Ok, you and I both know what I mean here but let me add your little keyword. Happy now?”

5 Likes

After seeing that, personally I only got confused because I had to cognitively, actively think "is this the last expression" just to get a return statement without the return.


-1

The omission of return will save negligent time but cause even more time and effort to read code regardless of the context (IDE, git/Github, plain text). Many, many times I look for the return token just to scope where the function body is because curly braces are physically smaller and exact indentation can sometimes be hard to grep. This will make reading code everywhere that uses this feature arguably worse. The usage of return is not an issue for normal functions with just 2 lines or a single, final expression. No, people cannot always infer the intent given syntax that omits additional clarity.

This pitch should not have multi-line expressions within it. While the original pitch thread was about then and this is just a somewhat minor change in the token, this pitch is different enough and has a larger impact within Swift that it should be separate. Much argument was also put towards the merit of multi-line expressions altogether. This should be removed and pitched separately.

19 Likes