Pitch: Multi-statement if/switch/do expressions

I like the proposal. Would definitely use this!

1 Like

How would you disambiguate whether return in your example means a return from the switch expression or return from the function? If it's always the innermost expression, then just by prepending let width = to the switch you've changed its control flow quite significantly.

If return returns from the function when used in statements, but only from innermost expressions when used in those expressions, that does not make the behavior the same across the language.

Either way, returning from functions and "returning" from expressions are different behaviors that need different keywords[1], they can't be unambiguously unified.


  1. or syntax rules, as proponents of "return last expression in a block" would argue ↩︎

7 Likes

I could get on board with semicolons, but I don't much like the code in your examples:

If I understand correctly, you're talking about introducing a generalized "multi-statement expression" concept, which is written by separating the statements with semicolons, and because of its generality it would need to be allowed in all places where expressions are expected, for example in if statements, but even also as arguments to functions?

doSomething(using: print("evaluating argument"); getUsersLastName())

What if we just allow these semicolon-separated "multi-statement expressions" in two places:

  1. The body of a function/computed property/subscript which contains nothing besides the multi-statement expression.
  2. A branch of an if/switch/do expression that contains nothing besides the multi-statement expression.

Meaning these are allowed:

let backgroundColor: Color =
    switch colorScheme {
        case .light: .white
        case .dark: .charcoal
        @unknown default:
            log("Unrecognized `colorScheme`: \(colorScheme)");
            .white
    }
func getOrderCount () -> Int {
    print("Entered the function");
    7
}

But these are not allowed:

func getUsersFavoriteNumber () -> Int {
    let x = foo()
    print("this isn't valid");
    7
}
func getUsersFavoriteNumber () -> Int {
    return (
        print("this isn't valid");
        7
    )
}
let backgroundColor: Color =
    switch colorScheme {
        case .light: .white
        case .dark: .charcoal
        @unknown default:
            let thisIsntValid = true
            log("Unrecognized `colorScheme`: \(colorScheme)");
            .white
    }
if let someOptional, (sideEffect(); aBoolean) {
}
doSomething(using: print("Evaluating argument (invalid)"); getUserLastName())

Hm, but let width = switch scalar.value {} is kinda new function where it's supposed to return something, like let width = { switch scalar.value {} }(). Ah, maybe I need to think about it more. :thinking:

upd. yeah, there are also different expressions and really should be combined with function return. :+1:
upd2. BUT you can't actually use return in an expression anyway and this could be an edge case, though it will be weird that for some cases it will work and for other it won't :thinking:

I think this might be bad wording on my part in the proposal. Your examples I think fit into the less discouraged alternative of a last expression rule. You just happen to be putting them on one line, so you need semicolons.

If we ended up picking the last expression rule instead of an explicit then statement, I would expect that this:

let x = .random() {
  print("true")
  1
} else {
  print("false")
  2
}

could be rewritten with semicolons instead of newlines as:

let x = .random() { print("true"); 1 } else { print("false"); 2 }

But I think of that as just falling out of how semicolon can be used today.

At that point, you're now down to just the extra do { } vs your suggestion of using parens? Perhaps do itself is redundant and any braced set of expressions could stand alone as an expression? At which point, it's just a question of braces vs parens.

7 Likes

Obviously return can't be used in sense "result of innermost expression", but I don't see why then can't be used instead of return in a scope of function. To me it seems ok to treat then as "result of any innermost scope used as expression (including functions)". In other words these functions can be equivalent:

func f() -> Int {
  42
}
func g() -> Int {
  return 42
}
func h() -> Int {
  then 42
}

A plain braced set of expressions is already a closure literal, so there needs to be some distinction.

I don't think this properly fits with Swift as it is today, while I understand the problems faced as laid out in the motivation.

If we're introducing a new keyword to the language, I feel it should have a very high bar to clear with "future directions" of usage of the keyword at least given a cursory thought.

All of this feels like it's approaching syntax sugar territory, and if we have this "hard edge", then it's probably providing a valuable "code smell" that someone needs to refactor or rethink what they're trying to express. As mentioned, moving to a function to properly encapsulate what you're trying to do in the assignment expression.

Which can be done easily with the ability to nest functions within themselves if you truly want to make it private to the callsite.

At best, I'd be in favor of the last expression rule others have put forward, because that's effectively the same mental model as how closures work today, so there's an easier "leap" in the mental model to make to understand the language rules.

24 Likes

This is where I also agree after thinking a bit more about pitch now. Haven't faced an issue actually by this date simply by using closure when needed (though it was a bit annoying).
And yeah, last expression rule will work at least for now for sure.

That's what I particularly dislike with the current syntax. Having

func some() -> Int { 
   foo() + bar() / baz() * quz()
 }

initially, to add a log statement before bar() I now need to change two lines rather than just one.

+/-   log("some log statement")
+/-   return foo() + bar() / baz() * quz()

That would pollute change history, and if the expression on the last line was complex lead to more effort during code reviews ("is anything really changed here") or a potential for abuse ("I'm adding an extra line before -> hence have to add "return" keyword -> hence nobody'd notice if I mess with the expression that was on the last line"). Then you remove the log statement and have the "return bar()", that someone would obviously want to change back to "bar()" down the road – yet more git traffic. Changing "only statement rule" to "last statement rule" solves all that.

I was sceptical about this suggestion at first, but now I feel like it might give some additional value. What convinces me is this code:

func f(x: Int?, y: Int?) {
  let z = if let x {
    if let y {
      then x * y
    } else {
      then x
    }
  } else {
    then 0
  }
}

Note how far then 0 is from if let x. Usually they should be close to each other. And "happy path" becomes nested.
Making then a control flow operator with meaning "result of any innermost scope used as expression" would allow to write this instead:

func f(x: Int?, y: Int?) {
  let z = do {
    guard let x else {
      then 0
    }
    guard let y else {
      then x
    }
    then x * y
  }
}

IMO, this reads easier top to bottom.

2 Likes

FTFY ; )

func f(x: Int?, y: Int?) {
  let z = do {
    guard let x else { 0 }
    guard let y else { x }
    x * y
  }
}
2 Likes

Multiple statement in if/switch/do expression will be great, but I cannot agree with the use of the keyword then. First and foremost, distinguishing between then and return might not be straightforward. Forcing the use of then even in cases where there is no ambiguity with return seems excessive. For instance, I believe the following function should simply work:

func bar(_ x: Double) -> Double {
  if x < 1 {
    print("x < 1")
    // this should just work
    return 1/x
  } else { x }
}

I'd like to suggest using then only if there is outer scope that can accept return. So that the case above will just work. Additionally, since return cannot currently be used within an expression, interpret a simple return in expression as a then and issue a warning to switch to then will be possible and great for learners.

Furthermore, I think a variant of return, not then is more appropriate. I'd like to propose inner return. Such a keyword does not seem to cause source breaks and can be easily understood. If necessary, we can also consider outer return for early returns. I believe it can be a possible alternative.

1 Like

+1 I really like “then” as the keyword. It looks like a great way to weave in sub-expressions, logging/tracing, and error handling. Use of “then” feels really good with sub-expressions since it implies dependency. I think "then" would sit better with people that prefer imperative programming.

I'm writing the following with the idea that these ideas might be extended to functions and closures for consistency since they have a similar implicit return style.

I don’t like the semicolon idea since I see too many places it isn't ideal.

EDIT: Based on @Paul_Cantrell's concerns, I think extending "then" to functions and closures especially makes sense.

// “then” should return from closures and functions for consistency.
let numbers = [1, 2, 3, 4]
let plusTwo = numbers.map {
  print("Value = \($0)")
  then $0 + 2 // blends in better with rest of expression
}

// "then" makes sense for functional-style 
// "return" makes sense for imperative-style
func scaledPosition(_ x: Double, _ y: Double) -> (Double, Double) {
  guard x != 0 && y != 0 else { return (0, 0) } // Guards are fine.
  print("Working on input: \(x), \(y)") 
  if x == 1 { print("one") } // assume mixing imperative style is allowed.
  let horizontal = x * 2
  let vertical = if x > 0 {
    let b = x + y 
    then y / b
  } else { y / 3 }
  then (horizontal, vertical) // Obvious to reader this is an expression and returns a value.
}

Using a bare last statement was a leading idea back in the if/switch expressions proposal limited to a handful of statement types. I don’t see it in alternatives considered, but maybe this was already dismissed.

// everything needs to be "guard", "let", or "do" before bare last statement.
let vertical = if x > 0 {
   do print("Working on input: \(x), \(y)")
   let b = x + y 
   y / b
} else { y / 3 }

// looks nicer with semicolons
let vertical = if x > 0 { do print("Working on input: \(x), \(y)") ; y / 4 } else { y / 3 }

Sorry for lots of edits. Should have drafted this awhile.

To expand on this with a more controversial idea: inlined(or macro-ized)-closures in expressions. It has always bugged me that it is hard to see the context of "return" inside higher-order functions included in expressions. This would be a separate proposal, but food for thought since it relates to expressions.

EDIT: Completely rethought this.

let numbers = [1, 2, 3, 4]
// This does not work for @escapable closures.
let plusTwo = numbers.map #{ // `#{ }` indicates this is inlined (or macro-ized) and "return" returns from the calling function or closure.
  guard $0 != 3 else {
    return "is 3" // returns from the calling function (or closure) instead
  }
  print("Value = \($0)")
  then $0 + 2
}
return "not 3"

Reading the other replies, just a note to say I’m a strong -1 on return and yield. They’re both too closely associated with other meanings (function return and generators), and overloading them in this way would just be horrendously confusing.

It’s then, bare last statement, or something nobody’s proposed.


Re the bare last statement: I’m amenable, but it would seem deeply bizarre to me unless we go “full Rust/Ruby" and use bare last statements as return values for functions too. Consider:

func describe(_ x: Int) -> String {
  if x == 42 {
    "meaning of life"
  } else {
    "just another number"
  }
}

Applying the “bare last statement” rule to conditional expressions (and only to conditional expressions), what happens if we add a print statement?

func describe(_ x: Int) -> String {
  if x == 42 {
    print("describing \(x)")  // Making this a multi-statement block...
    "meaning of life"         // ...requires no modification
  } else {
    "just another number"
  }
}
func describe(_ x: Int) -> String {
  print("describing \(x)")  // But making this a multi-statement block...
  return if x == 42 {       // ...suddenly requires a new keyword?!
    "meaning of life"
  } else {
    "just another number"
  }
}

The inconsistency of that vexes me. Either we're in or we're out. My sense is that adding the keyword when the block becomes multi-statement is the Swiftier way (cf the discussion around SE-0255), but if we're going to bless the last statement in a block, I don't think we should do it by halves.

18 Likes

I think this is easy to resolve.

This was created for result builders and should only be used in that context. I think it makes sense to recommend functions return with “then” if the function is to be considered an expression. “return” should be used for early escape and for imperative-style functions.

I'd like to clarify that when I compared the pitch to C's comma operator, I meant that as a negative :grimacing: As in "the comma operator is generally understood to be a bad thing; we shouldn't copy it in Swift".

(I have also used the comma operator a fair amount in my day – usually to add some printf logging somewhere awkward. I don't consider my actions to be a good defense of the practice.)

3 Likes

Perhaps I'm in the minority here, but to me, this really doesn’t seem like a problem worth solving. Swift already has more keywords than almost any other language out there, and we know that it will need even more to tackle hard problems like non-copyability. But this isn’t a hard problem – it's a paper cut with a simple workaround. I just can’t see how that justifies adding yet another keyword that users have to learn and remember.

18 Likes

During reviews we're asked to answer these questions:

  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?

For me to answer "yes" to those questions, I think the proposal needs to change significantly before going into a full-blown review. The problem might be significantly enough to warrant some solution, but it is certainly not significant enough to me, to warrant the proposed solution.

Specifically, the addition of new keywords, is a pretty high bar to pass, IMHO. This significantly changes how code is evaluated, read and understood, for what I understand to be a pretty minuscule gain.

I do like that if and switch statements can act as expressions, but I hope it is possible to accomplish in a much less intrusive way. I don't have many suggestions for alternative directions, but as it stands, I hope this will go back to the drawing board before entering review.

12 Likes