borrowing and consuming parameter ownership modifiers
01 何が問題だったのか
Swiftは自動参照カウント(ARC)で値の寿命を管理しており、関数の引数を呼び出し先に渡すときには、呼び出し先がその値をどのように受け取るかについて主に次の2種類の規約があります。
- borrow: 呼び出し先は引数を借りるだけで、呼び出し元が引数のライフタイムを保証します。呼び出し先は自身が追加で行った retain を打ち消す以外に release する必要はありません。
- consume: 呼び出し先が引数の所有権を受け取り、以降の release(あるいは別の場所への所有権の譲渡)に責任を持ちます。呼び出し元が値を手放したくなければ、呼び出し先が余分な参照を consume できるように retain を追加する必要があります。
値型では、retain が独立した値のコピー、release がそのコピーの破棄に対応します。Swiftは典型的なコードの振る舞いに基づいたルールでどちらの規約を採用するかを自動的に決めます。初期化子やプロパティの setter は引数を別の値の構築・更新に使うことが多いため、引数を consume して新しい値に所有権をforwardする方が効率的です。それ以外の関数はほとんどの場合 borrow の方が効率的であるため、デフォルトで borrow になります。
この自動選択は多くの場合うまく機能しますが、常に最適とは限りません。最適化器はARCの呼び出しを減らせる場面では関数の規約を入れ替える「function signature optimization」を行えますが、適用範囲は限定的です。さらに、規約はABI安定ライブラリのpublic APIではABIの一部となるため、いったん確定した後には変えられません。最適化器は非 final なクラスメソッドやプロトコル要件のような多態的なインターフェースに対しても最適化を行いません。これらの状況でデフォルトと異なる挙動が必要な場合、従来は手段が存在しませんでした。
加えて、SE-0390 で導入される noncopyable な型にとっては、この区別がAPIの契約そのものを左右します。noncopyable な値を borrow する関数は値を一時的に使うだけで、呼び出し元は以降もその値を使えます(ファイルハンドルから読み出すイメージ)。一方 consume する関数は値を消費し、呼び出し元が以降の利用をできなくします(ファイルハンドルを閉じるイメージ)。noncopyable な型ではデフォルトを暗黙に仮定できないため、パラメータごとに規約を明示する手段が必要になります。
02 どのように解決されるのか
パラメータの所有権規約を開発者が直接制御できるよう、borrowing と consuming という2つのパラメータ修飾子を導入します。さらに、これらの修飾子が付いたパラメータのbindingは暗黙にコピー可能ではなくなり、コピーが必要な箇所では新しい copy x 演算子で明示する必要があります。
構文
borrowing / consuming はパラメータの型宣言内で文脈依存のキーワードとなり、inout と同じ位置に書けます。3つは相互に排他です。
func foo(_: borrowing Foo)
func foo(_: consuming Foo)
func foo(_: inout Foo)
クロージャや関数型でも同様に書けます。
bar { (a: borrowing Foo) in a.foo() }
let f: (consuming Foo) -> Void = { a in a.foo() }
メソッドでは、self の受け取り方を指定するのに consuming / borrowing を付けられます。これらは mutating とも相互に排他です。
struct Foo {
consuming func foo() // self を consume する
borrowing func foo() // self を borrow する
mutating func foo() // inout セマンティクスで self を変更する
}
エスケープしないクロージャ型のパラメータは本質的に常に borrow されるため、consuming を付けることはできません。
// ERROR: cannot `consume` a nonescaping closure
func foo(f: consuming () -> ()) {
}
プロトコルと関数型における規約の変換
プロトコル要件にも consuming / borrowing を付けられ、これはジェネリックインターフェース越しに要件を呼ぶ側の規約に影響します。copyable な型のパラメータについては、要件と異なる規約を持つ実装でも要件を満たせます。
protocol P {
func foo(x: consuming Foo, y: borrowing Foo)
}
// いずれも有効な適合:
struct A: P { func foo(x: Foo, y: Foo) }
struct B: P { func foo(x: borrowing Foo, y: consuming Foo) }
struct C: P { func foo(x: consuming Foo, y: borrowing Foo) }
同様に、関数値も copyable な型のパラメータについては、無指定・borrowing・consuming の間で暗黙に変換できます。
let f = { (a: Foo) in print(a) }
let g: (borrowing Foo) -> Void = f
let h: (consuming Foo) -> Void = f
let f2: (Foo) -> Void = h
noncopyable なパラメータ型の場合は、これらの暗黙変換は使えず、規約は完全に一致する必要があります。
関数本体での振る舞い
consuming パラメータ(および consuming func の self)は関数本体の中でミュータブルとして扱えます。この変更は関数自身が所有権を受け取った値に対して行われるため、呼び出し元に残っているコピーには影響しません。所有権の譲渡後は値が一意であることを活かして、効率的にインプレース変更ができます。
extension String {
// 可能なら in-place で self に other を追記する
consuming func plus(_ other: String) -> String {
self += other
return self
}
}
// O(n^2) ではなく償却 O(n) で動作します
let helloWorld = "hello ".plus("cruel ").plus("world")
暗黙のコピー禁止
borrowing と consuming のパラメータbindingは、関数本体の中で暗黙にコピー可能ではなくなります。メソッドレベルで borrowing / consuming を付けた場合の self についても同じです。
func foo(x: borrowing String) -> (String, String) {
return (x, x) // ERROR: needs to copy `x`
}
func bar(x: consuming String) -> (String, String) {
return (x, x) // ERROR: needs to copy `x`
}
直感的には、これらのbindingは本体の中では noncopyable 型であるかのように振る舞います。コピーが必要になるのは、borrow したbindingに対して consume 操作を行うときや、すでに consume 済みのbindingを再び consume しようとするとき、同じbindingに対して borrow / mutate が同時進行している中でさらに consume が発生するときなどです(borrow / consume / mutate 操作の区別は SE-0390 に従います)。
コピーを許したいときは copy x 演算子で明示します。
func dup(_ x: borrowing String) -> (String, String) {
return (copy x, copy x) // OK: ここでは明示的にコピーを許可
}
copy x は x に対する borrow 操作で、x の現在の値から独立した所有権を持つコピーを返します。得られたコピーは元の x に影響を与えず自由に consume / mutate できます。ただし「必ずコピーする」わけではなく、意味的に不要とコンパイラが判断すれば最適化で除去されることがあります。
copy は文脈依存のキーワードで、直後に同じ行の識別子が続く場合にのみ演算子として解釈されます(consume x 演算子と同じ扱いです)。それ以外の場所では従来通り copy という宣言への参照として扱われます。
コピー禁止はbinding単位
この制約はパラメータbinding自身にのみ適用され、値を別の関数の引数や別の変数に渡してしまえば、その先ではまた暗黙のコピーが可能です。
func foo(x: borrowing String) {
let y = x // ERROR: x をコピーしようとしている
bar(z: x) // OK: bar(z:) の呼び出しは x のコピーを必要としない
}
func bar(z: String) {
let w = z // OK: z はここでは暗黙にコピー可能
}
func baz(a: consuming String) {
// let aa = (a, a) // ERROR: a をコピーしようとしている
let b = a
let bb = (b, b) // OK: b は暗黙にコピー可能
}
呼び出し側での「呼び出し式自体」もこの制約の対象です。呼び出しのために引数をコピーする必要があれば、そこでエラーになります。関数本体が制約の境界となります。
struct Bar {
var a: String
var b: String
init(ab: String) {
// ab はここでは暗黙にコピー可能
a = ab
b = ab
}
}
func foo(x: borrowing String) {
_ = Bar(ab: x) // ERROR: Bar.init に consume させるために x のコピーが必要
}
Source / ABI への影響
copyable な型のパラメータについては、consuming や borrowing の追加・削除・変更は呼び出し側のソースには影響しません。呼び出し構文はそのままで、プロトコル要件を異なる修飾子の実装で満たすこともできます。効果は修飾子を付けた関数本体に局所化されます。そのため、APIの作者は呼び出し側に所有権を意識させずに実装のコピー挙動を微調整でき、ソース配布のパッケージであれば修飾子を後から足したり外したりしても大丈夫です。
一方で、borrowing から consuming への変更は、同じ修飾子を採用しているクライアント側のコピー位置に影響するため、ソースブレーキングになり得ます。consuming から borrowing への変更は copyable 型では一般にソースブレーキングではありません。パラメータ型が noncopyable な場合は、どちらの方向の変更もソースブレーキングです。
ABIレベルでは、consuming / borrowing は呼び出し規約そのものを変えるため、ABI安定ライブラリでは変更できません(コピーが memcpy と同等で破棄が no-op となる “trivial type” を除きますが、そうした型では実質的な差も出ません)。
noncopyable 型との関係
noncopyable 型では consuming と borrowing の違いがAPI契約上きわめて重要になります。borrow する関数は値をそのまま残し、consume する関数は値を壊して以降の利用を不可能にします。
struct FileHandle: ~Copyable { ... }
func open(path: FilePath) throws -> FileHandle
func read(from: borrowing FileHandle) throws -> Data
func close(file: consuming FileHandle)
func hackPasswords() throws -> HackedPasswords {
let fd = try open(path: "/etc/passwd")
let contents = try read(from: fd) // read は borrow なので以降も fd を使える
close(file: fd) // close は consume
let moreContents = try read(from: fd) // ERROR: consume 後の使用
return hackPasswordData(contents)
}
そのため、SE-0390 では noncopyable 型のパラメータに対して borrowing / consuming のいずれかの明示を必須としています。
consume 演算子との組み合わせ
SE-0366 で導入された consume 演算子と組み合わせると、呼び出し側でライフタイムを明示的に終わらせつつ、consuming パラメータへの所有権の転送をコピーなしで行えます。
func consume(x: consuming Foo)
func produce() {
let x = Foo()
consume(x: consume x)
doOtherStuffNotInvolvingX()
}
Future directions
以下は今回のスコープ外で、今後の検討対象として言及されています(実現を約束するものではありません)。
- 呼び出し側で明示的に borrow を行う
borrow演算子。グローバル変数やクラスの格納プロパティなど、排他アクセス違反を避けるためコンパイラが防御的コピーを入れてしまう場面で、開発者が「ここでは干渉する書き込みは起きない」と明示してコピーを抑制できるようにするためのものです。 - 集約型の一部を取り出してコピーせずに別名を付ける
borrowing/mutating/consumingなローカルbinding。 - 初期化されていない引数を受け取って新しい値で埋める
set/outパラメータ規約。 inoutパラメータやmutating selfも暗黙にコピー可能ではなくする方向への整理(Swift 6 以降で段階的に検討)、および-ing形に揃えたmutatingパラメータ修飾子の導入。