Swift Digest
SE-0405 | Swift Evolution

String Initializers with Encoding Validation

Proposal
SE-0405
Authors
Guillaume Lessard
Review Manager
Tony Allevato
Status
Implemented (Swift 6.0)

01 何が問題だったのか

Swift の String は常に well-formed な Unicode テキストを表すことが保証されています。ファイル・ネットワーク・その他の外部ソースから受け取ったバイト列をそのまま String に取り込もうとすると、まずそのバイト列がエンコーディング(UTF-8 など)として妥当かを検証する必要があります。

標準ライブラリには、不正なコードユニットを置換文字(U+FFFD)などで「修復」してでも String を作るイニシャライザはありますが、そうした修復が望ましくない ケースは多くあります。たとえば JSON デコーダは、テキストを表すはずのバイト列に不正な UTF-8 が含まれていれば、勝手に置換するのではなく失敗しなければなりません。信頼できないソースからの入力を扱う場合も同様です。

これまでこの「検証のみを行い、不正なら失敗する」機能は標準ライブラリから直接は提供されていませんでした。既存の公開 API を組み合わせて実装することはできますが、余分なメモリコピーやアロケーションを伴います。標準ライブラリが内部実装を活用することで、より高いパフォーマンスで提供できる機能でした。

また、以前から存在した String.init?(validatingUTF8:)C の null 終端文字列へのポインタ を受け取るイニシャライザでしたが、引数ラベルからその前提が読み取りにくく、「UTF-8 バイト列を検証するイニシャライザ」と誤解して任意の [UInt8] の先頭要素のアドレスなどを渡してしまう誤用が実際に発生していました。

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

String に、入力を検証して不正なら nil を返す failable イニシャライザを追加します。エンコーディングは Unicode.Encoding に適合する型パラメータとして指定します。あわせて、誤用を招いていた既存の validatingUTF8: ラベルを validatingCString: へリネームします。

任意のエンコーディングを検証する

新しい String(validating:as:) は、コードユニット列と、そのエンコーディングを表す型(UTF8.self / UTF16.self / Unicode.ASCII.self など Unicode.Encoding に適合する型)を受け取ります。入力が妥当ならその内容をコピーして String を作り、不正な箇所が一つでも見つかれば nil を返します。修復は一切行われません。

let validUTF8: [UInt8] = [67, 97, 0, 102, 195, 169]
let valid = String(validating: validUTF8, as: UTF8.self)
print(valid)
// Prints "Optional("Café")"

let invalidUTF16: [UInt16] = [0x41, 0x42, 0xd801]
let invalid = String(validating: invalidUTF16, as: UTF16.self)
print(invalid)
// Prints "nil"

入力は Sequence<Encoding.CodeUnit> に適合していれば何でも渡せます。String.init?(validatingCString:) と違って 入力全体を変換対象とする ため、途中に含まれる \0 もそのまま String に取り込まれます。

Int8 列(C 由来のバイト列)の検証

C から受け取った UTF-8 データは、UInt8 ではなく Int8(典型的には CChar)の列として表現されていることがよくあります。そのまま UInt8 版に渡すには変換が必要になるため、Int8 列を直接受け取るオーバーロードも用意されます。エンコーディング側の CodeUnitUInt8 に制約されています。

let validUTF8: [Int8] = [67, 97, 0, 102, -61, -87]
let valid = String(validating: validUTF8, as: UTF8.self)
print(valid)
// Prints "Optional("Café")"

let invalidASCII: [Int8] = [67, 97, -5]
let invalid = String(validating: invalidASCII, as: Unicode.ASCII.self)
print(invalid)
// Prints "nil"

この形にすることで、CCharInt8 の typealias であるプラットフォームと UInt8 の typealias であるプラットフォームの両方で、ソース互換性を保ったまま同じ書き方ができます。

validatingUTF8:validatingCString: へリネーム

既存の String.init?(validatingUTF8:)null 終端の UTF-8 文字列へのポインタ を受け取るイニシャライザで、これは今回追加される String(validating:as:) とは別物です(前者は \0 までで打ち切り、後者は入力全体を変換します)。ラベルからその前提が伝わらず誤用が起きていたため、validatingCString: にリネームします。

extension String {
  public init?(validatingCString nullTerminatedUTF8: UnsafePointer<CChar>)

  @available(*, deprecated, renamed: "String.init(validatingCString:)")
  public init?(validatingUTF8 cString: UnsafePointer<CChar>)
}

旧ラベルは非推奨となり警告が出ます。fix-it で新ラベルへ移行できます。ABI 上はリネーム後も既存のエントリポイントを再利用するため、既存バイナリとの互換性は保たれます。

今後の見通し

今回は「検証して成功か失敗かだけを返す」最小構成に絞られていますが、議論の対象としては次のような方向性が挙げられています(speculative で、実現が約束されているわけではありません)。

  • 検証失敗の位置や原因といった詳細情報をエラーとして投げるバリアント。
  • UTF-8 専用の、よりディスカバラビリティの高い input-repairing なイニシャライザ。
  • 正規化(normalization)を伴うイニシャライザや、既存 String の正規化メソッド。
  • some Sequence<UnicodeScalar> から String を作る非 failable なイニシャライザ。