📱 Mobile

Actor Protocol Conformance on Pre-async Protocol

originalUrl
date
Jun 29, 2024
slug
actor-protocol-conformance
author
status
Public
tags
#ios
#swift-concurrency
#og
summary
In this post, we'll explore how Swift's actors handle protocol conformance and adapt existing synchronous delegates to asynchronous contexts. By leveraging nonisolated declarations, we'll ensure thread safety and compatibility with async/await patterns.
type
Post
thumbnail
category
📱 Mobile
updatedAt
Dec 12, 2024 03:45 PM

Introduction

In this post, we'll explore how Swift's actors handle protocol conformance and adapt existing synchronous delegates to asynchronous contexts. By leveraging nonisolated declarations, we'll ensure thread safety and compatibility with async/await patterns.
 
Let’s start with this example:
actor BankAccount { nonisolated let accountNumber: Int var balance: Double // ... } extension BankAccount { // Produce an account number string with all but the last digits replaced with "X", which // is safe to put on documents. nonisolated func safeAccountNumberDisplayString() -> String { let digits = String(accountNumber) // okay, because accountNumber is also nonisolated return String(repeating: "X", count: digits.count - 4) + String(digits.suffix(4)) } } let fn2 = BankAccount.safeAccountNumberDisplayString // type of fn is (BankAccount) -> () -> String
 
The actors proposal describes the rule that an actor-isolated function cannot satisfy a protocol requirement that is neither actor-isolated nor asynchronous, because doing so would allow synchronous access to actor state. However, non-isolated functions don't have access to actor state, so they are free to satisfy synchronous protocol requirements of any kind. For example, we can make BankAccount conform to Hashable by basing the hashing on the account number:
 
extension BankAccount: Hashable { nonisolated func hash(into hasher: inout Hasher) { hasher.combine(accountNumber) } } let fn = BankAccount.hash(into:) // type is (BankAccount) -> (inout Hasher) -> Void
 
Similarly, one can use a nonisolated computed property to conform to, e.g. CustomStringConvertible:
 
extension BankAccount: CustomStringConvertible { nonisolated var description: String { "Bank account #\(safeAccountNumberDisplayString())" } }

Adapting Existing Delegates

How can we adapt the existing delegate that was written in synchronous Objective-C?
actor AudioManager: NSObject { private var audioPlayer: AVAudioPlayer? = nil private var cont: CheckedContinuation<Void, Error>? = nil func play(_ url: URL) async throws { stop() audioPlayer = try AVAudioPlayer(contentsOf: url) audioPlayer?.delegate = self audioPlayer?.play() try await withCheckedThrowingContinuation { cont in self.cont = cont } } ... } extension AudioManager: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { // ERROR: Actor-isolated instance method 'audioPlayerDidFinishPlaying(_:successfully:)' cannot be used to satisfy nonisolated protocol requirement } func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: (any Error)?) { // ERROR: Actor-isolated instance method 'audioPlayerDidFinishPlaying(_:successfully:)' cannot be used to satisfy nonisolated protocol requirement } }
 
Non-isolated declarations are particularly useful for adapting existing asynchronous protocols, expressed using completion handlers, to actors. Over time, this protocol should evolve to provide async requirements. However, one can make an actor type conform to this protocol using a non-isolated declaration that launches a detached task:
 
actor AudioManager: NSObject { private func finishPlaying(successfully: Bool = true, with error: Error? = nil) { audioPlayer = nil resumeContinuation(with: error) } } extension AudioManager: AVAudioPlayerDelegate { nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { Task { await self.finishPlaying(successfully: flag) } } nonisolated func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: (any Error)?) { Task { await self.finishPlaying(with: error) } } }
 
We do this because AVAudioPlayerDelegate is not our own protocol, and Apple doesn't seem to plan on making these compatible with async/await anytime soon. In case we can change the protocol, we can simply make it async to allow actor-isolated conformance:
protocol Fooable { func foo() async -> Int } actor Bar: Fooable { func foo() -> Int { return 42 } }