Pitch: Double (or more) trailing closures

I think there's a beauty and consistency in being able to re-implement parts of the language without having to use meta-programming. An example of this would be implementing if with an auto-closure and a trailing closure. I propose extending that to support more than one trailing closure, so we could re-implement if else for example. It would be something like:

func when<T>(condition: @autoclosure () -> Bool, then trueBranch: () -> T, `else` falseBranch: () -> T) -> T {
    if condition() {
        return trueBranch()
    } else {
        return falseBranch()
    }
}

let a = when (2 < 3) { 3 } else { 4 };

So the first closure would work like a trailing closure works right now, and the rest would be separated by the argument label.

Update: As brought up by others, there might be a problem with ambiguity, so I propose to enforce a semicolon at the end in the case of trailing closures. Also I would be in favor of being able to remove the parenthesis if the case of a single argument before the first closure, as in when 2 < 3 {3} else {4} but that might be best as a separate pitch.

1 Like

it’s an interesting idea, though the (machine) parsing looks hard. I’ll need to think about it some more. :thinking:

2 Likes

Yeah I was about to add a note about that at the end, but then realized I don't know what I'm talking about so I just left it for people who do :slight_smile: .

P.S. It's probably reasonable to disallow keywords that can start a statement in this case, like if, for etc. which would remove ambiguity in parsing, but ones like else should be fine imho.

1 Like

There’s also this problem that the trailing closure could get picked up as a separated statement:

func when<T>(condition: @autoclosure () -> Bool, then trueBranch: () -> T, otherwise falseBranch: () -> T) -> T { ... }

when(2 < 3) { doSomething() }
// Is this a separated statement?
// How do we know it’s a continuation?
otherwise { doSomethineElse() }

PS
@autoclosure is used when you don’t want to write out a closure. You can’t even if you want to, so that’s probably not what you want here.

2 Likes

Not sure if you need to disambiguate before, but at the semantical layer you would know because the the otherwise parameter is unfulfilled so there's just one way to interpret it. This does however raise the issue of what happens if otherwise is optional. Maybe that should be disallowed.

PS: you're absolutely right, I've been testing it locally as let x = when(2 < 3, 3, 4) but forgot to remove the autoclosure.

I would prefer a syntax that works even in the presence of optional and overloads. Otherwise it’d be too limited to be useful.

Marking arguments with : could be confused with labeling too.

Also adding : defeats the purpose. Without : it serves as a mental model that's consistent with if else, with : it's just another special case that adds more mental overhead. I can't think of a way to make it non-ambiguous if they can be optional and overloaded except forcing a semicolon at the end of the statement. Correct me if I'm wrong but it seems like that would make parsing possible, not sure if it's desirable though.

Why is this not just the ternary operator?
let a = (2 < 3) ? 3 : 4
This provides the same result. Perhaps there’s another concrete example that demonstrates the point?

1 Like

Of course the example can be substituted by the ternary, I'm not suggesting that we add when to the language it's just an example that it would be possible to implement things that look like language features with just plain functions. This is something that would just be useful for creating DSLs, so while I can think of a few examples they are by definition too narrow to be good for illustrating the proposal.

2 Likes

While on a philosophical level I can appreciate a “I can’t wait to see what people make with these building blocks” attitude, it seems to me that a demonstrable use case is a reasonable ask for a pitch for new language features.

To be clear, I mean something that’s not trivially doable with existing language features, like the given example in this case.

1 Like

Ok, I'll try my best. While it may have other use cases, I think this is mostly useful when there's something that acts like a callback. In my field (data engineering) this is often creating DAGs, that get defined at some point but executed at a later point. While I think that could make a nice DSL I'm assuming most people aren't familiar with something like https://airflow.apache.org/ to make a good example so I'll just use plain old javascript-like callbacks as an example. Say we wanted to create a library like axios, where you define a request like:

axios.get('/user?ID=12345')
  .then(function (response) {
    // handle success
    console.log(response);
  })
  .catch(function (error) {
    // handle error
    console.log(error);
  })
  .finally(function () {
    // always executed
  });

In swift with this proposal this could be written as:

requests.get("/user?ID=12345") { response in
    let user = response.args["user"]
    ...
} catch {
    print("Something went wrong")
} finally {
    // Always execute
};

I added the semicolon because I think having it mandatory is the only way to parse it easily.

4 Likes

Just a nitpick, but wouldn't...

...given your original example actually have to be written...
let a = when (2 < 3) then { 3 } else { 4 }

I really like the pitch, I've wanted something like this many times.

I don't like this at all, it would make it very hard to see what closure is there as a part of the bigger function call, which are part of language keywords (in that example catch is a reserved keyword) and which are part of a new method call:

requests.get("/user?ID=12345") { response in
    let user = response.args["user"]
    ...
}
andThen { 
  // do something else
}
finally {
  // do something more
}

the above would be reasonable in this proposal but there is no way of knowing whether andThen is a separate function or part of requests.get. On top of that, the following is also something that you might write:

requests.get("/user?ID=12345") { response in
    let user = response.args["user"]
    ...
}
.andThen { 
  // do something else
}
.finally {
  // do something more
}

The difference is so subtle that it's easy to miss when scanning code and can easily lead to confusion.

What I would possibly be in favor of is requiring : to make it somewhat clearer that we're dealing with a function argument. Might also be too subtle though but it at least looks very similar to existing syntax:

requests.get("/user?ID=12345") { response in
    let user = response.args["user"]
    ...
} 
catch: {
    print("Something went wrong")
} 
finally: {
    // Always execute
};

I proposed omitting the first label so it's consistent with if/while/for syntax, but I guess if it's preferable it could be omitted only if the label is _.

Thanks for the constructive criticism.

That's why I proposed enforcing a semicolon at the end for multiple trailing closures. This would solve the parsing/ambiguity problem, right? I guess it's a matter of taste whether a mandatory semicolon at the end is a good idea or not, but I prefer it to using : because that would be another special case, whereas with myIf (2 < 3) { A() } else { B() }; it's consistent since if you substituted myIf with if it's valid code, and it's a mental model you already have.

I'm also in favour of being able to drop the parenthesis if there's just one argument before a trailing closure to make it even more consistent.

I don't think there's any part in the Swift language where a semicolon is mandatory, they're always optional. Making them mandatory for this feature seems very counterintuitive and not in-line with Swift standards IMO.

8 Likes

Depends on how you look at it. They're mandatory whenever it's not clear where a statement finishes and another one starts (which as far as I know is just when there's more than one statement per line). This would be introducing one more case where it's unclear, thus it could be argued that it's reasonable to resolve it with a semicolon.

I see your point but I don't think it's clear here that we're looking at one long statement:

requests.get("/user?ID=12345") { response in
    let user = response.args["user"]
    ...
}
andThen { 
  // do something else
}
finally {
  // do something more
};

This would also be valid code without multiple trailing closure syntax. We'd just consider the ; out of place.

1 Like

Yeah I see your point too, but it's mostly because of how you formatted the code. Maybe it's better to only allow this kind of syntax if the labels are keywords that can't be a start of a statement (eg: else, catch, finally but not if, else, for). My guess is that most of the cases where this is useful can fit into those keywords. It's also consistent with existing statements by not introducing new "pseudo-keywords" and doesn't require a semicolon at the end.

donny_wals:

the above would be reasonable in this proposal but there is no way of knowing whether andThen is a separate function or part of requests.get . On top of that, the following is also something that you might write:

fyi - I don't think the semicolon solves it. Looking at the requests.get example again:

requests.get("/user?ID=12345") { response in
    let user = response.args["user"]
    ...
}
andThen { 
  // do something else
}
finally {
  // do something more
};

Does requests.get take three trailing closures or does it just take one and andThen take two? i.e. - it marks the end, but what lets someone tell where the trailing closures start?

edit: actually, it's worse. What if requests.get only takes one closure and andThen is a second call. Then a new release of requests comes out and it gets a new get method with the two additional trailing closures. Suddenly what was compiled one way would not get compiled another way, no?

4 Likes
Terms of Service

Privacy Policy

Cookie Policy