Swift Digest
SE-0265 | Swift Evolution

Offset-Based Access to Indices, Elements, and Slices

Proposal
SE-0265
Authors
Michael Ilseman
Review Manager
John McCall
Status
Returned for revision

01 何が問題だったのか

Swift の Collection は、インデックスを前後に進めたり要素にアクセスしたりするための最低レベルの API を提供していますが、パフォーマンスのために事前条件を満たしている必要があり、違反すると回復不能な論理エラーとしてトラップします。そのため、コレクションの先頭や末尾からの「オフセット位置」で要素・インデックス・スライスを扱いたい、という日常的なニーズに対しては、少し重たく扱いづらい API しかそろっていませんでした。

先頭・末尾からオフセットした要素やスライスを取り出しづらい

たとえば Step C must be finished before step A can begin. という形の行から、先頭から 6 文字目(C)と末尾から 12 文字前(A)を取り出したい場合、次のようにインデックスを手動で進める必要があります。

func parseRequirements(_ s: String) -> [(finish: Character, before: Character)] {
  s.split(separator: "\n").map { line in
    let finishIdx = line.index(line.startIndex, offsetBy: 5)
    let beforeIdx = line.index(line.endIndex, offsetBy: -12)
    return (line[finishIdx], line[beforeIdx])
  }
}

index(_:offsetBy:) をこのように呼び出すコードは冗長で、本来やりたい「先頭から N 文字目」「末尾から N 文字前」という意図がコードから読み取りづらくなっています。

dropFirst / dropLast を経由して書くこともできますが、いったん SubSequence を作ってからさらに要素を取り出すという回り道になり、読み手の負荷が増えます(元 Proposal の著者自身も、最初に書いたときは off-by-one エラーを混入させたそうです)。

func parseRequirements(_ s: String) -> [(finish: Character, before: Character)] {
  s.split(separator: "\n").map { line in
    (line.dropFirst(5).first!, line.dropLast(11).last!)
  }
}

インデックス型が Int のコレクションにまつわる誤解

コレクションのインデックス型が Int のとき、「インデックスは 0 から始まる」と無意識に仮定してしまうのはよくあるミスです。たとえば Int の範囲(Range<Int>)のインデックスは要素そのものであり、0 から始まるとは限りません。

print((3..<10)[5...]) // 5..<10

スライスは元のコレクションとインデックスを共有するため、この仮定はジェネリックなコードや、自身がスライスにもなる Data のような型で特に危険です。

func fifth<C: Collection>(_ c: C) -> C.Element? where C.Index == Int {
  return c.count >= 5 ? c[4] : nil
}

let array = [1,2,3,4,5,6,7,8,9]
print(fifth(array)!)        // 5
print(fifth(array[2...])!)  // 5 のまま。本来「5 番目」なら 7 のはず

Data についても同じ問題があり、よくある対処として data[data.startIndex + 4] のように書くことが勧められます。しかしこの「インデックスに整数を足す」操作は、一般のコレクションでは正しい位置への前進にはなりません。たとえば要素を 1 つおきに見せる RandomAccessCollection を定義すると、startIndex + 1 で得られる位置は、そのコレクションが保持しているとは限らない要素を指してしまいます。

let everyOther = EveryOther(storage: [1,2,3,4,5,6,7,8])
let startIdx = everyOther.startIndex
print(everyOther[startIdx + 1]) // 2。ただし everyOther は 2 を含まない
print(everyOther[everyOther.index(after: startIdx)]) // 3

つまり、汎用的に「先頭・末尾からのオフセットで要素を取りたい」「範囲外なら nil を返してほしい」という、データ競合ならぬ「インデックス誤用」を避けるための高水準 API が欠けていた、というのがこの提案の出発点です。

Proposal の状態

この提案はレビューののち Returned for revision となり、以降 Swift 本体への取り込みには至っていません。以下では、提案されていた API の骨子を紹介します。

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

コレクションの「先頭からのオフセット」または「末尾からのオフセット」を表す OffsetBound という型を新しく導入し、それを使った添字や index(at:)Collection / RangeReplaceableCollection に追加することで、先述の課題に応えます。

OffsetBound の使い方

OffsetBound は、コレクション自体を持たず「先頭から何番目」「末尾から何番目」という位置だけを表すバリューです。.first.last がその起点で、+ / - でオフセット量を指定します。

let str = "abcdefghijklmnopqrstuvwxyz"
print(str[.first + 3 ..< .first + 6]) // "def"
print(str[.first + 3 ..< .last - 2])  // "defghijklmnopqrstuvw"
print(str[.last])                     // Optional("z")
print(str[.last - 1])                 // Optional("y")
print(str[.first + 26])               // nil(範囲外)
print(str[(.last - 3)...])            // "wxyz"

print(str.index(at: .last - 1))  // Optional(... "y" のインデックス)
print(str.index(at: .last - 25)) // Optional(... "a" のインデックス)
print(str.index(at: .last - 26)) // nil

位置が範囲外のときは nil、スライスは通常の部分範囲と同様にクランプされて空スライスが返る、という挙動です。低レベル API(index(_:offsetBy:) など)がトラップする場面でも、こちらはドメインエラーとして Optional で返します。

冒頭の parseRequirements は次のように書き直せます。

func parseRequirements(_ s: String) -> [(finish: Character, before: Character)] {
  s.split(separator: "\n").map { line in
    (line[.first + 5]!, line[.last - 11]!)
  }
}

Int インデックスのコレクションにまつわる誤解も、.first + n で「先頭から数えた位置」であることが明示的になるため回避しやすくなります。

print((3..<10)[(.first + 5)...]) // 8..<10

func fifth<C: Collection>(_ c: C) -> C.Element? { c[.first + 4] }

let array = [1,2,3,4,5,6,7,8,9]
print(fifth(array)!)       // 5
print(fifth(array[2...])!) // 7(従来は 5 が返って混乱のもとだった)

OffsetBound の比較

OffsetBoundComparable に適合し、RangeExpressionBound として使えるようになっています。.first からのオフセットと .last からのオフセットは、どれだけ大きな n, m を与えても前者が常に小さい、という順序が定義されます。

.first + n < .last - m   // どの n, m でも true
.first + n < .first + m  // n < m と同値
.last - n  < .last - m   // n > m と同値(.last に近いほど大きい)

実コレクションを伴わない純粋な位置の比較なので、必ずしも実際のコレクションが空でなかったときのインデックス順序と一致するわけではない点に注意は必要ですが、これにより範囲式を介した部分範囲((.first + 3)......(.last - 1) など)を素直にサポートできます。

Collection 側に追加される API

Collection に次の API が加わります。

extension Collection {
  func index(at position: OffsetBound) -> Index?
  subscript(position: OffsetBound) -> Element? { get }
  subscript<ORE: RangeExpression>(range: ORE) -> SubSequence
    where ORE.Bound == OffsetBound
}

計算量はコレクションの種類に応じて次のように保証されます。

  • RandomAccessCollection なら O(1)
  • BidirectionalCollection ならオフセット量に比例した O(k)
  • .first + n.last + 1 のような前方からの前進で済む形なら O(k)
  • それ以外(末尾方向のオフセットで bidirectional でもない場合)は O(n)

この計算量保証のおかげで、双方向かどうかに関わらず動く汎用アルゴリズム(双方向なら速く、そうでなければ線形時間にフォールバックする、というもの)を自然に書けるようになります。

RangeReplaceableCollection 側に追加される API

変更も含めて OffsetBound で指せるよう、対応するメソッドと setter が追加されます。

extension RangeReplaceableCollection {
  mutating func replaceSubrange<C: Collection, R: RangeExpression>(
    _ subrange: R, with newElements: __owned C
  ) where C.Element == Element, R.Bound == OffsetBound

  mutating func insert(_ newElement: __owned Element, at position: OffsetBound)
  mutating func insert<S: Collection>(
    contentsOf newElements: __owned S, at position: OffsetBound
  ) where S.Element == Element

  mutating func remove(at position: OffsetBound) -> Element?
  mutating func removeSubrange<R: RangeExpression>(_ range: R)
    where R.Bound == OffsetBound

  subscript(position: OffsetBound) -> Element? { get set }
  subscript<ORE: RangeExpression>(range: ORE) -> SubSequence
    where ORE.Bound == OffsetBound { get set }
}

.last + 1 は「末尾の次の位置(=endIndex)」を表し、そこへの insert は末尾追加に相当します。setter に nil を代入すると、その位置の要素を削除する挙動になります(要素アクセスの setter の場合)。

var abcs = "abcdefg"
abcs[.first + 2] = "C"
print(abcs) // "abCdefg"
abcs[.last - 1] = nil
print(abcs) // "abCdeg"

var rulers = "ルルル定規"
rulers.removeSubrange(.first + 1 ... .last - 1)
// 中央の文字を削除

内部カスタマイズポイントと suffix / dropLast の高速化

この提案では、_reverseOffsetIndex(_:by:) というアンダースコア付き(internal な位置づけ)のカスタマイズポイントが Collection に追加されます。既存の index(_:offsetBy:limitedBy:) は非双方向コレクションに負のオフセットを渡すとトラップしますが、こちらは双方向でないコレクションの場合に先頭から数え直すフォールバックを行い、呼び出し側からは常に「後方へのオフセット」として使えるようにするものです。BidirectionalCollection は効率的な実装で上書きします。

これに合わせて、Collection.suffix(_:)Collection.dropLast(_:) の計算量保証も更新され、非双方向でも O(n) で完了することがドキュメント上も明記されます。結果として、BidirectionalCollection 側に重複して存在していた suffix / dropLast のオーバーロードは冗長になるため、新しいバージョンで obsolete 化されます。

設計上のトレードオフ

index(at:) や単一要素アクセスの添字が Optional を返すのは、これらが「要素の存在が呼び出し時点では保証されていない」高水準 API であるためです。低レベルな index(_:offsetBy:)subscript(_:Index) は、前提条件違反を論理エラーとしてトラップする設計で、ループ内などの性能が重要な場面で使われます。一方、.first + n.last - m は「範囲内かどうかわからない位置」を自然に扱う場面が多く、単純なドメインエラーとして nil で返すのが Swift の API 方針(Dictionarysubscript(_:Key) -> Value?first/lastmin() などと同じ方針)に沿うというのが Proposal の立場です。値が必ず存在するとわかっている箇所では、これまで通り末尾に ! を付けるのが自然な使い方です。