UTF8Span: Safe UTF-8 Processing Over Contiguous Bytes
01 何が問題だったのか
Swiftの String は、ネイティブ表現ではバリッドなUTF-8が入った連続メモリバッファとして保持されており、String のAPIはこの「UTF-8として正しくエンコードされている」という不変条件と Unicode 特有の知識を使って、内部的に効率よく実装されています。一方で、既にメモリ上に存在するUTF-8バイト列に対して String と同じような処理をライブラリや低レイヤのコードから行おうとすると、今の標準ライブラリでは次のような不都合があります。
Stringは参照カウントされるネイティブストレージにバイトをコピーして持つため、インスタンスの生成に確保・コピーのコストがかかります。既存のデータ構造の一部として保持されているUTF-8バイト列を処理したいだけの場面でも、Stringを作るとサイズが実質2倍以上になります。- 逐次再生成して使い捨てにすると、毎回の確保・解放と線形時間のコピーが挟まり、
String.Indexと元バッファのバイトオフセットの相互変換も自分で面倒を見る必要があります。 - メモリ割り当てができないような制約の強い環境では、そもそも
Stringを使えないこともあります。
UTF-8バリデーションそのものも重要な関心事です。バリッドなUTF-8であることが保証できれば、デコード、書記素クラスタ分割、比較などの後段処理を「不正なバイトが来ない」前提で最適化できます。逆に、バリデーションを省いたUTF-8を安易に扱うと、先頭バイトが示すスカラ長がバッファ境界をはみ出る、同じスカラ値が overlong encoding で二通り表現できてしまいバイト比較ベースのチェックを迂回される、といったセキュリティ・安全性上の問題につながります。
String を経由せずに、バリッドなUTF-8バイト列を所有権を増やさず借用したまま安全に処理する手段、そしてUTF-8エンコーディングエラーの種類と位置を正確に報告する手段が、標準ライブラリに欠けていました。
02 どのように解決されるのか
バリッドなUTF-8が入った連続メモリへの借用ビューとして、non-escapable な UTF8Span 型を導入します。位置付けとしては、SE-0447 で導入された Span に近く、そこに「UTF-8として正しくエンコードされている」という不変条件と、isKnownASCII のような追加情報を重ねた型です。
public struct UTF8Span: Copyable, ~Escapable, BitwiseCopyable {}
UTF8Span 自体はトリビアルな構造体で、64ビットプラットフォームでは2ワードサイズです。
生成とバリデーション
UTF8Span は Span<UInt8> から生成します。既定のイニシャライザは生成時にUTF-8バリデーションを行い、不正なバイト列が入っていれば Unicode.UTF8.EncodingError を投げます。
extension UTF8Span {
public init(validating codeUnits: Span<UInt8>) throws(UTF8.EncodingError)
@unsafe
public init(unsafeAssumingValidUTF8 uncheckedCodeUnits: Span<UInt8>)
}
バリデーション済みであることを呼び出し側が知っている場面では、unsafeAssumingValidUTF8: でバリデーションをスキップできます。ただし、これを破ると生成された UTF8Span を使うあらゆる場所(そこからコピーで作った String も含む)で未定義動作が起きる可能性があるため、 @unsafe としてマークされています。
生成された UTF8Span は、元の Span<UInt8> と同じ寿命制約を受ける借用です。
逆方向として、UTF8Span から再バリデーションなしに String を作る初期化子も追加されます。
extension String {
public init(copying codeUnits: UTF8Span)
}
また、String / Substring からも借用で UTF8Span が得られます(SE-0456 の延長)。
extension String {
public var utf8Span: UTF8Span { borrowing get }
}
extension Substring {
public var utf8Span: UTF8Span { borrowing get }
}
UTF-8 エンコーディングエラー
エラーの種類と位置を表現するため、Unicode.UTF8.EncodingError と、その内部の Kind が提案されます。
extension Unicode.UTF8 {
public struct EncodingError: Error, Sendable, Hashable, Codable {
public var kind: Unicode.UTF8.EncodingError.Kind
public var range: Range<Int>
public init(
_ kind: Unicode.UTF8.EncodingError.Kind,
_ range: some RangeExpression<Int>
)
public init(_ kind: Unicode.UTF8.EncodingError.Kind, at: Int)
}
}
extension UTF8.EncodingError {
public struct Kind: Error, Sendable, Hashable, Codable, RawRepresentable {
public var rawValue: UInt8
public init(rawValue: UInt8)
public static var unexpectedContinuationByte: Self
public static var surrogateCodePointByte: Self
public static var invalidNonSurrogateCodePointByte: Self
public static var overlongEncodingByte: Self
public static var truncatedScalar: Self
}
}
Kind が表現するエラーカテゴリは次の通りです。
unexpectedContinuationByte: 新しいスカラの先頭であるべき位置に継続バイト (10xxxxxx) が現れた。入力が任意バイトだったり、スカラ境界でスライスされていない場合に起きがちです。truncatedScalar: マルチバイトスカラの途中で入力が途切れた。ストリームの末尾で起きやすい不完全入力を表します。surrogateCodePointByte: サロゲート (U+D800..U+DFFF) のバイト列。UTF-8として不正で、実は CESU-8 / WTF-8 / Java Modified UTF-8 などでエンコードされていた可能性を示唆します。invalidNonSurrogateCodePointByte:U+10FFFFを超えるコードポイントのバイト列。overlongEncodingByte: 本来より多いバイト数でエンコードされたスカラ。セキュリティチェックの迂回に使われうる(NULの0xC0 0x80など)ため、別種のエラーとして分類されます。
エラー範囲は Unicode の “Maximal subpart of an ill-formed subsequence” に従い、truncatedScalar だけが複数バイトの範囲、それ以外は1バイトごとのエラーになります。必要に応じてこの結果から「連続する不正バイトを1つのエラーにまとめる」「PEP 383風に1バイトずつ分ける」といった他の報告形式を構築できます。
スカラ単位の反復
スカラを前後どちらにも走査できる UnicodeScalarIterator が提供されます。UTF8Span が non-escapable なので、このイテレータ自身も ~Escapable で、IteratorProtocol には適合しません。
extension UTF8Span {
public func makeUnicodeScalarIterator() -> UnicodeScalarIterator
public struct UnicodeScalarIterator: ~Escapable {
public let codeUnits: UTF8Span
public var currentCodeUnitOffset: Int { get private(set) }
public init(_ codeUnits: UTF8Span)
public mutating func next() -> Unicode.Scalar?
public mutating func previous() -> Unicode.Scalar?
public mutating func skipForward() -> Int
public mutating func skipForward(by n: Int) -> Int
public mutating func skipBack() -> Int
public mutating func skipBack(by n: Int) -> Int
public mutating func reset(roundingBackwardsFrom i: Int)
public mutating func reset(roundingForwardsFrom i: Int)
public mutating func reset(uncheckedAssumingAlignedTo i: Int)
public func prefix() -> UTF8Span
public func suffix() -> UTF8Span
}
}
currentCodeUnitOffset は常にスカラ境界に揃っています。任意のバイトオフセットから位置を合わせ直したい場合は reset(roundingBackwardsFrom:) / reset(roundingForwardsFrom:) を使うと、最も近いスカラ境界へ安全に丸めてくれます。正規表現エンジンのバックトラックのように、呼び出し側が事前に境界を保証できる用途向けには reset(uncheckedAssumingAlignedTo:) も用意されますが、これは境界外やスカラ境界でない位置を渡すと未定義動作になります。
prefix() / suffix() を呼ぶと、現在位置までの内容・現在位置以降の内容を同じ寿命の UTF8Span として取り出せます。
使い方のイメージはこうなります。
var scalars = utf8.makeUnicodeScalarIterator()
while let s = scalars.next() {
process(s)
}
Character 単位の反復
書記素クラスタ境界を扱う CharacterIterator も同じ形で提供されます。
extension UTF8Span {
public func makeCharacterIterator() -> CharacterIterator
public struct CharacterIterator: ~Escapable {
public let codeUnits: UTF8Span
public var currentCodeUnitOffset: Int { get private(set) }
public init(_ span: UTF8Span)
public mutating func next() -> Character?
public mutating func previous() -> Character?
public mutating func skipForward() -> Int
public mutating func skipForward(by n: Int) -> Int
public mutating func skipBack() -> Int
public mutating func skipBack(by n: Int) -> Int
public mutating func reset(roundingBackwardsFrom i: Int)
public mutating func reset(roundingForwardsFrom i: Int)
public mutating func reset(uncheckedAssumingAlignedTo i: Int)
public func prefix() -> UTF8Span
public func suffix() -> UTF8Span
}
}
CharacterIterator は UTF8Span の先頭・末尾がそのまま内容の先頭・末尾であると仮定して書記素クラスタを切り出します。スカラ境界であればどこからでも反復を始められますが、リージョナルインジケータの中など Character 境界に揃っていない位置を開始点にすると、そこから見える Character は全体の先頭から走査したときと異なる場合があります。
スライスの扱い
UTF8Span をバイトオフセットで勝手に切ると、バリッドUTF-8の不変条件を壊す可能性があります。さらに、書記素クラスタ境界で切らないと CharacterIterator の結果が文脈依存になります。これを踏まえ、UTF8Span そのものには自由な extracting(_:) のようなメソッドは用意されず、スライスは上記イテレータの prefix() / suffix() 経由で取り出す形に限定されています。
比較
UTF8Span は、バイトレベル・スカラレベル・書記素クラスタレベルの等価性、そして Unicode 正準等価を扱う比較APIを提供します。
extension UTF8Span {
public func bytesEqual(to other: UTF8Span) -> Bool
public func bytesEqual(to other: some Sequence<UInt8>) -> Bool
public func scalarsEqual(to other: some Sequence<Unicode.Scalar>) -> Bool
public func charactersEqual(to other: some Sequence<Character>) -> Bool
public func isCanonicallyEquivalent(to other: UTF8Span) -> Bool
public func canonicallyPrecedes(_ other: UTF8Span) -> Bool
}
charactersEqual(to:) や isCanonicallyEquivalent(to:) は、String.== / Character.== と同じ Unicode 正準等価の意味論を提供します。canonicallyPrecedes(_:) は NFC での正規化済みコードユニット順による順序比較です。
テキスト形式のパーサで「構文解析そのものはバイト等価で行い、フィールドの値を解釈するときだけ正準等価で比較する」といった使い分けを、1つの UTF8Span のまま素直に書けるようになります。
問い合わせ系のAPI
UTF8Span は生成時に「全バイトがASCIIか」を調べて覚えておきます。あとから NFC であるかのチェックを走らせて記録することもできます。
extension UTF8Span {
public var isKnownASCII: Bool { get }
public mutating func checkForASCII() -> Bool
public var isKnownNFC: Bool { get }
public mutating func checkForNFC(quickCheck: Bool) -> Bool
}
isKnownASCII は「true なら全コードユニットがASCII」という片側保証で、false は「非ASCIIを含むかもしれない」という意味です。たとえば以前に非ASCIIを含んでいた String から得た UTF8Span は、その後の編集でASCIIのみになっていても isKnownASCII は false のままになることがあります。
NFC であることが分かれば正準等価の比較を大幅に高速化できるため、checkForNFC(quickCheck:) が提供されます。quickCheck: true は NFCQuickCheck アルゴリズムで、フルの正規化より速い代わりに一部の NFC 内容を検出できない場合があります。
Span<UInt8> へのダウンキャスト
UTF8Span は Span<UInt8> に追加情報を載せた型なので、素の Span<UInt8> として取り出すこともできます。
extension UTF8Span {
public var isEmpty: Bool { get }
public var span: Span<UInt8> { get }
}
Future Directions
今回の提案は UTF8Span の土台部分で、周辺のAPIは後続提案に回されています。いずれも speculative で、実現を約束するものではありません。
- ストリーミングな書記素クラスタ分割:
Unicode.GraphemeBreakingStateを公開し、スカラ境界で揃った連続スパンを跨いで書記素クラスタを切り出せるようにする(AttributedStringのようなロープ状ストレージ向け)。 - ワード・行などの境界: 単語境界や行境界のイテレータ、任意バイトオフセットに対する「スカラ境界か」「
Character境界か」の問い合わせAPI。 - パターンマッチ演算子:
UTF8Spanに対する~=のような「既定の比較意味論」の定義。バイト等価と正準等価のどちらを既定にするかが決め切れていないため見送られています。アプリやライブラリ側で、UTF8Spanをラップした独自型に~=を定義するのが当面の回避策です。 - 検証API の拡張: 任意の
Sequence<UInt8>に対してUTF-8エラーを検出するUTF8.findFirstError(_:)/UTF8.findAllErrors(_:)のような関数。現時点では、まずSpan<UInt8>へ通してからUTF8Span(validating:)でバリデーションする形が推奨されます。 - 遅延トランスコード・正規化・ケースフォールディングのイテレータ、および将来的な
Regexサポート、ヌル終端や改行有無といった追加の「ビット」の追跡。
これらが揃ってくるにつれ、UTF8Span は「連続メモリ上のUTF-8を String を経由せずに安全に処理する」ための標準的な通貨型として機能していくことが見込まれています。