Pitch: Multi-statement if/switch/do expressions

Immediately-executed closures are a bit weird but a whole new then keyword feels a little "heavy" to deal with them.

It's been mentioned upthread but its feasibility hasn't been addressed so wanted to raise it again: would break + return value instead of then work here?

We already use break to get out of inner scopes and they can also be labelled (not sure if that's useful in this context, just pointing it out, because @taylorswift mentioned something similar). It seems break is close enough to what's needed if it could return a value.

12 Likes

Only if you allow "more than one exit" from a single if/switch expression branch:

let x = if condition {
    print("condition is true")
    if secondaryCondition {
        break 42 // this is the value, not the label
    }
    print("something else")
    break 24 // this is the value, not the label
}

which is questionable.


I don't think this is a strong objection as so could be said about many things that we have in the language:

// valid app with many readability issues
func main(){var 𝖍𝖆𝖑𝖑𝖔𝖜𝖊𝖊𝖓=1;var ʇxǝʇ=3;var vаr=1;let lеt=2; var ䷯=1;var ䷮=2;var 👪🌙👻👦🤖👧🧛🔦🏚🕸👴👵🍫🍬🍭=𝖍𝖆𝖑𝖑𝖔𝖜𝖊𝖊𝖓;func 👪(){print("x");print("y");};👪();if(({{(_,_)in{_ in(true)}}}()(vаr,lеt)(䷯))){👪();👪();}else{👪();}}
4 Likes

I like the “immediately executed closure” approach as its simple and in line with what is currently already available.

The second approach (a ‘do’ block for side effects and a single expression to return a value proper) to me feels off. Intuitively it would seem that what is in the do block has nothing to do with the eventual expression where they are obviously coupled in some way: why else would they be in the same case?

Wouldn’t the immediate evaluated closure approach be the simplest solution for the problem and therefore preferable?

2 Likes

It would be the obvious solution if there were no fragility cases. The worry is how ambiguous that is. One could work around ambiguities with parentheses or using semicolons, but a language change could easily break a lot of programs in the wild. That is why under alternatives it was suggested to always use semicolons. This is how Rust gets away with it.

I'm not opposed to allowing this with a semicolon. If nothing else, I think it is close enough to what is already supported and could kick the can down the road on adding a keyword. I'm actually leaning toward a personal preference of both a then keyword and allowing a semicolon before the last expression as another option.

I really like this syntax idea. It feels like a tree. You have distinct leaves as single expressions and branches as if/switch/do statements.

let color = if isSelected {
    print("Selected")
    do { .purple } // must end in a trailing if, switch, or do 
} else {
    // a semicolon as a fourth compact option for inline use or small blocks
    print("Not Selected"); // easy temporary logging/tracing
    .gray
}

// functions and `return` allowed in trailing position too
let success = if attestationPassed {
    if needsFurtherValidation {
        print("Checking again.")
        doubleCheck(expectedState: true) // standard functions allowed
    } else {
        print("We are done.")
        return // return from function is allowed
    }
} else {
    print("An error has occurred.")
    fatalError("Invalid device.") // Never-type functions allowed
}

EDIT: Refined this a bit.

I find this approach (and the reasoning behind it) quite persuasive. I particularly like how it leverages subtle but pervasive patterns that all Swift users are already familiar with, whether they realise it or not - like using closures to represent imperative code inside non-imperative flows (e.g. filter.map.reduce etc).

It's bound to be confusing, on multiple levels.

break (the existing statement) can be used anywhere in a series of statements, whereas in an expression context it would be nonsensical anywhere but as the last statement. So you'd have this context-specific difference in grammar. Not ideal.

(or, alternatively, you do allow it anywhere in the series of statements, in which case you've created the ability to have unreachable code inside an expression, which I think needlessly complicates the language)

break already takes an argument, which is the label of the block to break from. It'll be ambiguous whether break foo means break from the block labelled foo or that the expression should evaluate to foo (some variable, constant, type, etc). Also not a major problem, but not ideal. ("she'll be right" applied to this sort of thing is exactly how you end up with languages like C++)

Aside on the existing break <name>: Some folks might dismiss the existing use of break as esoteric and unimportant. Although it is true that it's not often utilised, I think that's more because it's unfamiliar to people from other programming languages, not because it's of little use. I find it incredibly useful and elegant when it is applicable, and you can pry it from my cold dead fingers. :slightly_smiling_face:

Also, admittedly, as a matter more or principle than practicality, break is a non-linear control-flow statement, whereas in this case of expressions we're explicitly trying to avoid non-linear control flow. break is a thinly-veiled abstraction on goto, remember. (which is not bad; goto is not inherently bad, just abused and misunderstood)

3 Likes

In my mind it is the preferable solution for genuinely multi-statement branches (like the color computation in my example), but for situations where the other statements are just for temporary debugging purposes I would find it annoying to have to put the expression in a closure and then pull it out again when I'm done with the print statement. In that case I think it would be nice to be able to insert an imperative sub-scope that doesn't break the declarative features that are otherwise working on my behalf in the surrounding context.

5 Likes

I’m trying warm up to this idea, but finding it difficult: break carries such a strong implication — both via tradition and via its English meaning — of stopping control flow that would otherwise proceed. The keyword’s history in C switch statements does open the door for this proposed use, yes, but…I just have trouble making it sit well when I try it in context:

let description =
  if x % 2 == 0 {
    "even"
  } else {
    print("Huh, that's odd")
    break "odd"   // “Why do I have to put a break here? Break from what?
  }               //  It's the end of the conditional anyway!”

I agree with @sspringer’s lines of thought. I'm not sure I love use. I'd previously proposed result, which I also don't love. And I don't love then either, but none of the alternatives sit any better for me, and at least it has a natural association with conditionals.

I think it's helpful to see these alternatives in context:

Keyword options in context
let description =
  if x % 2 == 0 {
    "even"
  } else {
    print("Huh, that's odd")
    use "odd"
  }
let description =
  if x % 2 == 0 {
    "even"
  } else {
    print("Huh, that's odd")
    result "odd"
  }
let description =
  if x % 2 == 0 {
    "even"
  } else {
    print("Huh, that's odd")
    then "odd"
  }
let description =
  if x % 2 == 0 {
    "even"
  } else {
    print("Huh, that's odd")
    return "odd"   // **Really** don't like this: makes it seem like we will return "even"
  }                // instead of returning "6 is an even number"
return "\(x) is an \(description) number"
let description =
  if x % 2 == 0 {
    "even"
  } else {
    {
      print("Huh, that's odd")
      return "odd"
    }()    // The only reason this has a _chance_ of looking good is familiarity bias
  }
return "\(x) is an \(description) number"

(Still a very, very strong -1 on return. It's the most confusing of any of these alternatives.)

3 Likes
  • Of all the explicit keywords mentioned so far, I have to admit I'd choose then. The various nouns and verbs (result, use, etc) just seem to be trying too hard to fit in. I'm a bit … embarrassed … on their behalf.

  • Twisting history just a bit, the only thing that really "sold" the original if/switch expression pitch was the lack of a keyword — which is where the single-line restriction came from. TBH, I think the only viable multi-line solution is the hard-to-implement no-keyword version that @Ben_Cohen reluctantly spelled out.

  • As an off-the-wall suggestion, we could try something other than a keyword. For example, based on @Paul_Cantrell's example immediately above, something like:

    let description =
       if x % 2 == 0 {
        "even"
      } else {
        print("Huh, that's odd")
        { "odd" } // <- this
      }

See? Both the "even" and "odd" result values are expressions in single-statement scopes!

Won't that cause a lot of ambiguity, to humans if not also the compiler, as to whether { is the opening bracket of a trailing closure argument?

4 Likes

I don't have strong opinions on the pitch, but I am strongly opposed to any "bare expression"/"last expression" concepts, on the grounds of readability. I'll link back to my comment on SE-0380 to show why:

7 Likes

I am sorry if I will sound very angry (I just feel very weird about the majority of messages in this thread), and please do not take it personally, but I am totally in the opposite camp.

Making the branches way too long and nested is likely a code smell, regardless of whether they are used in expressions or not.

The more I read the suggestions, the more I realise that the bare return is the most reasonable solution (least complicated to reason and teach, imho). Most others are not very logical or consistent with what the if and switch as expressions mean to the flow of data in the code.

What I am personally struggling to understand is this approach: 'we do not like using one new keyword. So, let's use 2 separate symbols - { ... }, or even better - 3: do { ... }'

Using so many brackets where the compiler knows exactly what one needs seems like a language design failure to me.

OK, while I sound very grumpy, I do not oppose solutions that do not complicate the code to write without a good reason. So, what if we use the original proposal but maybe use an even shorter keyword, such as out ?

(That is, assuming that we must treat expression blocks differently from other code blocks. Will it be a third type then? If the result builders are considered the second?)

That may work as a compromise that no one likes but accepts as a middle ground.

I oppose this proposal for making code less predictable.

if and switch expressions are (to my mind) a fancier C ternary. They're at home in Swift, which "embraces its C heritage".

These expressions serve their role well: inlining assignment of two / several possible values. They needn't (and for code comprehensibility shouldn't) do anything else.

4 Likes

I had a use case for this feature just yesterday. I had 5 permutations of input data that would result in different Vapor responses. For most cases, it was just a different response code, but just one also required a single header to be added to the response object.

2 Likes

Hear Hear. Not more syntax sugar. More effort to improve async
/generic system/ performance / SPM / debug toolchain... Please.

3 Likes

FWIW I will almost never need to embed imperative code in a declarative if/switch expression. In the case you describe I would write it like this:

let response =
    if conditionOne {
        standardResponse(status: 200)
    } else if conditionThatRequiresHeader {
        standardResponse(status: 200)
            .addingHeader(key: "abc", value: "foo")
    } else {
        standardResponse(status: 201)
    }

My point is that I think that there are very few situations where an imperative block of code is actually preferable to a single, functional-style expression. Therefore, I find it strange that we would optimize for multi-statement calculations embedded in if/switch expressions, when I see that as an uncommon edge case. I think we should discourage its frequent use, and I think the "bulkiness" of wrapping the statements in an immediately-executed closure does that nicely. Adding an entire new keyword just to support this seems quite unjustified to me.

That being said, the specific use case of inserting print statements in a branch does seem to me to be entirely common and valid, so I would love the do { } solution or anything else that allows me to insert that print statement without touching the surrounding code (as I generally am able to do when inserting print statements anywhere else in my code). Being able to not add return in a single-expression function body when inserting a print statement would be a lovely additional improvement, so I hope that we come up with a solution that solves for that as well.

In response to the possible complaint: "I don't want to have to make helper methods like `addingHeader` for each of the mutations I may want to make to my value types just for the sake of maintaining single-expression-ness..."

I have a protocol ExpressionErgonomic that provides various methods that are helpful for building up values in a functional way. It would allow the example code to be written like this:

let response =
    if conditionOne {
        standardResponse(status: 200)
    } else if conditionThatRequiresHeader {
        standardResponse(status: 200)
            .mutated {
                $0.headers["abc"] = "foo"
            }
    } else {
        standardResponse(status: 201)
    }
4 Likes

Adding do {} feels weird, almost like adding monadic do notation to the language :upside_down_face:

Very much +1 on this, fantastic proposal. Also love the do application, it something that I would likely use on a daily basis.

A couple of very short notes on my side:

  • last expression as value of the outer expression: please no, I think it's very, very bad idea, and I think that languages that use this shouldn't;
  • don't add this, and just declare a function: no thanks, adding unnecessary indirections, like extra functions with "clear names" and other "clean code" practices are, to me and many others, bad ideas, and sources of accidental complexity, that make the code much harder to understand for no gain.
2 Likes

Many versions of this objection in the thread, but the horse has long bolted with SE-0380. The question is merely whether existing rules should be extended to multiple statements.

If the then keyword had been required in SE-0380 then this proposal would be the obvious choice to me, but it wasn't, so it isn't.

1 Like

The original now adopted proposal by Dave Abrahams for if-expression syntax used "{ ... }" as a way to enclose a single-expression, so I like the idea of consistently keeping with that too. I thought it might be interesting to do a variation of this with do-expressions, since the syntax you show is unfortunately too ambiguous.

I suggested something similar where only expression-style statements can return in the trailing position.

    let description =
       if x % 2 == 0 {
        "even"
      } else {
        print("Huh, that's odd")
        do { "odd" } // push the result in to a single-expression context
      }

If we never have do-expressions, it might be interesting to use it to lift code out of an expression context. It appears that several people liked this approach reading back in this thread. Reminiscent of fake monadic notation as someone mentioned.

    let description =
       if x % 2 == 0 {
        "even"
      } else {
        // use do to escape out of the single-expression syntax
        do { print("Huh, that's odd") } 
        "odd" 
      }

// maybe this could be an alternative without "do"
    let description =
       if x % 2 == 0 {
        "even"
      } else {
        // allow a leading closure
        { print("Huh, that's odd") }()
        "odd" 
      }

I'm currently struggling with the following in the proposed then-syntax: "then if", "then switch", and "then do" feels weird to me. It shifts the keyword further to the right where it is more difficult to read. Maybe I could get used to it, but if we go with "then", maybe allow it to be dropped for trailing if/switch/do-expressions.

    let description =
       if x % 2 == 0 {
        "even"
      } else {
        print("Huh, that's odd")
        then if x == 0 { // <- This feels odd to me.
            "zero" 
        } else { 
            "non-zero"
        }
      }

// instead maybe?
    let description =
       if x % 2 == 0 {
        "even"
      } else {
        print("Huh, that's odd")
        if x == 0 { // <- "then" not needed here
            print("Is zero")
            then "zero" // <- "then" is needed here
        } else { 
            "non-zero"
        }
      }