Improving String.Index’s printed descriptions
01 何が問題だったのか
String.Index は文字列の内部ストレージ上の位置を表す値で、UTF-8 または UTF-16 のコードユニット単位のオフセットを内部に保持しています(Swift の文字列は基本的に UTF-8 で格納されますが、Objective-C から橋渡しされた文字列は UTF-16 のままになっていることもあります)。
この String.Index を print などで文字列化すると、これまでは次のように不可解な表示になっていました。
let string = "👋🏼 Helló"
print(string.startIndex) // ⟹ Index(_rawBits: 15)
print(string.endIndex) // ⟹ Index(_rawBits: 983047)
print(string.utf16.index(after: string.startIndex)) // ⟹ Index(_rawBits: 16388)
print(string.firstRange(of: "ell")!) // ⟹ Index(_rawBits: 655623)..<Index(_rawBits: 852487)
これは String.Index がどの標準的な文字列変換プロトコルにも適合していなかったため、リフレクションに基づくデフォルトの変換経路が使われてしまっていた結果です。内部のビットパターンがそのまま露出するだけで、このインデックスが文字列のどの位置を指しているのかも、どのエンコーディングに基づくオフセットなのかも読み取れません。文字列処理のコードをデバッグする際、インデックスを print しても手がかりにならないというのは長年の不満点でした。
02 どのように解決されるのか
String.Index に CustomDebugStringConvertible への適合を追加し、debugDescription としてデバッグに役立つ形式の文字列を返すようにします。print や String(reflecting:) を通じて、そのインデックスが指している位置・想定しているエンコーディング・必要ならトランスコード後のオフセットまでを読み取れるようになります。
let string = "👋🏼 Helló"
print(string.startIndex) // ⟹ 0[any]
print(string.endIndex) // ⟹ 15[utf8]
print(string.utf16.index(after: string.startIndex)) // ⟹ 0[utf8]+1
print(string.firstRange(of: "ell")!) // ⟹ 10[utf8]..<13[utf8]
表示の読み方は次のとおりです。
15[utf8]は「UTF-8 エンコードされたStringのオフセット 15 のコードユニットを指す」ことを表します。startIndexのようにオフセットが 0 の位置はどのエンコーディングでも同じ位置になるため、0[any]と表示されます。- Swift 6.0 以降、一部プラットフォームでは
Stringが UTF-16 のままテキストを保持している場合があり、そのようなインデックスは[utf16]と表示されます。 0[utf8]+1の+1は、トランスコードした Unicode スカラ内でのオフセットです。上の例では先頭の絵文字 U+1F44B(UTF-8 ではF0 9F 91 8B、UTF-16 ではD83D DC4B)を UTF-16 として見たときの後続サロゲートDC4Bを指しており、この位置はストレージ上には存在せず、アクセスのたびに計算されます。
なお、debugDescription が返す文字列の具体的な書式や情報量は規定されていません。これは CustomDebugStringConvertible に適合する型でよく採られる方針で、String の内部実装が進化すればそれに合わせて表示も変わり得ます。書式をプログラムから解釈したり、debugDescription の内容に依存したコードを書いたりすることは想定されていません(ここで示した例も、現行実装を説明するための参考例にすぎません)。この書式は LLDB では Swift 5.8 から既にデータフォーマッタとして採用されており、そこで有用性が確かめられたものです。
バックデプロイと ABI について
この適合自体は Swift 6.1 の標準ライブラリで追加されたもので、ABI 安定プラットフォーム上ではバックデプロイできません。ただし String.Index.debugDescription プロパティ自体はバックデプロイ対応(@backDeployed(before: SwiftStdlib 6.1))になっており、古い OS 上でも明示的に debugDescription を呼び出せば新しい表示を得られます。
let str = "🐕 Doggo"
print(str.firstRange(of: "Dog")!)
// 旧 stdlib: Index(_rawBits: 327943)..<Index(_rawBits: 524551)
// 新 stdlib: 5[utf8]..<8[utf8]
print(str.endIndex.debugDescription)
// どの環境でも: 11[utf8]
もっとも、通常のコードで .debugDescription を明示的に呼び分けるスタイルは推奨されていません。基本的には「新しい stdlib では print の結果が読みやすくなる」という位置づけで理解しておけば十分です。
Future Directions
将来的には、ここで表示されている情報の一部(インデックスが想定するエンコーディング、UTF-8 / UTF-16 オフセットなど)を公開 API として取り出せるようにする案が挙げられています。debugDescription は不安定な書式なので、必要な情報がそこからパースされたり、ビットパターンを覗き見る形で取り出されたりするのは望ましくないためです。また、Set.Index / Dictionary.Index / Slice / PartialRangeFrom など、標準ライブラリの他の型にもデバッグ表示を整備していく方向も示されています。これらは speculative な方向性で、実現を約束するものではありません。