Pitch: Multi-statement if/switch/do expressions

I'm still opposed to this proposal, but in the event it goes through evolution and is accepted I'd like to bikeshed some other syntax with the aim of making this behaviour more intuitive (i.e. avoiding an entirely new keyword):

Bare leading "="

Clearer than a bare last expression as it clearly denotes an assignment of some kind. Alternatively augmenting this with a prefix could help to give additional context: if=.

let x = if condition {
   let local = ...
   = local + 23
} else {
   let local = ...
   = local * local + 45
}

Keyword ret

A little hypocritical of me as this would technically be a new keyword, but this reads to me as a "mini return". More intuitively analogous to return.

let x = if condition {
   let local = ...
   ret local + 23
} else {
   let local = ...
   ret local * local + 45
}

Scoped return

Very verbose but clear what the context of the return is. No additional keywords required as return is parameterized with the context of the original assignment.

let x = if condition {
   let local = ...
   return(if) local + 23
} else {
   let local = ...
   return(if) local * local + 45
}
1 Like

This gets parsed as foo().bar regardless of foo having a return type of Void and Void having a member called bar. But I don’t think, it’s an issue that speaks against bare last expressions. You can just write Foo.bar instead of .bar and there’s already places in the language where you have to do this (result builders come to mind).

2 Likes

This isn't quite what I meant.

The expression-ness of an if doesn't rely on the return type of a function, as it happens before type checking. So even in cases where the return value of a function is e.g. an Int, solving for "bare last expression" needs a solution for disambiguating the leading dot.

Any last expression rule would likely assume .bar is a postfix member expression still. This is not just for source compatibility – this is also just what folks would expect, given how Swift works elsewhere. The following should certainly not change behavior if multi-line if is introduced:

func f() -> Int { 1 }
extension Int { var bar: Int { 0 } }
extension Double { static var bar: Double { 0 } }

let x = if .random() {
    f()
    .bar // parsed as f().bar not f(); .bar
} else {
    1
}

To get the other interpretation would require disambiguating syntax:

let y: Double = if .random() {
    f();
    .bar // Double.bar
} else {
    f()
    (.bar) // Double.bar
}

Without the semicolon or parens you would get a type error at compile time saying f().bar is of type Int not Double.

What is source breaking is something like this (I apologize, the example is contrived but illustrating a more realistic edge case):

let closure = { (i: Int?, h: inout Hasher) in
    if .random() {
       print("true path")
        i?.hash(into: &h) // return type of ()?
    } else {
        print("false path")
        i?.hash(into: &h)
    }
}

// this no longer compiles, because closure is now of type
// (inout Hasher, Int?) -> ()?
["1"].map(Int.init).reduce(into: Hasher(), hashOptional)
5 Likes

And this source breaking change example is roughly similar to what you mention here?

Strong -1 to this proposal.

I only supported if/switch as expressions because it allowed me to be a bit quicker when I had enums with trivial properties per case, allowing me to remove the return_s. Also it looks a little prettier in Xcode ;)

However before that, I thought that if/switch (didn't think about do) should have allowed single-line implicit returns/returns at all because it's the "returned" expression in that branch of the control flow/scope. I think that this would have enhanced the idea of "implicit returns single-expression(s)" because this would have still allowed stuff like:

// 1
func foo(bar: Bool) -> String {
    if bar {
      "isBar"
    } else {
      // do other stuff, taking up multiple lines
      return "isNotBar"
    }
}

// 2 - assume in `enum Cookie`
var stringValue: String {
  switch (self) {
    case .chocolateChip:
      "Chocolate Chip"
    case .oatmeal:
      "Oatmeal"
    case .sugar(let frostingColor):
      if frostingColor == .blue {
        "Blue Sugar"
      } else {
        // do other stuff, taking up multiple lines
        return "Not Blue Sugar"
      }
  }
}

// 3 - I think that this would have been allowed as well:
// follow the control flow to the returned/resolved value in scope
var myValue = if bar {
  "isBar"
} else {
  // do other stuff, taking up multiple lines
  return "isNotBar"
}

I find this a bit more natural: follow the path of control flow and the first (and only) single-line expression is the expected return/resolving value or we require a return-ed expression for multiple lines within a scope, like we already have. I also find this more consistent since in any context that would require a resolved expression (functions, computed properties, closures, etc.), the rules are the same; just follow the control flow.

if/switch becoming expressions suited my needs desires and I was ... content with it. I still saw it as a small but maintainable anti-pattern and only as a nice-to-have: it allowed me to remove a few return_s in my enums.

However, I find all of the ideas to push this proposal forward to be very anti-Swift-like and only make the language more confusing:

  • Introducing a new keyword
    This only adds another keyword that people have to learn about and is exclusive to this feature. This is why I'm glad that we don't use yield. I echo all resistance against then in this thread and resist all new keywords.

  • First/Last/Any bare expression
    But I also echo all resistance/resist against no keywords. First/Last/Any bare expressions would be - again - exclusive to this feature. It doesn't make sense why this specific feature/context would have different value returning/resolving rules from the rest of the language. So, I would say implementing this new rule would have to be universal*. But while Scala may have this rule and some find it cool, it's very anti-Swift-like and makes reasoning about return values difficult. This "allowance" would change the semantics of the entire language, and I think we should solidify exactly what the syntax for returning/resolving a value looks like in all contexts/scopes**.

  • Using return
    This fits in with my thoughts of how it should work in the first place. Not because if/switch are expressions, but because: it's the returned/resolved expression in that branch of the control flow (see example 3 above).

I would/will never use or allow multi-line/statement if/switch/do expressions in code that I control (even my example 3 above), or even the currently existing single-line expressions if it becomes more than a basic binary if/else. It's an anti-pattern to the safer, easier to read, and more declarative approach of the following below. It requires at most 2 more lines (declaration and traditional newline) and prevents headaches in the future with reading and indentation/linting.

let foo: Int

// if/switch/do your stuff
// foo must be resolved like always in all branches

print(foo)

Counter Proposal

I counter-propose that we change how this entire feature is implemented. Remove if/switch/do resolving as expressions and replace with the control-flow resolving rules:

  • follow control flow/scopes to returned values
  • scopes expecting a returned value with multiple lines must have a return _***
  • scopes expecting a returned value with a single-line-expression will implicitly return that value***

I find this to be more natural, more easily teachable/educational****, and concrete. I also think that this wouldn't break the usage of the existing feature.

Yeah sure this is syntactically equivalent to this feature just with return for multi-line blocks but is at least technically different in description.


tldr: this entire feature should have just been syntactic sugar for an immediately returning closure - we would already have this and any other future issues sorted out.


*or it would at least begin the slippery slope of proposal threads where that is the new rule.
**see above for my reasoning about control flow.

***But what about nested control statements?

This is ugly, but I surely won't stop you from writing it and it doesn't break existing rules or introduce special rules. I leave all of the other cases as an exercise to the reader.

let a: Int = if b {
  if c {
    if d {
      // do other stuff, taking up multiple lines
      return 1
    } else if e {
      2
    } else {
      3
    }
  } else {
    if f {
      4
    } else {
      // do other stuff, taking up multiple lines
      return 5
    }
  }
} else {
    6
}

****We should never have to say: "if you want to return a value in a function, here are X rules. If you want to return a value in this feature/scope, use Y rules. If you want to return a value in this feature/scope, use Z rules. There is no reason why it shouldn't be consistent.

9 Likes

Thanks! Exactly what I was looking for and I think the context needed to debate this approach.

Is it a legitimate worry this might increase compilation or type checking time? Would it complicate tooling, autocomplete, etc. in the presence of source that is being edited and may not type check yet? Admittedly I'm naive to the impact on compiler internals. There will always be some of this during editing, but in my mind it is worse when more than one statement can't resolve in the presence of incomplete code.

I've certainly been frustrated in Swift (especially in result builders) when the compiler couldn't figure out how to parse huge swaths of code because the result builder didn't type check without additional code that is now being written without editor support. Possibly needing to specify types to make it easier to type check. I'd hate to see this extended to multi-statement expressions too. Some of this has improved with improvements to the Swift type checker, but it isn't perfect.

A couple examples, but there are others.

// Expression or argument to immediately executed closure? 
// The type system probably needs to figure it out.
{ ... }
(1 + 2)

// Expression or parameters to a closure returned from a function? 
// The type system probably needs to figure it out.
f()
(1 + 2)

Aside-
I'm not sure if it is possible to do a survey, but that might be nice to get a general idea of what people feel is Swifty.

FTR this would be treated as a function call, as if "f" was typed:

    enum E { case bar }
    func f() -> (E) -> T

Putting semicolon on the previous line works for me and we use it in other scenarios:

func foo() {
    return; // would be an error without semicolon
    // TODO later
    // should be unreachable
    print("This won't be printed")
    ...
}

+1 for the poll plaque in this case.

1 Like

I'm not sure that's right?

func f() -> (Int)->Int { {$0*2}  }

extension Int {
    static var bar: Int { 0 }
}

func g() -> Int {
    f()(.bar) // function application, as expected
}

func h() -> Int {
    f()    // surprisingly, an error about the unused result, not a warning
    (.bar) // Reference to member 'bar' cannot be resolved without a contextual type
}

Nevertheless, the semicolon is probably the most idiomatic fix.

4 Likes

I don't think we want to encourage use of polls like this. Evolution discussions are about building the case for the right answer. They aren't votes[1], and use of polls might imply otherwise.

And while the poll results might be interesting, they should not be considered representative of Swift users as a whole. Evolution participants are a self-selected group of a particular Swift community demographic.


  1. which is why personally I think it would be nice if folks wouldn't use the "+/-1 on this" idiom ↩︎

14 Likes

Hmm, you are right... There must be some special rule in the parser that disallows putting () or [] on the next line:

var x = [1, 2]
func baz() -> Int {
    x       // 🔶 Variable is unused
    [0]     // 🔶 Expression of type '[Int]' is unused
}           // 🛑 Missing return in global function expected to return 'Int'

Is this encoded in swift grammar somehow, or just a special ad-hoc rule in the parser no-one knows about until tries?

1 Like

A next line rule for "[" and "(" would probably help a lot for implicit return. If it already works, then it could be codified in to the grammar without worrying about breaking code.

Or one could always just write Int.bar instead of .bar...

Heh, right. But people love their leading dot syntax, I don’t want to turn them against me ;)

7 Likes

True, it's one of the greatest things about Swift period. But I mean there are already a few places where you often can't use it, like result builders (although once when I was writing a result builder and wanted to use leading dot syntax, I ended up duplicating all the static members as members on the expression type just so that I could continue to pretend to use this syntax^^).

1 Like

Yes, (I am) loving leading dots, despising semicolons (where they do not separate consecutive statements in one line), and not wanting to burden non-experts with a not-easy to decipher last-line rule or confuse them with different uses of return or break, same for some obscure parentheses. Not many choices left then.

-1 for me. Anonymous closures already work for this use case. We shouldn't add language features that encourage the types of complicated, nested ifs given in the examples.

4 Likes

I do not like this explosion of closures.

I think using an appropriate new keyword (my favourite is „use“) does not hurt, except for the fact that one introduces a new keyword.

The complexity of the simple case of if/switch expressions was already tangible and now we are embarking on adding more and more complexity to address what?

I appreciate you do not like anonymous closures like that, but beyond not liking them are we seriously saying they pose a higher cognitive load than all the Evolution proposals fleshing out multi line if/switch and at some point guard expressions? I do not think we can honestly say that, but if there are any additional dangers of using {}() closures as an alternative to expressions I would really want to know, but I am not seeing that beyond some light hand waving to be frank :(.

5 Likes

I would not call it danger, but in closures you are always outside the current control flow, that is why “for…in” is — in the general case — “nicer” than “forEach” (and might also be more efficient, but I leave it to others to judge on that). Closures are nice where they are a good fit “conceptionally”, but not to realize control flow where the available patterns do not “suffice”.

1 Like

If this is the reason to not use closures and instead add a new keyword then it would only make sense if we simultaneously allow return to return early from the enclosing context (and if we allow continue to skip to the next loop iteration, etc). There has been discussion of this up-thread, but I don’t believe that that is the currently pitched behavior.