Thoughts on Swift 5/6 compatibilty for Packages

I've started putting real effort into migrating packages of mine to make better use of Swift 6 language features. I'm finding it incredibly hard to live without them. The thing is, a lot of these changes affect my public API.

Occationally, I can pull of what I want with a typealias:

#if compiler(>=6.0)
	typealias Operation = @isolated(any) () async throws -> Void
#else
	typealias Operation = @Sendable () async throws -> Void
#endif

But, often I end up finding myself wanting to do stuff like this:

	public func doThing(
#if compiler(>=6.0)
	closure: sending @escaping () -> Void
#else
	closure: @escaping @Sendable () -> Void
#endif
	) {
		// ...
	}

This does not compile, and this forces me to duplicate the function declaration in a way that is typically quite hard to factor into a single common shared internal implementation.

Does anyone have any tips/tricks on how to best deal with this kind of stuff? I think I'm just going to abandon Swift < 6.0, but I thought I'd ask just in case.

9 Likes

In the latter case, iirc, you can wrap the entire method signature in the conditional, but not the method body. And barring that, you can create two methods that call a shared private implementation. I have not tested this in a playground yet, but I recall seeing this pattern before.

1 Like

If you can wrap the whole signature, I cannot figure out how. All permutations I can think of fail to compile...

But, worse, these changes can have profound semantic differences which can, sometimes, make that common shared private implication really hard to actually implement.

1 Like

Like @sky suggested, you can create two public functions that effectively just share an implementation. For example, one could do:

#if compiler(>=6.0)
@inline(__always)
public func doThing(closure: sending @escaping () -> Void) { _doThing(closure) }
#else
@inline(__always)
public func doThing(closure: @escaping @Sendable () -> Void) { _doThing(closure) }
#endif

@usableFromInline
internal _doThing(_ closure: @escaping () -> Void) {
	// ...
}

It’s certainly not ideal and rather cumbersome, but theoretically it should work just fine. I think the trickier part will be finding a signature for the implementation function that works for both compiler versions

1 Like

Right, this totally is a feasible approach if the type differences for that closure argument do not matter. But that's exactly what I'm talking about.

The difference between a sending, @Sendable, and plain closure are significant and will almost certainly make _doThing unimplementable without unsafe casting.

Hey Mattie, I know its a bit off-topic but in your first snippet you replace @Sendable with @isolated(any), does this generally apply when using Swift 6 (I haven't looked into @isolated(any) yet) or it applies for your own solution in particular?

In fact, that's a weird artifact of my particular solution.

@isolated(any) and @Sendable are type attributes, and are not interchangable. sending is often a great replacement for @Sendable, but is not a type attribute.

So, this is why these interfaces are so hard to build for two compilers. They have fundamentally different types and signatures, and will affect all code that interacts with them.

1 Like

But if they do have different signatures it is fine to do overloads and slowly deprecate the old one, isn't that easier for evolving your API?

You can send deprecation warnings if users use your old APIs.

1 Like

You can try to limit unsafe to a narrow score, maybe something like this can work?

#if compiler(>=6.0)
func doThing(closure: sending @escaping () -> Void) { 
    nonisolated(unsafe) let closure = closure
    _doThing(closure) 
}
#else
func doThing(closure: @escaping @Sendable () -> Void) { 
    _doThing(closure) 
}
#endif

private func _doThing(_ closure: @escaping @Sendable () -> Void) {
	// ...
}

(this compiles, but I'm not sure how safe this assumption on @Sendable for inner implementation when going from sending, in theory this should be fine?)

1 Like

Sort of! This is more of an evolution of an existing API. It is 100% source-compatible with both Swift 5 and Swift 6 mode, but requires a Swift 6 compiler.

You are making me wonder how overloads might work in this situation. But at the end of the day, the internal implemenation still gets quite messy, at best, because of the different meanings of the types being used.

Oh! This is something I have not thought of. You're right though, you have to be quite careful to make sure the runtime behavior is compatible. But this is something that would at least help specifically for the @Sendable -> sending transition, and possibly in other situations as well. It's less-unsafe than a bitcast.

At the end of the day, though, I'm just not sure it's worth it. Even if I do decide this is something I do, I'm still expecting a rapid transition to the Swift 6 compiler across the community...

1 Like