Swift Digest
Blog | Swift.org Blog

標準ライブラリにおける条件付き適合

Conditional Conformance in the Standard Library

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

この記事の要点

Equatable なコンテナ

conditional conformance のもっとも分かりやすい恩恵は、ArrayOptional のように他の型を格納する型が Equatable プロトコルに適合できるようになったことです。Equatable は、2 つのインスタンス間で == を使えることを保証するプロトコルです。

Swift 4.0 でも、要素が equatable な配列同士であれば == は使えました。

[1,2,3] == [1,2,3]     // true
[1,2,3] == [4,5]       // false

equatable な型を包む optional 同士でも同様です。

// String を取る failable イニシャライザは Int? を返す
Int("1") == Int("1")                        // true
Int("1") == Int("2")                        // false
Int("1") == Int("swift")                    // false, Int("swift") は nil

しかしこれらは == 演算子のオーバーロードで実現されていただけで、ArrayOptionalEquatable適合している わけではありませんでした。これらの型は equatable でない型も格納できるため、「equatable な型を格納しているときに限り equatable である」と表現する手段が必要だったのです。

この制約のため、当時の == は 2 段階深くなると使えませんでした。Swift 4.0 で次のように書くと、

// [String] を [Int?] に変換する
let a = ["1","2","x"].map(Int.init)

a == [1,2,nil]    // 'true' を期待

コンパイルエラーになりました。

Binary operator ‘==’ cannot be applied to two ‘[Int?]’ operands.

Array== は要素が equatable であることを要求しますが、OptionalEquatable に適合していなかったためです。

conditional conformance を使うと、格納する型が equatable な場合に限り、これらの型が Equatable に適合すると書けます。

extension Array: Equatable where Element: Equatable {
    // Array 向けの == の実装
}

extension Optional: Equatable where Wrapped: Equatable {
    // Optional 向けの == の実装
}

Equatable への適合は == 以外の恩恵ももたらします。要素が equatable であれば、検索のようなコレクションのヘルパー関数も使えます。

a.contains(nil)                 // true
[[1,2],[3,4],[]].index(of: [])  // 2

Swift 4.1 では conditional conformance により、Optional / Array / Dictionary が、値や要素が適合するときに限り EquatableHashable に適合するようになりました。

同じ仕組みは Codable にも適用されます。codable でない型の配列をエンコードしようとすると、従来の実行時トラップではなく コンパイル時エラー になります。

プロトコル適合を段階的に積み上げる

conditional conformance には、型に機能を少しずつ積み上げ、コードの重複を避けられるという利点もあります。標準ライブラリでの活用例として、Collection に「遅延分割(lazy split)」という機能を追加する例を見ていきます。コレクションを分割したスライスを供給する新しい型を作り、基となるコレクションが双方向(bidirectional)のときに双方向の機能を追加する方法を見ます。

eager な分割と lazy な分割

Sequence プロトコルの split メソッドは、シーケンスをサブシーケンスの Array に分割します。

let numbers = "15,x,25,2"
let splits = numbers.split(separator: ",")
// splits == ["15","x","25","2"]
var sum = 0
for i in splits {
    sum += Int(i) ?? 0
}
// sum == 42

この split は呼んだ瞬間にシーケンス全体を分割して配列に詰めるため、eager(先行評価) です。しかし、たとえば巨大なテキストファイルの先頭数行だけをプレビューに使いたい場合、最初の数行のためにファイル全体を処理したくはありません。

mapfilter も既定では eager で、同じ問題があります。これを避けるため、標準ライブラリには lazy プロパティ経由でアクセスできる「lazy なシーケンス・コレクション」があります。lazy な map などは即座には実行されず、要素にアクセスされたときに初めて変換やフィルタリングを行います。

// 巨大なコレクション
let giant = 0..<Int.max
// lazy に map する: まだ何の処理も走らない
let mapped = giant.lazy.map { $0 * 2 }
// 先頭の数要素だけ合計する
let sum = mapped.prefix(10).reduce(0, +)
// sum == 90

mapped を作る時点では map は実行されません。giant の全要素を 2 倍すると途中で Int をオーバーフローしてトラップしますが、lazy な map ではアクセスされた要素だけが計算されるため、この例では reduce が合計する先頭 10 要素しか計算されません。

lazy な分割ラッパー

標準ライブラリには lazy な split はありませんが、自作する例を見ていきます。まず、任意の基コレクションと、分割位置を判定するクロージャを保持するジェネリックなラッパー構造体を作ります。

struct LazySplitCollection<Base: Collection> {
    let base: Base
    let isSeparator: (Base.Element) -> Bool
}

次に Collection プロトコルに適合させます。コレクションになるには、startIndexendIndex、インデックスから要素を返す subscript、インデックスを 1 つ進める index(after:) の 4 つを用意すれば十分です。このコレクションの要素は基コレクションのサブシーケンスなので、インデックスの型は基コレクションのものを再利用できます。

extension LazySplitCollection: Collection {
    typealias Element = Base.SubSequence
    typealias Index = Base.Index

    var startIndex: Index { return base.startIndex }
    var endIndex: Index { return base.endIndex }

    subscript(i: Index) -> Element {
        let separator = base[i...].index(where: isSeparator)
        return base[i..<(separator ?? endIndex)]
    }

    func index(after i: Index) -> Index {
        let separator = base[i...].index(where: isSeparator)
        return separator.map(base.index(after:)) ?? endIndex
    }
}

次のセパレータを探して間のシーケンスを返す処理は subscriptindex(after:) で行います。セパレータが見つからない場合、index(where:)nil を返すため、?? endIndex で末尾を代用します。

lazy への拡張

このラッパーを lazy な split メソッドとして使えるよう、すべての lazy コレクションが適合する LazyCollectionProtocol を拡張します。

extension LazyCollectionProtocol {
    func split(
        whereSeparator matches: @escaping (Element) -> Bool
    ) -> LazySplitCollection<Elements> {
        return LazySplitCollection(base: elements, isSeparator: matches)
    }
}

要素が equatable な場合に、クロージャの代わりに値を取る版も用意するのが慣例です。

extension LazyCollectionProtocol where Element: Equatable {
    func split(separator: Element) -> LazySplitCollection<Elements> {
        return LazySplitCollection(base: elements) { $0 == separator }
    }
}

これで lazy なサブシステムに split メソッドを追加できました。

let one = "one,two,three".lazy.split(separator: ",").first
// one == "one"

さらに、このラッパー自身を LazyCollectionProtocol でマークしておくと、後続の操作も lazy になり、利用者の期待どおりに振る舞います。

extension LazySplitCollection: LazyCollectionProtocol { }

条件付きで双方向にする

ここまでで先頭の数要素を効率よく取り出せるようになりました。では末尾の数要素はどうでしょうか。BidirectionalCollection はインデックスを末尾側へ戻す index(before:) を追加するプロトコルで、last プロパティなどを支えます。

基となるコレクションが双方向なら、分割ラッパーも双方向にできるはずです。Swift 4.0 では、これを実現するには LazySplitBidirectionalCollection という別の型を丸ごと用意し、split をオーバーロードする必要があり、かなり煩雑でした。

conditional conformance なら、LazySplitCollection が「基が双方向のときだけ BidirectionalCollection に適合する」と書くだけで済みます。

extension LazySplitCollection: BidirectionalCollection
where Base: BidirectionalCollection {
    func index(before i: Index) -> Index {
        let reversed = base[..<base.index(before: i)].reversed()
        let separator = reversed.index(where: isSeparator)
        return separator?.base ?? startIndex
    }
}

ここでは双方向コレクションの順序を反転するもう 1 つの lazy ラッパー reversed() を使い、後ろ向きに次のセパレータを探しています。この 1 つのメソッドだけで、last プロパティや reversed() メソッドなど双方向コレクションの機能が使えるようになります。

let backwards = "one,two,three"
                .lazy.split(separator: ",")
                .reversed().joined(separator: ",")
// backwards == "three,two,one"

この段階的な conditional conformance は、独立した複数の適合を組み合わせるときに特に効きます。たとえば「基がミュータブルなときだけ MutableCollection に適合する」も追加したいとします。双方向であることとミュータブルであることは独立しているため、Swift 4.0 までは組み合わせごとに専用の型を作る必要がありました。conditional conformance なら 2 つ目の条件付き適合を足すだけです。

これはまさに標準ライブラリの Slice 型が必要としていた機能です。Slice は任意のコレクションに既定のスライス機能を提供する型です。

// dropFirst() は先頭要素を除いたスライスを作る
let slice = "a,b,c".lazy.split(separator: ",").dropFirst()
print(type(of: slice))
// prints: Slice<LazySplitCollection<String>>

Swift 4.0 までは、最悪ケースの MutableRangeReplaceableRandomAccessSlice まで含めて十数種類の実装が必要でした。conditional conformance により、1 つの Slice 型と 4 つの条件付き適合で済むようになり、この変更だけで標準ライブラリのバイナリサイズが 5% 削減されました。

なお、ここで示した LazySplitCollection は conditional conformance を説明するための簡略版のスケッチです。eager な split にある空のサブシーケンスの結合(coalescing)には対応しておらず、次のセパレータ位置をキャッシュする独自インデックスのような性能上の最適化や、一定長ずつ切り出すチャンク処理など、実用的な実装にするにはさらに検討すべき点があります。

関連リンク