この記事について
この記事は Swift Tweets 2017 Fall での発表内容を記事の形で再編したものです。
導入
Swiftはネイティブコンパイルや値型など、高速な動作を意識して設計されていますが、CPUの性能を活かしきるコードを書くには機能が足りていません。コアチームは、その不足している機能群のうち、あるグループに Ownership 機能と名前をつけ、今後の対応方針を Ownership Manifesto として文書化しました。
この記事では、この文書に基づいて Ownership 機能を紹介します。その内容のうち、すでにSwiftに導入されているものもありますが、そうでないものについては、あくまで方向性が提示されているものであり、確定した仕様ではないことに注意してください。
Ownership
Ownership は主にコピーの回避を実現します。これまで「コピーしていた」場面で、そのかわりに「貸したり」「渡したり」できるようにして、より高速なコードを書けるようにします。
コピーコスト
つまり前提として、コピーについてのコスト意識があります。
値型がコピーされるときは、その全ての stored property がコピーされます。参照型がコピーされるときは、参照カウンタの増加が起きます。
値がコピーされた数だけ、それが破棄される可能性があります。値型が破棄されるときは、その全ての stored property が破棄されます。参照型が破棄されるときは、参照カウンタの減少が起こり、もしそれが 0 になると、stored property の破棄とヒープメモリの解放処理が起きます。
これらの処理は stored property を通じて連鎖するので、型のサイズに応じてコストが大きくなります。こうした場面で、不要なコピーが生じないコードを書く手段が提供されます。
排他則
Ownership Manifesto では大きく分けて3つの機能が提案されています。第1の機能は、値アクセスの排他則というものです。原文では Law of Exclusivity と書かれています。
排他則とは、ある値への書き込みアクセスが存在するとき、そのアクセスが排他的である事を強制し、その他のアクセスが同時に生じる事を禁止するものです。
これは、今後の機能追加にあたって、その安全性のために先立って必要なもので、現在のSwiftにすでに導入されています。ただ、この機能それ自体も、コンパイル最適化のチャンスを与えるため、高速化に貢献するとされています。
この制約は、コンパイルエラーや実行時エラーとして実現されます。
書き込みアクセスには以下のようなものがあります。
- 変数への代入
- 関数のinout引数に変数を渡す
- mutating func なメソッドを呼ぶ(レシーバに対する書き込みアクセス)
排他則の適用例
例えば、以下のコードはエラーになります。
func f0(_ a: inout Int, _ b: inout Int) {}
func main() {
var x: Int = 3
f0(&x, &x)
}
main()
以下のエラーメッセージが出ます。
/Users/omochi/github/omochi/swift-ownership-example/x01_loe-inout/Sources/x01_loe-inout/main.swift:5:12: error: inout arguments are not allowed to alias each other
f0(&x, &x)
^~
/Users/omochi/github/omochi/swift-ownership-example/x01_loe-inout/Sources/x01_loe-inout/main.swift:5:8: note: previous aliasing argument
f0(&x, &x)
^~
/Users/omochi/github/omochi/swift-ownership-example/x01_loe-inout/Sources/x01_loe-inout/main.swift:5:8: error: overlapping accesses to 'x', but modification requires exclusive access; consider copying to a local variable
f0(&x, &x)
^~
/Users/omochi/github/omochi/swift-ownership-example/x01_loe-inout/Sources/x01_loe-inout/main.swift:5:12: note: conflicting access is here
f0(&x, &x)
^~
2つの inout 引数に同じ変数 x を渡しているので、変数 x の値への書き込みアクセスが同時に2つ存在してしまうからです。
以下のコードもエラーになります。
struct Cat {
var age: Int = 3
mutating func updateAge(_ f: () -> Int) {
age = f()
}
}
func main() {
var cat = Cat()
cat.updateAge {
cat.age * 2
}
}
main()
以下のエラーメッセージが出ます。
/Users/omochi/github/omochi/swift-ownership-example/x02_loe-mutating/Sources/x02_loe-mutating/main.swift:10:5: error: overlapping accesses to 'cat', but modification requires exclusive access; consider copying to a local variable
cat.updateAge {
^~~
/Users/omochi/github/omochi/swift-ownership-example/x02_loe-mutating/Sources/x02_loe-mutating/main.swift:11:13: note: conflicting access is here
cat.age * 2
~~~~^~~
updateAge は mutating func なので、このメソッド呼び出しはレシーバ cat に対する書き込みアクセスです。その呼び出しの最中に、引数に渡されたクロージャからも cat に読み込みアクセスをしています。書き込みアクセスの最中は他のアクセスが禁止されるためエラーになります。
このように、排他則はプログラマに制約を課すものであり、一見不便が生じてしまいますが、値の同時アクセスの可能性を考慮したプログラムを書くのは難しく、考慮漏れがあるとバグにつながるため、それを未然に防いでくれます。
最適化
また、排他則があることで適用できるようになる最適化があり、高速なコードにコンパイルできる場合があります。そのような最適化の例として、コピーオンライト型のユニーク性チェックの巻き上げを紹介します。
Arrayのようなコピーオンライトな型では、何か書き込み操作が生じる直前に、その内部ストレージの参照カウンタを調べて、複数箇所から共有参照されているかどうか調べます。一箇所だけから参照されているとき、その参照はユニークなので、これをユニーク性のチェックといいます。もし共有されていた場合は、操作の前にストレージを複製します。
以下のコードを見てください。
extension Array {
mutating func update(_ f: () -> Element) {
for i in 0..<count {
self[i] = f()
}
}
}
このコードはArrayの要素をクロージャの返り値によって更新しています。もしかするとupdateが以下のように呼び出されているかもしれません。
var xs: [Int] = [1, 2, 3]
var ys: [Int] = []
xs.update {
ys = xs
return 10
}
update に渡しているクロージャの中で xs が ys にコピーされています。よって、update 内部で 引数 f を呼び出すと、xs の内部ストレージが ys と共有され、複数箇所から参照された状態になります。
このように、渡されたクロージャが共有を生じさせる可能性があるので、コンパイラはループ内部の self[i] に代入する直前でユニーク性のチェックをするコードを生成しなければなりません。このループは要素数だけ繰り返されるので、チェックコストは大きくなります。
ここで排他則が導入されれば、このようなクロージャが渡される可能性が排除されます1。update は mutating func による書き込みアクセスであり、その他のアクセスが禁止されるからです。その結果、ユニーク性のチェックはループ突入前に1度だけ行えばよくなります。これをユニーク性のチェックの巻き上げ最適化といいます。
shared 参照
提案されている第2の機能は、shared 参照というものです。
swift にはすでに inout 参照による inout 渡しがあります。これは関数の引数へ値を渡すときに、値をコピーして渡す代わりに変数への参照を渡すものです。そしてこの参照は変数の値への書き込みアクセスとなります。
通常の引数は、関数を呼び出す際に値がコピーされるため、コストがかかります。実は、 inout 渡しは、そのストレージへのポインタを渡すだけであり、そのようなコピーコストがかからず高速です。
shared 参照はこれの読み取り専用版です。inout 渡しでは、関数内部からその引数を var のように扱い、値を「書き戻す」事ができましたが、shared 渡しでは、その引数は let のように扱われ、読み取り専用となります。
inout 渡しでは、呼び出し側にアンパサンド(&)をつける必要があり、変数などのストレージの式を渡す必要がありますが、 shared はアンパサンドは不要で、一般の値が渡せます。つまり、使い心地は通常の引数とほとんど変わりません。
以下にコード例を示します。
func getLength(_ v: shared Vec2) -> Float {
return sqrt(v.x * v.x + v.y * v.y)
}
func main() {
getLength(Vec2(x: 1, y: 1))
}
呼び出され側で v を使っているところと、呼び出し側で Vec2 を渡しているところを見ると、特に通常の引数と変わらないことがわかります。このように、shared は使いやすくて高速な引数の渡し方となります。
ただし、shared 渡しされている値は、その関数の呼び出しの間ずっと、読み込みアクセスされることに注意が必要です。先述した排他則により制約をうける場合があります。
参照仕様の発展
shared と inout は「第一級の参照」にはしないという方針が示されています。返り値や変数の型など、他の型と同じようにどこでも使えるようにはしないということです。第一級の参照が入ると言語や型システムが難解になり、それにより得られるメリットよりも、苦労のほうが大きいとのことです。
第一級にはなりませんが、参照に関する発展的な仕様として、ローカル束縛、for-in 対応、computed property 対応などが議論されています。特に後者の2つはその実現のために言語仕様へのコルーチンの導入が同時に提案されており、今後の方向性として興味深いですが、この記事では省略します。
コピー不可能な値型
提案されている第3の機能は、コピー不可能な値型です。値型を定義する際に、これはコピーができない型である、と指定できるようになります。コピー不可能である事を、ムーブのみ可能であると捉えて、 moveonly というキーワードを使用することが提案されています。
ムーブというのは、ある変数からある変数に値を移す操作で、元の変数は未初期化状態に戻って使用不可能になります。以下に例を示します。
moveonly struct Stone {}
var a = Stone()
// a から b に Stone が移動する
var b = move(a)
// a は未初期化であるため、コンパイルエラー
print(a)
大きな値型を moveonly にしておけば、代入文がコンパイルエラーになるので、
コストの高い代入文をうっかり書くことを禁止できます。本当に必要な場面では、明示的に init などで複製します。
RAII
deinit が定義できるので、今まで適用できなかったパターンに値型が適用できるようになります。
例えば、開かれたファイルハンドルを class で表現することがあります。そして init でファイルパスを与えて開き、 deinit でそれを閉じるようにします。すると、そのインスタンスへの参照が無くなった時点でインスタンスの解放と共にファイルハンドルが閉じられるので、閉じ忘れや、二重に閉じてしまうバグが回避できます。このような実装パターンを RAII と呼びます。
しかし、 class の取り回しには参照カウンタ操作やヒープ使用のコストがかかるため、できることなら値型を使いたいのが swift です。ですが、値型ではこのパターンは使えませんでした。deinit が書けないうえ、代入文でコピーできてしまうため、その時点で1つの同一リソースを指す2つの実体が生まれ、破綻してしまうからです。
moveonly では deinit が定義できるうえ、コピーができないので、このような場合に最適です。以下に例を示します。
moveonly struct FileHandle {
var fp: UnsafeMutablePointer<FILE>
init(path: String) {
fp = fopen(path, "r")
}
deinit {
fclose(fp)
}
}
なお、関数の引数は呼び出し時にコピーが生じるので、コピー不可能な型は渡せません。そこで、前述した shared 渡し を活用していくことになります。そして、参照を使うことによる重複アクセスによるバグを排他則が防いでくれます。
終わりに
以上が Ownership の大きな3つの機能です。
Ownership が入ったら Swift は上級者向けの難解言語になるだろう、といった意見を見たことがありますが、自分はそのようなことは起こらないと考えています。全体の目標として、これまでのSwiftの使いやすさを保ちつつ、高速化の手段を追加的に提供するとあるのですが、現状の提案を見る限り、注意深くそれを達成できていると思うからです。
この記事を見て、 Ownership に興味を持つ人が増えたら嬉しいです。