Pitch: Multi-statement if/switch/do expressions

Potentially, but not all character [counts] are equal.

Consider the basic switch statement. It's a pretty wordy thing if there's lots of cases. But it's conceptually pretty simple. Size != complexity.

For an imperative switch, the cognitive burden is pretty low even if there's many cases and they contain long blocks of code themselves, because all you have to understand to navigate that is some basic control flow and it's essentially top to bottom - "monotonic" in the sense that you never jump backwards, only ever forwards.

For a switch expression, there's more inherent burden because it's not 'monotonic'; you're not just walking forwards through the code, you ultimately also have to backtrack to the beginning once you've determined what the result of the expression is. And expressions can be arbitrarily nested (in principle) so the control flow is a lot more complicated, with potentially lots of back and forth.

It's kind of like the difference in complexity between walking an array versus a tree.

And I think that's why it's wise to put limits on permitted complexity in expressions (in general) that aren't necessary for imperative code.

Swift actually aids in doing this - moreso than most languages - thanks to the lazy keyword. That helps a lot with deconstructing complex expressions without losing the benefits of short-circuited evaluation.

Though, it's just a bit unfortunate that lazy can only be applied to variables, not constants. Those two aspects are orthogonal.

Wouldn't that be confusing, though? The problem is that out does sound very similar to in and inout yet has no actual commonality with either of them. in is used in completely unrelated fashions (e.g. for syntactic clarity in e.g. for x in array), and inout applies only to function parameters. In the midst of an expression there are no 'parameters' per se (I guess one could argue referenced variables, constants, etc are parameters, but I don't think that's how most people mentally model thingsā€¦?).

I think I could probably retrain my brain to permit that interpretation, but I suspect it'd always remain a bit awkward because "out" is inherently imperative, and expressions are (side-effects notwithstanding) not.

That's a very interesting point, insofar as it is pretty contentious topic within programming in general.

It drives me nuts working with people that dogmatically create a constant / variable for every intermediary. e.g. instead of return (x * y) + c they'd write:

let product = x * y
let result = product + c
return result

It seems bananas, to me, to deconstruct an already simple expression like this, but a lot of programmers do it.

Apparently the reason those folks do that is because they believe it's conceptually simpler to build the result up piece-meal, "bottom-up" as it were, rather than going "top-down" and having to try to comprehend the entire thing at once.

You can also see someone's preference revealed here by whether they write their code with helper functions above or below their use sites. Objective-C convention was to put subroutines later in the file, but Swift convention seems to predominately be the opposite.

But then I think we can all relate to the challenge of trying to learn a non-trivial bit of code and how a depth-first walk, while seemingly a logical method, can end up with you completely lost. Sometimes you really do need to compartmentalise and master one small subsection before you can even consider what it's for in the bigger picture.

Maybe it's not so much bottom-up or top-down as it is middle-out. :laughing:

Anyway, the point of this cool story is that I don't think we'll ever reach consensus on this aspect of design. Even your example is likely to be counter-intuitive to many folks, whom would find it much more logical to put the 'where' clause up front because otherwise they first encounter isAdmin without any known definition and can be thrown off on a wild goose chase trying to figure out where that comes from.

The two uses are quite similar:

{ param in
    foo(param)
}

let x = if condition {
    let result = foo()
    out result
}

BTW, what is the stance on having several exits from a single branch?

I can write:

let x = if condition {
    if otherCondition {
        1 // "first" exit
    } else {
        2 // "second" exit
    }
} else {
    3
}

but not this?

let x = if condition {
    if otherCondition {
        <WHATEVER MARKER IS HERE> 1 // "first" exit
    }
    <WHATEVER MARKER IS HERE> 2 // "second" exit
} else {
    3
}

or do we allow it?

1 Like

It's true, there is a symmetry in that case. And I like the symmetry - symmetry in general is elegant.

But, I've never liked the use of in there. It just reads weird, to me. Pragmatically I'm used to it - it's not too confusing once you've seen and written it a lot - but I don't think it's a good reference.

It is indeed a bit odd if only the first form - i.e. the "verbose" version with } else { and } explicitly spelt out - is accepted. From a "but these are just two purely-stylistic ways of writing the exact same thing" perspective.

I can imagine that bothering a fair few people, and confusing beginners.

But I can also see that one is declarative and one is imperative, so from a "expressions are declarative [not imperative]" perspective it makes sense to not allow the imperative form.

I was initially mildly in favour of this pitch, but I think I'm converging on mildly against it. I think primarily for the key point that a few people have already made: while there may not be an obvious, hard line between acceptable and unacceptable expression complexity, encouraging more complex expressions is probably a bad idea.

2 Likes

The proposal to allow multiple statements inside if / switch / do expressions looks like a really good addition! Howeverā€¦

Problem

I donā€™t quite understand the need for a new keyword which adds confusion and harms consistency of the language.

Background / Motivation

To me, the expressions supported in Swift 5.9 look semantically and syntactically similar to closures of the type (OldValue) -> NewValue which are often used for matching:

let inputMatcher = { input in
    if input > 0 {
        return true
    } else if input == 0 {
        return false
    } else {
        print("Unexpected value")
        return false
    }
}
let isEnabled = inputMatcher(input)

As seen above, constructions like this require the return keyword but support multiple statements out of the box.

Another Approach to Matching

Another way to match values Iā€™ve seen and used goes like this:

let isEnabled: Bool
if input > 0 {
    isEnabled = true
} else if input == 0 {
    isEnabled = false
} else {
    print("Unexpected value")
    isEnabled = false
}

This code, despite its bulkiness, guarantees isEnabled is assigned (exactly once) by the time itā€™s used, and also supports multiple statements.

Hereā€™s an if expression which does the same thing:

let isEnabled = if input > 0 {
    true
} else if input == 0 {
    false
} else {
    // Cannot print anything here
    false
}

This expression doesnā€™t allow explicit returns anywhere and doesnā€™t support multiple statements.

In result, one has to keep in mind the constraints of each construction, which is confusing and error-prone.

Alternative Proposal

I imagine the least confusing way of supporting multiple statements inside if, switch, and do expressions would be to use the return keyword, not introduce something totally new. These expressions act very much like the closure aboveā€”they receive an OldValue and return a NewValueā€”so bringing the two constructions closer to each other will reduce the cognitive load.

let isEnabled = if input > 0 {
    return true
} else if input == 0 {
    return false
} else {
    print("Unexpected value")
    return false
}

Of course, if all statements are one-line, no returns are needed.

Further Improvement

In the future, the need for explicit returns can be resolved on a case-by-case basis (though thatā€™s up for debate):

let isEnabled = if input > 0 {
    true
} else if input == 0 {
    false
} else {
    print("Unexpected value")
    return false
}

This results in very clear and easy-to-remember rules:

  • IF the branch has only one statement, it MUST be the value (a return is optional)
  • ELSE you do whatever you want BUT point to the value explicitly (with a return)
2 Likes

Another reason I like the idea of using return instead of then:

If we introduce a then keyword for defining the result of an if/switch expression, both of these examples would be valid and equivalent:

// using return
func isEnabled(input: Int) -> Bool {
  if input > 0 {
    return true
  } else if input == 0 {
    return false
  } else {
    print("Unexpected value")
    return false
  }
}
// using then
func isEnabled(input: Int) -> Bool {
  if input > 0 {
    then true
  } else if input == 0 {
    then false
  } else {
    print("Unexpected value")
    then false
  }
}

Having two different keywords that are interchangeable and fill the exact same role here suggests there may be too much overlap between them.

7 Likes

Is give on the table?
Disclaimer: I'm not a native English.
I think verbs, especially those in the imperative mood, suit the imperative style better, while nouns and adverbs look good in the declarative style.
I see that the proposal is inclined to minimize the imperative style in such expressions. But I don't really get why? Could someone expand on the reluctance to use constructs like guard?

Strong -1 to using "return", it would be very confusing.

func foo() -> Int {
    if condition {
        return 1 // returns from foo
    } else {
        print("hello")
        return 2 // returns from foo
    }
    print("unreachable")
}

func foo() -> Int {
    let x = if condition {
        return 1 // doesn't return from foo
    } else {
        print("hello")
        return 2 // doesn't return from foo
    }
    print("reachable")
    ...
}

It's too subtle difference with major differences in control flow.

It is already non so obvious with closures:

func foo() -> Int {
    iff (condition) { // a closure
        return 0 // doesn't return from foo
    }
    ...
}
11 Likes

That then and return decay to rough equivalence in the trivial case of a single expression function body is not that compelling to me as a reason to merge the keywords. The potential confusion specifically arises in cases where you arenā€™t in a context where those keywords are otherwise equivalent and youā€™re forced to reason what the control-flow behavior of the keyword is rather than having it immediately clear.

10 Likes

On a related noteā€”SE-0380 had a brief discussion about control flow out of expressions in the face of multi-statement branches, and I didnā€™t see this brought up in the pitch. Should the following be allowed? (Iā€™d vote no):

func f() -> Int {
  var x = if condition() {
    if exceptionalCase {
      return -1 // error?
    }
    then 0
  } else {
    1
  }
  return x + 1
}
5 Likes

Yes, I'm trying to raise the same question. Why do you think it shouldn't?

On a purely procedural level, banning it to start is the more conservative optionā€”I am, if Iā€™m being maximally charitable, unconvinced of the value mid-expression return and would not want us to introduce the functionality only to regret it down the road but be unable to justify a source break to remove it.

Substantively, too, though, I just donā€™t think thatā€™s a pattern Iā€™d want to encourage. When scanning code I think itā€™s useful to see an if expression and know ā€œokay, weā€™re creating a value based on some conditions, and all the code here is (roughly) in service of producing that value.ā€ If multi-statement branches can have arbitrary control-flow effects as well then the reasoning and case analysis becomes substantially more difficult, IMO.

Maybe, if we introduce multi-statement branches, Iā€™ll end up feeling that this rule is too restrictive. But Iā€™d like to develop that opinion based on experience with the feature rather than being too permissive at the outset.

6 Likes

To me, this is actually a good example of why return should be used rather than then. As I recall, the primary confusion in the original pitch (and I was one who said it) was that it proposed allowing return to not only return a value from the expression but to break out of the expression and return a value from the enclosing function as well. That is definitely confusing, as you can break through multiple scopes at once. If the rule is simply that you can use return within an expression to return the expression's value, at worst the confusing is brief, as the developer learns that rule and then, has to understand the scope at which it applies. But then seems almost worst, as it requires learning a new keyword that can only be used in particular contexts.

In short, it seems easier to me to learn a few more rules around return than it is to learn a new keyword, the rules around it, and new rules around return anyway.

4 Likes

To me it feels like a break of symmetry between throw and return.
If a function has signature func foo() throws early exit from an expression is allowed, but if it's func foo() -> Result<Void, Error> it's not.
This changes programming model within a scope of expression significantly.
On the other hand, if we treat bodies of if/do expressions the same way as bodies of if/do statements, except for the requirement of a result, this could be less surprising.

1 Like

As written, this pitch would not, AFAICT, introduce any new rules around return. And just to clarify, I am primarily concerned with the reader of code using these features. As the adage goes, code is read far more often than it is written, and moreover the author has the added benefit of whatever diagnostics we implement to guide correct usage.

OTOH, the reader does not benefit from diagnostics, and overloading return with new rules means that every existing use of return is suddenly imbued with a potential new meaning. Readers will have to consider whether any return statement is being applied at an expression level or a function level. Using a new keyword is, yes, one more keyword to learn, but it lets the reader ignore the different semantics unless theyā€™re actually relevant.

6 Likes

I agree and I like the chosen keyword. I'm not really a fan of bike-shedding too much either. It feels the pushback is in a few categories.

  1. People think it is confusing because it reminds them of if/then/else and then is being used differently here.

  2. They prefer implicit return. I personally like implicit return in functional contexts too, but the more I've thought about this I think it will be mostly to add some imperative code in to expressions. The "Full Expression" future direction may allow the "then" keyword to be used less often which may satisfy people who prefer implicit return.

  3. They prefer blocks in an expression to act like an immediately executed closure and use return. I'm not a fan of this because I think it unnecessary restricts future evolution of the language and doesn't allow some common patterns in imperative code that I think are still appropriate.

1 Like

I would vote yes on this because I think the feature will be used heavily in imperative code to reduce the number of variable declarations. You will still want to use return with its normal meaning in imperative code.

In other words, it will be use heavily to remove the "let x" declaration, so I wouldn't want to restrict the use of return in this instance. It would be odd to me if "do" suddenly has semantics similar to a closure just because it has a value.

let x: Value
do {
   let y = stepOne()
   if (y == 1) { return nil }
   x = try doSomething(y)
} catch {} 

// This takes 1 fewer lines of code vs traditional way.
let x = do {
   let y = try stepOne()
   if (y == 1) { return nil }
   then try doSomething(y)
} catch {} 

I actually wouldn't mind if "then" meant exactly the same as "return" in some contexts such as at the end of a function or inside a closure used as an argument. That way they would only have separate meanings where there is ambiguity of intent.

Technically true, but I can't see an impact in practice. It's no different that any other implicit behavior in Swift requiring contextual knowledge to really reason about. And since this use of return is very close to any other use of return, it's not as if this is a radical departure from existing usage. When you come down to it, the reader's question here isn't really new (is this return returning from the enclosing function, or some local context like a closure?), it just now applies to expressions.

return will be what users reach for first to support multiline statement in expressions. Rather than the compiler detecting that fact and offering a fixit to switch to then, which the user accepts by rote, why not support the natural usage in the first place?

Very busy hereā€¦

Without bringing any new arguments in favor or against something, I would like to mention again that Swift has a big advantage (in my opinion) compared to some other quite new languages like Rust that ā€” even if quite complex by now ā€” Swift is also quite accessible for beginners or at least for ā€œnon-frequentā€ programmers on a higher application level.

So some solutions are smart and somehow completely OK, but might be harder to grasp for a non-expert compared to maybe a ā€œless smartā€ solution. I think this is worth considering.

3 Likes

Well, Iā€™d contend that calling this the ā€˜naturalā€™ usage begs the question a bit. :slightly_smiling_face: IMO itā€™s at best ambiguous and I think thatā€™s a good reason to be cautious.

Iā€™d also point out again that this proposal and SE-0380 before it are (in part) specifically targeted at reducing the prevalence of the current ambiguity with return that you call out. I donā€™t find ā€œthe meaning of return can already be ambiguous in some contexts so itā€™s not that big a deal to introduce yet another source of ambiguityā€ that compelling an argument.

3 Likes

I just want to point out, since I havenā€™t seen anybody mention this explicitly yet, that if we use return we are preventing this possibility in the future. I also would currently vote no if I had to choose, so closing off this future direction doesnā€™t bother me personally very much, but it seems to me like a point that is relevant to the debate.

The reason Iā€™d vote no is the same reason I proposed the new usage of where - I like that if/switch expressions are currently declarative in nature and Iā€™m not thrilled that this proposal aims to make each branch an imperative sub-scope: each one like a mini imperative function, except with a differently ā€œcoloredā€ return keyword to match the differently colored (but functionally equivalent) imperative scope.

3 Likes