Swift6: NonCopyable、Typed throws、Data-race safety
こんにちは、AmaziaでマンガBANG iOSアプリ開発を担当しています、山崎です。
先日のWWDC24では、Swift6の新機能についてのビデオが公開されました。この中から、いくつかの機能について、紹介したいと思います。
この記事では、このビデオで触れられた内容をベースに、Swift Evolutionの提案を確認しながら解説を加えています。
■ What's new in Swift
ビデオの前半部分では、過去10年間に渡るSwiftの歴史、コミュニティの進化、プラットフォームの拡大について、様々なトピックに触れています。
後半の約17分頃から、Swift 6で追加される新機能が紹介されます。
• Noncopyable types(★)
• Embedded Swift
• C++ interoperability
• Typed throws(★)
• Data-race safety(★)
この中から、(★)が付いた機能について取り上げます。
Noncopyable型(~Copyable)コピー不可型のアップデート
SE-0390 の提案により、Copyableプロトコルの定義と、それに関連するNoncopyable型が追加され、Swift 5.9で導入されました。この提案により、特定の型のインスタンスをコピー不可能なオブジェクトとして定義し、機能させることができるようになりました。これにより、一意の所有権(unique ownership)を持つリソースを表現する型を定義するための新たなメカニズムとして提供されました。
通常、Swiftでは、すべての型はデフォルトでコピー可能です。通常のclassやstructは暗黙的にCopyableに準拠しているため、これを明示的に指定する必要はありません。ある型をNoncopyable(コピー不可)として定義するには、型を~Copyableとして宣言します。これにより、そのインスタンスがコピー不可であることを示します。
例
次のFile型は、複数のライター(writer)が同じファイルにアクセスすることによる実行時の問題やリソースリークを防止するため、Noncopyableとして宣言されています。(※リソースリークとは、プログラムが使用したリソースを適切に解放せずに、無駄に保持してしまう状況のことを指します。)
struct File: ~Copyable {
private let fd: CInt
init? (name: String) {
guard let fd = open (name) else {
return nil
}
self.fd = fd
}
func write(buffer: [UInt8]) {
// ...
}
deinit {
close(fd)
}
}
Copyableの前のチルダ(~)は、否定(Not)の意味を表しています。
Swift 6での改善により、Noncopyable型がOptionalに対応したことで、失敗可能なイニシャライザを作成できるようになりました。このイニシャライザは、NoncopyableなFile型をOptionalとして返します。
let file = File(name: name)
guard let file else {
return
}
file.write(buffer: data)
guardでのOptionalバインディングが可能になっています。
Noncopyable typesのさらに詳しい説明は、次のビデオでも解説されています。
Noncopyable型は「そのオブジェクトがユニークであることを保証する機能」であるとのことが窺えます。どのような使用ケースがあるのか、理解を深めていきたいです。
Typed throws 型付きスロー
SE-0413 の提案を通じて、Typed throws が導入されました。
Swift 5と6の変化を、Before/Afterの比較でまとめます。
/* Swift5 */
enum IntegerParseError: Error {
case nonDigitCharacter(String, index: String. Index)
}
func parse(string: String) throws →> Int {
for index in string.indices {
//...
throw IntegerParseError.nonDigitCharacter(string, index: index)
}
}
do {
let value = try parse(string: "1+234")
} catch let error as IntegerParseError {
//...
} catch {
// error is 'any Error'
}
/* Swift6 Typed throws */
enum IntegerParseError: Error {
case nonDigitCharacter(String, index: String.Index)
}
// parse関数は、IntegerParseErrorをスローすることを明示
func parse(string: String) throws (IntegerParseError) →> Int {
for index in string. indices {
//...
throw IntegerParseError.nonDigitCharacter (string, index: index)
}
}
do {
let value = try parse(string: "1+234")
} catch {
// この場合、汎用的なcatchブロックでも、具体的なエラー型をハンドリング出来る
// error is 'IntegerParseError'
}
copy
これまでの型無しのスロー(Untyped throws)は、any Error 型の型付きスローと同じです。
// 型無しのスロー
func parse(string: String) throws -> Int {
//...
}
// any Error 型の型付きスロー
func parse(string: String) throws (any Error) -> Int {
//...
}
また、非スロー関数(non-throwing function)は、Never型の型付きスローと同じです。
// 非スロー関数
func parse(string: String) -> Int {
//...
}
// Never型の型付きスロー
func parse(string: String) throws (Never) -> Int {
//...
}
これまでの関数(非型付きスロー)も、any ErrorやNeverの型付きスローとしてとらえることが出来る、という説明には個人的に納得感を感じました。
いつ使うか
What's new in Swiftでは「スローされるエラー・タイプを柔軟に変更したい場合は、型付きでないスローを使い続けること。」とも述べられています。
また、SE-0413 の提案の中にも次のようにあります。
使用すべき場面についても記述があり、これらはSwift API Design Guidelinesに記載予定とのことです。以下については、私の解釈になりますが、
1.モジュールやパッケージ内で完結している
2.Errorが単に通過するだけ場合
3.Embedded Swiftのように制限のある環境で自身のエラーのみが生成される状況
と、まとめました。また、スローできるエラーの種類が現在1つだとしても、将来的に複数のError型を扱う可能性があれば、型指定なしのthrowsを使用すべきとのことです。たしかに複数のエラーが発生する状況では、汎用的なcatchで補足されるエラーの種類が増えるため、ダウンキャストが必要になることが考えられます。
public func loadBytes(from file: String) async throws(FileSystemError) -> [UInt8] // 型指定なしのthrowsを使用すべき
SE-0413 の提案での内容を踏まえて、Typed throwsについては使用する場面を適切に選んで使用する必要があるようです。
Data-race safety データ競合安全性
このセクションでは、Swift Concurrencyが、Data-race(データ競合)を防ぐために段階的に進化してきたことについてと、Swift 6でその目的がついに実現されたということ、また、Data-race safety について紹介されてています。Swift 6ではデフォルトでData-raceが起きうる状況をエラーとして扱うことや、そのdata-race checking 機能の強化について説明されています。
Concurrency checking(Data-race checking)の強化
例
class Client {
init(name: String, balance: Double) {}
}
actor ClientStore {
static let shared = ClientStore()
private var clients: [Client] = []
func addClient(_ client: Client) {
clients.append(client)
}
}
@MainActor
func openAccount (name: String, balance: Double) async {
let client = Client(name: name, balance: balance)
await ClientStore.shared.addClient(client) // ← Last reference of 'client'
}
func openAccount (name: String, balance: Double) async {
let client = Client(name: name, balance: balance)
await ClientStore.shared.addClient(client) // error: Sending 'client' risks causing data-races
logger.log("Opened account for \(client.name)")
}
Synchronizationモジュールの追加
Synchronizationモジュールが新たに追加され、Atomic型、Mutex型が追加されています。
ドキュメントはこちらです。
// Low-level synchronization primitives
// Atomic
import Dispatch
import Synchronization
let counter = Atomic<Int>(0)
DispatchQueue.concurrentPerform(iterations: 10) { _ in
for _ in 0 ..< 1_000_000 {
counter.wrappingAdd(1, ordering: .relaxed)
}
}
print (counter. load(ordering: .relaxed))
「C や C++ に似たmemory-orderingの引数の使用」とは?と疑問に思ったため、C++のAPIも確認すると、確かにordering:引数の仕様は、似た定義となっていることがわかりました。
- Swift: Synchronization / Atomic / AtomicUpdateOrdering Doc
- C++: atomic / memory_order Doc
// C++
// https://en.cppreference.com/w/cpp/atomic/memory_order
std::memory_order
C++ Concurrency support library
Defined in header <atomic>
typedef enum memory_order {
memory_order_relaxed,
memory_order_consume,
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst
} memory_order;
続いて、Mutex型です。
// Low-level synchronization primitives
// Mutex
import Synchronization
final class LockingResourceManager: Sendable {
let cache = Mutex<[String: Resource]>([:])
func save(_ resource: Resource, as key: String) {
cache.withLock {
$0[key] = resource
}
}
}
こちらも下記にリンクしましたので、C++のAPIと、SwiftのMutexモジュールを比較してみてください。
- Swift Mutex Doc
- C++ Mutex Doc
以上、いかがだったでしょうか。WWDC24で発表された「Swift 6の新機能」から、NonCopyable、Typed throws、Data-race safetyについて、紹介しました。
これらの機能の理解をさらに深めるためにも、プロジェクトで実際に使用したく、Xcode 16(Swift 6)の正式リリースが待ち遠しいですね。
Data-race safetyについては、WWDC24「Swift6への移行」で具体的にアプリの移行方法が解説されています。これについてもまた取り上げる予定です。次回の記事も、ぜひご覧いただければと思います。