Swift Digest
SE-0222 | Swift Evolution

Lazy CompactMap Sequence

Proposal
SE-0222
Authors
TellowKrinkle, Johannes Weiß
Review Manager
John McCall
Status
Rejected

01 何が問題だったのか

Swift の lazy なコレクションでは、.map / .filter / .compactMap を連鎖させて変換を宣言的に記述できます。しかし、これらを交互に組み合わせると、以下の2つの問題が発生していました。

型名がネストで巨大になる

lazy な変換は、結果の型に変換のチェーンがそのまま積み重なります。.map.filter を何度か挟むだけで、ユーザーの目に触れる型は次のようなものになります。

let array = [0, 1, 22]
let result = array.lazy
    .map(String.init)
    .filter { $0.count == 1 }
    .map { Int($0)! }
    .filter { $0 < 10 }

// 推論される型:
// LazyMapSequence<
//   LazyFilterSequence<
//     LazyMapSequence<
//       LazyFilterSequence<
//         LazyMapSequence<LazySequence<[Int]>.Elements, String>
//       >,
//       Int
//     >
//   >,
//   Int
// >

型推論に任せていれば気にしないで済むものの、変数に明示的に型を書きたい場合や、API の戻り値・プロパティとして公開したい場合には、この巨大な型名そのものが大きな負担になっていました。

連鎖した変換が単一ループにならない

より実害が大きいのが、生成されるコードの効率です。.map(f1).filter(p1).map(f2).filter(p2) のように .map.filter を交互につないだ場合、lazy コレクションの formIndex(after:) は概念的に次のような2重ループになってしまいます。

do {
    do {
        collection.formIndex(after: &i)
    } while !p1(f1(collection[i]))
} while !p2(f2(f1(collection[i])))

外側の filter を満たすかどうかを確かめるたびに内側のループを一周し、f1 が複数回評価されるといった無駄が発生します。本来であれば、次のように条件をまとめた単一ループで表現できるはずのものです。

do {
    collection.formIndex(after: &i)
} while !p1(f1(collection[i])) && !p2(f2(f1(collection[i])))

実際、同等の処理を compactMap ひとつの中に手で書き下せば単一ループになります。

collection.compactMap {
    let a = f1($0)
    guard p1(a) else { return nil }
    let b = f2(a)
    guard p2(b) else { return nil }
    return b
}

しかしこれでは、.map.filter を並べて書ける宣言的な合成のしやすさが失われてしまいます。

標準ライブラリには既に、.map(_:).map(_:).filter(_:).filter(_:) のように 同じ操作が連続 する場合にそれらを1つにまとめるオーバーロードが入っています。ただし、.map.filter交互に並ぶ ケースはカバーされておらず、実用的な変換パイプラインの多くで依然として上述のネストと非効率が残っていました。

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

.map / .filter / .compactMap のあらゆる組み合わせを、単一の型 LazyCompactMapCollection(および対応する LazyCompactMapSequence)に畳み込むことを提案していました。

新しい型 LazyCompactMapCollection

中核となるのは、「ベースコレクションの要素を Element? に変換する関数をひとつだけ持つ」というシンプルな型です。nil が返れば要素をスキップする、という点で compactMap の lazy 版そのものです。

public struct LazyCompactMapCollection<Base: Collection, Element> {
    internal var _base: Base
    internal let _transform: (Base.Element) -> Element?

    internal init(_base: Base, transform: @escaping (Base.Element) -> Element?) {
        self._base = _base
        self._transform = transform
    }
}

LazyFilterCollection と似たインデックス計算を持たせることで、コレクションとしても利用できるようにします。filter は「nil を返さないだけの compactMap」、map は「nil を返さない compactMap」と見なせるため、どちらもこの1つの型で素直に表現できます。

変換の連鎖を常に畳み込むオーバーロード

LazyMapCollection / LazyFilterCollection / LazyCompactMapCollection に対して、map / filter / compactMap のオーバーロードを追加し、結果がつねに LazyCompactMapCollection になるようにします。たとえば LazyMapCollectionfiltercompactMap は次のように、既存の変換関数と新しい述語・変換関数を内部で合成した1つのクロージャを持つ LazyCompactMapCollection を返します。

extension LazyMapCollection {
    public func compactMap<U>(
        _ transform: @escaping (Element) -> U?
    ) -> LazyCompactMapCollection<Base, U> {
        let mytransform = self._transform
        return LazyCompactMapCollection<Base, U>(
            _base: self._base,
            transform: { transform(mytransform($0)) }
        )
    }

    public func filter(
        _ isIncluded: @escaping (Element) -> Bool
    ) -> LazyCompactMapCollection<Base, Element> {
        let mytransform = self._transform
        return LazyCompactMapCollection<Base, Element>(
            _base: self._base,
            transform: {
                let transformed = mytransform($0)
                return isIncluded(transformed) ? transformed : nil
            }
        )
    }
}

LazyFilterCollectionLazyCompactMapCollection にも同様のオーバーロードを入れ、どの順序で .map / .filter / .compactMap をつないでも、結果として得られるのは常に LazyCompactMapCollection ひとつになります。

利用イメージ

冒頭の例は、この設計のもとでは次のように畳み込まれます。

let array = [0, 1, 22]
let result = array.lazy
    .map(String.init)
    .filter { $0.count == 1 }
    .map { Int($0)! }
    .filter { $0 < 10 }

// 推論される型: LazyCompactMapCollection<[Int], Int>

型名が劇的に短くなるうえ、内部では合成されたひとつのクロージャで Base.Element から Element? への変換を行うため、formIndex(after:) も単一ループで済むようになります。.map.filter が交互に並ぶケースでも、無駄な再評価や多重ループが消え、手書きの compactMap に近いパフォーマンスが得られる見込みでした。

なぜ Rejected になったのか

この提案は Swift Evolution のレビューを経て Rejected となりました。レビューでは、次のような懸念が表明されています。

  • 単一の型に畳み込んでしまうことで、ユーザーのコードの意味・可読性が失われる。連鎖の各段階が型に現れなくなるため、コードの意図がシグネチャからは読み取れなくなる。
  • 本当の目的はパフォーマンス改善であり、それは コンパイラの最適化 や、より汎用的な lazy 合成の仕組みで達成すべき課題である。API の形で型を増やす方向は筋が悪い。
  • compactMap という名前は「オプショナルを除去する」という意味合いが強く、filtermap を内包する統合型の名前として相応しいかに疑問がある。

結論として、ユーザーが目にする型を多様化することでパフォーマンスを稼ぐというアプローチは採用されず、lazy な変換連鎖の効率化は別の手段に委ねられることになりました。本提案で示された「.map.filter が交互に並ぶケースで lazy が非効率になる」という課題認識自体は残されたものの、標準ライブラリとしての解決策はこの提案の形では入らず、現在も LazyCompactMapCollection は存在しません。