@convention(c) with result builders — why does this work?

@convention(c) functions have some pretty strict limitations, amongst others:

  1. they can't capture context and
  2. they can't capture generic context or parameters (I read that as "be specialized")

That second limitation is why this doesn't work:

struct MyType<T> {

    static func doSomething() {
        let f: @convention(c) () -> Void = { print(T.self) }
        // ^ error: A C function pointer cannot be formed from a closure that captures generic parameters
    }

}

That's why I was a little surprised to see this does work:

@resultBuilder
struct RunnerBuilder<T> {
    
    static func buildBlock(_ components: Never...) {
        print(T.self)
    }
    
}

func run<T>(_ type: T.Type = T.self, @RunnerBuilder<T> with runner: @convention(c) () -> Void) {
    runner()
}

run(Int.self, with: { })

// prints "Int"

It even works when called from C code. This feels like it flies in the face of the second limitation I mentioned before, but presumably there's some sort of type resolution path the compiler takes here that makes this valid, even though it does end up generating what I would consider a sort of specialized C function.

I suppose my main question here is:

  1. is this reliable behavior, or a fluke? and
  2. if this is reliable... why isn't it extended to other generic contexts?

Generic parameters become ordinary parameters in the calling convention; specialization is an optimization that is performed, but not required by the implementation model.

I believe this is essentially equivalent to:

struct RunnerBuilder<T> {
    
    static func buildBlock(_ components: Never...) {
        print(T.self)
    }
    
}

func run<T>(_ type: T.Type, with runner: @convention(c) () -> Void) {
    runner()
}

run(Int.self, with: { RunnerBuilder<Int>().buildBlock() })

The @convention(c) function doesn't capture anything from the outer scope here.

2 Likes

That makes a certain sense but... how would you say that differs from:

func run2<T>(_ type: T.Type) {
    @RunnerBuilder<T> func runner() { }
    
    let cfunc: @convention(c) () -> Void = runner
    // ^ A C function pointer cannot be formed from a local function that captures generic parameters
}
1 Like

This is equivalent to:

func run2<T>(_ type: T.Type) {
    let cfunc: @convention(c) () -> Void = { RunnerBuilder<T>.buildBlock() }
}

The closure body captures a reference to T from the outer scope, which is not supported.

In the first example, the closure is not lexically nested inside a generic declaration, so there is nothing that it can possibly capture:

run(Int.self, with: { RunnerBuilder<Int>().buildBlock() })
3 Likes

I won't lie, that feels sort of arbitrary. It feels convenient that T is turned into Int inside the function's parameters but not it's body :thinking: At the end of the day I'm glad it works and I can use it, but it feels like the same rule should apply to both.

Are there any other contexts or examples like this — generics resolved outside of a scope(?) — besides functions (and maybe typealiases?)

Also, how does what you shared about scope apply in an instance like this?

struct Runner<T> {
    
    init(_ parserType: T.Type = T.self, @RunnerBuilder<T> runner: @convention(c) () -> Void) {
        runner()
    }
    
}

Runner(Int.self) { }
1 Like

If you approach type parameters as just another kind of input parameter to a function, then if you think about it, it’s actually the same rule as with captures of values.

Consider this example:

func f() {
  let fn: @convention(c) () -> () = {
    let n = 3
    print(n)
  }
}

This is clearly fine, because n is local to the closure. There’s no capture.

Now this is not allowed; the closure captures a value from the outer scope:

func f(n: Int) {
  let fn: @convention(c) () -> () = {
    print(n)
  }
}

This is the same except instead of n you’re capturing T:

func f<T>(t: T) {
  let fn: @convention(c) () -> () = {
    print(T.self)
  }
}

The closure expression here, { } is written at the top level of the source file outside of any generic declaration. It will have a non-trivial body after the result builder transform runs, but it will not have any captures (it’s at the top level).

As before, the result builder doesn’t fundamentally change anything, other than by adding some stuff to the closure’s body. Capture computation and @convention(c) checking happens after the result builder transform so you can reason about each feature in isolation.

5 Likes

That helps! Thank you for taking the time to explain.

1 Like