見出し画像

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 5でのNoncopyable型のサポートは、具象型のみに限定されていました。Swift6では、ジェネリクスやOptional、Result、Unsafe Pointers などでNoncopyable型が使用できるようになったため、失敗可能イニシャライザを作成できるようになりました。

What's new in Swift

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の比較でまとめます。

Before:

Swift 5のthrowsでは、汎用的なcatchハンドラーでthrowされたエラーはタイプが消去され、Errorとして表示されていました。具体的なエラー型をハンドリングする場合は、catch let error as FooError と型キャストを伴うcatchブロックを使用して処理していました。

/* 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'

After:

Swift 6で追加されたTyped throwsにより、関数がthrowするエラーの型を明示的に定義できるようになりました。その結果、汎用的なcatchブロックでも具体的なエラー型を扱えるようになりました。

/* 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 の提案の中にも次のようにあります。

多くのシナリオでは、Untyped throws(型指定なしのthrows)の方が適していることが多いです。(中略)実装がスローできるエラーの種類が1つしかないからといって、Typed throwsを使用したくなる誘惑に抵抗してください。

When to use typed throws

使用すべき場面についても記述があり、これらは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 機能の強化について説明されています。

・Data-race(データ競合)とは、並行プログラム(concurrent programs)を作成する際に発生するプログラムエラーのことです。Data-raceエラーは、複数のスレッドが共有データにアクセスし、そのうちの1つがデータを変更しようとする際に発生する可能性があります。これにより、予期しない実行時の動作、クラッシュ、再現が困難な不具合を引き起こすことがあります。

What's new in Swift

・Data-race safety は、Swift Concurrencyの当初からの主要な目標であり、段階的に進歩してきました。

・Swift Concurrencyは、データ隔離性(data isolation)を実現するメカニズムであり、可変な状態(mutable state)を防ぐためのActors、安全なデータ共有(safe data sharing)を実現する Sendable、を中心に設計されました。



・Swift 5.10 は、complete concurrency checkingフラグ有効化の下、Data-race safety を実現しました。

・Swift 6 では、デフォルトで Data-race safety を実現し、すべてのデータ競合の問題を、コンパイル時のエラーに変えます。アプリのセキュリティを大幅に向上させ、デバッグ作業を軽減します。

What's new in Swift

・新しいSwift6の言語モードに合わせて、コードを調整する必要がある場合があるため、その調整が終わった後でSwift6を採用できます。また、Swift6.0のアップデートをモジュールごとに採用して、Swift5のまま相互運用することも出来ます。

・Data-race safetyは、Swift 6 言語モードを有効にすることによって採用される唯一のアップデートです。 Swift 6 言語モードを有効にすると、他のすべてのアップデートもデフォルトになります。

What's new in Swift

Concurrency checking(Data-race checking)の強化

データ競合チェック(data-race checking)も大幅に改善されました。Swift 5.10でcomplete concurrency checkingを有効化すると、アクターの隔離境界を越えてSendableでない値(non-Sendable values)の全ての値を渡すことが禁止されていました(banned passing all non-Sendable values across actor isolation boundaries)。

しかし、Swift 6では、元の隔離境界から参照できなくなったシナリオでは、Sendableでない値も安全に引き渡しができると認識されるようになりました。

What's new in Swift

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'

・例)non-Sendable 参照である"Client"を、MainActor から"ClientStore" actorに渡すと、Swift5.10 のcomplete concurrency checkingでは警告が発生します。



・ただし、"Client"オブジェクト参照は"ClientStore" アクターに送信された後は、MainActor 上で参照されなくなります。この時、MainActorと "ClientStore" アクターの間では"Client"のステート(client’s state)は共有されなくなるため、Data-raceが発生することはありません。Swift 6 のData-race checkingの改善により、これは正常にコンパイルされるようになりました。

What's new in Swift
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)")
}

・"Client"参照を"ClientStore" Actorに渡した後に取り込む(この例ではlogger.logで使用)と、コンパイラはData-raceエラーを発生させます。

What's new in Swift

Synchronizationモジュールの追加

Synchronizationモジュールが新たに追加され、Atomic型、Mutex型が追加されています。

ドキュメントはこちらです。

actor型は高レベルな同期モデルですが、Swift 6では新たにいくつかの低レベルなプリミティブ型(の同期モデル)が追加されています。

What's new in Swift
// 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))

安全な並行アクセスのため、Atomicは常に「let」プロパティにストアする必要があります。C や C++ に似たmemory-orderingの引数を使用します。

What's new in Swift

「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
    }
  }
}

Atomicと同様に、Mutexも let プロパティにストアする必要があり、安全に並行アクセスできます。Mutexによって保護されたストレージへのすべてのアクセスは、withLock メソッドに渡されたクロージャを介して行われます。これにより、排他的なアクセスが保証されます。

What's new in Swift

こちらも下記にリンクしましたので、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への移行」で具体的にアプリの移行方法が解説されています。これについてもまた取り上げる予定です。次回の記事も、ぜひご覧いただければと思います。


株式会社Amaziaでは新しいメンバーを積極的に採用しています!詳しくは採用ページをご覧ください!