Swift Digest
SE-0154 | Swift Evolution

Provide Custom Collections for Dictionary Keys and Values

Proposal
SE-0154
Authors
Nate Cook
Review Manager
Doug Gregor
Status
Implemented (Swift 4.0)

01 何が問題だったのか

Swift 3 時点の Dictionarykeys プロパティと values プロパティは、どちらも LazyMapCollection 型でした。これは元のコレクション(ここでは Dictionary 本体)の要素をクロージャで変換して見せるだけの薄いラッパーであり、「この要素は辞書のキーである」といったドメイン固有の情報を一切持っていません。そのため、利用する側から見ると無視できない問題が二つありました。

keys.contains がハッシュ検索にならない

辞書にあるキーが含まれているかを調べる一番読みやすい書き方は dict.keys.contains(...) ですが、keys が単なる LazyMapCollection だった都合で、この呼び出しは内部の辞書ストレージにハッシュで問い合わせることができず、線形探索に落ちていました。

var dict = ["one": [1], "two": [2, 2], "three": [3, 3, 3]]

// 読みやすいが O(n)
if dict.keys.contains("one") { ... }

同じ意味になる書き方はいくつかありましたが、どれも「Swifty」ではない、あるいは意図が伝わりにくいものでした。

// O(1) だが読みにくい
if dict["one"] != nil { ... }
if let _ = dict["one"] { ... }

dict.keys.index(of:)dict.index(forKey:) の関係にも同じ不整合があり、自然に書いた方が遅い、という落とし穴になっていました。

値をその場で書き換える手段がない

辞書の値を書き換える手段はキー添字しかなく、しかもその添字は Optional<Value> を返す setter なので、格納されている値が copy-on-write なコレクションでも「自分が唯一の保持者である」ことを Swift が認識できませんでした。結果として、以下のような素直な更新でも内部ストレージの不要なコピーが発生します。

// どちらも配列のコピーを伴う
dict["one"] = (dict["one"] ?? []) + [1]
dict["one"]?.append(1)

一つ目はそもそも読みにくく、二つ目はキーが存在しないケースを無視してしまいます。さらに根本的な問題として、辞書のキー添字はミュータブルな形では “in-place” な更新を表現できないため、格納されている配列やデータを「ここにある値を直接ミュータブルに触る」形で書き換えられません。

インデックス添字に書き込み可能な setter を持たせる手もありません。インデックスに紐づくキーを書き換えるとハッシュ値が変わってしまい、MutableCollection の要件(インデックスが指す位置が書き換え後も有効であること)を満たせないからです。

つまり、keys の高速検索も、values のミュータブルなアクセスも、LazyMapCollection のままでは表現できない、というのがこの Proposal の出発点です。

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

Dictionary に、keysvalues 専用のコレクション型を新たに導入します。Stringcharactersutf8 など複数のビューを持つのと同じ発想で、辞書本体に対する二つの「ビュー」を Dictionary.KeysDictionary.Values として提供します。

struct Dictionary<Key: Hashable, Value>: ... {
    /// 辞書のキーのコレクションビュー
    struct Keys: Collection {
        subscript(i: Index) -> Key { get }
        // その他の Collection 要件
    }

    /// 辞書の値のミュータブルなコレクションビュー
    struct Values: MutableCollection {
        subscript(i: Index) -> Value { get set }
        // その他の Collection 要件
    }

    var keys: Keys { get }
    var values: Values { get set }
}

これらの新しい型は利用者が直接構築するものではなく、あくまで辞書のビューとして得られるだけです。

keys.contains がハッシュ検索になる

Dictionary.Keys は辞書本体のストレージを知っているため、contains(_:)index(of:) の問い合わせをハッシュ検索に落とし込めます。これまで書きにくかった「速さ」と「読みやすさ」が両立します。

// 速く、かつ Swifty
if dict.keys.contains("one") { ... }

values をミュータブルなコレクションとして扱える

Dictionary.ValuesMutableCollection に適合するので、インデックス経由で値をその場で書き換えられます。キー添字と違って値は Optional で包まれないため、格納されている配列などが copy-on-write なら、そのままコピーなしで成長させられます。

if let i = dict.index(forKey: "one") {
    dict.values[i].append(1)  // ここで配列のコピーは起きない
} else {
    dict["one"] = [1]
}

keysvalues は辞書本体と同じ Index 型を共有するので、keys.index(of:) で見つけたインデックスをそのまま values の添字に渡せます。

if let i = dict.keys.index(of: "one") {
    dict.values[i].append(1)
} else {
    dict["one"] = [1]
}

既存コードへの影響

keys / values の型は LazyMapCollection から新しい型に変わるため、Dictionary.KeysDictionary.Values を明示的に型注釈として書いているコードは書き換えが必要です。とはいえ、これらのプロパティは一時的に for ループや条件式に渡す使われ方がほとんどなので、実務的な影響は限定的です。性能面とミュータブル書き換えの追加は純粋な機能追加なので、意味が変わってしまうコードはありません。