Support trailing closure syntax for single-argument Array and Dictionary initializers

In the recent discussion about @ArrayBuilder, it came up that trailing closure syntax is not currently supported in the most intuitive way for Array initializers like init(@ArrayBuilder build: () -> [Element]):

I investigated this on the parsing side, and have a prototype implementation: Support `[Element] { ... }` trailing closure init syntax by calda · Pull Request #86244 · swiftlang/swift · GitHub

We can pretty easily allow trailing closure syntax to be used for Array and Dictionary initializers following the sugared type name. For example:

extension Array {
    init(@ArrayBuilder_ build: () -> [Element]) {
        self = build()
    }
}

var value = [String] {
    "a"
    "b"
}

Code like this is currently rejected with various errors, and would become supported:

// error: 'let' declarations cannot be computed properties
let a = [String] {
    "a"
}

// error: variable with getter/setter cannot have an initial value
var b = [String] {
    "b"
}

The more impactful change is that this also affects array value literals that could be confused for types with a closure on the following line. For example, this closure is currently interpreted as a separate, unused closure (producing an error):

let string = "foo"
let array = [string]
{ print("bar") } // error: closure expression is unused

but would instead become interpreted as a trailing closure (which also produces an error);

let string = "foo"
let array = [string]
{ print("bar") } // error: cannot call value of non-function type '[String]'

Since these examples are all invalid today, would be fine to change the meaning of them. The only example I can find where this is valid today is a result builder that takes closure values:

@resultBuilder
enum FunctionArrayBuilder {
    static func buildBlock(_ components: (() -> Void)...) -> [() -> Void] {
        components
    }
}

@FunctionArrayBuilder
var buildFunctions: [() -> Void] {
    let foo = "foo"
    let array = [foo]
    { print(array) } // Currently compiles, but if parsed as trailing closure would error with "cannot call value of non-function type '[String]'"
}

To avoid this source break, we could include heuristics like:

  1. The trailing closure open brace { must be on the same line as the closing brace ] of the array type.
  2. The array literal must parse successfully as a type (excludes arrays like ["foo"], but still includes arrays like [foo]).
  3. The array literal must only include a single element (excludes arrays like [foo, bar]).

There is existing precedent in the language for the the presence of a new line changing whether code is interpreted as a call or two separate expressions:

func foo(
  a: String,
  b: String
) {
  print("Called", a)
}

// Calls `foo`
let first = foo(
  a: "1", 
  b: "1"
)

// Does not call `foo`
let second = foo
(
  a: "2", 
  b: "2"
)

// Prints:
// Called 1
// ()
// (String, String) -> ()
print(type(of: first))
print(type(of: second))

Given all known potential source breaks currently include the newline, and there is existing precedent for that sort of heuristic, including that one heuristic to avoid the source break seems reasonable. Including the other heuristics probably doesn’t hurt, but I can’t think of an example where it would matter.

What do folks think about the pitch overall? Can anyone think of any other source compatibility concerns? I worked through some examples with if statements/expressions and everything checked out.

5 Likes

Wait—why is [string] (with lowercase 's') being confused for a type when there is no such element type string?

More specifically, when parsing expressions, []s are always parsed as array literals:

let array: [String] // Parsed as an ArrayType

[String].foo() // Parsed as an ArrayExpr literal
[string].foo() // Parsed as an ArrayExpr literal

The array literal later becomes interpreted as a type during type checking. We don’t know during parsing whether or not the array literal is a type.

That isn't what's happening here. The error is saying that the type of [string] is [String], and now it's treating [string] { ... } as a function call as if it was written [string]({...}) and correctly diagnosing that it can't call a value of that array type.

Now if we wrote this:

extension [String] {
  func callAsFunction(_ body: () -> Void) { body() }
}
let string = "foo"
let array = [string]
{ print("bar") }

I would expect that to successfully compile and print bar, based on the new treatment.

So technically this would be a source break, because it changes the meaning of the last two lines of code, even though it's unlikely to occur in practice because the closure is unused (but as Cal points out, the result builder case is currently valid).

What's interesting is that this is required for function call arguments (the open paren must be on the same line) but not a trailing closure brace. I guess that's to let people use Allman-style braces, like you mention. And swift-format does use Allman-style braces by design on occasion (depending on the indentation of the line preceding the brace), so I definitely wouldn't want to force a special narrow case like "trailing closures following collection literal sugar must keep the brace on the same line".

I always get error: closure expression is unused in these sorts of non-result-builder examples, so I don’t think would be a source break, unless we can find an example that doesn’t produce the closure expression is unused error. The result builder case definitely is a source break though.

It seems reasonable for the long-term goal to be to not include this sort of heuristic, since it doesn’t exist for other trailing closure use cases.

If we are fine with the result builder source break (which we have no evidence of actually happening in practice), then we don’t need this heuristic. I recall we did ship minor result builder source breaks in a non-major language version somewhat recently ( Improved Result Builder Implementation in Swift 5.8 , well-justified given the benefits of that change).

The more conservative approach would be to use the same-line heuristic in the Swift 6 language mode, and drop it in the next language mode.

The only other path I can think of would be to not enable trailing closures here at all until the next language mode.

You're right, I misread the diagnostic and thought it was a warning, because most other cases of unused code (like a local variable) are only warnings, not errors. So I guess that "saves" us in this case, though I don't know how much, since it would be awkward and probably undesirable to treat result builders and regular code differently here (especially since the decision about whether it's a trailing closure has to be made at parse time, where we don't know if we're in a builder or not).

1 Like

Last I checked, this wasn't actually supported for array literals just as the currently pitched usage isn't supported, so it'd not be a source break with actual working code.

However, it certainly ought to work this way by the articulated rules of the language, so I'd hope the currently pitched feature wouldn't muddy the waters by hardcoding treatment as a type here even if it's not seeking to make callAsFunction supported in this way.

Right, what I'm saying is that under the new (pitched) treatment I would expect that example to work. It certainly doesn't work today.

2 Likes

By accepting trailing closures here, the pitch also enables using trailing closures with array literal callAsFunction calls. This falls out automatically from the implementation (just double-checked that it works as expected using the current implementation).

I agree that this should work given how trailing closures work elsewhere. Will make sure to mention this in the next revision.

2 Likes

Can you test that your change works with InlineArray as well? It already has an initializer with that shape:

[4 of Int] { 1 << $0 }
1 Like

Tested it, it works! :smiley:

4 Likes

Although this also already compiles today in Swift 6.2.1:

let value = [4 of Int] { 1 << $0 }
print(value[0]) // prints 1

Since there is no inline array literal, [4 of Int] is unambiguously parsed as a TypeExpr here, isLiteral returns false here, and the brace is parsed as a trailing closure.

2 Likes

...implying, though, that if—if—we adopted [4 of x] notation for inline array values, we could actually break this currently working code if we didn't have your pitch...

2 Likes

I was just about to post a reply advocating for being maximally conservative. However, I then played around with your result builder example and noticed the following doesn't compile in today's Swift (and rightly so IMO):

@resultBuilder
enum FunctionArrayBuilder {
    static func buildBlock(_ components: (() -> Void)...) -> [() -> Void] {
        components
    }
}

@FunctionArrayBuilder
var buildFunctions: [() -> Void] {
    let foo = "foo"
    let array = [foo]
    let array2 = array // Note this added line
    { print(array) }
}

With that in mind, any intended existing usage of bare { ... } in such a context is already extremely brittle. Not only could it potentially stop compiling due to a change one line above, it could (worse yet) continue to compile but with a different meaning if the preceding line ended with something that could be initialized or called as a function.

The status quo seems like a misfeature that's prone to unintentional badness, and I don't know that we want to perpetuate or accommodate it. For that reason, I think we ought not to be conservative here and should proceed without trying to bend over backwards.

4 Likes

Question for folks on the language steering group: I’m drafting a proposal for both this change and this other change related to generic result builders. For now I’ve written it as a single proposal covering both changes, with the thinking being that they’re thematically related to the same use case (ergonomics of a generic result builder like a hypothetical @ArrayBuilder) and combining them reduces the operational overhead of running the review.

Here’s the current draft, which I still need to update based on the great discussion in this thread: [WIP] xxxx-generic-result-builder-ergonomics.md · GitHub

Does this bundling seem reasonable, given the same overarching theme / use case, or do you think it would be more reasonable as two completely separate proposals?

It seems like the change for trailing closures is independent and unrelated to the one on generic result builders, even though they serve an eventual goal of improving the ergonomics of yet a third feature you'd like to propose in some form.

So my personal opinion would be to propose them separately. Small focused proposals are totally fine (and often a nice break from the heavier proposals!) rather than bundling unrelated things together and leading the reader to think there's a connection that really isn't there.

9 Likes

Makes sense works for me, thanks!

Here’s an updated draft for the proposal: Array expression trailing closures by calda · Pull Request #3063 · swiftlang/swift-evolution · GitHub

And here’s a toolchain you can download and install if you want to try out the change: [Pull Request] Swift Build Toolchain - macOS #2171 [Jenkins]

1 Like