Swift Digest
SE-0263 | Swift Evolution

Add a String Initializer with Access to Uninitialized Storage

Proposal
SE-0263
Authors
David Smith
Review Manager
Ted Kremenek
Status
Implemented (Swift 5.3)

01 何が問題だったのか

String は、すでにメモリ上に連続したバイト列がある場合には効率よく初期化できます。たとえば malloc で確保された C 文字列など、外部から渡される連続バッファをそのまま取り込むようなケースです。

一方で、まだバッファが存在せず、これから書き込みたい というケースでは事情が違います。典型的な例として、次のようなものが挙げられます。

  • NSString から String への bridge(内部的には CFStringGetBytes で一度バッファにコピーする必要がある)
  • IntFloat などを文字列化する処理(内部で一時的なスタックバッファを確保して書き込んでから String を作る)
  • SwiftNIO のように、ストリーミングデータから String を組み立てたいライブラリ

これらのケースでは、一度 UnsafeMutableBufferPointer をヒープに確保してそこに書き込み、その内容を改めて String の初期化時にもう一度コピーする、という 二度手間のコピー が発生していました。標準ライブラリの内部ではこれを避けるための非公開 API が使われていましたが、ユーザーコードからはその最適化ができない状態でした。

提案の動機は、こうした String のストレージに直接書き込ませてほしい」 というニーズに応える公開 API を用意することです。

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

String に、未初期化のストレージへ直接書き込めるイニシャライザを追加します。呼び出し側は希望する容量を UTF-8 コードユニット数で指定し、クロージャの中でそのバッファを埋め、書き込んだコードユニット数を返します。

public init(
    unsafeUninitializedCapacity capacity: Int,
    initializingUTF8With initializer: (
        _ buffer: UnsafeMutableBufferPointer<UInt8>
    ) throws -> Int
) rethrows

capacity には「String のストレージとして確保してほしい UTF-8 コードユニット数」を指定します。クロージャに渡される buffer は、少なくともこの容量をカバーする未初期化領域を指します(結果として作られる String 自体は、後述の不正 UTF-8 の修復などでこれより大きくなることがあります)。

クロージャは、バッファに書き込んだ 初期化済みコードユニット数 を返します。何らかの理由で初期化できなかった場合(たとえば指定した容量では足りないことが判明したとき)は 0 を返すと、空文字列が得られます。

使用例: CFStringGetBytes からの取り込み

NSStringString に変換する典型的な最適化シナリオです。CFStringGetMaximumSizeForEncoding で上限バイト数を見積もり、その容量でバッファを受け取って直接書き込みます。

let myCocoaString = NSString("The quick brown fox jumps over the lazy dog") as CFString
var myString = String(
    unsafeUninitializedCapacity: CFStringGetMaximumSizeForEncoding(myCocoaString, ...)
) { buffer in
    var initializedCount = 0
    CFStringGetBytes(
        myCocoaString,
        buffer,
        ...,
        &initializedCount
    )
    return initializedCount
}
// myString == "The quick brown fox jumps over the lazy dog"

従来は UnsafeMutableBufferPointer をヒープに確保してそこへコピーし、さらに String を作る際にもう一度コピーが発生していました。このイニシャライザでは、String のストレージそのものに書き込めるため、余分なコピーが不要になります。

不正な UTF-8 は置換文字で修復される

バッファに書き込んだバイト列が不正な UTF-8 シーケンスを含んでいた場合、そのシーケンスは Unicode の置換文字 "\u{FFFD}"(�)で置き換えられます。そのため、このイニシャライザは失敗しない(non-failable)設計になっています。この修復の過程で、最終的な String のストレージサイズが指定容量を超えて拡張されることもあります。

// 正しい UTF-8 の例: "Café"
let validUTF8: [UInt8] = [67, 97, 102, 0xC3, 0xA9]
let s1 = String(unsafeUninitializedCapacity: validUTF8.count) { ptr in
    _ = ptr.initialize(from: validUTF8)
    return validUTF8.count
}
// s1 == "Café"

// 末尾が不正な UTF-8 の例: "Caf" + 不正バイト
let invalidUTF8: [UInt8] = [67, 97, 102, 0xC3]
let s2 = String(unsafeUninitializedCapacity: invalidUTF8.count) { ptr in
    _ = ptr.initialize(from: invalidUTF8)
    return invalidUTF8.count
}
// s2 == "Caf\u{FFFD}"  (末尾の不正バイトが置換文字に)

// クロージャから 0 を返すと空文字列になる
let s3 = String(unsafeUninitializedCapacity: invalidUTF8.count) { ptr in
    _ = ptr.initialize(from: invalidUTF8)
    return 0
}
// s3 == ""

クロージャが throw したときの状態

UTF8.CodeUnit(= UInt8)は trivial な型で、デイニシャライズの必要がありません。そのため、クロージャが途中で例外を投げた場合にバッファの状態を特別に気にする必要はなく、Array の同種イニシャライザのような追加の考慮は不要です。rethrows で宣言されているので、クロージャが投げた例外はそのまま呼び出し側に伝播します。