Pitch: Multi-statement if/switch/do expressions

I agree with everything you said (and I also lean towards use rather than then), save for this remark

I very strongly disagree with the very concept implicit evaluation to last expression in a code block, with no keyword, and I think languages that adopt it are worse for it (in fact, my anecdotal perspective is that the main reason why this was considered for some languages is that other languages did it, which is a very weak and divergent argument).

I don't buy the "too many" keywords argument, nor that the bar to add a new keyword should be "very high", nor that there has been "too much" bikeshedding: these are "quantitative" arguments that don't look particularly rational or logical to me, because the metrics are arbitrary. The thing discussed in this proposal is a new feature, it's something that simply cannot be done in Swift today, and as such requires a new syntactic construct: to me, a specific keyword is a very good way to express such construct. It's not the only option, I'm sure, but the idea of not adding a keyword for it, or reusing an existing one, should be supported with strong arguments related to the clarity of the code or the easiness to learn its rules and constraints.

6 Likes

I've been following this thread for weeks now and was very hesitant to pitch in, since I too am very biased regarding syntax and I didn't want to pile on the bikeshedding regarding the keywords and so on.

My goal here is to shed some light on the way Kotlin solved this problem, hoping that it would maybe offer some outside perspective and out-of-the-box thinking, to help resolve this.

I'll try to describe the Kotlin way in general and as easy as possible, without delving into too much details.

I fully agree with the statement, that writing code in a language should be fun. I love Swift, but boy do I miss some features from Kotlin and wish they find their way here! Having said that, I'll try to be as objective as possible. (examples follow).

  • In Kotlin, if and when (switch equivalent), lambdas are expressions
  • There are only three "jump" keywords: return, break, continue
  • These keywords behave the same wherever they are in the code
  • Implicit return for single- as well as multi-line expressions
  • Expressions can be marked with labels (however, it is something rarely seen in the wild)
  • Returns and jumps can be qualified with those labels, i.e. we can return to specific labels
  • Expressions also have implicit labels (this is probably the most important part)

With these rules in place, we can cover any possible sceraio consistently, while keeping the code:

  • Readable & understandable
  • Explicit when needed
  • Keywords behave the same and we can always grok their meaning at call-site

Here are some exampes to clarify this:

  • If expression:
val res = if (condition) {
  42
} else {
  val x = computeFoo()
  val y = computeBar()
  x + y
}
  • Lambdas:
val res = items
  .filter {
    it.price > 100
  }
  .map {
  	if (it.shouldDiscount) {
      println("Disount applied")
      it.price * discount
	} else {
	  it.price
	}
  }
  • Qualified returns (very contrived exmaple to clarify this)
fun doStuff(): Int {
  val res = listOf(1, 2, 3, 4, 5, 11)
    .filter {
      if (it == 4) {
        return 100 // this returns from the function `doStuff`, as we would expect
      } else {
        return@filter true // this is not required, but would return to the implicit label of the enclosing `filter` lambda
      }
    }
    .map {
      if (it < 3) {
        it // this is an implicit return form `map`
      } else if (it < 5) {
        return@map it * 2 // this is a return to the implicit label of the enclosing `map`
      } else {
        return 200 // this returns from the function `doStuff`
      }
    }

  print(res)
  return 42
}

As you can see, this way return is not overloaded and always returns from the nearest enclosing function or anonymous function.

To return from expressions, either an implicit return can be used or a qualified return.

I hope this helps with expanding the "box", that we are sitting in now, and maybe enrich the proposal.

References:

7 Likes

I'm firmly in the camp that making return mean anything other than "exit this function/closure" should never ever happen.

My (also anecdotal) experience is the opposite, I find implicit return of the last expression in Rust much easier to read than if there were return statements everywhere. I would strongly prefer implicit last value of the expression to a new keyword, but if that would cause too much breakage than I can live with use.

9 Likes

That's fair; I amended my "direct review" at the bottom with an alternative for implied-last-statement-return.

Sorry in advance for the lack of constructiveness in this feedback, at the same time I feel like some things just simply don't need changing. From reading this proposal I'm not convinced that the problem it's trying to solve is big enough to warrant more syntax creep in Swift.

To me this proposal looks more like "the language designers / compiler engineers are looking for something to do" rather than solving a need I've personally ever considered important. I should add though that I might be in the minority here, because I could also have happily done without switch statements as expressions to begin with, even if I appreciate them in other languages (they're other languages though – I'm happy with Swift being Swift).

I'm sorry, but I'd much rather keep Swift simple than adding more and more edge cases to the syntax, more keywords, and otherwise more sugar. I'd personally be much happier keeping the status quo.

8 Likes

There's so many ways in which - while I sympathise with it - I find that is not a useful line of thinking:

  • Progressive disclosure is the name of the game; I've never used many of those keywords, and they have zero cognitive load for me. I believe that's the experience for most Swift programmers. For the most part what you don't know doesn't hurt you, regarding Swift keywords.

  • The "there's too many things" argument can nominally be applied to other aspects of the language too - there are too many Swift packages, there are too many Apple frameworks, there are too many APIs, etc. By which I'm just emphasising that such assertions, if applied without nuance of context or otherwise dogmatically, doesn't serve much of a purpose.

  • Comparisons across languages are further fraught due to the simple fact that a lot of functionality can be expressed with keywords but doesn't have to be, and that choice doesn't necessarily impact the functional complexity of the language (e.g. lazy).

(Also, I think it's a bit misleading to include every possible pragma and pragma value in there. If you want to genuinely be fair in comparisons with other languages then you have to do the same for them, in which case C & C++ have infinite keywords.)

What I suspect you're actually concerned about is genuine language complexity. But as has been discussed - at length - in this thread, more keywords is not really correlated with complexity, and indeed appropriate use of keywords can reduce complexity (e.g. guard). We must evaluate each specific proposal on its own merits.

8 Likes

I was literally laying awake for hours last night thinking about this thread, not able to get to sleep. There has been a bit more back and forth since the last time @Ben_Cohen posted, but still no clearly winning ideas that would make it easy for @Ben_Cohen to draft a V2 with confidence that it would be any more acceptable to the people who are hesitant about the proposal in the first place, or to people who have strong opinions on what alternative is better.

Although I am still perfectly happy leaving things the way they are, I came up with a compromise that makes the proposal acceptable to me, and I hope others will agree.

It splits the proposal up into three parts. I think separate threads will allow for more focussed discussion, and less circular arguing, but they could all be kept together. My proposal is basically to split out the least controversial piece. Then split the rest of the proposal into two pieces. It compromises with the idea that we need a keyword for clarity without adding an obscure/unprecedented one. My idea possibly avoids adding a new keyword, while allowing the use of ones we already have in certain contexts. I think it keeps Swift more intuitive in general and more familiar to the larger programming community as well.

Part 1: Allow single line do-catch expressions.

I’ve said it before, but separating this out would make it quite easy to review and approve. Given that SE-380 is already in the language, I think even people who don’t like it that much will be fine with this in order to make the language consistent. I haven’t seen anyone in this thread express direct opposition to that part of the pitch.

If part 1 is accepted, it will inherit the functionality of parts 2 and 3:

Part 2: multi-statement if-expression in return or throw statements.

In this part we require the use of “return” or “throw” in the in the multi-statement blocks. For example:

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

When the if-expression is a return value there is no ambiguity with using “return” since that value is being returned from the function. If you use it as a source for “throw” you would use the keyword “throw”.

More examples:

return switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default: 
  log("this is unexpected, investigate this")
  return 4
}
throw switch CODE {
case 400: NetworkError.bad_request
case 401: 
    logout()
    throw NetworkError.unauthorized
case 403: NetworkError.forbidden
}

This would also allow for guard condition else { return value } within if expression if they are used as a return value or throw.

This feels very consistent to me since the multi-statement blocks follow the same rules that other function level multi-statement blocks follow.

Limiting the scope of Part 2 to return and throw statements mitigates the need to add a new keyword in a way that I hope will have more consensus.

Part 3: multi-statement if-expression as the source for an assignment

Option 1:

(I think this is the option I prefer.)

When using an if expression as an assignment, we use the bare last expression as the result.

Yes, you can write gnarly code this way, but just don’t.
Yes, that feels inconsistent with functions that require the use of return if they are more than one line, but Kotlin has the same inconsistency and we can be okay with that.
Yes, there can be ambiguity with implicit static member look up, but I think we’re just going to have to be okay with that; using a semicolon or write out the type name are appropriate ways to disambiguate that already exist in the language.

Option 2:

When using an if-expression as an assignment, introduce a new key word to mark the result. Right now it seems like the discussion is between “then” and “use”. I think either are easy enough for Swift developers to learn, but more insight may come from showing example code to non-swift developers and see if they can intuit the meaning to test for readability.

8 Likes

One thing I really like about Swift compared to Kotlin is that I do not need IDE hints to quickly read unfamiliar code.

Real world example:

val imageModifier = Modifier
        .fillMaxWidth()
        .heightIn(max = imageMaxHeight)
        .clip(RoundedCornerShape(LayoutMargins.large))
        .let {
            val aspectRatio = size?.let { size ->
                size.width / size.height
            }
            if (aspectRatio != null) {
                it.aspectRatio(aspectRatio, matchHeightConstraintsFirst = true)
            } else {
                it
            }
        }

I find it much easier to read this in Android Studio compared to GitHub because the implicit language features are highlighted.

4 Likes

I don't think it is worth separating these out. Single-expression if and switch branches are very common (at least for me). But single-line do/catch expressions are probably not. It's in the nature of catching an error that I suspect the most common use case is 1. do something, like report you caught an error, then 2. produce a default instead.

The other potential use of do expressions (without catches) is to supplant the immediately executed closure idiom, which itself only exists when you want an expression spanning multiple statements.

In this part we require the use of “return” or “throw” in the in the multi-statement blocks.

Having control flow such as returning mid-if expression was explicitly removed during the acceptance of SE-0380. The reasons for this were sound and probably rule out this path.

It's important not to see lack of consensus alone as a reason to defer things. This is clearly a very personal matter of taste, and it's unlikely that strong opinions are going to be swayed. Dividing the decision in two does have merit (or I think so – that's why we did SE-0380 first). But I don't recommend slicing and dicing the proposal into smaller pieces as an alternative to making the hard calls we face here.

9 Likes

Thanks for taking the time to read my response :heart:

1 Like

Was it suggested already to use the compulsory parens for the multi-statement expression?

let x = if condition {
    print("something")
    (1)  // parens are compulsory here
} else if otherCondition {
    2    // single statement, no parens needed
} else {
    (3)  // but we could still use them if we want so
}

We do have a precedent of using parens "to disambiguate" somewhere if memory serves me right. It would be low enough ceremony and no new keyword is needed.


If the keyword, in addition to what has been discussed so far we could add "result".

Hmm, an interesting idea. Definitely novel.

It might be a challenge for the parser because of ambiguity with function calls…?

It's further overloading an already overloaded part of the syntax, since parentheses are used for specifying operator ordering as well, among several other applications. The common thread between the existing applications is that parentheses group things together, which is not what they'd be doing in this use.

From a typing perspective it's more work to write than just then or use because it requires coordinating multiple keys in combos, on most keyboards (shift + 9, shift + 0), and it splits the 'keyword' in two.

To my knowledge it doesn't have precedence in other notable languages, which is a good thing in this case since that then won't encourage misinterpretation of the meaning, although I'm not sure it's intuitive, either. It looks like how ordered lists, or footnotes / references, are often written in text fields with limited formatting support, when used with integers.

2 Likes

I am glad you wrote this. It is an excellent point, highly relevant, and completely true.

The features you listed are part of the language and cannot be removed. They will remain there forever, as will all other language features. That is the reality of how Swift evolution works: things can be added, but never taken away.

And if the proposed multi-statement expression idea were to become a language feature, then it too would remain part of Swift forever, unable to be removed at a later date regardless of how it turns out in practice and with hindsight.

Thus, until and unless we become absolutely certain that this is definitely something we will want in the language forever and always, it is imperative that we must not add it.

• • •

Judging by the discussion in this thread, it is abundantly clear to me that we do not have any sort of agreement in that regard. A substantial number of respondents have expressed that they view this as an anti-feature, which would promote disorganized code, decrease readability, and generally run counter to Swift’s guiding principles.

And even among those who support the idea, there are a multitude of camps promoting different keywords or lack thereof, each arguing vociferously that the other spellings would be confusing and detrimental.

10 Likes

As Ben stated subsequently:

That's not actually accurate. The bar is certainly high for any kind of breaking change, whether it's technically a removal or not, but it can be (and historically has been) done.

e.g.:

Also, technically Swift 6 removes the ability to do a bunch of concurrency-unsafe stuff. :stuck_out_tongue_winking_eye:

I for one would like more breaking changes (in Swift major versions - 6, 7, …), because they often represent really valuable clean-ups and corrections (just look at some of the historical examples, and imagine if we'd kept all that stuff instead).

5 Likes

You keep doing that and the vessel explodes.

1 Like

We were discussing the function call ambiguity aspect upthread: interestingly there's something in the compiler (if not in the grammar?) that prevents () or [] from being treated a function call / subscript if at least the opening ( or [ is not on the same line as the preceding expression, so that's no issue. We just haven't considered then the "compulsoriness" aspect (using () even if it's otherwise not needed to resolve the ambiguity, but as an "expression value" marker).

Yes. It is meant to solve the issue often brought up against using the bare last expression - makes it easier to spot them.

BTW, if to use the last expression rule (bare or "dressed up") it is now obvious to me that it must not be done in the multi statement functions/closures (e.g. for the sake of consistency). The reasoning is the same as the reasoning not to use "return" to mean expression values. Whatever we choose, retuning a value from a function closure should be visually different to "expression statement" value (this is broken for the single-statement but let's not break it further in the multi-statement case). "Option 5" rules.

1 Like

The grammar posted in TSPL is very much an approximation of the actual rules used by the parser. The parser itself explicitly requires that the ( of a function call must be on the same line as the callee: swift-syntax/Sources/SwiftParser/Expressions.swift at 6e708c4fa643057f41e3af7e6c0a2c405a487e56 · swiftlang/swift-syntax · GitHub

      // If there is an expr-call-suffix, parse it and form a call.
      if let lparen = self.consume(if: TokenSpec(.leftParen, allowAtStartOfLine: false)) {

So the suggestion to require parens around a final expression wouldn't be ambiguous with function calls.

You know, I don't completely hate this idea. It doesn't seem worse to me than a new keyword and it takes advantage of existing syntax rules instead of having to invent new ones. The compiler will already parse it as a standalone expression without the ambiguities around implicit member references so I don't believe we'd be introducing any new ambiguities? But there might be some edge case I've missed.

And if the value being returned was a tuple, I think we could say that the parens around the tuple would be sufficient to satisfy the final expression; you wouldn't need to write ((x, y)).

The more I think about it, the more I like it, actually.

6 Likes

I don't remember anyone suggesting save:

x = if condition {
    doSomething()
    save 5
} else {
    save 0
}

Just wanted to add it to the list.

Caveat: it can also mean "except".

There's one thing about human language I'm wondering here. When we write return we say we're returning a value. But when we write then should we say… "thening" a value? The proposal text actually explain things using the verb "produce", but to me that illustrates a disconnect.

let value = do {
   print("producing 1 for value")
   then 1 // `do` will produce the value 1
}

Wouldn't it be clearer to use the same term in both places? This is what makes me think use / "using" is a better option:

let value = do {
   print("using 1 for value")
   use 1 // `do` will use the value 1
}

I like then, but I think it'd be better to use a verb.


Some other ideas…

I suppose we could also use produce, but for some reason I find this unappealing (perhaps it's too long?):

let value = do {
   print("producing 1 for value")
   produce 1 // `do` will produce the value 1
}

Or if we feel generous we could give a value:

let value = do {
   print("giving 1 to value")
   give 1 // `do` will give the value 1
}

I like this last one.

5 Likes