Swift Digest
SE-0100 | Swift Evolution

Add sequence-based initializers and merge methods to Dictionary

Proposal
SE-0100
Authors
Nate Cook
Review Manager
Status
Withdrawn

01 何が問題だったのか

ArraySet には、任意のシーケンスから新しいインスタンスを作れるイニシャライザがあります。これは、フィルタやマップなどのシーケンス操作の結果を再び「標準のコレクション型」に戻すために欠かせません。たとえば Set を filter すると結果は LazyFilterCollection<Set<Int>> のような型になってしまい、isSubsetOf(_:) などの集合演算が使えなくなりますが、Set(_:) に通せば元の Set の機能をすぐに取り戻せます。

let numberSet = Set(1 ... 100)
let fivesOnly = numberSet.lazy.filter { $0 % 5 == 0 }
let fivesOnlySet = Set(fivesOnly)
fivesOnlySet.isSubsetOf(numberSet) // true

Dictionary には同等のイニシャライザがない

一方で、Dictionary には同じ発想のイニシャライザがありません。(Key, Value) のシーケンスから Dictionary を組み立てたいときは、可変の Dictionary を一度作ってループで詰め直すか、reduce でコピーしながら組み立てるしかなく、いずれも冗長で、型推論も効きません。

let numberDictionary = ["one": 1, "two": 2, "three": 3, "four": 4]
let evenOnly = numberDictionary.lazy.filter { (_, value) in
    value % 2 == 0
}

// 可変 Dictionary にループで詰める
var viaIteration: [String: Int] = [:]
for (key, value) in evenOnly {
    viaIteration[key] = value
}

// reduce でコピーしながら組み立てる(性能的にも不利)
let viaReduce: [String: Int] = evenOnly.reduce([:]) { (cumulative, kv) in
    var dict = cumulative
    dict[kv.key] = kv.value
    return dict
}

zip で 2 本のシーケンスを組にしてそのまま Dictionary 化する、enumerated() の結果をインデックス付き辞書に変換する、といったよくあるパターンも、簡潔に書く手段がありません。

既存の Dictionary にシーケンスを「足す」手段もない

Array には append(contentsOf:) があり、Set には任意のシーケンスを受け取る unionInPlace(_:) があります。どちらも「既存のコレクションに、別のシーケンスの要素をまとめて追加する」操作です。

Dictionary にはこれも用意されていません。別の Dictionary(Key, Value) のシーケンスの内容を既存の Dictionary に取り込みたくても、キーが重複したときの扱いを決める作法が決まっておらず、結局ループで 1 要素ずつ dict[key] = value と書くしかない、というのが Swift 2 時点の状況でした。

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

この提案は Withdrawn(取り下げ) となりました。ただし、ここで検討されていた API は形を変えて SE-0165 Dictionary & Set Enhancements として再提案され、Swift 4 で Dictionaryinit(uniqueKeysWithValues:)init(_:uniquingKeysWith:)merge(_:uniquingKeysWith:) として採用されています。そのため、この提案で示されたシグネチャそのものは Swift には入っていません。以下では「もし採択されていたら何がどう書けるはずだったのか」を整理します。

シーケンスから Dictionary を作る failable なイニシャライザ

まず 1 つ目は、(Key, Value) のシーケンスから Dictionary を作る失敗可能イニシャライザです。Array.init(_:)Set.init(_:) と同じ位置づけの「full-width イニシャライザ」で、各キーがシーケンス内で一意であることを要求します。重複キーがあった場合は nil を返し、リテラル由来の Dictionary のように trap(実行時クラッシュ)させない設計です。

init?<S: Sequence where S.Iterator.Element == (key: Key, value: Value)>(
    _ keysAndValues: S)

これによって、フィルタ結果の Dictionary 復元が一気に素直に書けるようになります。

let viaProposed = Dictionary(evenOnly)!

同じイニシャライザで、次のような使い方も自然に書けるはずでした。

// DictionaryLiteral(型名。リテラル構文ではない)からの初期化
let literal: DictionaryLiteral = ["a": 1, "b": 2, "c": 3, "d": 4]
let dictFromDL = Dictionary(literal)!

// キーと値を入れ替えた Dictionary を作る
guard let reversedDict = Dictionary(dictFromDL.map { ($1, $0) }) else {
    throw Errors.ReversalFailed
}

// 配列をインデックス付きの Dictionary に変換する
let names = ["Cagney", "Lacey", "Bensen"]
let dict = Dictionary(names.enumerated().map { (i, val) in (i + 1, val) })!

// zip した結果から Dictionary を作る
let letters = "abcdef".characters.lazy.map { String($0) }
let dictFromZip = Dictionary(zip(letters, 1...10))!

重複キーを許容する merging イニシャライザ

ただし、シーケンス由来の (Key, Value) には重複が混ざることもあります。失敗可能イニシャライザは重複をすべて nil に倒すため、「重複したときはこう合成する」と決めたいケースでは使いにくい、というのが 2 つ目の問題意識です。

そこで、重複キーの衝突解決用のクロージャを受け取る別のイニシャライザが提案されていました。呼び出し側で合成方法を明示的に指定する以上、こちらは失敗可能ではありません。

init<S: Sequence where S.Iterator.Element == (key: Key, value: Value)>(
    merging keysAndValues: S,
    combine: @noescape (Value, Value) throws -> Value
) rethrows

たとえば、重複したときに「最初の値を残す」挙動や、「大きいほうを残す」挙動を簡潔に書けます。

let duplicates: DictionaryLiteral = ["a": 1, "b": 2, "a": 3, "b": 4]

// 最初の値を優先
let letterDict2 = Dictionary(merging: duplicates, combine: { (first, _) in first })
// ["b": 2, "a": 1]

// 大きい値を優先
let letterDict3 = Dictionary(merging: duplicates, combine: max)
// ["b": 4, "a": 3]

この仕組みを使えば、「要素の出現回数を数える frequencies()」のようなユーティリティも短く書けます。

extension Sequence where Iterator.Element: Hashable {
    func frequencies() -> [Iterator.Element: Int] {
        return Dictionary(merging: self.lazy.map { v in (v, 1) }, combine: +)
    }
}

[1, 2, 2, 3, 1, 2, 4, 5, 3, 2, 3, 1].frequencies()
// [2: 4, 4: 1, 5: 1, 3: 3, 1: 3]

既存の Dictionary にシーケンスを取り込む merge / merged

3 つ目は、すでにある Dictionary(Key, Value) のシーケンスを流し込むためのメソッドです。mutating 版の merge(contentsOf:combine:) と、新しい Dictionary を返す非 mutating 版 merged(with:combine:) の 2 つを提供します。

mutating func merge<
    S: Sequence where S.Iterator.Element == (key: Key, value: Value)>(
    contentsOf other: S,
    combine: @noescape (Value, Value) throws -> Value
) rethrows

func merged<
    S: Sequence where S.Iterator.Element == (key: Key, value: Value)>(
    with other: S,
    combine: @noescape (Value, Value) throws -> Value
) rethrows -> [Key: Value]

実用イメージは、たとえば「既存の設定にデフォルト値を足すが、既存値を優先する」や、「差分データを合計していく」といった処理です。

// 既に値があるキーはそのままにして、デフォルト値で穴埋めする
let defaults: [String: Bool] = ["foo": false, "bar": false, "baz": false]
var options: [String: Bool] = ["foo": true, "bar": false]
options.merge(contentsOf: defaults) { (old, _) in old }
// options は ["foo": true, "bar": false, "baz": false] になる

// カウントをストリーミング合算する
var bugCounts: [String: Int] = ["bees": 9, "ants": 112]
while bugCountingSource.hasMoreData() {
    bugCounts.merge(contentsOf: bugCountingSource.countMoreBugs(), combine: +)
}

現在の Swift での立ち位置

この提案は取り下げられたため、init?(_:) / init(merging:combine:) / merge(contentsOf:combine:) / merged(with:combine:) というシグネチャそのものは Swift には入っていません。一方で、同じモチベーションは SE-0165(Swift 4)で再検討され、現在の Swift では Dictionary に次の API が用意されています。

  • init(uniqueKeysWithValues:): シーケンス由来の (Key, Value) からキー一意前提で Dictionary を作る(重複があれば trap)。
  • init(_:uniquingKeysWith:): 重複キーの合成クロージャを受け取るイニシャライザ。
  • merge(_:uniquingKeysWith:) / merging(_:uniquingKeysWith:): 既存の Dictionary にシーケンスを取り込むメソッド。

挙動と目的は本提案とほぼ同じで、ラベル名と「失敗可能ではなく uniqueKeysWithValues では重複を trap する」という方針が変わった形になります。したがって、この SE-0100 は「いまの Dictionary のシーケンス系 API がどのような議論から生まれたか」を知るための歴史的な提案として読むのがよい位置づけです。