[Pitch] Last expression as return value

I think anyone here respects the others‘ opinions, sometimes the formulations should be more diplomatic so to speak. (Some seemed to be offended by my own last comment above, I should have expressed it differently, sorry.)

A hopefully more “diplomatic” formulation might be that the author of this comment thinks that one might to easily object a new idea on first impression and that the author rates the “getting used to” higher than other people might do. I think this is a valid opinion that does not invalidate the objections formulated by others.

Indeed. "I reject your biases in favor of my own because mine are clearly logically superior because they are mine" is equally "uncompelling". Dismissive rhetoric generally is about squishing the other people into submission to make them go away and "win" that way. (note Ben Cohen has NOT done any of that. He's doing really well. Additionally people who just say it's what they want with enthusiasm, also great! Love the joy.)

Out of curiosity, does Swift/Apple even have a budget line item for Human Factors testing of proposed changes? (I mean real broad recruitment PhD designed Human Factors testing.) I think I brought this up before.

Personal opinions about what will or will not confuse certain groups of users are just opinions. Until they are tested.

I have objections beyond readability but I think a lot of discussions on Swift proposals come down to a bunch of developers having strong opinions about what they THINK will or will not matter to different developer groups. Great. That is useful and not to be dismissed anecdotal data. Thank you for bothering to engage with the community.

I do think it would be nice if the LSG got additional input, actually collected data. Give developers of varying levels of experience actual tasks to complete with different features enabled. Record how much time it takes / what their confidence rating is / etc. Consider it an option 4 to add to the arsenal.

Human factors testing is not the be all end all. It's not always great at creating a new feature. But it can cut through feelings about what will or will not happen when certain significant changes are made. Frequently the results are surprising to all parties.

Recruitment is incredibly expensive, so batch testing proposals would likely have to be done.

I don't mind reading about other people's feeling on a topic. It's informative. But then people... go off the rails. It'd be nice to have "Thanks for your input. We'll for test that" to keep things on the road.

3 Likes

Yes, you’re right, the post came across harshly — which obscured its point. I’ve updated it to hopefully take the edge off, and to clarify its main point: many replies here (pro as well as con) are appeals to gut reaction, and wrongly assume that others necessarily share the same reaction.

This, if possible, is even more insulting.

1 Like

Paul, thank you for editing your post; that was indeed pretty dismissive.

Hovik, this is definitely crossing a line and should also be edited.

5 Likes

Proposals like this one, which tackle core syntactic questions that could change how a lot of people write a lot of code, are inevitably going to be controversial. As a moderator, I think most of the discussion here so far has been good and productive. People have been making a reasonable effort to engage with each other and advance our understanding as a community of why some people want this and why other people are worried about it. But when we feel strongly about things, and we see something that we disagree with, it's sadly easy to have a harsh reaction, and so often the conversation will quickly unravel from there.

I had to close the thread temporarily to give myself an opportunity to catch up, decide what to do, and write out this post. I hope that's also given folks a chance to cool down a bit. Let's try to get back to that spirit of open exploration and collegial discussion.

35 Likes

So far my overall takeaway is that the strongly mixed reactions in this thread suggest that the proposal addresses an important pain point, but perhaps more alternatives need to be explored.

Personally I'm opposed because I don't like how it blurs a line that is currently very clear between a "trivial" body and a more complex one. In my head, it's strongly analogous to how other languages let you omit braces for one-line if bodies. I'm not trying to find ways to slip more stuff in there without having to have the braces. I'll either make a wrapper function, or go ahead and have the braces.

Admittedly the analogy breaks down a bit with switch statements where some case bodies meet the trivial threshold and others don't, so the result is a bit inconsistent, but I'm okay with it.

12 Likes

Coming from Clojure, having a do block that represents "side effects" in an expression context feels very familiar. In fact, Clojure's do form is precisely used to add "side effecty" statements where they normally wouldn't work:

(if (odd? x)
 (do
   (println "x was odd")
   (inc x))
 x)

Which, it seems, is at the heart of this proposal. It nicely disambiguates forms that are incidental to the result of the expression.

In swift, perhaps this might look like:

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

or maybe:

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

While some forms in Clojure have an implicit do (i.e. let), for Swift I think it makes sense to have the explicit do where you want a side effect in an expression context to avoid ambiguity with statement contexts.

So maybe the switch example might be:

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

or

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

FWIW, if there was a proposal that let something like the above be valid (slight edit to add the =), I'd have objections, but I'd probably keep them to myself. I'd probably keep them to myself if the below was valid, too, but it would hurt more (because the do is keeping it "one line" in a way the below is not).

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

One aspect I don't like about this is how a function could return both explicitly and implicitly depending on the code path.

I feel this proposal would be more acceptable if any use of return in the function body would disqualify an implicit return at the end. In other words, an implicit return should be for when it is guarantied to be the sole (non-throwing) exit point of a function:

// must be explicit because of the early return
func test() -> Int {
   if Bool.random {
      return 0
   }
   /* lines of other stuff */
   return 1
}
// can be implicit in the absence of an early return
func test() -> Int {
   if Bool.random {
      print("random true")
   }
   /* lines of other stuff */
   1 
}

This mirrors what is acceptable in if and switch expressions. You can't break or return from within those, or they stop being an expression.


All this said, I don't feel like this proposal is solving a problem I have. I don't mind writing a return at the end of a function when the function returns something. I suppose this is all down to coding style.

I fear a bit the impact on closures where the return type is deduced. I've seen my share of @discardableResult needing to be explicitly ignored, and I guess this will increase the need for those workarounds.

10 Likes

My understanding is that nothing this proposal puts forward changes anything about early exiting, it’s all about the ‘good path’ so the speak (code that gets through to the last expression/statement of the function). A guard in a non-void function is never going to be the last expression, by definition. And if you used an if instead of a guard you’d still need to include an explicit return.

I can see that since guards must exit there’s an argument to be made that the last statement could implicitly be interpreted as a return, however in my mental model guards are distinctly statements and so it makes sense that they aren’t getting the same ‘expression’ treatment that if/switch/do are currently getting.

To me that decision seems consistent with the rest of the proposal.

I agree that the function/closure part isn’t really solving a problem. To me the value is in the if/switch part of the proposal since the lack of multi statement support in if/switch expressions is currently quite a pitfall. The commonly quoted logging in a switch statement example has happened to me enough to get quite annoying. And the previous way of forward declaring the variable (and type) and then assigning in every branch of a switch statement can be very tedious and annoying to maintain when converting larger enums.

I wouldn’t be completely against this either. I can see the logic in it and there are definitely a few somewhat compelling examples floating around.

1 Like

I think this is imperative for security reasons. I am reminded of the attempt to backdoor the Linux kernel by relying on the subtle difference between = and == in C:

// This `if` expression unconditionally assigns 0 (root) to the process’s uid
// which evaluates to `false` and therefore skips the `return`
if ((options == (__WCLONE|__WALL)) && (current->uid = 0))
    retval = -EINVAL;

I am also reminded of goto fail, which was caused by accidentally inserting unconditional control flow during a merge:

SSLVerifySignedServerKeyExchange(SSLContext *ctx, bool isRsa, SSLBuffer signedParams,
                                 uint8_t *signature, UInt16 signatureLen)
{
	OSStatus        err;
	...

	if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0)
		goto fail;
	if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
		goto fail;
		goto fail; // <-- this `goto` always executes
	if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
		goto fail;
	...

fail:
	SSLFreeBuffer(&signedHashes);
	SSLFreeBuffer(&hashCtx);
	return err;
}

Humans are not wired to find these extremely subtle mistakes. We are much better at detecting flaws in logic than we are at finding typos, or else story editors (who review content and grammar) and copy editors (who review spelling and capitalization) would not have been historically separate positions.

I fear this proposed feature is almost guaranteed to be implicated in a goto fail-like security vulnerability.

18 Likes

Following up on the topic of result builders, and also on unused results:

A number of comments on here asked about result builders. SE-0380 explicitly subsetted out result builders, since in those contexts if statements were already expressions, but of a different form (they build a kind of Either). The same would be true of this proposal – result builders put the compiler into a very different mode where there is already handling of multi-statement bodies without returns. I should have included this in the proposal.

So this code would remain unchanged:

struct V: View {
  // returns TupleView<(Text, Text)>  
  var body: some View {
      Text("Hello")
      Text("World")
  }
}

This works this way, in today's Swift and under this proposal, because View.body is a @ViewBuilder.

As others have pointed out though, if you define similar but non-ViewBuilder function, it behaves differently. Today:

// Error: Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type
var standalone: some View {
    Text("Hello")
    Text("World")
}

Under this proposal, this would compile, but importantly, generate a warning:

var standalone: some View {
    Text("Hello")  // Warning: Result of 'Text' initializer is unused
    Text("World")
}

This is essentially a variant of many other examples in this thread, where the concern is that a user places a new line after what was previously a return value:

// Constant 'carBackDoorLabel' inferred to have type '()', 
// which may be unexpected
let carBackDoorLabel = switch carBackDoorButtonAction {
  case .lock: 
    "Locked" // Warning: String literal is unused
    print("childSafety: \(childSafety)")
  case .unlock: 
    if childSafety {
      "Locked" // Warning: String literal is unused
    } else {
      "Unlocked" // String literal is unused
    }
    print("childSafety: \(childSafety)")
}

So this code would compile (though in all likelihood, it would fail later due to carBackDoorLabel being of the wrong type) but generate new warnings indicating the mistake that was made.

One option here is to add logic that, when implicit return (or a multi-statement if branch) is used, and the scope contains an unused value, then this would generate an error, not a warning. This would make the error impossible to ignore (it could also be customized to explain why it is an error i.e. "did you mean to return or store this value?"

The exception here is where @discardableResult is involved. This particular issue might be fairly niche. A step further might be to ignore result discard ability when using implicit return (given discardable results are just sugar that avoids a _ = this is basically sugar vs sugar – you can silence the warning with an explicit return, or with a _ =).

Of course, this does not address the "this harms readability even when correct" concern – but does seem to address the "refactoring would be more risky" issue to some extent.

11 Likes

@Ben_Cohen
First of all thank you for your effort.

As you say in your last sentence, all this is fine (or at least ok) for the compiler, but it is too complicated for human readers. When I browse through source code, I don't have the luxury to look at compilation warnings, I just need to quickly understand the logic. If it is more than 2-3 simple lines of rules, the mental effort is too big.

5 Likes

This is a really interesting thought! In a way, it's a similar solution to the then keyword proposal, but instead applying the "last statement is the expression value" rule only to do expressions, not more widely.

This has the big win of not requiring a keyword like then (and also eliminates the awkward parsing ambiguities).

Of course, it doesn't make everyone happy:

  • simple closures still need fiddling with just to add a second trivial statement, so it doesn't make people who are enthusiastic about this proposal happy
  • it still has an implicit last value rule for do expressions, which some folks will still find too subtle

Taking a step back for a second: speaking personally, my main goal is to lift the major limitation on if/switch expressions, and to introduce do expressions to replace the somewhat odd immediately executed closure idiom (and allowing for nice syntax for assigning results from either values or caught errors)

I feel like solving both these issues is an imperative for Swift. Inaction would be bad.

How they are solved though is where there are options. So far we have a continuum:

  • Introduce a new yielding keyword like then
  • Introduce do expressions with a last expression rule, use them to solve the if/switch limitation
  • Use trivia like ; as Rust does
  • Introduce a last expression rule for if, switch, do, but not functions/closures
  • Go all in on a last expression rule

Of all of these options, using ; is the only one I'm firmly against. I would be fairly happy with all 4 of the other outcomes. I'm currently warmest towards the full on last-expression rule, and I actually think the do option may be better than the keyword option.

edit: realized I was missing a middle ground option

17 Likes

What is the issue with a new keyword? then was a bad choice, but there are better choices. A dedicated keyword makes the new feature crystal clear, without interfering with existing features. I have sent my proposal in the previous thread. It has less rules and it is more general. I repeat the main principles below.

A binding assignment is of the form:

variable = bind_expression

where bind_expression is the body of a closure, with the restriction that it is a single if/switch/do/while/for command, which contains bind instead of return. It is syntactic sugar for the assignment:

variable = { bind_closure }()

To expand the binding expression form, add {}() and change bind to return (this could be a straight-forward implementation). To reduce the closure form, remove {}() and change return to bind.

In addition, the binding expression may have an else part:

variable = bind_expression else { other }

which is syntactic sugar for

variable = {
  bind_closure
  other_commands
}()

where other_commands is other with bind replaced by return. If the first part returns (binds), the else part is not executed, otherwise control continues to the else part.

Effectively this syntax generalizes the binding expression even more: a binding expression together with its else part, is a closure which starts with an if/switch/do/while/for command.

As a last convenience, when variable is Optional, an else { bind nil } is implied.

Maybe I miss something important, but to me it seems that the above is simple to understand, it covers the wanted use cases (if/switch), and it covers even more with a simple generalization that fits to Swift syntax and style.

You may allow a binding expression also after return, if you want to treat it as a value; this is not in my proposal and it is not my preference.

1 Like

Weird (probably half-baked) thought. Suppose we added a keyword, but it was one you applied to the block, not to the last expression?

let foo = if x.isOdd fnord {    // using `fnord` as a placeholder
  print("x was odd")
  x + 1
} else {
  x
}

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

func selectRandomNum() -> Int fnord {
  print("getting random num")
  nums.randomElement()
}

for i in nums fnord {    // an error, but at least we can diagnose it!
  ...
}

Yeah, you still have to add something to insert a print statement—but it's a single keyword, not a pair of braces with a new indentation level.

5 Likes