Why can initializer parentheses be omitted with callAsFunction + trailing closure

I’m experimenting with callAsFunction and noticed something about initializer parentheses:

struct Tester {
    func callAsFunction(with block: () -> Void) -> Int { 5 }
}
let result = Tester { } // This compiles and runs: 5

At first glance, it feels like the initializer isn’t explicitly called — I’d expect Tester() before the trailing closure.
For comparison, these also compile:

let result = Tester()(with: {}) // No trailing closure
let result = Tester()() {} // Trailing closure
let result = Tester() {} // Trailing closure with function's "()" being omitted
  • Why is it valid to write Tester { } without the explicit () after Tester?
  • Is there a proposal or source reference that explains this behavior?
5 Likes

Found in SIL, there is an instantiation of Tester of let result = Tester { }, specifically represented by the instruction %6 = apply %5(%4).

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s4main6resultSivp                // id: %2
  %3 = global_addr @$s4main6resultSivp : $*Int    // user: %12
  %4 = metatype $@thin Tester.Type                // user: %6
  // function_ref Tester.init()
  %5 = function_ref @$s4main6TesterVACycfC : $@convention(method) (@thin Tester.Type) -> Tester // user: %6
  %6 = apply %5(%4) : $@convention(method) (@thin Tester.Type) -> Tester // user: %8
  // function_ref implicit closure #1 in 
  %7 = function_ref @$s4mainSiyyXE_tcAA6TesterVcfu_ : $@convention(thin) (Tester) -> @owned @callee_guaranteed (@guaranteed @noescape @callee_guaranteed () -> ()) -> Int // user: %8
  %8 = apply %7(%6) : $@convention(thin) (Tester) -> @owned @callee_guaranteed (@guaranteed @noescape @callee_guaranteed () -> ()) -> Int // users: %13, %11
  // function_ref closure #1 in 
  %9 = function_ref @$s4mainyyXEfU_ : $@convention(thin) () -> () // user: %10
  %10 = thin_to_thick_function %9 : $@convention(thin) () -> () to $@noescape @callee_guaranteed () -> () // user: %11
  %11 = apply %8(%10) : $@callee_guaranteed (@guaranteed @noescape @callee_guaranteed () -> ()) -> Int // user: %12
  store %11 to %3 : $*Int                         // id: %12
  strong_release %8 : $@callee_guaranteed (@guaranteed @noescape @callee_guaranteed () -> ()) -> Int // id: %13
  %14 = integer_literal $Builtin.Int32, 0         // user: %15
  %15 = struct $Int32 (%14 : $Builtin.Int32)      // user: %16
  return %15 : $Int32                             // id: %16
} // end sil function 'main'
1 Like

it appears that this construct did not typecheck prior to swift 5.7 (see this demonstration with a similar example), so i'm not sure whether it's intentional that it currently does. i can't quite divine the difference in the typechecker's constraint solving debug output, but it seems like in the older compilers it maybe always considered the closure as an initializer parameter, and if the types did not align, it errored, but in later compilers it considers Tester {} as containing an implicit call to the zero-parameter synthesized initializer followed by a callAsFunction invocation. perhaps @Slava_Pestov or @xedin could shed more light on the details and intentionality of this behavior.

confusingly, if the type itself contains a closure, it has varying odd behavior:

struct Tester {
  var impl: () -> Void

  func callAsFunction(with block: () -> Void) -> Void {
    print("call as function invoked")
    impl()
  }
}
Tester { print("init or call arg?") }
// prints nothing

// but if you add a default value to the closure property...

struct Tester {
  var impl: () -> Void = { print("default closure invoked") }

  func callAsFunction(with block: () -> Void) -> Void {
    print("call as function invoked")
    impl()
  }
}
Tester { print("init or call arg?") }
// prints:
// call as function invoked
// default closure invoked

edit: these fuzzing sessions are very productive it seems – found another nearby crash: [Typechecker]: Crash involving trailing closures, callAsFunction, and duplicate declarations · Issue #85364 · swiftlang/swift · GitHub

4 Likes

I am not seeing the behavior you are, running in the latest beta at least. callAsFunction requires parentheses to be invoked:

(Tester { print("init or call arg?") }) { }

The original problem is still a problem. It can be subverted by using an initializer.

struct Tester {
  init(_: () -> some Any = { }) { }
  func callAsFunction(with block: () -> Void) -> Int { 5 }
}

let tester1 = Tester { }
let tester2 = Tester() { }
let int1 = (Tester()) { }
let int2 = (Tester { }) { }
let int3 = (Tester() { }) { }

i had tested it in a playground (some version of Xcode 26...), but here's a godbolt example that seems to have the same behavior (on the latest main branch snapshot): Compiler Explorer

I suspect this will be due to [ConstraintSystem] Match trailing closures to implicit `.callAsFunction` when necessary by xedin · Pull Request #41189 · swiftlang/swift · GitHub, which allows you to write e.g Foo() {} and have the trailing closure be part of an implicit callAsFunction call on Foo(). It's not immediately clear to me whether it was intentional to allow Foo {} as a callAsFunction though, cc @xedin.

5 Likes

I don’t remember exactly but I think it helps to support SwiftUI modifier types. We do allow parenthesis omission for normal initializer calls with trailing closures, so this is really no different, it just injects .callAsFunction like it would after ().

Hmm this seems quite different to me—in the case of TypeName { ... } there is never a clear invocation of the initializer so it is not at all obvious to me that there is an instance on which to invoke callAsFunction in the first place. The case of TypeName(...) { ... } there's clearly an instance on which we could invoke callAsFunction.

5 Likes

I vaguely remember that something like this came up in callAsFunction discussion, this is very similar to multiple trailing closures argument as well. We do allow skipping parenthesis and that’s why this works because Test { } means Test(<<defaults>>?) {} at the moment with or without callAsFunction.

Right, but I have always understood the model to be that you are allowed to skip (empty) parentheses for the call that receives the trailing closure. In this case we are omitting an entirely unrelated set of parentheses, for a prior call, merely because the type happens to implement callAsFunction. That doesn't seem particularly principled to me.

IOW, in my view, TypeName { ... } unambiguously attempts to invoke a TypeName.init and pass a trailing closure. TypeName() { ... } could be an invocation of TypeName.init or TypeName.callAsFunction.

4 Likes

I’m not sure what you mean, parenthesis are allowed to be omitted not because of callAsFunction but because it’s a type reference before the closure literal, Test() {} and Test {} are represented in exactly the same way - Test.init(){} because the solver even attempts to inject .callAsFunction anywhere.

My point is that the parentheses are omitted because of the trailing closure matching—the decision to transform Test {} into Test.init() {} is in my view, implicitly a decision that the trailing closure is indeed matched to the initializer. OTOH, Test() {} is genuinely ambiguous, because we could choose to either pass the trailing closure to the Test.init() call, or find an omitted set of parentheses for a callAsFunction invocation (Test()() { ... }) and pass the trailing closure to that call.

2 Likes

I unfortunately can't make any sense of what you've been saying here, but trying to figure it out did lead me to find that the related bug crashes the compiler when using .init.

struct Tester {
  func callAsFunction(_: () -> Void) { }
}
_ = Tester.init { } // 💥
_ = Tester.init() { } // 💥

I don’t think there is any disagreement on that, the solver gets exactly the same call representation in both cases is what I’m saying, and

indeed it used to be, and this is what the PR Hamish referenced was about. The solver would attempt to match closure to the initializer first and if that fails fallback to .callAsFunction if the type is callable to avoid this ambiguity.

I'm arguing that it shouldn't from a language perspective. The decision to expand Test { ... } into Test.init() { ... } should encompass a decision that the trailing closure is conclusively passed to the initializer. Otherwise there was no reason to expand the missing parentheses in the first place!

5 Likes

I understand you point but I cannot say that I agree that one should preclude the other because the subsequent transformation happens at a later point. This is just one example of how multiple features in the language interact in surprising ways.

Could you please file this as an issue on GitHub?

Doesn't seem worth it. Calling initializers without parentheses is the same as calling methods without parentheses. The bug needs to removed from initializers, to match methods, which do not have broken behavior. Once this is all fixed, I don't suspect there will be a need to address the crashing issue.

struct S {
  static func `init`() -> Self { .init() }
  func callAsFunction(_: () -> Void) { }
}
(S.`init`()) { } // Compiles, as it should.

// 💥 Extra trailing closure passed in call
// I don't care much if this compiles.
S.`init`() { }

S { } // Compiles. Makes no sense.

// 💥 Extra trailing closure passed in call.
// If the above line compiles, then this has to as well,
// for consistency with this subset of Swift that is not Swift.
S.`init` { }
1 Like

TSPL describes the rule for trailing closures and omission of parentheses as I've always understood it:

If a closure expression is provided as the function’s or method’s only argument and you provide that expression as a trailing closure, you don’t need to write a pair of parentheses () after the function or method’s name when you call the function

In other words you are allowed to omit the parentheses from a call when you are passing a trailing closure as the only (explicit) argument to a call. In the case of TypeName { ... }, if this invokes the instance's callAsFunction method, we've omitted the parentheses for a call (namely, the TypeName.init() call) which does not receive a trailing closure argument.

3 Likes

This sounds outdated because it means that multiple trailing closures shouldn’t allow omission on parenthesis either. Anyway, it sounds like this is something LSG should decide on that take into account possible source breaks changing this behavior would cause.