Swift 6 upgrade Preparation
Swift 5 updates have been slowly building up to the release of Swift 6. Some of the major updates have been the addition of async/await (concurrency) and existentials. If you use any of these features there will be some significant changes that will require some refactoring. Continue reading to learn how to prepare your projects and packages before the release of Swift 6 so you can also take advantage of new features (such as Swift 5.10's full data isolation) and have a smooth easy transition without any disruptive refactoring.
Migration to Swift 6
As of this publishing there doesn’t yet exist a formal migration guide to Swift 6, but the committee has indicated one will be available when remaining work on language features has been completed. There is a forum post discussing progress toward the Swift 6 language mode that goes into some of the details involved in transitioning. Since Swift 5.8 piecemeal adoption of Swift 6 language features can be adopted on as needed basis. In my experience most refactoring work will revolve around the StrictConcurrency
feature as that has continued to evolve through Swift 5.10 and introduces the most breaking changes.
Upcoming features that will be enabled by default in Swift 6
- SE-0274: Concise magic file names (
ConciseMagicFile
): replaces#file
with just the file name instead of a full path name. - SE-0286: Forward-scan matching for trailing closures (
ForwardTrailingClosures
): better matching for unnamed trailing closures. - SE-0337: Incremental migration to concurrency checking (
StrictConcurrency
): Turns on compiler support for static checking of Swift Concurrency usage. - SE-0354: Regex Literals (
BareSlashRegexLiterals
): Allows use of regular expression literals in code. - SE-0383: Deprecate @UIApplicationMain and @NSApplicationMain (
DeprecateApplicationMain
): Use@main
instead. - SE-0384: Importing Forward Declared Objective-C Interfaces and Protocols (
ImportObjcForwardDeclarations
): Improves usage of Objective-C in Swift code. - SE-0401: Remove Actor Isolation Inference caused by Property Wrappers (
DisableOutwardActorInference
): Removes the surprise conformance of a type to a property that has a property wrapper annotation which is also bound to a global actor instance type. - SE-0409: Access-level modifiers on import declarations (
InternalImportsByDefault
): Changes the default import of a package to be internal instead of public. - SE-0411: Isolated default values (
IsolatedDefaultValues
): Clarifies the rules of how to initialize a type with multiple properties that have default values but are bound to different global actor instance types. - SE-0412: Strict concurrency for global variables (
GlobalConcurrency
): Enabled static checking for cases where global variables could be used in an unsafe way in concurrent situations.
It seems that ExistentialAny
(SE-0335: Introduce existential any) has been withdrawn from the original planned default behavior of Swift 6, but it is expected to be part of the language default at some future point (currently defined for Swift 7). If you’ve been keeping up to date with this feature then you most likely have already applied many of the Xcode fix-its for conformance. If this is the case for you my recommendation would be to keep the changes as is and not worry about reverting anything as that would likely be more tedious than the original fix-its. If there are any breaking changes introduced from ongoing evolution implementations I would deal with those changes at that time, most likely just another round of simple fix-its.
Preparing Xcode Projects for Swift 6
Xcode has a specific build setting Swift Concurrency Checking that should be set to Complete. Most likely turning this on will cause breaking changes that will need to be fixed before further builds will succeed.
Additional features can be adopted by setting the Other Swift Flags field in Xcode’s build settings. Be aware that the -enable-upcoming-feature
and the actual feature name must be on separate lines.
Preparing Packages for Swift 6
Upcoming features can be turned on for Swift Packages that are using Swift 5.8 or greater. According to the piecemeal adoption proposal any features that become enabled by default in a future language version will be an error. This can be guarded against by using the preprocessor to enable features that are not yet already enabled.
An example implementation of a Package.swift
file:
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "MySwiftPackage",
platforms: [.macOS(.v12)],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "MySwiftPackage",
targets: ["MySwiftPackage"]
),
.library(
name: "MySwiftPackageTest",
targets: ["MySwiftPackageTest"]
)
],
dependencies: [
.package(url: "https://github.com/jamf/Haversack.git", .upToNextMajor(from: "1.0.0"))
],
targets: [
.target(
name: "MySwiftPackage",
dependencies: [
.product(name: "Haversack", package: "Haversack"),
]
),
.target(
name: "MySwiftPackageTest",
dependencies: ["MySwiftPackage"]
),
.testTarget(
name: "MySwiftPackageTests",
dependencies: ["MySwiftPackageTest"]
)
]
)
// If future target types are also incompatible with swiftSettings then
// the where test will need to be updated.
for target in package.targets where target.type != .binary {
var swiftSettings = target.swiftSettings ?? []
// According to Swift's piecemeal adoption plan features that were
// upcoming features that become language defaults and are still enabled
// as upcoming features will result in a compiler error. Currently in the
// latest 5.10 compiler this doesn't happen, the compiler ignores it.
//
// If the situation does change and enabling default language features
// does result in an error in future versions we attempt to guard against
// this by using the hasFeature(x) compiler directive to see if we have a
// feature already, or if we can enable it. It's safe to enable features
// that don't exist in older compiler versions as the compiler will ignore
// features it doesn't have implemented.
// swift 6
#if !hasFeature(ConciseMagicFile)
swiftSettings.append(.enableUpcomingFeature("ConciseMagicFile"))
#endif
#if !hasFeature(ForwardTrailingClosures)
swiftSettings.append(.enableUpcomingFeature("ForwardTrailingClosures"))
#endif
#if !hasFeature(StrictConcurrency)
swiftSettings.append(.enableUpcomingFeature("StrictConcurrency"))
// StrictConcurrency is under experimental features in Swift <=5.10 contrary to some posts and documentation
swiftSettings.append(.enableExperimentalFeature("StrictConcurrency"))
#endif
#if !hasFeature(BareSlashRegexLiterals)
swiftSettings.append(.enableUpcomingFeature("BareSlashRegexLiterals"))
#endif
#if !hasFeature(ImplicitOpenExistentials)
swiftSettings.append(.enableUpcomingFeature("ImplicitOpenExistentials"))
#endif
#if !hasFeature(ImportObjcForwardDeclarations)
swiftSettings.append(.enableUpcomingFeature("ImportObjcForwardDeclarations"))
#endif
#if !hasFeature(DisableOutwardActorInference)
swiftSettings.append(.enableUpcomingFeature("DisableOutwardActorInference"))
#endif
#if !hasFeature(InternalImportsByDefault)
swiftSettings.append(.enableUpcomingFeature("InternalImportsByDefault"))
#endif
#if !hasFeature(IsolatedDefaultValues)
swiftSettings.append(.enableUpcomingFeature("IsolatedDefaultValues"))
#endif
#if !hasFeature(GlobalConcurrency)
swiftSettings.append(.enableUpcomingFeature("GlobalConcurrency"))
#endif
// swift 7
#if !hasFeature(ExistentialAny)
swiftSettings.append(.enableUpcomingFeature("ExistentialAny"))
#endif
target.swiftSettings = swiftSettings
}
Advice for Updating Packages for Strict Concurrency
Audit your package to add concurrency annotations. Most of this work will involve adding Sendable conformance to types. A Sendable
type can be safely passed across concurrency domains. Updating packages with concurrency annotations allows the compiler to check for potential data races and other concurrency problems at compile time. Even if static checking is turned off by using features such as nonisolated(unsafe)
or @unchecked Sendable
the thread-analyzer can still detect violations and report them during runtime.
- Add conformance to
Sendable
for types even if they are implicitlySendable
such as structs. When your package is imported the compiler doesn’t implicitly infer whether the type isSendable
and Xcode will display a warning. AddingSendable
conformance will typically cause breaking changes for consumers of the library, I would recommend using SemVer to version the update as a major new version. - If you need to use
@unchecked Sendable
then be sure the type is actually safe to use as aSendable
type. Class types that already implement their own internal synchronization to protect their mutable state can safely use@unchecked Sendable
but be aware this turns off static checking by the compiler. In the rare event a new type needs to be created that can’t be an actor type then implementing some type of synchronization should be done. This can be accomplished with a serialDispatchQueue
or other types of locks as you see fit. - If the class has a complex inheritance/subclass hierarchy then you may consider refactoring it using actors and switching to a composite design pattern since actors cannot be subclassed but ultimately that is a subjective decision. One advantage of converting to an actor type is that the compiler can check for concurrency issues that may otherwise be missed in a class marked as
@unchecked Sendable
. - Convert methods that take completions to be async instead. Avoid overuse of
Task { }
when converting a method to be async as this can introduce unintended hops out of a context you did not previously expect. Convert any methods that call the new async method to also be async and continue this until all callee methods are async if necessary. If the method that has a completion handler doesn’t have a complex call hierarchy then adding an async alternative should be a simple process. - Consider converting classes that use a serial
DispatchQueue
to an actor type. Actor types are implicitlySendable
and the reduction in code complexity can be significant. Actor types also undergo static analysis by the compiler for concurrency issues that would otherwise be ignored from a class that uses@unchecked Sendable
.
Solutions to Help Migration
Some features have Xcode fix-its that can be mass applied very easily, other features that enable Strict Concurrency can cause breaking changes that may require some non-trivial refactoring. Most if not all the solutions mentioned below will be specific for adopting Strict Concurrency since that’s where the most headaches will occur. It may be helpful to read the proposal On Actors and Initialization to have some familiarity with changes to how init
and deinit
isolation/nonisolation is implemented.
Convert Methods that Use Completion Handlers to Async Methods
Convert Function to Async and Add Async Wrapper are typically the better options. Add Async Alternative refactors the original method to wrap its call in a Task { }
and call the async version, this may introduce an unintended hop where it would otherwise not be needed. I would recommend re-implementing a new async version of the method instead.
Conversion to an async method can be achieved without the helpers by using a CheckedContinuation
or an UnsafeContinuation
see the following methods:
- withUnsafeContinuation(_:)
- withUnsafeThrowingContinuation(_:)
- withCheckedContinuation(function:_:)
- withCheckedThrowingContinuation(function:_:)
Continuations are a way to interface synchronous code with asynchronous code.
Global or Static Var is Not Concurrency-Safe in a Non-Isolated Context
- If possible convert the global or static to a
let
constant and also addSendable
conformance to the type. - If the value is only used within a certain concurrency domain then add a global actor instance type attribute to its declaration (e.g. add
@MainActor
attribute to the declaration). - Audit the type and if it’s determined its use is actually concurrency-safe then
nonisolated(unsafe)
can be used.
Actor-Isolated Property Can Not Be Referenced from a Non-Isolated Context (deinit
)
This has always been an error when attempting to modify an actor’s property from outside the actor context or from a nonisolated actor method. In newer versions of Swift this will appear as a warning inside a deinit
method on an actor because the actor’s deinit
method is inherently nonisolated, solving this problem can be quite tricky and an obvious use of Task { }
could lead to a situation where self
is captured after deallocation, typically resulting in a crash of some sort. There are evolution proposals to fix this problem but until then we must resort to the tools we have.
Consider the following actor definition:
public actor BackgroundScheduler {
private lazy var instanceSchedulers = [String : NSBackgroundActivityScheduler]()
public init() {}
deinit {
instanceSchedulers.forEach { $0.value.invalidate() }
}
}
The property instanceSchedulers
cannot be accessed from the nonisolated deinit method, at least in Swift 6 that will be an error. If the value is Sendable
or has its own synchronization for protecting mutable state then we can assume isolation in the deinit
method and safely cleanup what we need to (note in this case it should be reasoned that NSBackgroundActivityScheduler
is thread-safe which according to the documentation it uses a serial queue so we will assume it is).
The following code example shows how to assume isolation in a deinit
method:
public actor BackgroundScheduler {
private lazy var instanceSchedulers = [String : NSBackgroundActivityScheduler]()
public init() {}
deinit {
// use the following method to avoid a hop outside of this deinit, this is probably safe because even though something else could capture the `NSBackgroundActivityScheduler` instance we are assuming that its `invalidate()` method is synchronized internally
assumeIsolatedDuringDeinit { actor in
actor.instanceSchedulers.forEach { $0.value.invalidate() }
}
}
}
extension BackgroundScheduler {
// An actor's deinit is nonisolated so we need to cleanup state that needs to exist past this instance's deinit. Currently there is no way to accomplish this that wouldn't be an error in Swift 6, hopefully that changes at some point. This evolution proposal attempts to address this problem: https://github.com/apple/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md
// Method influenced from https://github.com/apple/swift/blob/47803aad3b0d326e5231ad0d7936d40264f56edd/stdlib/public/Concurrency/ExecutorAssertions.swift#L351
@_unavailableFromAsync(message: "express the closure as an explicit function declared on the specified 'actor' instead")
private nonisolated func assumeIsolatedDuringDeinit<T>(_ operation: (isolated BackgroundScheduler) throws -> T) rethrows -> T {
typealias YesActor = (isolated BackgroundScheduler) throws -> T
typealias NoActor = (BackgroundScheduler) throws -> T
// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn(self)
}
}
}
Conversely you may have a need to assume isolation in init
and this same technique can work in that case also. Be careful to disambiguate from the library provided assumeIsolated
method if you decide to generalize your implementations.
Custom Actor Executors (using DispatchQueue)
One reason to implement a custom actor executor is to adopt the same synchronization that a non-actor type uses. This is useful in cases where a library type uses a delegate pattern and synchronizes itself on DispatchQueue
supplied by the enclosing type. CBPeripheralManager
in the CoreBluetooth
framework is one such type that can take advantage of a custom actor executor and the following example will illustrate that with a partial implementation.
First define a class that conforms to SerialExecutor
, implementation will be slightly different on newer OS versions.
extension BluetoothBeaconService {
final class SerialDispatchQueueExecutor: SerialExecutor {
private let queue: DispatchQueue
init(queue: DispatchQueue) {
self.queue = queue
}
func enqueue(_ job: UnownedJob) {
let unownedExecutor = asUnownedSerialExecutor()
queue.async {
job.runSynchronously(on: unownedExecutor)
}
}
// @available(macOS 14.0, iOS 17.0, *)
// func enqueue(_ job: consuming ExecutorJob) {
// let unownedJob = UnownedJob(job)
// let unownedExecutor = asUnownedSerialExecutor()
//
// queue.async {
// unownedJob.runSynchronously(on: unownedExecutor)
// }
// }
// this has a default implementation in macOS 14.0
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
}
Next define a method that will assume the delegate callbacks from a CBPeripheralManager
are isolated to the DispatchQueue
used by both instances. This is necessary because the delegate protocol must conform to nonisolated methods and this is safe because the callbacks occur on the serial dispatch queue. Notice that this method performs a check to make sure execution is taking place on the correct dispatch queue.
extension BluetoothBeaconService {
// this is very similar to `assumeIsolated` in the public Swift stdlib execpt we want to assume we are isolated on the same DispatchQueue without having a Task spun up. https://github.com/apple/swift/blob/47803aad3b0d326e5231ad0d7936d40264f56edd/stdlib/public/Concurrency/ExecutorAssertions.swift#L351
@_unavailableFromAsync(message: "express the closure as an explicit function declared on the specified 'actor' instead")
private nonisolated func assumeIsolatedToQueue<T>(_ operation: (isolated BluetoothBeaconService) throws -> T) rethrows -> T {
typealias YesActor = (isolated BluetoothBeaconService) throws -> T
typealias NoActor = (BluetoothBeaconService) throws -> T
dispatchPrecondition(condition: .onQueue(queue))
// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn(self)
}
}
}
Now the entire actor implementation and delegate methods can be completed. Note that the CBPeripheralManagerDelegate
methods are nonisolated
in order to satisfy the protocol requirements and this is one of the places assumeIsolatedToQueue
is used because it is assumed the callbacks occur on the serial dispatch queue. If the internal implementation ever changes the dispatchPrecondition
will catch this during development.
actor BluetoothBeaconService: NSObject {
private let queue = DispatchQueue(label: "beacon.control", qos: .userInitiated)
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
// we need to maintain a reference to our executor to keep it alive
private nonisolated let executor: SerialDispatchQueueExecutor
private var btManager: CBPeripheralManager!
private let beaconData: BeaconData
var canAdvertise = false
private var _isAdvertising = false
nonisolated var isAdvertising: Bool {
// since we have direct access to the queue we can maintain the original interface where this didn't require an await
queue.sync {
assumeIsolatedToQueue { actor in
actor._isAdvertising
}
}
}
init(beaconData: BeaconData) {
executor = SerialDispatchQueueExecutor(queue: queue)
self.beaconData = beaconData
super.init()
btManager = CBPeripheralManager(delegate: self, queue: queue)
}
deinit {
btManager.stopAdvertising()
}
func start() {
advertiseIfNeeded()
}
func stop() {
btManager.stopAdvertising()
_isAdvertising = false
}
func advertiseIfNeeded() {
if canAdvertise && !_isAdvertising {
btManager.startAdvertising(beaconData.advertisementDictionary)
}
}
}
extension BluetoothBeaconService: CBPeripheralManagerDelegate {
nonisolated func peripheralManagerDidUpdateState(_ peripheralManager: CBPeripheralManager) {
assumeIsolatedToQueue { actor in
actor.canAdvertise = (peripheralManager.state == .poweredOn)
if actor.canAdvertise {
actor.advertiseIfNeeded()
} else {
actor._isAdvertising = false
}
}
}
nonisolated func peripheralManagerDidStartAdvertising(_ peripheralManager: CBPeripheralManager, error: (any Error)?) {
assumeIsolatedToQueue { actor in
if let error {
actor._isAdvertising = false
} else {
actor._isAdvertising = true
}
}
}
}
Now the actor type is conveniently synchronized with the same serial dispatch queue one of its properties uses. This reduces excessive hopping from awaiting new Task
blocks that would otherwise need to be used in the delegate callbacks.
Conclusion
Although migration to Swift 6 is entirely optional there are benefits to opting into features that will eventually be language defaults such as additional static analysis by the compiler. At the minimum you can begin writing code today that conforms to future default features that will reduce the amount of eventual refactoring required to adopt those features at a later date.
Further Reading
- If you’re curious what features are available in the main branch or a specific release of Swift you may be interested in a helpful script from Ole Begemann.