Swift closures normally consist of two pointer-sized fields, one for the function entry point, and one for a reference to the captures. There are many occasions where developers want to work abstractly with functions that are known not to have any captures, and they would like to statically enforce that no capture context is allocated, and get the size and performance benefits of passing around a single, non-reference-counted function pointer. Officially, the only way to do this in Swift today is with @convention(c)
function types, but these types are limited by C interop, and can only have parameters and returns of C-compatible types. Unofficially, there's @convention(thin)
, but it was never intended for human consumption, and attempting to use it will cause compiler crashes and miscompiles for anyone not intimately aware of Swift's implementation details. Let's talk about fixing that so that @convention(thin)
can become an officially supported feature.
Making calling convention details explicit
Swift function types by default abstract away a number of calling convention details, and Swift will automatically convert between different calling conventions for the same high-level function type. However, it does this by capturing the old function value in order to create the new function value, and we can't generally do that with a function representation that doesn't support captures. The relevant details include:
- what ownership convention is used to manage the parameter's lifetime
- whether a parameter is passed or returned indirectly (as a pointer to a memory location containing the value) or directly (as one or more registers directly containing the value)
SE-0377 already puts the ownership convention under developer control with the borrowing
and consuming
modifiers, and SE-0390 made these modifiers a requirement for parameters of ~Copyable
type, since the convention cannot generally be abstracted away without the ability to implicitly copy values. For similar reasons, I think we could say that a @convention(thin)
function type must make its parameter and return ownership conventions explicit, as well as the indirectness of those parameters and returns. In addition to the existing consuming
/borrowing
/inout
modifiers for specifying parameter convention, we could also introduce direct
and indirect
modifiers to explicitly state the indirectness of parameters and returns, and so you can state a thin function type with full specificity as:
func foo(_ x: Int) -> Void {
print(x)
}
func bar(_ x: String) -> Void {
print(x)
}
func callTwice<T>(_ fn: @convention(thin) (indirect borrowing T) -> direct Void, with value: T) {
fn(value)
fn(value)
}
callTwice(foo, with: 17)
callTwice(bar, with: "38")
In order to maintain source compatibility with the existing feature, we can start warning if a @convention(thin)
function doesn't explicitly state the directness and ownership convention of any parameters or returns.
That said, having to specify explicit directness and ownership for every parameter is very verbose. We could conceivably still default to borrowing
ownership for copyable parameters. We could also decide to make one of direct
or indirect
implicit, and have only the other need to be stated explicitly. Only certain types support being passed or returned directly, and indirect
arguments are more amenable to type punning and other shenanigans for the iron-hearted, so indirect
might be a better default if we decide to go that way.
Manually setting the symbol name for Swift functions
Another unofficial feature we've had for a long time, and told people not to use if there are any alternatives for as long, is the @_silgen_name
attribute, which sets the symbol name to use for the Swift entry point of a function. We ward people away from this feature for similar reasons to @convention(thin)
, that it isn't possible to use correctly without deep knowledge of the Swift implementation. However, with full manual control of the calling convention, and proper support for thin function pointers, a lot of that reason goes away, since you could then conceivably write in one binary:
@symbolName("foo")
public func foo(x: direct borrowing Int, y: indirect borrowing Any) { ... }
and in another binary do:
let foo = unsafeBitCast(
dlsym(sohandle, "foo")!,
to: (@convention(thin) (direct borrowing Int, indirect borrowing Any) -> ()).self)
foo(17, "hello")
to dynamically look up the symbol, which you can't do today.