Swift Digest
SE-0180 | Swift Evolution

String Index Overhaul

Proposal
SE-0180
Authors
Dave Abrahams
Review Manager
Ted Kremenek
Status
Implemented (Swift 4.0)

01 何が問題だったのか

Swift 3 時点の String には、文字列を異なる粒度で眺めるための複数のビュー(CharacterView / UnicodeScalarView / UTF16View / UTF8View)が用意されており、それぞれが 別々のインデックス型 を持っていました。String.IndexCharacterView と共有されている一方で、UnicodeScalarView.Index / UTF16View.Index / UTF8View.Index はそれぞれ独立した型でした。

ビュー間でインデックスを受け渡すのが面倒

この設計のため、あるビューで得たインデックスを別のビューで使うには、専用のイニシャライザや samePosition(in:) メソッドで明示的に変換する必要がありました。変換はビューの組み合わせによって失敗し得るので、多くの場合 Optional を返します。

if let j = String.UnicodeScalarView.Index(
  someUTF16Position, within: s.unicodeScalars) {
  // ...
}

if let j = someUTF16Position.samePosition(in: s.unicodeScalars) {
  // ...
}

ビューの数だけイニシャライザ・変換メソッドが定義されるため、標準ライブラリの API 面積は大きく膨らんでいました。それでいて、実際のコードでインデックスをビュー間でやり取りするのは「位置がちょうど境界に一致していて変換が必ず成功する」ケースがほとんどで、得られる利益に対してコードが無駄に煩雑でした。

UTF-16 ベースで検索して元の String を切り出す、といった操作が書きにくい

たとえば HTML 文字列からタグを抜き出すとき、パフォーマンスのために UTF-16 ビュー上で <> の位置を探し、その結果を使って String をスライスしたい、という要求があります。しかしビューごとにインデックス型が違うため、途中で毎回変換を挟まないと書けず、コードが読みにくくなっていました。

位置をシリアライズ・デシリアライズできない

もうひとつの問題は、String.Index の内部表現が不透明であるために、文字列中の位置をファイルなどに保存して、後から復元する ことが難しかった点です。インデックスを整数オフセットとして取り出す公式な手段がなく、永続化や IPC で位置情報をやり取りするユースケースに対応できませんでした。

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

String のすべてのビューが 単一のインデックス型 String.Index を共有するようにします。UTF8View / UTF16View / UnicodeScalarViewIndex は、いずれも String.Index の型エイリアスになります。あわせて、インデックスの位置をコードユニット単位のオフセットとして取り出せる encodedOffset プロパティと、そのオフセットからインデックスを再構築するイニシャライザが追加されます。

ビュー間でそのままインデックスを使える

同じ String.Index 型をすべてのビューで共通に使えるようになるので、あるビューで求めたインデックスを別のビューやスライスにそのまま渡せます。変換のための専用イニシャライザや samePosition(in:) は不要になり、よくあるパターンが素直に書けます。

let html: String = "See <a href=\"http://swift.org\">swift.org</a>"

// パフォーマンスのため、文字ではなく UTF-16 上で検索する
let open = "<".utf16.first!, close = ">".utf16.first!
let tagStart = html.utf16.index(of: open)
let tagEnd = html.utf16[tagStart...].index(of: close)

// 同じインデックスで String 側をスライスしてタグを取り出す
let tag = html[tagStart...tagEnd]

位置を整数オフセットとしてやり取りできる

String.Index に次の API が追加され、インデックスを整数(現在の実装では UTF-16 コードユニットでのオフセット)として取り出したり復元したりできるようになります。これによりファイルへの保存やプロセス間での受け渡しなど、位置情報のシリアライズが可能になります。

public extension String.Index {
  /// String が内部で持つ(UTF-16)コードユニット列における、
  /// 先頭からのオフセットに対応する位置を作ります。
  init(encodedOffset: Int)

  /// このインデックスが、String が内部で持つ(UTF-16)
  /// コードユニット列の先頭から何コードユニット目かを返します。
  var encodedOffset: Int
}

使い方はシンプルで、endIndex のオフセットを保存しておき、あとで同じ位置を復元する、といった使い方ができます。

let n: Int = html.endIndex.encodedOffset
let end = String.Index(encodedOffset: n)
assert(end == html.endIndex)

比較とスライスのセマンティクス

単一のビュー内で有効な 2 つのインデックス同士の比較は、これまで通り Collection の要件で定義されます。問題は、エンコーディングが異なるビューの間で Unicode スカラー境界の途中に落ちるインデックス同士を比較する場合です。たとえば絵文字 "\u{1f773}"(🝳)は UTF-16 では 0xD83D, 0xDF73、UTF-8 では 0xF0, 0x9F, 0x9D, 0xB3 と表現され、2 コードユニット目どうしを素直に対応付ける方法がありません。このようなインデックスは encodedOffset 同士を比較して順序を決める ことにします。結果として全順序にはなりませんが、sort などのアルゴリズムが期待通り動くための strict weak ordering は満たします。

スライスについては、インデックスが指すビューの「正確な要素境界」に一致しない場合でも、encodedOffset の位置でコードユニット列を区切り、そこから標準の grapheme breaking ルールで Character 列を組み立てる、という形でうまく扱えます。

let s = "e\u{301}galite\u{301}"           // "égalité"
let i = Array(s.unicodeScalars.indices)
print(s[i[1]...])                         // "◌́galité"
print(s[..<i.last!])                      // "égalite"
print(s[i[1]])                            // "◌́"

スライスへの代入も同様に、対応するコードユニット列を置き換えてから改めて grapheme breaking を適用する、という形でセマンティクスが定義されます。

既存コードへの影響

インデックス型が統合された結果、これまでは失敗しないと型付けされていたビュー間の変換がすべて失敗可能(Optional を返す)になります。この変更で Swift 3 のコードがそのままでは壊れてしまうケースがあるため、移行期間向けに Optional<String.Index> を受け取るオーバーロード(..< / ... や、各ビューの index(after:) / index(_:offsetBy:) / distance(from:to:) / subscript など)が deprecated 扱いで追加されます。これらは Swift 3 互換モードで既存コードを素通しさせるための暫定対応で、Swift 4 では obsoleted になり、利用者は明示的にアンラップして新しい API に移行していきます。

なお、既存の samePosition(in:) などの変換 API 自体はこの提案では撤去されず、引き続き利用できます。失敗可能な変換をより便利にしたり、近い境界へインデックスを丸めたりする API は、今後の提案に委ねられています。