Swift Digest
SE-0464 | Swift Evolution

UTF8Span: contiguous bytes上の安全なUTF-8処理

UTF8Span: Safe UTF-8 Processing Over Contiguous Bytes

Proposal
SE-0464
Authors
Michael Ilseman, Guillaume Lessard
Review Manager
Tony Allevato
Status
Implemented (Swift 6.2)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

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ワードサイズです。

生成とバリデーション

UTF8SpanSpan<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: 本来より多いバイト数でエンコードされたスカラ。セキュリティチェックの迂回に使われうる(NUL0xC0 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
  }
}

CharacterIteratorUTF8Span の先頭・末尾がそのまま内容の先頭・末尾であると仮定して書記素クラスタを切り出します。スカラ境界であればどこからでも反復を始められますが、リージョナルインジケータの中など 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のみになっていても isKnownASCIIfalse のままになることがあります。

NFC であることが分かれば正準等価の比較を大幅に高速化できるため、checkForNFC(quickCheck:) が提供されます。quickCheck: true は NFCQuickCheck アルゴリズムで、フルの正規化より速い代わりに一部の NFC 内容を検出できない場合があります。

Span<UInt8> へのダウンキャスト

UTF8SpanSpan<UInt8> に追加情報を載せた型なので、素の Span<UInt8> として取り出すこともできます。

extension UTF8Span {
  public var isEmpty: Bool { get }
  public var span: Span<UInt8> { get }
}

03 今後の見通し

今回の提案は UTF8Span の土台部分にあたり、周辺のAPIは後続の提案に回されています。以下はいずれも将来の構想として示されているもので、実現を約束するものではありません。

ストリーミングな書記素クラスタ分割

Unicode.GraphemeBreakingState を公開し、スカラ境界で揃った連続するスパンをまたいで書記素クラスタを切り出せるようにする方向性が示されています。AttributedString のように、コンテンツ全体がスカラ境界で揃ったスパンの列として表現されるロープ状ストレージで、書記素クラスタが個々のスパン境界をまたぐ場合への対応が想定されています。

ワード・行などの境界とアラインメント問い合わせ

単語境界や行境界のイテレータ、任意のコードユニットオフセットがスカラ境界・書記素クラスタ境界に揃っているかを問い合わせるAPIが、UTF8Span への追加候補として挙げられています。

~= などの演算子

UTF8Span はバイト等価と Unicode 正準等価のどちらも扱えるため、「既定の比較意味論」をどちらにすべきか決め切れていません。そのため switch でリテラルにマッチさせるための ~= の定義は今後の課題として保留されています。当面は、アプリやライブラリ側で UTF8Span をラップした独自型を用意し、その型に ~= を定義するのが回避策として挙げられています。

String のコピー生成APIの拡充

UTF8Span の内容を所有する String を生成する初期化子について、UTF-8 バリデーションを省略する形での追加が検討されています。Container プロトコル周辺の整理が進むのを待って判断する選択肢も示されています。

正規化

NFC 以外の正規形に対しても、内容がその正規形であるかをチェックするAPIを追加することが将来の候補とされています。

UnicodeScalarView / CharacterView

Span と同様に、non-escapable な UTF8Span に対する Collection 風のビュー型は今回見送られています。将来的に新しい Container 風プロトコルが整理されたタイミングで、これに適合するビュー型を追加することが検討されています。

より多くのアルゴリズム

今回は scalarsEqual のような等価比較APIに絞られていますが、non-escapable なコレクションの整理が進めば、それ以外のアルゴリズムを段階的に追加していく方向性が示されています。

検証APIの拡張

任意の Sequence<UInt8> に対してUTF-8エラーを検出する UTF8.findFirstError(_:) / UTF8.findAllErrors(_:) のような関数を追加する案があります。ただし、segmented storage 向けの Container 風プロトコルに合わせて再定式化したほうが良い可能性があるため、現時点では将来の課題とされています。当面は、Span<UInt8> を経由して UTF8Span(validating:) でバリデーションする形が推奨されます。

遅延トランスコード・正規化・ケースフォールディングのイテレータ

UTF8Span に対して、遅延的にトランスコード・正規化・ケースフォールディングを行うイテレータを提供する案があります。これらを追加する場合は、StringSubstring にも同等のビューを揃えることが想定されています。

Regex 対応

UTF8Span 上での Regex サポートが将来の方向性として挙げられています。書記素クラスタ単位・スカラ単位の意味論に加え、バイト単位の意味論を導入することが想定されています。あわせて、正規表現エンジン内部の処理に対応するルーチン群を公開し、パーサコンビネータライブラリが標準ライブラリの最適化された実装を活用できるようにする案も示されています。

追加の「ビット」の追跡

内容がヌル終端されているか、改行を含むか/末尾に1つだけ含むか、といった追加情報を UTF8Span に持たせることで、C との橋渡しや Regex. の高速化に役立てる案があります。

String への問い合わせAPIの追加

isKnownNFC のような問い合わせAPIや対応するスキャンメソッドは、String 自体にとっても有用です。StringNSString から遅延的にブリッジされている可能性があるため、必ずしもこれらの「ビット」を即座に問い合わせ・設定できるとは限りませんが、ブリッジまわりの改善が進めば実現の余地があるとされています。

印字・ロギング機構の一般化

多くの印字・ロギング系のプロトコルや機構は String を前提としていますが、これらをUTF-8バイト列ベースに一般化する方向性も示されています。Embedded Swift の文脈では特に重要な拡張と位置付けられています。