Swift Digest
SE-0437 | Swift Evolution

Noncopyable Standard Library Primitives

Proposal
SE-0437
Authors
Karoy Lorentey
Review Manager
John McCall
Status
Implemented (Swift 6.0)

01 何が問題だったのか

SE-0427 によって、non-copyable な型(~Copyable な型)が Swift のジェネリクスに参加できるようになり、標準ライブラリに Copyable プロトコルが導入されました。しかし、Swift の標準ライブラリ自体は長年「あらゆる型はコピー可能である」という前提の上に設計されており、基本的な型や関数の多くがその前提に縛られていたため、non-copyable な型を実際に使おうとするとすぐに壁にぶつかる状態でした。

具体的には、次のような基本的な操作が non-copyable な型に対して行えませんでした。

  • UnsafePointer / UnsafeMutablePointer で non-copyable な pointee を扱うことや、そのためのヒープアロケーション(UnsafeMutablePointer.allocate など)。
  • UnsafeBufferPointer / UnsafeMutableBufferPointer で non-copyable な要素を保持すること。
  • ManagedBuffer / ManagedBufferPointer による末尾割り当て領域に non-copyable な要素を置くこと。
  • withUnsafePointer(to:)withUnsafeTemporaryAllocation による一時的なポインタ取得。
  • MemoryLayout で non-copyable な型のサイズ・アラインメントなどを問い合わせること。

さらに深刻なのが、OptionalResult が non-copyable な値を包めないという問題です。Optional は「値が存在しないかもしれない」ことを表す標準的な手段であり、optional chaining、failable initializer、try? などの言語機能もこれを前提に作られています。non-copyable な値を返す関数が存在できなければ、ownership 制御を使ったコードは API 境界で非常に不便になります。

OptionalResult の中身が non-copyable な場合、その OptionalResult 自体も non-copyable でなければなりません。つまり、型パラメータに応じて条件付きで Copyable になる、という大きな設計変更が必要になります。これはすでに広く使われている型に対して後方互換を保ちながら適用しなければならない、難易度の高い変更です。

また、高階関数についても問題がありました。例えば Optional.map は戻り値の型 U に暗黙にコピー可能性を要求しており、non-copyable な値を返そうとするだけでコンパイルエラーになります。

let name: FilePath? = ...
let file: File? = try name.map { try File(opening: $0) }
// error: noncopyable type 'File' cannot be substituted for copyable generic parameter 'U' in 'map'

map のような関数の「入力側」(self を consume するか borrow するか)を整理するには言語機能側の追加検討が必要ですが、「出力側」のコピー可能性の制約は今すぐ外しても問題ない、という点も明確になっていました。

この Proposal は標準ライブラリ全体を一度に non-copyable 対応にするものではなく、ownership 制御を本格的に使い始めるために最低限必要な、ポインタ系のプリミティブと Optional / Result を優先的に一般化するための第一歩です。

02 どのように解決されるのか

標準ライブラリの次の型・関数について、型パラメータのコピー可能性要件を緩和し、non-copyable な型を扱えるようにします。OptionalResult は条件付きで Copyable(中身がコピー可能なときにだけ自身もコピー可能)になり、それ以外のポインタ系の型は、pointee/要素が non-copyable でも自身は常にコピー可能なまま保たれます。

対象となる型は次のとおりです。

  • enum Optional<Wrapped: ~Copyable>
  • enum Result<Success: ~Copyable, Failure: Error>
  • struct MemoryLayout<T: ~Copyable>
  • struct UnsafePointer<Pointee: ~Copyable> / struct UnsafeMutablePointer<Pointee: ~Copyable>
  • struct UnsafeBufferPointer<Element: ~Copyable> / struct UnsafeMutableBufferPointer<Element: ~Copyable>
  • class ManagedBuffer<Header, Element: ~Copyable> / struct ManagedBufferPointer<Header, Element: ~Copyable>

あわせて、ExpressibleByNilLiteral プロトコルだけは ~Copyable 対応に一般化されます(nil を non-copyable な Optional に使い続けられるようにするためです)。Equatable / Hashable / Sequence / Collection などそれ以外の標準プロトコルは今回は対象外で、引き続きコピー可能性を要求します。

トップレベル関数では swap(_:_:)withExtendedLifetime(_:_:)withUnsafeTemporaryAllocationwithUnsafePointer(to:_:) / withUnsafeMutablePointer(to:_:)withUnsafeBytes(of:_:) / withUnsafeMutableBytes(of:_:) が一般化されます。

ポインタを使った non-copyable 値の管理

まず、non-copyable な型に対してヒープアロケーションや基本的なポインタ操作が普通にできるようになります。

struct Foo: ~Copyable {
  var value: Int
  mutating func increment() { value += 1 }
}

let p = UnsafeMutablePointer<Foo>.allocate(capacity: 2)
let q = p + 1
p.initialize(to: Foo(value: 42))
q.initialize(to: Foo(value: 23))
p.pointee.increment()
print(p.pointee.value)        // Prints "43"
print(p[0].value, p[1].value) // Prints "43 23"
print(p < q)                  // Prints "true"
let foo = p.move()
q.deinitialize(count: 1)
p.deallocate()

ポインタ自身はコピー可能なまま保たれるので、UnsafePointer / UnsafeMutablePointer は pointee が non-copyable でも Equatable / Hashable / Comparable / Strideable に適合し続けます。pointee プロパティと [i] による subscript も、それぞれ in-place で borrow / mutate する特殊なアクセサを通じて non-copyable に対応します。

一方で、値を複製することが本質的に必要な操作、例えば initialize(repeating:count:)update(from:count:)initialize(from:count:) などは、引き続き pointee がコピー可能な場合にのみ使える形で残ります。key path に依存する pointer(to:) も同様です。

UnsafeMutablePointer.initialize(to:) は、以前は引数を borrow するように振る舞っていて、non-copyable の文脈では意味が通らなくなってしまう定義でした。今回の一般化では、この関数は引数を consuming で受け取る形に変わります。

extension UnsafeMutablePointer where Pointee: ~Copyable {
  func initialize(to value: consuming Pointee)
}

コピー可能な値を渡した場合の呼び出し側のコードはそのままで動きますが、明示的に consuming が付いたことで、不要なコピーが発生しないことも保証されるようになります。

同様の問題は UnsafeMutableBufferPointer.initializeElement(at:to:) にもあり、こちらも valueconsuming で受け取る形に直されます。

また、メモリリバインド系の関数(withMemoryReboundbindMemoryassumingMemoryBound など)は、pointee だけでなく変換先の型 T やクロージャの戻り値 Result についてもコピー可能性の要件が外され、T: ~Copyable, Result: ~Copyable を許すようになります。OpaquePointerUnsafeRawPointer との相互変換、Int / UIntinit(bitPattern:) なども同様に一般化されます。

バッファポインタと extracting

UnsafeBufferPointer / UnsafeMutableBufferPointer も、要素型が non-copyable でも使えるようになります。ただし Sequence / Collection は今回一般化されないため、これらへの適合は引き続き Element: Copyable の場合に限られ、IteratorSubSequence / Indices といった関連型もそのときだけ存在します。つまり、要素が non-copyable なバッファポインタは for-in ループでは回せません。

とはいえ、Collection の中核に近い概念はバッファポインタ自身のメソッド・プロパティとして non-copyable 要素向けに提供されます。

extension Unsafe[Mutable]BufferPointer where Element: ~Copyable {
  typealias Index = Int
  var isEmpty: Bool { get }
  var startIndex: Int { get }
  var endIndex: Int { get }
  var count: Int { get }
  func index(after i: Int) -> Int
  func index(before i: Int) -> Int
  // ...
  subscript(i: Int) -> Element // borrow/mutate する特殊なアクセサ
}

subscript は通常の getter ではなく、in-place で borrow / mutate するアクセサとして提供されます。通常の getter はコピーを返すことになってしまい、non-copyable な要素では成立しないためです。

スライス用の subscript(buffer[i..<j])は Slice を返す必要があり、Slice 自体が現時点では non-copyable 要素に一般化できません。その代わりとして、同じ用途を新しいメソッド extracting で提供します。

extension Unsafe[Mutable]BufferPointer where Element: ~Copyable {
  func extracting(_ bounds: Range<Int>) -> Self
  func extracting(_ bounds: some RangeExpression<Int>) -> Self
  func extracting(_ bounds: UnboundedRange) -> Self
}

extractingbuffer[i..<j] とは異なり、0 始まりの独立したバッファポインタを返します。これは UnsafeBufferPointer(rebasing: buffer[i..<j]) と同等の結果で、non-copyable な要素を持つバッファを分割する唯一の手軽な手段になります。コピー可能な要素でもこのメソッドは使え、スライス+リベースのよく書かれるイディオムの短縮形として便利に使えます。

import Synchronization

let bank = UnsafeMutableBufferPointer<Atomic<Int>>.allocate(capacity: 4)
for i in 0 ..< 4 {
  bank.initializeElement(at: i, to: Atomic(i))
}
let part = bank.extracting(2 ..< 4)
print(part[0].load(ordering: .sequentiallyConsistent)) // Prints "2"
print(part[1].load(ordering: .sequentiallyConsistent)) // Prints "3"
bank.deinitialize().deallocate()

要素のコピーや Sequence を使った初期化・更新、Slice を引数に取るイニシャライザ(init(rebasing:) など)は、これまでどおりコピー可能な要素にのみ適用されます。

一時ポインタ・一時アロケーション・ManagedBuffer

withUnsafePointer(to:) / withUnsafeMutablePointer(to:)withUnsafeBytes(of:) / withUnsafeMutableBytes(of:) は、対象の値 T とクロージャの戻り値 Result の双方について ~Copyable を許すようになります。borrow 版の withUnsafePointer(to: borrowing T, ...) も同様です。

ただし borrow は排他的ではないため、同じ non-copyable インスタンスに対して入れ子で呼び出すと、ポインタのアドレスが異なって見えることがある点には注意が必要です(Swift の呼び出し規約に由来するもので、セマンティクス上はひとつのインスタンスです)。

withUnsafeTemporaryAllocation も同様に、要素型・戻り値のいずれも ~Copyable を許します。

func withUnsafeTemporaryAllocation<T: ~Copyable, E: Error, R: ~Copyable>(
  of type: T.Type,
  capacity: Int,
  _ body: (UnsafeMutableBufferPointer<T>) throws(E) -> R
) throws(E) -> R

ManagedBuffer / ManagedBufferPointerElement のコピー可能性要件が外され、withUnsafeMutablePointerToHeader / withUnsafeMutablePointerToElements / withUnsafeMutablePointers はクロージャの戻り値 R についても ~Copyable を許します。ただし、ManagedBuffer.header は stored property として既に公開されているため、互換性維持の都合で Header は当面コピー可能でなければならない点は残ります。

OptionalResult の条件付き non-copyable 化

OptionalResult は、型パラメータから Copyable を継承する形に書き換えられます。

@frozen
enum Optional<Wrapped: ~Copyable>: ~Copyable {
  case none
  case some(Wrapped)
}
extension Optional: Copyable where Wrapped: Copyable {}
extension Optional: Sendable where Wrapped: ~Copyable & Sendable {}
extension Optional: ExpressibleByNilLiteral where Wrapped: ~Copyable {
  init(nilLiteral: ())
}

@frozen
enum Result<Success: ~Copyable, Failure: Error>: ~Copyable {
  case success(Success)
  case failure(Failure)
}
extension Result: Copyable where Success: Copyable {}
extension Result: Sendable where Success: Sendable & ~Copyable {}

nil はそのまま使えます。

var document: File? = nil // OK

non-copyable な Optional で使える主な新機能は次のとおりです。

  • Optional を nil にリセットしつつ元の値を取り出す take() メソッド。non-copyable な stored property を部分的に consume したいときに便利です。

      extension Optional where Wrapped: ~Copyable {
        mutating func take() -> Self // self を nil にして元の値を返す
      }
    
  • nil 合体演算子 ?? は第一引数を consuming で受け取る形に更新されます。

      func ?? <T: ~Copyable>(
        optional: consuming T?,
        defaultValue: @autoclosure () throws -> T
      ) rethrows -> T
    
  • == nil / != nil と、case nil でのパターンマッチ(~=)は non-copyable な Optional でも使えます。ただし Equatable は一般化されていないので、非 nil 同士の == による比較は Wrapped: Equatable のときだけです。

Result 側では、init(catching:)get()mapError / flatMapErrorSuccess: ~Copyable に対応します。mapError 系は内部の Success の所有権を結果に渡す必要があるため、consuming メソッドになります。

map / flatMap については「self を consume するか borrow するか」の整理が必要で、その判断は将来の Proposal に委ねられます。そのため、Optional.mapResult.map は引き続き Wrapped / Success にコピー可能性を要求します。ただし戻り値の型についてはコピー可能性の要件が外され、non-copyable な値を返せるようになります。

extension Optional {
  func map<E: Error, U: ~Copyable>(
    _ transform: (Wrapped) throws(E) -> U
  ) throws(E) -> U?

  func flatMap<E: Error, U: ~Copyable>(
    _ transform: (Wrapped) throws(E) -> U?
  ) throws(E) -> U?
}

これにより、冒頭で問題になっていた次のようなコードがそのまま書けるようになります。

let name: FilePath? = ...
let file: File? = try name.map { try File(opening: $0) } // OK

Optional.unsafelyUnwrapped は borrow / consume の両方に対応できるアクセサを言語側で用意する必要があり、今回は引き続きコピー可能な Wrapped にのみ提供されます。

MemoryLayoutswapexchangewithExtendedLifetime

MemoryLayout は non-copyable な T に対しても size / stride / alignment と、値を渡す size(ofValue:) などを提供します(offset(of:) は key path を使うため引き続きコピー可能な T 限定)。

swap~Copyable に対応し、ownership 制御の機能でシンプルに書き直されます。さらに、片方の値をいったん取り出して新しい値に置き換える新しい exchange 関数が追加されます。

func swap<T: ~Copyable>(_ a: inout T, _ b: inout T) {
  let tmp = consume a
  a = consume b
  b = consume tmp
}

public func exchange<T: ~Copyable>(
  _ value: inout T,
  with newValue: consuming T
) -> T

withExtendedLifetimeT: ~CopyableResult: ~Copyable に対応します。

Future Directions

標準ライブラリを non-copyable 対応にする作業はこれが第一歩で、今後は次のような方向性が検討されるとされています。いずれも本 Proposal の範囲外で、実現が約束されているわけではありません。

  • Optional / Result の non-escapable 対応(~Escapable との組み合わせ)。
  • Optional.map / flatMap の consuming / borrowing 版の追加。consumingMap / borrowingMap のような命名や、.borrowing.map { ... } のような「view」経由の表現など、複数の案があります。
  • Optional.unsafelyUnwrapped を、コルーチンベースの read accessor や consuming getter を使って non-copyable 対応にする方向性。
  • ManagedBufferHeader の non-copyable 化。
  • Equatable / Hashable / ComparableRawRepresentableErrorExpressibleByArrayLiteral などのプロトコルの一般化と、borrowing / consuming を分けた新しい sequence / collection プロトコルの設計。
  • 固定容量配列やスタック割り当ての辞書など、copy-on-write ではない新しいコンテナ型。