Add first(where:) method to Sequence
01 何が問題だったのか
シーケンスの中から「条件を満たす最初の要素」を取り出したい場面はよくあります。しかし Swift 2 時点の標準ライブラリでは、Sequence に対してこれを直接行う手段が用意されていませんでした。
Collection であれば、index(of:) や index(where:) で条件を満たすインデックスを求め、それを使って subscript で要素を取り出すことはできます。ただし「要素が欲しいだけ」なのにインデックスを経由しなければならず、書き方として回りくどくなります。さらに Sequence 一般にはインデックスが無いため、この方法も使えません。結局、手書きの for ループを書くしかない状況でした。
// 条件を満たす最初の要素を求めたい、というだけの処理に
// わざわざループを書く必要があった
var found: Int? = nil
for x in sequence {
if x > 10 {
found = x
break
}
}
filter(...).first は期待通りに動かない
代わりのイディオムとして seq.lazy.filter(predicate).first と書く例も見かけましたが、これは意図した通りには動きません。first は Collection のメソッドであり、Sequence にはありません。そのため seq.lazy.filter(...) の filter は遅延版ではなく、Array を返す即時評価版の Sequence.filter として解決されてしまいます。
結果として、lazy を付けたつもりでも内部でシーケンス全体をフィルタリングして Array を作り、その先頭を取っているだけという動作になります。利用者はこれに気付きにくく、想定よりずっと多くの仕事をしてしまうことになります。
02 どのように解決されるのか
Sequence に、述語を受け取って条件を満たす最初の要素を返す first(where:) メソッドを追加します。条件に合う要素が見つかれば Optional にくるんで返し、見つからなければ nil を返します。
extension Sequence {
public func first(
where predicate: (Self.Iterator.Element) throws -> Bool
) rethrows -> Self.Iterator.Element? {
for elt in self {
if try predicate(elt) {
return elt
}
}
return nil
}
}
使い方
シーケンスやコレクションに対して、条件をクロージャで渡すだけで利用できます。途中で条件を満たす要素が見つかった時点で反復は止まるため、シーケンス全体をなめる必要はありません。
let numbers = [1, 3, 7, 10, 15, 21]
// 10 より大きい最初の要素
let found = numbers.first(where: { $0 > 10 })
// Optional(15)
// 見つからないときは nil
let notFound = numbers.first(where: { $0 > 100 })
// nil
Collection で使っていた「index(where:) で添え字を求めてから subscript で要素を取り出す」というパターンも、値そのものが欲しいだけならこのメソッドで素直に書けるようになります。
// Before
if let i = numbers.index(where: { $0 > 10 }) {
let value = numbers[i]
// value を使う
}
// After
if let value = numbers.first(where: { $0 > 10 }) {
// value を使う
}
また、クロージャは throws に対応しているため、エラーを投げる述語も扱えます(呼び出し側は try が必要になります)。
filter(...).first との違い
seq.filter { ... }.first と書くと、条件に合う要素を集めた Array を一度作ってから先頭を取り出すため、マッチする要素が複数あっても全部を評価してしまいます。first(where:) はマッチした時点で反復を終了するため、無駄な処理が発生しません。意図が「最初の1件だけ」であれば、first(where:) の方が挙動としても素直です。