Pitch: Double (or more) trailing closures

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.

2 Likes

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

Yes you're absolutely right, thanks for pointing this out, I'm starting to think the best option is what I proposed in the comment before yours (ie: only allowing else, catch, finally etc.) as labels.

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

This should cover 99% of the use cases without the ugliness/parsing issues introduced by allowing arbitrary labels to act as "pseudo-keywords".

A simple solution to the ambiguity problem of multiple ‘trailing’ closures is to simply require that they start on the same line as the preceding closure, after the }. e.g.:

// This is a single call to when(_:_:otherwise:)
when (2 < 3) { doSomething() } otherwise { do SomethingElse() }

// This is two independent calls, one to when(_:_:), the other to otherwise(_:).
when (2 < 3) { doSomething() }
otherwise { do SomethingElse() }

Or if you prefer:

// Again, unambiguously all a single statement & function call.
when (2 < 3) {
  doSomething()
} otherwise {
  doSomethingElse()
}

// Two independent statements & function calls (though it would arguably
// be stylistically superior to put a blank line between them, for further clarity).
when (2 < 3) {
  doSomething()
}
otherwise {
  doSomethingElse()
}

This builds on the notion that Swift implicitly uses line returns to signal the end of a statement, in lieu of an explicit semicolon. It also still leaves you the option of using an explicit semicolon to differentiate the scenario where you really do mean to start a second statement, not pass an additional, optional argument.

The compromise it makes is that it rules out the style choice of placing line-returns after the }, but I feel that’s fine since that’s atypical style in Swift, and if it comes down to a choice it’s more in line with idiomatic Swift than requiring special look-ahead parsing, semicolons on argument names, etc. It also ensures you can precisely mimic existing control flow syntax, if that’s your thing.

Mechanism aside, and thinking about whether Swift should adopt such functionality… I am intrigued by this. It does have some obvious concerns that would need assuaging, such as that it won’t end up being a C++ templates situation where people get carried away and produce confusing pseudo-DSLs and generally over-designed monstrosities. I’m also not all that interested in the intellectual amusement of replicating existing control flow structures by other means - that’s cute, but doesn’t seem practical.

However, I am interested in it being a cleaner way to express the very common patterns involved in using futures & promises, asynchronous / conditional control flow, and “N+1” call sequences (i.e. call this first closure for each of the N iterations / things / whatever, then this second closure once at the end).

It must be noted however that, unless variadic arguments of closures are also supported, this doesn’t seem to scale as well for … }.andThen { … }… sort of things.

2 Likes

This should be disallowed because it is so unswifty

Forcing trailing closures onto the same line feels like a really strange and non-obvious constraint to place on your code formatting, even though it does fit most of the time.

As much as I would love this feature, I'm wondering if the difficulty in making it unambiguous to the compiler that it's a trailing closure rather than a separate method call is going to cause similar issues for the user. Syntax highlighting that can make it more obvious but even that would require a decent amount of thought.

There are quite a few places in the standard library source code where they use a style of

if {
  ...
}
else {
  ...
}

and this is a configurable option in swift-format.

SwiftUI also seems to encourage a line break after the } when adding modifiers, although the situation is different since those are preceded by a dot:

Button {
  ...
}
.padding(...)
.background(...)

So this isn't a safe assumption to make, and if the goal is to allow people to write closure-based APIs that look like built-in constructs, the following inconsistency is a hard pill to swallow:

// Compiles
if { ... }
else { ... }

// Doesn't compile
myCustomThing { ... }
followupClause { ... }
2 Likes

No one is addressing what I proposed a few comments up. I'd like to hear thoughts on whether that would be better or not. If people feel that else, catch, finally are not enough maybe a select few keywords could be reserved for this use (andThen, etc.).

I'm starting to think the best option is what I proposed in the comment before yours (ie: only allowing else, catch, finally etc.) as labels.

I'd say that restricting them to keywords is awfully limiting. There are also other usages like multiple callbacks (alter values, cancel), which don't easily have keyword counterpart.

1 Like

I’m not clear on when these blocks would be executed, save finally obviously being executed last and always. Does the return value of the first closure need to be a Bool for an else block to be conditional on it?

It is, but it currently looks like the only way to avoid parsing issues, syntax workarounds and the strangeness of having things that look like keywords but aren't. We could get around that limit by carefully picking a handful of select keywords to add to the language that have no meaning on their own but are reserved to be used as labels for multiple trailing closures.

I can see that it would be hard to agree on a list of keywords to add that is both short, generic enough to cover most use cases we can foresee while not being something you'd usually pick as a function name. It might still be worth considering.

Well that would be up for implementation and as long as it's reasonable then it would be understood. For example, we already have another use for else in guard statements, but we can still understand what it does because it's consistent (it runs when the condition is false). I would expect further uses of else to be consistent with this, so returning to your question the first closure would never be called (unless by first closure you meant the condition auto-closure, in this case I'd expect the return to be a bool but it's not something that should be enforced).

Terms of Service

Privacy Policy

Cookie Policy