Contiguous Strings
01 何が問題だったのか
Swift 5 で String の優先エンコーディングが UTF-8 になり、パフォーマンスを重視するユーザからは、文字列の生の UTF-8 コードユニットにポインタ経由で直接アクセスしたいという要望が多く寄せられていました。Swift の文字列には、内部表現が連続した UTF-8 バイト列になっている contiguous な文字列 と、そうではない noncontiguous な文字列 があります。
- contiguous な文字列: すべての Swift ネイティブ文字列、および連続した ASCII へのポインタを提供できる遅延ブリッジされた Cocoa 文字列
- noncontiguous な文字列: 連続した ASCII を持たない遅延ブリッジされた Cocoa 文字列(連続した UTF-16 を持っていても該当)
contiguous な文字列は定数時間で UTF-8 バッファへのポインタを取り出せ、さまざまな高速パスや最適化の恩恵も受けられます。一方 noncontiguous な文字列は、Swift 側にインポートされた時点で内容をコピーしなくてよいという利点があり、読み書きされない NSString の辞書キーのように、実際には読まれないかもしれない文字列にとってはこちらが有利です。
Swift 5.0 時点で唯一用意されていた API は String.UTF8View の withContiguousStorageIfAvailable() でした。これは contiguous な場合は成功しますが、noncontiguous な場合は nil を返します。
let result = string.utf8.withContiguousStorageIfAvailable { buffer in
// 連続した UTF-8 バッファに対する高速処理
}
if result == nil {
// noncontiguous だった場合のフォールバックを自前で書く必要がある
}
この API には次の問題があります。
- 成功するかどうかが実行時まで分からず、呼び出し側は必ずフォールバックを用意しなければなりません
- たいていは成功するとしても、
nilを受け取ったときにどう振る舞うべきかを毎回考えるのは煩雑で、エルゴノミクスに欠けます - contiguous にするかどうかを利用者側から強制する手段がなく、「以降のアクセスも含めて高速パスに乗せる」という指示ができません
String の状態を問い合わせたり contiguous 化したりする手段、そして noncontiguous であっても安全にフォールバックしつつ生の UTF-8 バッファを処理できる統一的な API が必要でした。
02 どのように解決されるのか
String と Substring に、contiguous な UTF-8 を扱うための3つの API を追加します。
isContiguousUTF8: 現在の文字列が contiguous な UTF-8 かどうかを問い合わせるプロパティmakeContiguousUTF8(): noncontiguous なら contiguous に変換する mutating メソッドwithUTF8(_:): 連続した UTF-8 バッファ上でクロージャを実行する mutating メソッド
extension String {
public var isContiguousUTF8: Bool { get }
public mutating func makeContiguousUTF8()
public mutating func withUTF8<R>(
_ body: (UnsafeBufferPointer<UInt8>) throws -> R
) rethrows -> R
}
extension Substring {
public var isContiguousUTF8: Bool { get }
public mutating func makeContiguousUTF8()
public mutating func withUTF8<R>(
_ body: (UnsafeBufferPointer<UInt8>) throws -> R
) rethrows -> R
}
使い方
withUTF8(_:) は withContiguousStorageIfAvailable と違い、常に成功します。noncontiguous な場合は内部で contiguous 化してからクロージャを呼び出すため、呼び出し側はフォールバックを書く必要がありません。
var s = someString
let count = s.withUTF8 { buffer in
buffer.count
}
計算量は、すでに contiguous であれば O(1)、そうでなければ O(n) です。一度 contiguous 化すれば以降のアクセスも高速パスに乗るので、読み込む価値のある文字列については積極的に contiguous 化してしまうのが良いという設計になっています。
isContiguousUTF8 で状態を確認したり、makeContiguousUTF8() で明示的に変換したりすることもできます。
var s = importedNSString as String
if !s.isContiguousUTF8 {
s.makeContiguousUTF8()
}
// 以降は常に O(1) で UTF-8 バッファにアクセスできる
なぜ mutating なのか
withUTF8(_:) が mutating なのは、noncontiguous な文字列に対して contiguous 化という状態変化を実際に適用するためです。選択肢としては「その場で一時的にコピーして使い捨てる」「noncontiguous ならトラップする」なども検討されましたが、前者はパフォーマンス特性を推測しづらくし、後者はテストが不十分な箇所で実行時クラッシュを招きます。mutating にする案は、利用側に var 宣言を強いる代わりに「読む価値があるなら contiguous にしてしまう」という方針を明確にしたものです。
そのため、let で受け取った文字列に対しては一度 var にコピーしてから使う必要があります。
let s: String = getString()
var copy = s
copy.withUTF8 { buffer in
// ...
}
注意点
withUTF8(_:) に渡されたポインタをクロージャの外へ逃がしてはいけません。長さ 15 UTF-8 コードユニット以下の文字列は small-string 表現として値の中に埋め込まれており、withUTF8(_:) の実行中だけ一時的なスタック領域に展開されるため、クロージャ終了後にはそのポインタは無効になります。
また、makeContiguousUTF8() や withUTF8(_:) が内容を変更した場合、事前に取得していたインデックスは無効になる点にも注意が必要です。
なぜ StringProtocol ではないのか
これらの API を StringProtocol に追加すれば String と Substring に対してジェネリックに書けますが、makeContiguousUTF8() は具象型を変えずに contiguous 化する必要があるため、プロトコルに載せるには追加のリクワイアメントやウィットネステーブルエントリが必要になります。今回はまず String と Substring に直接追加し、StringProtocol への引き上げは後日 ABI 互換な形で行える余地を残す形になっています。