[Pitch] Last expression as return value

Do you think they are both equally important?

It might be useful to consider these options as existing along a risk continuum as well. How likely is each of these alternatives to lead to a bug that would have been caught or not written otherwise? I contend that the last-expression alternatives are the riskiest.

I don’t find this line of reasoning fully convincing. Swift’s do already has a dedicated error-handling purpose. Unlike most other C-derived languages, Swift has a dedicated repeat keyword for loops. It seems strange to give do an entirely new purpose (turning statement blocks into expressions) when the language has gone out of its way split off a different, long-established meaning of the same keyword. Especially when the new meaning cannot be taught by way of analogy, since Swift doesn’t have or need a concept of a “perform monadic stuff” keyword.

I think a new keyword is warranted here.

2 Likes

I'm seeing in some comments bits of the real underlying problem, that the pitch doesn't directly address: if, else, switch, do, and catch are just missing the capability that closures have, via return. That has always been true, but the problem became more noticeable with if and switch expressions.

We don't need a new keyword. break and continue are both available. I prefer the former but don't care much either way.

let width = switch scalar.value {
default:
  log("this is unexpected, investigate this")
  break 4
}
let foo: String = do {
  try bar()
} catch {
  print(error)
  break "Error \(error)"
}
let foo = if x.isOdd {
  print("x was odd")
  break x + 1
} else {
  x
}

For reference: This compiles.

var void = {
  switch 1 {
  default: return
  }
} ()

And this compiles:

void = switch 1 {
default: ()
}

But this doesn't.

void = switch 1 {
default: break
}

I think it should. These are the same:

return 
return ()

So these should be the same.

break 
break ()

And all other values but () should follow.

3 Likes

I feel like this has been mentioned before. These keywords already have meanings, and this would introduce an ambiguity. If not in the language, at least for the reader:

while true {
  var x = if cond {
    // Does this assign () to x,
    // or does it break from the while loop?
    break
  }
}
7 Likes

Maybe not "break" specifically, but "break(value)" or a different keyword altogether. This pitch is about if/switch/do but what happens later if we indeed consider loops? Could this provide a value of my choice?

let x = while true {
    ... whatever syntax here to break the loop with a given value
}

if we ever do this how that will reconcile with the approach we chose for if/switch/do expressions?

3 Likes

Made the same suggestion earlier in this thread, so +1 to this but with a reminder that I'm still against the overall proposal of an optional return. This is just an attempt of a compromise that will, let's say, soften the edges for those of us who don't like it.

2 Likes

Wanted to add my +1 to this pitch, as many already said—this will bring consistency to the language, as we already have it in some sort. And after experiencing languages that have this feature—you're quickly used to.

Even though I don't want to bring functional vs. imperative language debate, I've wanted to point out that most of those languages that I'm thinking of that have this feature are functional—and it's natural to have it there. Also, @Max_Desiatov already wrote about small functions, but composable nature of functional languages just forces you to write that way, and think that's where people are raising their voices—if you even consider Swift functional it's still have imperative shell and lot's of code written have big flatten functions. Guess it's more readable to have return keyword for bigger functions. Still think it's quite easy to find an end of it without and we can define different warnings (or already have) and cases for this feature to work better for beginners.

Compile time is the only thing I'm concerned about in this pitch. It's getting somewhat unpleasant to work with big Swift codebase lately, hope team aware of this and addresses, but so far adding new features that increases compile time will just increase this pain. :melting_face:

If pitch won’t pass, I wonder why everyone wants to fix a problem by adding new keyword, but not consider this option. As stated in original proposal you can do:

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"); // load-bearing semicolon
      4  // allowed as the preceding statement ends with a semicolon
}

where ; will define end of an expression. Swift already have it in a way for one-liners if something { do(); another() }. Also AFAIK OCaml is working that way and looks ok.

Also, to bring more inconsistency—what people are thinking about brining last expression as a return value to closures and multi-statements, but require return for functions?


Same applies when you add function in the middle, I'm not sure it's a relevant case tbh.

return as last expression or last statement return is a quite common functionality in industry.

3 Likes

I love the idea of using = as the explicit indicator of an expression returning a value. It is short enough to satisfy those unwilling to type out return and obvious enough to a reader that the value/expression to the right of it is the result of the expression. The compiler should make it clear that any lines in the scope following a line beginning with = would not be executed.

1 Like

I was having a similar out of the box thought that perhaps instead of putting a keyword on the block or on the yielding line you might put it on the non-yielding/returning code like this:

let foo = if x.isOdd {  
  do{print("x was odd")}
  x + 1
} else {
  x
}

let sortedNums = nums.sorted { a, b in
  do{ print("comparing \(a) and \(b)")}
  abs(a) < abs(b)
}

So the rule is single line return but do blocks don’t count. Stuff all your sideeffectful, logging or intermediate code in the do block and then you have a single line outside of the do block that returns.

4 Likes

This is immediately what I thought when I saw the Clojure example above as well. (In fact, isn’t this more akin to what that code is actually showing? Not a Clojure expert here.)

To me this is a much more appealing escape hatch, if it’s possible to implement without breaking other stuff:

  1. Add do expressions (thus solving the immediately-invoked closure pattern / problem)
  2. Add small escape hatch to the existing implicit return rule where multiple lines are permitted only if the non-returning line is a do expression that returns void (thus solving the “I want to log from my if expression” problem)

You then cannot use variables invented before your return statement and now inside the block, which is a common use case.

2 Likes

This idea really gets to the heart of the matter: all of this discussion is about turning blocks into expressions. GCC took this approach with the “statement expressions” language extension: int fortyTwo = {( printf("foo\n"); 42; })

Unfortunately, this doesn’t harmonize with the version that was already adopted for single-line method bodies.

This proposal is dangerous and introduces more potential programmer error and debug hell, makes code harder for humans to reason about (especially if you are unfamiliar with the code), and adds little value to the language.

More Inconsistency
Introducing a 'last expression as return value' is actually creating inconsistency. It is creating a special case for multi-line closures and functions where 'return' is required in all other instances to exit the function except in this special case. It is consistent to say "if it is single-line, you can elide it, if it is not you cannot".

Debug, Code-Evolution Hell*

Readability is not the necessary concern -- using the word 'readability' makes it sounds like a 'this is just the way I like it' argument. It's actually safety, correctness, explicitness, and prevention of unexpected behavior concern.

The reason that multi-line expressions need an explicit return is due to debugging and code safety. When we have these discussions, we give these extremely trivial examples that are less than 10 lines each where the security issue isn't apparent. These examples are super easy to reason about so it gives the appearance that a 'readability' concern is just a lack of intelligence on the part of the say-sayers. However, code does not typically look like this. We are often writing functions that are hundreds of lines long, with commented code sprinkled about. Something that is rarely possible in single line functions/closures where the current exception exists and where it should stay.

A library of String processors is often going to have loads of @discardableResult functions which if placed at the end of a function that also returns the same type will be a valid return type but not necessarily the intended return value.

Imagine writing a large function that returns a particular value and you have elided return. Imagine writing additional code to that function, which later returns the a different value. Going back to your function you might not even notice or remember that a particular line of code was the official return value even if it was the last line and you were adding more to it. The signal that the function even returned is now gone.

Now imagine writing a function that previously did NOT return a value, and you now give it a return type. The code could easily compile due to the last line being a valid return type -- again not the intended value.

Even worse, now the proposal suggest that eliding 'return' to make the following example more fun to write, but actually this is also debug hell because now an if statement that is your last line of code could be providing your return value neither of which TRULY come from the last line. Without knowing it, I could be getting a return value from DEEP inside of a if statement. The example below, btw is not typical of a function, a function is often VERY large, so spare the 'oh well I can clearly see where the return is happening.'

static func randomOnHemisphere(with normal: Vec3) -> Vec3 {
  let onUnitSphere: Vec3 = .randomUnitVector
    if onUnitSphere • normal > 0 {
      onUnitSphere <---- this could be the value returned
    } else {
      -onUnitSphere <----- this could be the value returned!
    }
}

Removing explicitness is not just a 'reading' issue, it is a very real security concern. It allows the compiler to do guess your intentions and attempt to disambiguates situations that should otherwise be explicit -- except we added an exception that if it is on the last line then we can just return that.

A lot of time, we ignore these concerns with 'well the IDE could help us here… it will tell us what is happening'… Some of my projects compile exclusively in the terminal, there is no IDE. I use a text editor often to debug in certain situations.

No Real Value
We are not adding usability value to Swift by eliding 'return' on the last line of a function/closure. It is at most saving 7 characters that brings you explicitness and does not introduce unexpected behavior in the process of actually editing and evolving your code.

Furthermore within if expressions, we should introduce a keyword for yielding/returning the value when there are multi lines (I actually wish this was required for single line too)

24 Likes

return means both "return to what called this" and "return this value". The full form is "return to the caller with this value, which may be ()", but nobody speaks of it that way.

People definitely have a problem understanding this—on day one of programming. It's a conflation, but we all get over it.

break only has the incomplete half-meaning of return, and that's inconsistent.


Expressions are already the same as closures. They can't alter external scope flow.

func f() {
  // Cannot 'return' in 'if' when used as expression
  var x = if cond { return } else { () }

  while true {
    // Cannot 'break' in 'if' when used as expression
    x = if cond { break } else { () }
  }
}

So, it wouldn't do either, because there's no else clause, but if there were, it would provide a value of () to x. If we want to enforce explicit break (), to compile, or at least avoid a warning, I'm okay with that, even though it's inconsistent with how return works.

(And while it needs to be handled, I don't think the cases where Void is assigned to a variable is a real use case.)

2 Likes

Great comment @austintatious, fully agreed.

Besides, if we go a bit higher-level, I think we should acknowledge that keywords not only bear some function in the language, they also serve as visual markers for scanning and understanding the code faster, especially in IDE's, but not only.

There are probably a few other places where Swift's syntax could be simplified by removing a keyword but the price of reducing discoverability by removing an important marker may outweigh the benefits.

Being a staunch minimalist myself as an engineer, I cheered the removal of return in single-line function bodies. I cheered the shorter if let construct (who didn't?) and many other improvements. But this one crosses a certain line that I can't agree to.

17 Likes

I've gone through my current hobby project - a SpriteKit game with ~32kloc - and experimented with how this feature would affect my code. FWIW, going into this I was on implicit returns in if/switch/do, but strongly -1 on implicit returns in functions as a whole, based on many years witnessing their use in other languages.

For better or worse, my code is full of @discardableResult, mainly to add 'fluent' extensions to SKNode subclasses. If I do a refactoring to e.g make a protocol method return a SKNode, implementations that end with a fluent helper won't get flagged as needing to be updated, which might cause bugs. I use a lot of protocols and refactor them often, so this could be fairly horrible. Does this also mean implicit returns could confuse automated refactoring tools?

Many of my protocol methods specify a return type, so that it is impossible for an implementation to end without having made a decision. Implicit returns obscure this intent, particularly on very long methods, and for some reason they seem to read especially badly when the last statement is a constructor, which is very common. Lastly, this code is often full of early returns that are not in any way special, so the last statement is both inconsistent and has its full meaning 'disappear', despite its actual significance.

All in call, I've come across many places where implicit returns made my code less clear, and at best, a few where it didn't matter too much. With time and experience, that would probably improve. But I can only see them as something you 'get used to', and not something that makes things better.

11 Likes

I realized I was unclear: "Last expression as return value" doesn't solve this problem, like a keyword does. I read through the original pitch, today. It's missing a do/catch example where multiple scope exits are necessary. I think it would have been more popular with a good one included.

The following is not a good one, but has the form of what needs to be addressed.

Current compiling Swift:

let foo = try {
  do {
    return try bar()
  } catch {
    guard fallbackCondition else  {
      print("this is unexpected, investigate this")
      return "Error \(error)"
    }

    return switch value {
    case 0x80..<0x0800: "No a problem"
    default: try {
      print("Ultra broken! \(error)")
      throw error
    } ()
    }
  }
} ()

Better, even if guard doesn't work like it should :

let foo = try do {
  try bar()
} catch {
  if !fallbackCondition {
    print("this is unexpected, investigate this")
    break "Error \(error)"
  }

  break switch value {
  case 0x80..<0x0800: "Not a problem"
  default: 
    print("Ultra broken! \(error)")
    throw error
  }
}

I appreciate your listing of ideas so far.

Overall, I'd say I'm +1 on the capability pitched, and -0.5 on using the last expression rule. I also soured on the last expression rule but only applied to do expressions, for requiring another level of indentation, as other folks have already mentioned.

Thinking about only these options made me like the idea of a new keyword in place of return/yield. I think use fits this use case, being a verb like return and yield, and reads fine in the body of a switch or if statement:

let foo = if x.isOdd {
  print("x was odd")
  use x + 1
} else if x.isPrime {
  use x * x
} else {
  x
}

It is inoffensive enough to be ok for single-expression branches too, if people prefer a style where adding more lines to a branch doesn't require changing the value used for that branch.

use hypothetically ending up functioning as return feels goofy, but not outrageous, too (though maybe another keyword that can work as return is more confusing than helpful):

func transmogrify(_ x: Int) -> Int {
//  return if x > 1 {
    use x / 2
//  } else {
//    x
//  }
}
1 Like

The try keyword is unnecessary. The compiler can easily figure out whether a function could throw an error from its signature. It's really hard to type try, almost requiring PhD level knowledge to pull off. Let's get rid of it!

The await keyword is unnecessary. The compiler can easily figure out whether a function is async from its signature, so there's no need to type it. And typing those five characters is so difficult, that I'm writing this from the hospital after breaking my arm from the sheer labor of typing it earlier in this paragraph.

We don't really need the func keyword. The presence of the parens after the function name and a curly brace is enough to infer that this declaration is a function. Get rid of it! Oh, hey, speaking of those curly braces......

When does it all end? In case the sarcasm is not obvious, extremely strong -1 from me. I use Ruby at work, and it's already confusing enough there to review a PR and wonder, did this person intend to return a value here, or did they just happen to do it because this method they called for the side effects happened to return something. Did that method mean to return something? Without reaching out the person who wrote it, who knows!

Please don't do this to Swift.

11 Likes

The reason for rejecting the then statement is mentioned at the beginning of the proposal. However, one advantage of the then statement that wasn’t discussed is its ability to allow early exits. As shown below, early exits can be implemented conveniently, which is why I prefer the then statement.

func handle(post: Post) -> Result<Entity, Err> {
    var errors = Err()

    let empty = Entity.empty

    let entity = switch validate(post: post) {
    case .failure(let e):
        errors.append(e)
        return .failure(errors)
    case .success(let x):
        if x.isEmpty {
            // We can make an early exit like this when using `then`
            then empty
        }

        logger.info("ok \(x)")
        then x
    }

    entity.store()

    return .success(entity)
}
4 Likes

Even in the proposal that used the then keyword, I don't think it was allowed have a return statement inside of a switch expression.

Regardless, the success case can be still be written using an if expression even with the last expression result version:

case .success(let x):
    if x.isEmpty {
        empty
    } else {
        logger.info("ok \(x)")
        x
    }
}