Swift ~Copyableの導入 - every Tech Blog

every Tech Blog

株式会社エブリーのTech Blogです。

Swift ~Copyableの導入

参考

~Copyableの導入

Swift 5.9でCopyableと~Copyableが導入されました。 全ての型が暗黙的にCopyableに準拠するので、今まで通りCopyableを前提にするなら特にCopyableと~Copyableを意識しなくても問題はありませんが、一方、~Copyableを使うと一意なリソースを表現でき、それによってより効率的なコードを書けたり、論理的に正しいコードを書くのに役立つ場合があります。

この記事では、~Copyable導入の利点についての調査と、弊社プロダクトへの導入の検討状況をご紹介します。

CopyableなStructとの比較

Structは値型です。値のコピーが複数作られるので、一意なリソースを表現するのには適していません。 また、値のコピーはメモリを占有しコピー作業に処理時間がかかるので、~CopyableなStructを使うことでコンピューティング資源の消費が少ない効率的なコードを書ける可能性があります。

Classとの比較

Classは参照型です。一意なリソースを表現することができます。 複数の箇所から同時に参照、変更が可能です。そのため、非同期的な処理でデータ競合の問題を起こしたり、参照元の管理のミスでメモリリークを発生させる恐れがあるなどコードを複雑化させる要因になります。 ~CopyableなStructを使うことでよりシンプルで安全なコードを書ける可能性があります。

所有権

~Copyableな型の値の所有者を明確にするために、 所有権(ownership) という概念が導入されています。 値の受け渡しをするとき、値をコピーする代わりに所有権の移動もしくは共有が行われます。 所有権の取り扱い方には以下の3つの種類があります。

  • consume
    • 値の所有権を移動する
    • 所有権が移動した後、元の値は無効になる
  • borrow
    • 値の所有権を共有する
    • 元々の所有者から値の所有権が剥奪されることはない
    • 借りた側は値を参照だけできる。値を変更することはできない
  • mutating (or inout)
    • 値の所有権を一時的に移動する
    • 操作が終わるまでの間、値の参照、変更ができる
    • 操作終わったら元の所有者に所有権が戻る

~Copyableを使ったコードの記述例

~Copyableな型の宣言

struct FloppyDisk: ~Copyable {}

所有権の移動

値の代入操作をすると、値がコピーされる代わりに所有権が移動します。 system が持っていた所有権は消費され、使用できなくなります。

func copyFloppy() {
  let system = FloppyDisk()    // error: 'system' used after consume
  let backup = system          // consumed here
  load(system)                 // used here
  // ...
}

func load(_ disk: borrowing FloppyDisk) {}

関数の引数として~Copyableな型の値を渡す場合

関数の引数として~Copyableな型の値を渡す場合、 consuming, borrowing, inout のいずれかを指定する必要があります。

consuming

関数を呼んだ時点で値の所有権が移動します。 値は関数の中で消費される必要があります。 この例では、関数の呼び出し元ではすでに所有権を失っているのに値を消費する操作をしようとしているため、コンパイルエラーになります。

struct FloppyDisk: ~Copyable { }

func newDisk() -> FloppyDisk {
  let result = FloppyDisk()    // error: 'result' consumed more than once
  format(result)    // consumed here
  return result    // consumed again here
}

func format(_ disk: consuming FloppyDisk) {
  // ...
}

borrowing

関数内では引数として受け取った値を変更できません。 この例では関数内で値を消費する操作をしようとしているため、コンパイルエラーになります。

struct FloppyDisk: ~Copyable { }

func newDisk() -> FloppyDisk {
  let result = FloppyDisk()
  format(result)
  return result
}

func format(_ disk: borrowing FloppyDisk) {    // error: 'disk' is borrowed and cannot be consumed
  var tempDisk = disk    // consumed here
  // ...
}

inout

関数内では一時的に所有権を持つため値を変更可能です。 関数の終了後は呼び出し元に所有権が戻ります。 この例では、format関数内で値を変更し、関数の終了後に変更された値を利用できています。

struct FloppyDisk: ~Copyable { }

func newDisk() -> FloppyDisk {
  var result = FloppyDisk()
  format(&result)
  return result
}

func format(_ disk: inout FloppyDisk) {
  var tempDisk = disk
  // ...
  disk = tempDisk
}

~Copyableな型のインスタンスメソッド

~Copyableな型のインスタンスメソッドには、 borrowing, consuming, mutating のいずれかを付与します。 borrowing がデフォルト値のため、明示しない場合は borrowing として扱われます。

~Copyableの利用例

WWDCのセッション (https://developer.apple.com/jp/videos/play/wwdc2024/10170/) では、~Copyableな型を使って一意なリソースを表現することで、静的に論理的に正しいコードを書く例を紹介しています。

BankTransfer(銀行振込)を題材にしています。

~Copyableを使わない例

class BankTransfer {
  func run() {
    // .. do it ..
  }
}

func schedule(_ transfer: BankTransfer,
              _ delay: Duration) async throws {

  if delay < .seconds(1) {
    transfer.run()
    // A
  }

  try await Task.sleep(for: delay)
  transfer.run()
}

~Copyableを使わない記述例です。

このコードにはバグがあります。Aの箇所に本来returnが必要なところ、書き忘れています。これによってdelayが1秒未満の場合 transfer.run() が2回実行されてしまいます。

振込の実行(run)は一つのBankTransferに対して複数回行われてはいけないのですが、この例ではそのような制約を実装していないため、呼び出し側の誤った実装によってこのような問題が発生してしまいます。

BankTransferの状態管理を追加した例

class BankTransfer {
  var complete = false

  func run() {
    assert(!complete)
    // .. do it ..
    complete = true
  }

  deinit {
    if !complete { cancel() }
  }

  func cancel() { /* ... */ }
}

func schedule(_ transfer: BankTransfer,
              _ delay: Duration) async throws {

  if delay < .seconds(1) {
    transfer.run()
  }

  try await Task.sleep(for: delay)
  transfer.run()
}

この例ではBankTransferの状態管理を追加し、run()の複数回実行を防いでいます。 ただし、assertによる動的な検証のため実行時にしか問題を検出できません。 適切なテストを書かない限り見つけられず(この場合、delayが1未満の条件のテストが必要)もしテストで見つけられないと、本番でクラッシュしてしまいます。

~Copyableを使った例

struct BankTransfer: ~Copyable {
  consuming func run() {
    // .. do it ..
    discard self
  }

  deinit {
    // .. do the cancellation ..
  }
}

~CopyableなBankTransferを定義しています。 インスタンスメソッドに付与した consuming キーワードによって、run()が複数回実行されることを防いでいます。

func schedule(_ transfer: consuming BankTransfer,
              _ delay: Duration) async throws {    // error: 'transfer' consumed more than once

  if delay < .seconds(1) {
    transfer.run()    // consumed here
  }

  try await Task.sleep(for: delay)
  transfer.run()    // consumed again here
}

バグのあるschedule関数です。 transfer.run() が複数回実行される可能性があることをコンパイル時に検出できています。

func schedule(_ transfer: consuming BankTransfer,
              _ delay: Duration) async throws {

  if delay < .seconds(1) {
    transfer.run()
    return
  }

  try await Task.sleep(for: delay)
  transfer.run()
}

returnを追加し、バグを修正できました。 このように、~Copyableな型を使って一意なリソースを表現することで、論理的な問題点をコンパイル時に検出できるようになりました。 問題を早期に発見でき、コードの品質向上に役立ちそうです。

弊社プロダクトで~Copyableを適用する

弊社プロダクトではまだ~Copyableを導入していませんが、以下のような箇所で導入することを検討中です。

  • ネットワーク接続
    • ネットワーク接続を一意なリソースとして表現する
  • PDF出力
    • PDF出力ではサイズの大きいデータを扱うため、~Copyableを使うことで不用意なメモリの消費を防止する
  • 広告表示
    • 広告を一意なリソースとして表現する
    • 広告リクエスト、インプレッション、クリックなどの一連の処理を~Copyableな型のインスタンスとして表現する