Pitch: Multi-statement if/switch/do expressions

I wanted to try do-expressions, but cannot find the proposal on Swift Evolution, and adding -enable-experimental-feature DoExpressions to the Swift build flags (in Xcode) doesn't work (the project doesn't compile, as the flag isn't recognized).

I have Swift 5.9.2. Does anyone know what I'm doing wrong?

You can grab a nightly compiler snapshot from Swift.org - Download Swift that has the experimental feature implemented.

1 Like

Thank you, @Joe_Groff. Much appreciated.

IMO, any expression that must use then at least once should be required to use then consistently throughout the expression:

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

You'd still be able omit then when every branch contained a single expression, so this would be ok:

let width = switch scalar.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 4
}

But then you run into a similar problem to the one that current switch expressions have (in that you need to refactor to add a single log statement). In this case it's almost worse cause you have to touch every branch of the switch expression just to add a single log statement (which you can at least avoid with the somewhat messy immediately-invoked closure workaround). Switches can have a lot of cases, especially in situations where switch expressions make a big difference.

3 Likes

You're right. I stand corrected.

1 Like

I'm thinking that Xcode could issue a yellow warning and
offer to fix it for us by adding the keywords that are missing.
The longer the keyword, the more dramatic the change of
appearance would be to an extensive switch.

The question then is if we want a sudden change of
appearance just because of a single log addition.

A quick addition to this everlasting thread: "emit" instead of "then" would look more appropriate:

let width = if scalar.value == 0x80 { 42 } else {
    log("this is unexpected, investigate this")
    emit 24
}

That is if we decide to not to use the bare last expression rule.

5 Likes

Doesn't requiring the keyword on every case just because one case needs it defeat the purpose of using a switch expression over a switch statement?

If you're going to have to add a then value to the end of every case the moment you do it in one case then you might as well just replace:

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

With this:

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

I think there would still be a marginal benefit in terms of visibly declaring author intent ("this switch expression exists for the purpose of initializing width" vs. "this switch statement contains arbitrary logic, one of the side effects of which is that width ends up initialized"), but I agree that it defeats much of what I see as the value of multi-statement if/switch expression branches (solve the issue of local changes requiring non-local refactoring/modifications).

2 Likes

Having thought about this a bit more I'd like to circle back to an idea that briefly occurred to me earlier in this thread:

We could use the last expression rule, but require that all unused values prior to the final unused value are of type Void.

Thus, logging is enabled (because the output is Void)...

let sfSymbolIdentifier =
    switch deviceType {
        case .mac: "laptopcomputer"
        case .iPhone: "iphone"
        default: 
            log("Unexpected device type...")
            "questionmark.square.dashed"
    }

...intermediate expressions are enabled because they do not result in unused values...

let accessibleRepos: [Repo] =
    switch signedInEntity {
    case .companyMember (let employee, let company):

        ///
        let isAdmin: Bool =
            company
                .admins
                .contains(employee.id)

        ///
        employee
            .personalRepos
            .appending(company.publicRepos)
            .appending(
                isAdmin ? company.adminRepos : []
            )

    case .individual (let user):
        user.personalRepos
    }

...imperative control flow is fine because again, no unused values...

let backgroundColor =
    switch colorScheme {
    case .light: .white
    case .dark:
        var (r, g, b) = (0.0, 0.0, 0.0)
        for value in myCollection {
            // ... update rgb values
        }
        Color(r: r, g: g, b: b)
    @unknown default: .white
    }

...and in situations where you want to use a static member of the type but precede it with Void-returning expressions that result in ambiguity, you resolve it the same way as we already have available to us with result builders which is separating the expressions using a semicolon...

let backgroundColor: Color =
    switch colorScheme {
    case .light: .white
    case .dark: .charcoal
    @unknown default:
        log("Unrecognized color scheme: \(colorScheme)");
        .white
    }

With this approach we don't have to touch the return value when adding print statements before it, which is nice. We also can't change the return value by adding a line underneath the current return value, because that would make the previous return value an unused value that isn't Void. This possibility was for me one of the most unsettling aspects of an unrestricted last-expression rule, although at the moment I can't thoroughly articulate why. This approach also generalizes to function contexts if we want it to. And we don't have to suffer the various implications of a new return-adjacent keyword.

Previously, I expressed concern that the lack of visual indication of whether or not a result builder is in effect combined with a last-expression-y rule would dramatically degrade readability, but I'm now leaning towards the idea that if a function's name does not make it clear whether or not it produces a value then that's the real source of confusion and is the real thing that should be changed to remove that confusion.

8 Likes

Here's a topsy-turvy approach.

Use last expression rule. If something has to be done after the last
expression, write keyword then, followed by a single statement.

That single statement could, for example, be a do, if, or switch.

let x = if condition {
    1
    then log()
} else {
    2
}

let x = switch someEnum {
case .one: 1
case .two: 2
    then do {
        log()
        print("done")
    }
}
2 Likes

That sounds like it could be extended to return e.g.

func foo() {
    return 1
    then log()
}

…and isn’t that essentially an alternate syntax for defer?

1 Like

With then it is not necessary to include curly braces if there
is only one statement. If curly braces would always be required,
then consider the following example (that wouldn't require a new
keyword to be introduced):

let x = if condition {
    do { before() }
    1
    do { after() }
} else {
    2
}

(do should not be allowed to be an expression in this case)

If it were possible to skip the curly braces when using do,
the above example could simply be:

let x = if condition {
    do before()
    1
    do after()
} else {
    2
}

EDIT: I think that there could be some problems with the
scopes in my latest suggestion – and with any variables
created within those scopes. That is, variables intended
to be used for the assignment could become unavailable.