Swift Digest

AttributedString のインデックス追跡

AttributedString Tracking Indices

Proposal
SF-0015
Authors
Jeremy Schonfeld
Review Manager
Tina Liu
Status
Accepted

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

01 何が問題だったのか

AttributedString は内部にロープ構造を持ち、テキスト上の位置を表すために AttributedString.Index という不透明なインデックス型を使います。Array の整数オフセットのような単純な値とは異なり、このインデックスは UTF-8 オフセットに加えてロープ構造内のパスを保持しているため、ミューテーションのたびに簡単に整合性が崩れます。実際これまでは、AttributedString に対して何らかのミューテーションを行うと、それ以前に取得していたインデックスは原則として無効になり、無効化されたインデックスを使った操作は意図的にクラッシュすることもありました。

これが特に問題となるのが、AttributedString をテキストエディタなどのストレージとして使い、テキストとは別の場所にインデックスを保持したいケースです。たとえば次のような状況が挙げられます。

  • 選択範囲を RangeSet<AttributedString.Index> としてビューモデルに保持し、その背後でテキストを書き換えたい
  • 大きな AttributedString をチャンクに分けて in-place で書き換える際に、現在の処理位置を AttributedString.Index で覚えておきたい

いずれも、ミューテーションの前後でインデックスを AttributedString と整合させ続ける必要がありますが、ミューテーションのたびに既存のインデックスがすべて無効になるため、これを安全に行う手段がほとんどありませんでした。さらに、保持しているインデックスが現時点で本当にその AttributedString に対して有効なのかを実行時にチェックする方法もなく、誤った使い方が即座にクラッシュにつながる可能性がありました。

リッチテキストを扱うより高度な API ほどこの問題の影響が大きく、保持したインデックスの有効性とセマンティクスの両方を保てる仕組みが求められていました。

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

この提案では、AttributedString に対するミューテーションの前後でインデックスを追跡する transform(updating:_:) ファミリーの API と、インデックスや範囲が現時点で有効かを確認する isValid(within:) API、そして「範囲内のインデックスは少なくともクラッシュさせない」という新しい挙動の保証が追加されます。いずれも FoundationPreview 6.2 以降で利用できます。

transform(updating:_:) でインデックスを追跡する

transform(updating:_:) は、与えられた範囲(または範囲の配列)を追跡しながらクロージャ内で AttributedString をミューテートし、ミューテーション後も意味的に同じ位置を指す範囲に更新してくれる API です。クロージャは inout AttributedString を受け取り、その値を直接書き換える形でミューテーションを記述します。

var attrStr = AttributedString("The quick brown fox jumped over the lazy dog")
guard var rangeOfJumped = attrStr.range(of: "jumped") else { return }

attrStr.transform(updating: &rangeOfJumped) {
    $0.insert("Wow!", at: $0.startIndex)
}

// rangeOfJumped はミューテーション後の attrStr 上で「jumped」の位置を指す
print(attrStr[rangeOfJumped]) // "jumped"

ここで重要なのは、rangeOfJumped が「先頭に挿入された 4 文字ぶんずれた位置」(”fox ju” のような位置)ではなく、依然としてテキスト上の “jumped” を指している点です。先頭に文字列が挿入されたことを踏まえて、論理的に同じ場所を指すように追跡されます。

API は範囲を inout で受け取って in-place に更新するオーバーロードと、新しい範囲を戻り値として返すオーバーロードの両方を提供します。さらに、単一の範囲ではなく範囲の配列をまとめて追跡する版も用意されており、追跡が失敗した場合のフォールバックの挙動を呼び出し側で選べるようになっています。

extension AttributedString {
    public mutating func transform<E>(
        updating range: inout Range<Index>,
        body: (inout AttributedString) throws(E) -> Void
    ) throws(E) -> Void

    public mutating func transform<E>(
        updating ranges: inout [Range<Index>],
        body: (inout AttributedString) throws(E) -> Void
    ) throws(E) -> Void

    public mutating func transform<E>(
        updating range: Range<Index>,
        body: (inout AttributedString) throws(E) -> Void
    ) throws(E) -> Range<Index>?

    public mutating func transform<E>(
        updating ranges: [Range<Index>],
        body: (inout AttributedString) throws(E) -> Void
    ) throws(E) -> [Range<Index>]?
}

範囲の配列を追跡する版では、戻り値の配列のサイズが入力と同じになり、各要素のインデックスも入力配列と対応することが保証されます。

追跡が失敗するケース

クロージャ内で AttributedString の値そのものを別の AttributedString に置き換えてしまった場合、その変更は「ミューテーション」ではなく「置換」とみなされ、追跡できません。

myAttrStr.transform(updating: someRange) {
    $0 = AttributedString("foo") // 置換: 追跡できない
}

このときの挙動はオーバーロードによって異なります。inout を受け取る版は fatalError で停止し、戻り値版は nil を返します。戻り値版を使えば、追跡できなかったときに呼び出し側でフォールバックの処理(既定の選択範囲に戻すなど)を実装できます。

inout 版と戻り値版の取り違えに対する診断

引数が inout かどうかで意味が変わるため、誤った使い方には次のような警告が出ます。

var str: AttributedString
var range: Range<AttributedString.Index>

// inout を付け忘れて戻り値も読まないケース
str.transform(updating: range) { // warning: Result of call to 'transform(updating:_:)' is unused
    $0.insert("Wow!", at: $0.startIndex)
}

// inout を付けたうえに戻り値を受け取ろうとしているケース
let updatedRange = str.transform(updating: &range) { // warning: Constant 'updatedRange' inferred to have type '()', which may be unexpected
    $0.insert("Wow!", at: $0.startIndex)
}

範囲が「点」に潰れるケース

追跡対象の範囲が、ミューテーションで完全に削除されたテキストを覆っていた場合、その範囲は「長さゼロの範囲」(同じインデックスを startIndexendIndex に持つ範囲)に潰れます。これは、削除されたテキストがあった「位置」を周囲のテキストとの相対関係として残すための仕様です。

var myAttrStr = AttributedString("Hello World")
var rangeOfHello = myAttrStr.range(of: "Hello")!

myAttrStr.transform(updating: &rangeOfHello) {
    $0.removeSubrange(rangeOfHello)
}
// rangeOfHello は myAttrStr.startIndex ..< myAttrStr.startIndex に更新される

カーソル位置のように長さゼロの選択を扱う UI では、この挙動によって「削除されたテキストがあった場所」を引き続き正しく追跡できます。

インデックス・範囲の isValid(within:)

別の場所に保持しておいた AttributedString.IndexRange<AttributedString.Index>RangeSet<AttributedString.Index> が、現在の AttributedString に対して整合しているかを実行時にチェックするための isValid(within:) が追加されます。

extension AttributedString.Index {
    public func isValid(within text: some AttributedStringProtocol) -> Bool
    public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool
}

extension Range<AttributedString.Index> {
    public func isValid(within text: some AttributedStringProtocol) -> Bool
    public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool
}

extension RangeSet<AttributedString.Index> {
    public func isValid(within text: some AttributedStringProtocol) -> Bool
    public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool
}

呼び出し側は次のように使えます。

var attrStr = AttributedString("The quick brown fox jumped over the lazy dog")
guard var rangeOfJumped = attrStr.range(of: "jumped") else { return }

// ... ここで attrStr に対して何らかの処理が行われる可能性がある ...

guard rangeOfJumped.isValid(within: attrStr) else {
    // 何らかのミューテーションで追跡できなくなっている
    // この範囲は使わずフォールバック処理を行う
    return
}
// ここでは rangeOfJumped を安全に使える

DiscontiguousAttributedSubstring 向けのオーバーロードは、SF-0014 で導入される同型に依存します。

「範囲内なら少なくともクラッシュしない」という保証

isValid(within:) の追加とあわせて、AttributedString.Index の使用に関する次の保証が新たに明文化されます。

  1. インデックスが startIndex <= someIndex < endIndex を満たすかぎり、その AttributedString に対するスライス操作はクラッシュしません。インデックス内のロープパスが対象の AttributedString と一致しない場合は、保持されている UTF-8 オフセットへのフォールバックによって(やや性能は落ちるものの)有効な位置として扱われます。ただし、その位置がミューテーション前と意味的に同じ位置であるとは限りません。
  2. ある AttributedString.IndexisValid(within:)true を返すなら、上記の保証に加えて、そのインデックスが「同じ AttributedString(途中でミューテーションされていない状態)から作られたもの」であり、作成時と意味的に同じ位置を指していることが保証されます。

この変更により、ミューテーション後にうっかり古いインデックスを使ってもクラッシュせずに済む範囲が広がります。実態としては既存の挙動の追認に近いものですが、ドキュメント上の保証として明確化されたことで、isValid(within:) と組み合わせて「クラッシュしない範囲を担保しつつ、意味的に正しいかどうかも実行時にチェックする」という使い分けがしやすくなります。

03 今後の見通し

将来の構想として、AttributedString と追跡対象のインデックスをひとまとめにする複合型の導入が挙げられています。実現を約束するものではありません。

イメージとしては次のような型で、text のミューテーションを transform(updating:_:) 経由で行うことを強制し、テキストと選択範囲などのインデックスが常に同期するようにします。

struct TrackedAttributedString {
    var text: AttributedString { get }
    var selectedIndices: RangeSet<AttributedString.Index> { get set }

    func transformText<E, R>(_ body: (inout AttributedString) throws(E) -> R) throws(E) -> R
}

text に setter を持たせず、ミューテーションを必ず transformText(_:) 経由にすることで、テキストの更新時に selectedIndices の更新が漏れる事態を構造的に防ぐ狙いです。一方で、こうした型はテキストエディタの選択モデルなど UI に近い領域に属するため、Foundation 本体ではなく UI フレームワーク側、あるいはアプリのビューモデル層で定義されるのがふさわしいのではないか、という見解が示されています。本提案ではこの方向性は今後の検討課題として残されています。