UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

What's new in Swift 5.5?

Async/await, actors, throwing properties, and more!

Paul Hudson       @twostraws

Swift 5.5 comes with a massive set of improvements – async/await, actors, throwing properties, and many more. For the first time it’s probably easier to ask “what isn’t new in Swift 5.5” because so much is changing.

In this article I’m going to walk through each of the changes with code samples, so you can see how each of them work in practice. This is the first time so many huge Swift Evolution proposals has been so tightly interlinked, so although I’ve tried to organize these changes in a cohesive flow some parts of the concurrency work only really make sense once you’ve read through several proposals.

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Async/await

SE-0296 introduces asynchronous (async) functions into Swift, allowing us to run complex asynchronous code almost is if it were synchronous. This is done in two steps: marking async functions with the new async keyword, then calling them using the await keyword, similar to other languages such as C# and JavaScript.

To see how async/await helps the language, it’s helpful to look at how we solved the same problem previously. Completion handlers are commonly used in Swift code to allow us to send back values after a function returns, but they had tricky syntax as you’ll see.

For example, if we wanted to write code that fetched 100,000 weather records from a server, processes them to calculate the average temperature over time, then uploaded the resulting average back to a server, we might have written this:

func fetchWeatherHistory(completion: @escaping ([Double]) -> Void) {
    // Complex networking code here; we'll just send back 100,000 random temperatures
    DispatchQueue.global().async {
        let results = (1...100_000).map { _ in Double.random(in: -10...30) }
        completion(results)
    }
}

func calculateAverageTemperature(for records: [Double], completion: @escaping (Double) -> Void) {
    // Sum our array then divide by the array size
    DispatchQueue.global().async {
        let total = records.reduce(0, +)
        let average = total / Double(records.count)
        completion(average)
    }
}

func upload(result: Double, completion: @escaping (String) -> Void) {
    // More complex networking code; we'll just send back "OK"
    DispatchQueue.global().async {
        completion("OK")
    }
}

I’ve substituted actual networking code with fake values because the networking part isn’t relevant here. What matters is that each of those functions can take some time to run, so rather than blocking execution of the function and returning a value directly we instead use a completion closure to send something back only when we’re ready.

When it comes to using that code, we need to call them one by one in a chain, providing completion closures for each one to continue the chain, like this:

fetchWeatherHistory { records in
    calculateAverageTemperature(for: records) { average in
        upload(result: average) { response in
            print("Server response: \(response)")
        }
    }
}

Hopefully you can see the problems with this approach:

  • It’s possible for those functions to call their completion handler more than once, or forget to call it entirely.
  • The parameter syntax @escaping (String) -> Void can be hard to read.
  • At the call site we end up with a so-called pyramid of doom, with code increasingly indented for each completion handler.
  • Until Swift 5.0 added the Result type, it was harder to send back errors with completion handlers.

From Swift 5.5, we can now clean up our functions by marking them as asynchronously returning a value rather than relying on completion handlers, like this:

func fetchWeatherHistory() async -> [Double] {
    (1...100_000).map { _ in Double.random(in: -10...30) }
}

func calculateAverageTemperature(for records: [Double]) async -> Double {
    let total = records.reduce(0, +)
    let average = total / Double(records.count)
    return average
}

func upload(result: Double) async -> String {
    "OK"
}

That has already removed a lot of the syntax around returning values asynchronously, but at the call site it’s even cleaner:

func processWeather() async {
    let records = await fetchWeatherHistory()
    let average = await calculateAverageTemperature(for: records)
    let response = await upload(result: average)
    print("Server response: \(response)")
}

As you can see, all the closures and indenting have gone, making for what is sometimes called “straight-line code” – apart from the await keywords, it looks just like synchronous code.

There are some straightforward, specific rules about the way async functions work:

  • Synchronous functions cannot simply call async functions directly – it wouldn’t make sense, so Swift will throw an error.
  • Async functions can call other async functions, but they can also call regular synchronous functions if they need to.
  • If you have async and synchronous functions that can be called in the same way, Swift will prefer whichever one matches your current context – if the call site is currently async then Swift will call the async function, otherwise it will call the synchronous function.

That last point is important, because it allows library authors to provide both synchronous and asynchronous versions of their code without having to name the async functions specially.

The addition of async/await fits perfectly alongside try/catch, meaning that async functions and initializers can throw errors if needed. The only proviso here is that Swift enforces a particular order for the keywords, and that order is reversed between call site and function.

For example, we might have functions that attempt to fetch a number of users from a server, and save them to disk, both of which might fail by throwing errors:

enum UserError: Error {
    case invalidCount, dataTooLong
}

func fetchUsers(count: Int) async throws -> [String] {
    if count > 3 {
        // Don't attempt to fetch too many users
        throw UserError.invalidCount
    }

    // Complex networking code here; we'll just send back up to `count` users
    return Array(["Antoni", "Karamo", "Tan"].prefix(count))
}

func save(users: [String]) async throws -> String {
    let savedUsers = users.joined(separator: ",")

    if savedUsers.count > 32 {
        throw UserError.dataTooLong
    } else {
        // Actual saving code would go here
        return "Saved \(savedUsers)!"
    }
}

As you can see, both those functions are marked async throws – they are asynchronous functions, and they might throw errors.

When it comes to calling them the order of keywords is flipped to try await rather than await try, like this:

func updateUsers() async {
    do {
        let users = try await fetchUsers(count: 3)
        let result = try await save(users: users)
        print(result)
    } catch {
        print("Oops!")
    }
}

So, “asynchronous, throwing” in the function definition, but “throwing, asynchronous” at the call site – think of it as unwinding a stack. Not only does try await read a little more naturally than await try, but it’s also more reflective of what’s actually happening: we’re waiting for some work to complete, and when it does complete it might end up throwing.

With async/await now in Swift itself, the Result type introduced in Swift 5.0 becomes much less important as one of its primary benefits was improving completion handlers. That doesn’t mean Result is useless, because it’s still the best way to store the result of an operation for later evaluation.

Important: Making a function asynchronous doesn’t mean it magically runs concurrently with other code, which means unless you specify otherwise calling multiple async functions will still run them sequentially.

All the async functions you’ve seen so far have in turn been called by other async functions, which is intentional: taken by itself this Swift Evolution proposal does not actually provide any way to run asynchronous code from a synchronous context. Instead, this functionality is defined in a separate Structured Concurrency proposal, although hopefully we’ll see some major updates to Foundation too.

Async/await: sequences

SE-0298 introduces the ability to loop over asynchronous sequences of values using a new AsyncSequence protocol. This is helpful for places when you want to process values in a sequence as they become available rather than precomputing them all at once – perhaps because they take time to calculate, or because they aren’t available yet.

Using AsyncSequence is almost identical to using Sequence, with the exception that your types should conform to AsyncSequence and AsyncIterator, and your next() method should be marked async. When it comes time for your sequence to end, make sure you send back nil from next(), just as with Sequence.

For example, we could make a DoubleGenerator sequence that starts from 1 and doubles its number every time it’s called:

struct DoubleGenerator: AsyncSequence {
    typealias Element = Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 1

        mutating func next() async -> Int? {
            defer { current &*= 2 }

            if current < 0 {
                return nil
            } else {
                return current
            }
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator()
    }
}

Tip: If you just remove “async” from everywhere it appears in that code, you have a valid Sequence doing exactly the same thing – that’s how similar these two are.

Once you have your asynchronous sequence, you can loop over its values by using for await in an async context, like this:

func printAllDoubles() async {
    for await number in DoubleGenerator() {
        print(number)
    }
}

The AsyncSequence protocol also provides default implementations of a variety of common methods, such as map(), compactMap(), allSatisfy(), and more. For example, we could check whether our generator outputs a specific number like this:

func containsExactNumber() async {
    let doubles = DoubleGenerator()
    let match = await doubles.contains(16_777_216)
    print(match)
}

Again, you need to be in an async context to use this.

Effectful read-only properties

SE-0310 upgrades Swift’s read-only properties to support the async and throws keywords, either individually or together, making them significantly more flexible.

To demonstrate this, we could create a BundleFile struct that attempts to load the contents of a file in our app’s resource bundle. Because the file might not be there, might be there but can’t be read for some reason, or might be readable but so big it takes time to read, we could mark the contents property as async throws like this:

enum FileError: Error {
    case missing, unreadable
}

struct BundleFile {
    let filename: String

    var contents: String {
        get async throws {
            guard let url = Bundle.main.url(forResource: filename, withExtension: nil) else {
                throw FileError.missing
            }

            do {
                return try String(contentsOf: url)
            } catch {
                throw FileError.unreadable
            }
        }
    }
}

Because contents is both async and throwing, we must use try await when trying to read it:

func printHighScores() async throws {
    let file = BundleFile(filename: "highscores")
    try await print(file.contents)
}

Structured concurrency

SE-0304 introduces a whole range of approaches to execute, cancel, and monitor concurrent operations in Swift, and builds upon the work introduced by async/await and async sequences.

For easier demonstration purposes, here are a couple of example functions we can work with – an async function to simulate fetching a certain number of weather readings for a particular location, and a synchronous function to calculate which number lies at a particular position in the Fibonacci sequence:

enum LocationError: Error {
    case unknown
}

func getWeatherReadings(for location: String) async throws -> [Double] {
    switch location {
    case "London":
        return (1...100).map { _ in Double.random(in: 6...26) }
    case "Rome":
        return (1...100).map { _ in Double.random(in: 10...32) }
    case "San Francisco":
        return (1...100).map { _ in Double.random(in: 12...20) }
    default:
        throw LocationError.unknown
    }
}

func fibonacci(of number: Int) -> Int {
    var first = 0
    var second = 1

    for _ in 0..<number {
        let previous = first
        first = second
        second = previous + first
    }

    return first
}

The simplest async approach introduced by structured concurrency is the ability to use the @main attribute to go immediately into an async context, which is done simply by marking the main() method with async, like this:

@main
struct Main {
    static func main() async throws {
        let readings = try await getWeatherReadings(for: "London")
        print("Readings are: \(readings)")
    }
}

The main changes introduced by structured concurrency are backed by two new types, Task and TaskGroup, which allow us to run concurrent operations either individually or in a coordinated way.

In its simplest form, you can start concurrent work by creating a new Task object and passing it the operation you want to run. This will start running on a background thread immediately, and you can use await to wait for its finished value to come back.

So, we might call fibonacci(of:) many times on a background thread, in order to calculate the first 50 numbers in the sequence:

func printFibonacciSequence() async {
    let task1 = Task { () -> [Int] in
        var numbers = [Int]()

        for i in 0..<50 {
            let result = fibonacci(of: i)
            numbers.append(result)
        }

        return numbers
    }

    let result1 = await task1.value
    print("The first 50 numbers in the Fibonacci sequence are: \(result1)")
}

As you can see, I’ve needed to explicitly write Task { () -> [Int] in so that Swift understands that the task is going to return, but if your task code is simpler that isn’t needed. For example, we could have written this and gotten exactly the same result:

let task1 = Task {
    (0..<50).map(fibonacci)
}

Again, the task starts running as soon as it’s created, and the printFibonacciSequence() function will continue running on whichever thread it was while the Fibonacci numbers are being calculated.

Tip: Our task's operation is a non-escaping closure because the task immediately runs it rather than storing it for later, which means if you use Task inside a class or a struct you don’t need to use self to access properties or methods.

When it comes to reading the finished numbers, await task1.value will make sure execution of printFibonacciSequence() pauses until the task’s output is ready, at which point it will be returned. If you don’t actually care what the task returns – if you just want the code to start running and finish whenever – you don’t need to store the task anywhere.

For task operations that throw uncaught errors, reading your task’s value property will automatically also throw errors. So, we could write a function that performs two pieces of work at the same time then waits for them both to complete:

func runMultipleCalculations() async throws {
    let task1 = Task {
        (0..<50).map(fibonacci)
    }

    let task2 = Task {
        try await getWeatherReadings(for: "Rome")
    }

    let result1 = await task1.value
    let result2 = try await task2.value
    print("The first 50 numbers in the Fibonacci sequence are: \(result1)")
    print("Rome weather readings are: \(result2)")
}

Swift provides us with the built-in task priorities of high, default, low, and background. The code above doesn’t specifically set one so it will get default, but we could have said something like Task(priority: .high) to customize that. If you’re writing just for Apple’s platforms, you can also use the more familiar priorities of userInitiated in place of high, and utility in place of low, but you can’t access userInteractive because that is reserved for the main thread.

As well as just running operations, Task also provides us with a handful of static methods to control the way our code runs:

  • Calling Task.sleep() will cause the current task to sleep for a specific number of nanoseconds. Until something better comes along, this means writing 1_000_000_000 to mean 1 second.
  • Calling Task.checkCancellation() will check whether someone has asked for this task to be cancelled by calling its cancel() method, and if so throw a CancellationError.
  • Calling Task.yield() will suspend the current task for a few moments in order to give some time to any tasks that might be waiting, which is particularly important if you’re doing intensive work in a loop.

You can see both sleeping and cancellation in the following code example, which puts a task to sleep for one second then cancels it before it completes:

func cancelSleepingTask() async {
    let task = Task { () -> String in
        print("Starting")
        try await Task.sleep(nanoseconds: 1_000_000_000)
        try Task.checkCancellation()
        return "Done"
    }

    // The task has started, but we'll cancel it while it sleeps
    task.cancel()

    do {
        let result = try await task.value
        print("Result: \(result)")
    } catch {
        print("Task was cancelled.")
    }
}

In that code, Task.checkCancellation() will realize the task has been cancelled and immediately throw CancellationError, but that won’t reach us until we attempt to read task.value.

Tip: Use task.result to get a Result value containing the task’s success and failure values. For example, in the code above we’d get back a Result<String, Error>. This does not require a try call because you still need to handle the success or failure case.

For more complex work, you should create task groups instead – collections of tasks that work together to produce a finished value.

To minimize the risk of programmers using task groups in dangerous ways, they don’t have a simple public initializer. Instead, task groups are created using functions such as withTaskGroup(): call this with the body of work you want done, and you’ll be passed in the task group instance to work with. Once inside the group you can add work using the addTask() method, and it will start executing immediately.

Important: You should not attempt to copy that task group outside the body of withTaskGroup() – the compiler can’t stop you, but you’re just going to make problems for yourself.

To see a simple example of how task groups work – along with demonstrating an important point of how they order their operations, try this:

func printMessage() async {
    let string = await withTaskGroup(of: String.self) { group -> String in
        group.addTask { "Hello" }
        group.addTask { "From" }
        group.addTask { "A" }
        group.addTask { "Task" }
        group.addTask { "Group" }

        var collected = [String]()

        for await value in group {
            collected.append(value)
        }

        return collected.joined(separator: " ")
    }

    print(string)
}

That creates a task group designed to produce one finished string, then queues up several closures using the addTask() method of the task group. Each of those closures returns a single string, which then gets collected into an array of strings, before being joined into one single string and returned for printing.

Tip: All tasks in a task group must return the same type of data, so for complex work you might find yourself needing to return an enum with associated values in order to get exactly what you want. A simpler alternative is introduced in a separate Async Let Bindings proposal.

Each call to addTask() can be any kind of function you like, as long as it results in a string. However, although task groups automatically wait for all the child tasks to complete before returning, when that code runs it’s a bit of a toss up what it will print because the child tasks can complete in any order – we’re as likely to get “Hello From Task Group A” as we are “Hello A Task Group From”, for example.

If your task group is executing code that might throw, you can either handle the error directly inside the group or let it bubble up outside the group to be handled there. That latter option is handled using a different function, withThrowingTaskGroup(), which must be called with try if you haven’t caught all the errors you throw.

For example, this next code sample calculates weather readings for several locations in a single group, then returns the overall average for all locations:

func printAllWeatherReadings() async {
    do {
        print("Calculating average weather…")

        let result = try await withThrowingTaskGroup(of: [Double].self) { group -> String in
            group.addTask {
                try await getWeatherReadings(for: "London")
            }

            group.addTask {
                try await getWeatherReadings(for: "Rome")
            }

            group.addTask {
                try await getWeatherReadings(for: "San Francisco")
            }

            // Convert our array of arrays into a single array of doubles
            let allValues = try await group.reduce([], +)

            // Calculate the mean average of all our doubles
            let average = allValues.reduce(0, +) / Double(allValues.count)
            return "Overall average temperature is \(average)"
        }

        print("Done! \(result)")
    } catch {
        print("Error calculating data.")
    }
}

In that instance, each of the calls to addTask() is identical apart from the location string being passed in, so you can use something like for location in ["London", "Rome", "San Francisco"] { to call addTask() in a loop.

Task groups have a cancelAll() method that cancels any tasks inside the group, but using addTask() afterwards will continue to add work to the group. As an alternative, you can use addTaskUnlessCancelled() to skip adding work if the group has been cancelled – check its returned Boolean to see whether the work was added successfully or not.

async let bindings

SE-0317 introduces the ability to create and await child tasks using the simple syntax async let. This is particularly useful as an alternative to task groups where you’re dealing with heterogeneous result types – i.e., if you want tasks in a group to return different kinds of data.

To demonstrate this, we could create a struct that has three different types of properties that will come from three different async functions:

struct UserData {
    let name: String
    let friends: [String]
    let highScores: [Int]
}

func getUser() async -> String {
    "Taylor Swift"
}

func getHighScores() async -> [Int] {
    [42, 23, 16, 15, 8, 4]
}

func getFriends() async -> [String] {
    ["Eric", "Maeve", "Otis"]
}

If we wanted to create a User instance from all three of those values, async let is the easiest way – it run each function concurrently, wait for all three to finish, then use them to create our object.

Here’s how it looks:

func printUserDetails() async {
    async let username = getUser()
    async let scores = getHighScores()
    async let friends = getFriends()

    let user = await UserData(name: username, friends: friends, highScores: scores)
    print("Hello, my name is \(user.name), and I have \(user.friends.count) friends!")
}

Important: You can only use async let if you are already in an async context, and if you don’t explicitly await the result of an async let Swift will implicitly wait for it when exiting its scope.

When working with throwing functions, you don’t need to use try with async let – that can automatically be pushed back to where you await the result. Similarly, the await keyword is also implied, so rather than typing try await someFunction() with an async let you can just write someFunction().

To demonstrate this, we could write an async function to recursively calculate numbers in the Fibonacci sequence. This approach is hopelessly naive because without memoization we’re just repeating vast amounts of work, so to avoid causing everything to grind to a halt we’re going to limit the input range from 0 to 22:

enum NumberError: Error {
    case outOfRange
}

func fibonacci(of number: Int) async throws -> Int {
    if number < 0 || number > 22 {
        throw NumberError.outOfRange
    }

    if number < 2 { return number }
    async let first = fibonacci(of: number - 2)
    async let second = fibonacci(of: number - 1)
    return try await first + second
}

In that code the recursive calls to fibonacci(of:) are implicitly try await fibonacci(of:), but we can leave them off and handle them directly on the following line.

Intermission

Despite my best efforts to present these changes in an approachable way, at this point you’re probably mentally exhausted.

Well, I’m afraid you’re only about half way through all the changes. So, take a break! Make some coffee, stretch your legs, and give your eyes a rest – this article will still be here when you return.

Continuations for interfacing async tasks with synchronous code

SE-0300 introduces new functions to help us adapt older, completion handler-style APIs to modern async code.

For example, this function returns its values asynchronously using a completion handler:

func fetchLatestNews(completion: @escaping ([String]) -> Void) {
    DispatchQueue.main.async {
        completion(["Swift 5.5 release", "Apple acquires Apollo"])
    }
}

If you wanted to use that using async/await you might be able to rewrite the function, but there are various reasons why that might not be possible – it might come from an external library, for example.

Continuations allow us to create a shim between the completion handler and async functions so that we wrap up the older code in a more modern API. For example, the withCheckedContinuation() function creates a new continuation that can run whatever code you want, then call resume(returning:) to send a value back whenever you’re ready – even if that’s part of a completion handler closure.

So, we could make a second fetchLatestNews() function that is async, wrapping around the older completion handler function:

func fetchLatestNews() async -> [String] {
    await withCheckedContinuation { continuation in
        fetchLatestNews { items in
            continuation.resume(returning: items)
        }
    }
}

With that in place we can now get our original functionality in an async function, like this:

func printNews() async {
    let items = await fetchLatestNews()

    for item in items {
        print(item)
    }
}

The term “checked” continuation means that Swift is performing runtime checks on our behalf: are we calling resume() once and only once? This is important, because if you never resume the continuation then you will leak resources, but if you call it twice then you’re likely to hit problems.

Important: To be crystal clear, you must resume your continuation exactly once.

As there is a runtime performance cost of checking your continuations, Swift also provides a withUnsafeContinuation() function that works in exactly the same way except does not perform runtime checks on your behalf. This means Swift won’t warn you if you forget to resume the continuation, and if you call it twice then the behavior is undefined.

Because these two functions are called in the same way, you can switch between them easily. So, it seems likely people will use withCheckedContinuation() while writing their functions so Swift will emit warnings and even trigger crashes if the continuations are used incorrectly, but some may then switch over to withUnsafeContinuation() as they prepare to ship if they are affected by the runtime performance cost of checked continuations.

Actors

SE-0306 introduces actors, which are conceptually similar to classes that are safe to use in concurrent environments. This is possible because Swift ensures that mutable state inside your actor is only ever accessed by a single thread at any given time, which helps eliminate a variety of serious bugs right at the compiler level.

To demonstrate the problem actors solve, consider this Swift code that creates a RiskyCollector class able to trade cards from their deck with another collector:

class RiskyCollector {
    var deck: Set<String>

    init(deck: Set<String>) {
        self.deck = deck
    }

    func send(card selected: String, to person: RiskyCollector) -> Bool {
        guard deck.contains(selected) else { return false }

        deck.remove(selected)
        person.transfer(card: selected)
        return true
    }

    func transfer(card: String) {
        deck.insert(card)
    }
}

In a single-threaded environment that code is safe: we check whether our deck contains the card in question, remove it, then add it to the other collector’s deck. However, in a multi-threaded environment our code has a potential race condition, which is a problem whereby the results of the code will vary as two separate parts of our code run side by side.

If we call send(card:to:) more than once at the same time, the following chain of events can happen:

  1. The first thread checks whether the card is in the deck, and it is so it continues.
  2. The second thread also checks whether the card is in the deck, and it is so it continues.
  3. The first thread removes the card from the deck and transfer it to the other person.
  4. The second thread attempts to remove the card from the deck, but actually it’s already gone so nothing will happen. However, it still transfers the card to the other person.

In that situation one player loses a card while the other gains two cards, and if that card happened to be a Black Lotus from Magic the Gathering then you’ve got a big problem!

Actors solve this problem by introducing actor isolation: stored properties and methods cannot be read from outside the actor object unless they are performed asynchronously, and stored properties cannot be written from outside the actor object at all. The async behavior isn’t there for performance; instead it’s because Swift automatically places these requests into a queue that is processed sequentially to avoid race conditions.

So, we could rewrite out RiskyCollector class to be a SafeCollector actor, like this:

actor SafeCollector {
    var deck: Set<String>

    init(deck: Set<String>) {
        self.deck = deck
    }

    func send(card selected: String, to person: SafeCollector) async -> Bool {
        guard deck.contains(selected) else { return false }

        deck.remove(selected)
        await person.transfer(card: selected)
        return true
    }

    func transfer(card: String) {
        deck.insert(card)
    }
}

There are several things to notice in that example:

  1. Actors are created using the new actor keyword. This is a new concrete nominal type in Swift, joining structs, classes, and enums.
  2. The send() method is marked with async, because it will need to suspend its work while waiting for the transfer to complete.
  3. Although the transfer(card:) method is not marked with async, we still need to call it with await because it will wait until the other SafeCollector actor is able to handle the request.

To be clear, an actor can use its own properties and methods freely, asynchronously or otherwise, but when interacting with a different actor it must always be done asynchronously. With these changes Swift can ensure that all actor-isolated state is never accessed concurrently, and more importantly this is done at compile time so that safety is guaranteed.

Actors and classes have some similarities:

  • Both are reference types, so they can be used for shared state.
  • They can have methods, properties, initializers, and subscripts.
  • They can conform to protocols and be generic.
  • Any properties and methods that are static behave the same in both types, because they have no concept of self and therefore don’t get isolated.

Beyond actor isolation, there are two other important differences between actors and classes:

  • Actors do not currently support inheritance, which makes their initializers much simpler – there is no need for convenience initializers, overriding, the final keyword, and more. This might change in the future.
  • All actors implicitly conform to a new Actor protocol; no other concrete type can use this. This allows you to restrict other parts of your code so it can work only with actors.

The best way I’ve heard to explain how actors differ from classes is this: “actors pass messages, not memory.” So, rather than one actor poking directly around in another’s properties or calling their methods, we instead send a message asking for the data and let the Swift runtime handle it for us safely.

Global actors

SE-0316 allows global state to be isolated from data races by using actors.

Although in theory this could result in many global actors, the main benefit at least right now is the introduction of an @MainActor global actor you can use to mark properties and methods that should be accessed only on the main thread.

As an example, we might have a class to handle data storage in our app, and for safety reasons we refuse to write out change to persistent storage unless we’re on the main thread:

class OldDataController {
    func save() -> Bool {
        guard Thread.isMainThread else {
            return false
        }

        print("Saving data…")
        return true
    }
}

That works, but with @MainActor we can guarantee that save() is always called on the main thread as if we specifically ran it using DispatchQueue.main:

class NewDataController {
    @MainActor func save() {
        print("Saving data…")
    }
}

That’s all it takes – Swift will make sure whenever you call save() on a data controller, that work will happen on the main thread.

Note: Because we’re pushing work through an actor, you must call save() using await, async let, or similar.

@MainActor is a global actor wrapper around the underlying MainActor struct, which is helpful because it has a static run() method that lets us schedule work to be run. This will execute your code on the main thread, optionally sending back a result.

Sendable and @Sendable closures

SE-0302 adds support for “sendable” data, which is data that can safely be transferred to another thread. This is accomplished through a new Sendable protocol, and an @Sendable attribute for functions.

Many things are inherently safe to send across threads:

  • All of Swift’s core value types, including Bool, Int, String, and similar.
  • Optionals, where the wrapped data is a value type.
  • Standard library collections that contain value types, such as Array<String> or Dictionary<Int, String>.
  • Tuples where the elements are all value types.
  • Metatypes, such as String.self.

These have been updated to conform to the Sendable protocol.

As for custom types, it depends what you’re making:

  • Actors automatically conform to Sendable because they handle their synchronization internally.
  • Custom structs and enums you define will also automatically conform to Sendable if they contain only values that also conform to Sendable, similar to how Codable works.
  • Custom classes can conform to Sendable as long as they either inherits from NSObject or from nothing at all, all properties are constant and themselves conform to Sendable, and they are marked as final to stop further inheritance.

Swift lets us use the @Sendable attribute on functions or closure to mark them as working concurrently, and will enforce various rules to stop us shooting ourself in the foot. For example, the operation we pass into the Task initializer is marked @Sendable, which means this kind of code is allowed because the value captured by Task is a constant:

func printScore() async { 
    let score = 1

    Task { print(score) }
    Task { print(score) }
}

However, that code would not be allowed if score were a variable, because it could be accessed by one of the tasks while the other was changing its value.

You can mark your own functions and closures using @Sendable, which will enforce similar rules around captured values:

func runLater(_ function: @escaping @Sendable () -> Void) -> Void {
    DispatchQueue.global().asyncAfter(deadline: .now() + 3, execute: function)
}

#if for postfix member expressions

SE-0308 allows Swift to use #if conditions with postfix member expressions. This sounds a bit obscure, but it solves a problem commonly seen with SwiftUI: you can now optionally add modifiers to a view.

For example, this change allows us to create a text view with two different font sizes depending on whether we’re using iOS or another platform:

Text("Welcome")
#if os(iOS)
    .font(.largeTitle)
#else
    .font(.headline)
#endif

You can nest these if you want, although it’s a bit hard on your eyes:

Text("Welcome")
#if os(iOS)
    .font(.largeTitle)
    #if DEBUG
        .foregroundColor(.red)
    #endif
#else
    .font(.headline)
#endif

You could use wildly different postfix expressions if you wanted:

let result = [1, 2, 3]
#if os(iOS)
    .count
#else
    .reduce(0, +)
#endif

print(result)

Technically you could make result end up as two completely different types if you wanted, but that seems like a bad idea. What you definitely can’t do is use other kinds of expressions such as using + [4] instead of .count – if it doesn’t start with . then it’s not a postfix member expression.

Allow interchangeable use of CGFloat and Double types

SE-0307 introduces a small but important quality of life improvement: Swift is able to implicitly convert between CGFloat and Double in most places where it is needed.

In its simplest form, this means we can add a CGFloat and a Double together to produce a new Double, like this:

let first: CGFloat = 42
let second: Double = 19
let result = first + second
print(result)

Swift implements this by inserting an implicit initializer as needed, and it will always prefer Double if it’s possible. More importantly, none of this is achieved by rewriting existing APIs: technically things like scaleEffect() in SwiftUI still work with CGFloat, but Swift quietly bridges this to Double.

Codable synthesis for enums with associated values

SE-0295 upgrades Swift’s Codable system to support writing enums with associated values. Previously enums were only supported if they conformed to RawRepresentable, but this extends support to general enums as well as enum cases with any number of Codable associated values.

For example, we could define a Weather enum like this one:

enum Weather: Codable {
    case sun
    case wind(speed: Int)
    case rain(amount: Int, chance: Int)
}

That has one simple case, one case with a single associated values, and a third case with two associated values – all are integers, but you could use strings or other Codable types.

With that enum defined, we can create an array of weather to make a forecast, then use JSONEncoder or similar and convert the result to a printable string:

let forecast: [Weather] = [
    .sun,
    .wind(speed: 10),
    .sun,
    .rain(amount: 5, chance: 50)
]

do {
    let result = try JSONEncoder().encode(forecast)
    let jsonString = String(decoding: result, as: UTF8.self)
    print(jsonString)
} catch {
    print("Encoding error: \(error.localizedDescription)")
}

Behind the scenes, this is implemented using multiple CodingKey enums capable of handling the nested structure that results from having values attached to enum cases, which means writing your own custom coding methods to do the same is a little more work.

lazy now works in local contexts

The lazy keyword has always allowed us to write stored properties that are only calculated when first used, but from Swift 5.5 onwards we can use lazy locally inside a function to create values that work similarly.

This code demonstrates local lazy in action:

func printGreeting(to: String) -> String {
    print("In printGreeting()")
    return "Hello, \(to)"
}

func lazyTest() {
    print("Before lazy")
    lazy var greeting = printGreeting(to: "Paul")
    print("After lazy")
    print(greeting)
}

lazyTest()

When that runs you’ll see “Before lazy” and “After lazy” printed first, followed by “In printGreeting()” then “Hello, Paul” – Swift only runs the printGreeting(to:) code when its result is accessed on the print(greeting) line.

In practice, this feature is going to be really helpful as a way of selectively running code when you have conditions in place: you can prepare the result of some work lazily, and only actual perform the work if it’s still needed later on.

Extend property wrappers to function and closure parameters

SE-0293 extends property wrappers so they can be applied to parameters for functions and closures. Parameters passed this way are still immutable unless you take a copy of them, and you are still able to access the underlying property wrapper type using a leading underscore if you want.

As an example, we could write a function that accepts an integer and prints it out:

func setScore1(to score: Int) {
    print("Setting score to \(score)")
}

When that’s called we can pass it any range of values, like this:

setScore1(to: 50)
setScore1(to: -50)
setScore1(to: 500)

If we wanted our scores to lie only within the range 0...100 we could write a simple property wrapper that clamps numbers as they are created:

@propertyWrapper
struct Clamped<T: Comparable> {
    let wrappedValue: T

    init(wrappedValue: T, range: ClosedRange<T>) {
        self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

Now we can write and call a new function using that wrapper:

func setScore2(@Clamped(range: 0...100) to score: Int) {
    print("Setting score to \(score)")
}

setScore2(to: 50)
setScore2(to: -50)
setScore2(to: 500)

Calling setScore2() with the same input values as before will print different output, because the numbers will get clamped to 50, 0, 100.

Tip: Our property wrapper is trivial because parameters passed into a function are immutable – we don’t need to handle re-clamping the wrapped value when it changes because it won’t change. However, you can make your property wrappers as complex as you need; they work just as they would with properties or local variables.

Extending static member lookup in generic contexts

SE-0299 allows Swift to perform static member lookup for members of protocols in generic functions, which sounds obscure but actually fixes a small but important legibility problem that hit SwiftUI particularly hard.

At this time SwiftUI hasn’t been updated to support this change, but if everything goes to plan we can stop writing this:

Toggle("Example", isOn: .constant(true))
    .toggleStyle(SwitchToggleStyle())

And instead write something like this:

Toggle("Example", isOn: .constant(true))
    .toggleStyle(.switch)

This was possible in early SwiftUI betas because Apple had put extensive workarounds in place, but these were withdrawn before release.

To see what’s actually changing here, imagine a Theme protocol with several structs conforming to it:

protocol Theme { }
struct LightTheme: Theme { }
struct DarkTheme: Theme { }
struct RainbowTheme: Theme { }

We could also define a Screen protocol that is able to have a theme() method called on it with some sort of theme:

protocol Screen { }

extension Screen {
    func theme<T: Theme>(_ style: T) -> Screen {
        print("Activating new theme!")
        return self
    }
}

And now we could create an instance of a screen:

struct HomeScreen: Screen { }

Following older SwiftUI code, we could enable a light theme on that screen by specifying LightTheme():

let lightScreen = HomeScreen().theme(LightTheme())

If we wanted to make that easier to access, we could try adding a static light property to our Theme protocol like this:

extension Theme where Self == LightTheme {
    static var light: LightTheme { .init() }
}

However, using that with the theme() method of our generic protocol was what caused the problem: before Swift 5.5 it was not possible and you had to use LightTheme() every time. However, in Swift 5.5 or later this is now possible:

let lightTheme = HomeScreen().theme(.light)

And there’s still more…

This has been a huge article, and I realize all these changes can feel a bit asynchronous in themselves – sometimes in order to understand one you need to refer to two others! Hopefully I’ve managed to present the key changes in a logical way that helps you see how they build on each other.

Although I’ve tried to cover all the main new features in Swift 5.5 there are still more I haven’t covered, not least:

Given the huge swathes of changes it seems peculiar that Swift 5.5 is Swift 5.5 as opposed to Swift 6.0, but perhaps Apple is planning to hold that name back until the second phase of the actor proposal arrives. I haven’t discussed it here because it’s very much not part of Swift 5.5, but as things stand the second phase of actor isolation is likely to cause the kind of code breakage that justifies a major version bump.

What I can say is that the Swift team are working extraordinarily hard to deliver an astonishing collection of changes in a relatively short time. Not only are these changes providing major new language features that deliver power and safety for Swift developers, but they have also received extensive community input through Swift Evolution – the actors proposal alone went through seven pitches and two proposals before finally being approved.

In fact, I think it says a lot that Apple recorded and released the original WWDC21 videos using older Swift Evolution proposal information, and were willing to continue making breaking changes to the proposals even after WWDC took place. They ended up re-releasing many WWDC videos because so many changes had happened, which was no easy job. Thanks for listening to the community, Apple!

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.