Swift Digest
SE-0241 | Swift Evolution

Deprecate String Index Encoded Offsets

Proposal
SE-0241
Authors
Michael Ilseman
Review Manager
John McCall
Status
Implemented (Swift 5.0)

01 何が問題だったのか

String は内部でどのエンコーディング(UTF-8 / UTF-16 など)を使っているかを隠蔽した型で、String.Index はその中の位置を表す不透明な値です。文字列をインデックスと一緒にシリアライズして保存・復元したい場合、インデックスを何らかの整数に変換しなければなりません。

この用途のために、SE-0180String.IndexencodedOffset というプロパティとそれを受け取るイニシャライザが追加されました。しかし、このアプローチには問題がありました。

ひとつ目は、encodedOffsetどのエンコーディングでのオフセットかを表現できない点です。文字列をシリアライズするときはエンコーディングを選べますし、オフセット自体もエンコーディングに依存します。SE-0180 のコメントでは「UTF-16 を前提とする」旨が示唆されていましたが、これは当時 String が内部的に UTF-16 を使っていたことによるもので、本来オフセットの意味をシリアライズのエンコーディングから独立に決めることはできません。

ふたつ目は、より深刻な問題で、Swift 5 で String の内部エンコーディングが UTF-8 に変わることです。Cocoa からブリッジされた文字列などでは UTF-16 にフォールバックする余地が残りますが、ネイティブな String では UTF-8 が基本になります。encodedOffset の値の意味が Swift 5 以降で変わってしまう可能性があり、既存コードの挙動が静かに壊れるおそれがありました。

そして 3 つ目は、現実の利用例のほとんどが SE-0180 の意図した使い方になっていないことです。GitHub 上の利用例を調べると、encodedOffset は主に次のように誤用されていました。

  • すべての Character が 1 コードユニットから成ると仮定して、Int のオフセットで Character を添字アクセスする。"\r\n".count == 1 のように、ASCII の範囲でもこの仮定は成り立ちません。
  • Range<String.Index>NSRange の相互変換。Foundation にはすでに NSRange(_:in:) / Range(_:in:) という正しいイニシャライザがあるため、そちらを使うべきです。
  • UTF-16 コードユニットのオフセットを表す IntString.Index に変換する。encodedOffset が UTF-16 前提だった時代にはたまたま動いていましたが、Swift 5 以降は必ずしも UTF-16 オフセットではなくなります。

これらの誤用は Swift 5 での内部エンコーディング変更によってバグとして表面化する可能性が高く、Swift 5.0 のリリースまでに、既存のセマンティクスを保つための緊急の受け皿を用意する必要がありました。

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

String.Index.encodedOffset と、そのオフセットを受け取るイニシャライザを deprecated にした上で、既存のセマンティクスを保つ代替として、String.IndexUTF-16 オフセットを明示した API が追加されます(Swift 5.0)。

追加された API

extension String.Index {
    /// このインデックスに対応する UTF-16 コードユニットのオフセット
    public func utf16Offset<S: StringProtocol>(in s: S) -> Int

    /// 指定した UTF-16 コードユニットオフセットに対応するインデックスを生成する
    public init<S: StringProtocol>(utf16Offset offset: Int, in s: S)
}

いずれも対象となる文字列 s を明示的に渡す点がポイントです。encodedOffset では文字列を参照せずにオフセットを計算していましたが、本来オフセットの計算にはその文字列の内容へのアクセスが必要です。新 API では文字列を渡すことで、エンコーディングに依存しない正しい変換を行えます。

使い方

encodedOffset を使っている既存コードは、まず UTF-16 ベースの新 API に置き換えることで、Swift 4 時代の挙動を保てます。

// 旧: Swift 5 で意味が変わる可能性のあるコード
let i = String.Index(encodedOffset: n)
let m = someIndex.encodedOffset

// 新: UTF-16 オフセットであることを明示した書き換え
let i = String.Index(utf16Offset: n, in: myString)
let m = someIndex.utf16Offset(in: myString)

イニシャライザに範囲外のオフセットを渡したときの挙動も、旧 API のセマンティクスにできるだけ近づけられています。負のオフセットや文字列の長さを超えるオフセットを渡すと無効なインデックスが作られ、オフセットがちょうど要素数と等しければ endIndex と等しいインデックスになり、それより大きければ endIndex より大きい(無効な)インデックスになります。

誤用パターンごとの正しい置き換え

UTF-16 オフセット API は、あくまで既存コードのセマンティクスを保つための受け皿です。用途によっては、さらに適切な書き換えが望まれます。

encodedOffset を「Character を整数オフセットで添字アクセスする」目的で使っていた場合、その想定自体が誤りです。"\r\n" のように単一の Character が複数のコードユニットにまたがる場合があるため、utf16Offset への機械的な置き換えでもバグは残ります。本来は String.Index を経由すべきで、たとえば次のように書けます。

let myIndices = Array(myString.indices)
// i, j は myIndices.count を基準に計算した整数
let sub = myString[myIndices[i]..<myIndices[j]]

Range<String.Index>NSRange の相互変換には、Foundation のイニシャライザを使うのが正解です。

let myNSRange = NSRange(start..<end, in: myString)
let myStrRange = Range(nsRange, in: myString)

UTF-16 オフセットの Int から String.Index を作る用途は、まさに新 API の想定する使い方で、そのまま String.Index(utf16Offset:in:) に置き換えられます。

今後の見通し

元々のシリアライズ目的(エンコーディングを選べる encodedOffset の後継)や、UTF-8 / Unicode スカラ / Character など他のエンコーディングのオフセット、さらにはオフセットベースの添字アクセスといった、より包括的な API の整備は Future Directions として触れられています。本 Proposal は Swift 5.0 に間に合わせるための緊急の受け皿に絞った提案で、これらの一般化された API は将来の設計作業に委ねられています(実現が約束されているわけではありません)。