Dictionary & Set Enhancements
01 何が問題だったのか
Swift 3 の時点で、標準ライブラリの Dictionary と Set には、日常的によく書く処理を素直に表現できない場面がいくつも残っていました。Array や Set と比べると、Dictionary は特にAPIが貧弱で、ちょっとした操作でも手作業でループを書かされる状況でした。
キーと値のペアの列から Dictionary を作れない
Array や Set は任意のシーケンスから初期化できますが、Dictionary には対応するイニシャライザがありませんでした。そのため、filter や map の結果から Dictionary を復元するには、ミュータブルな Dictionary を用意してループで詰め直すか、reduce を使うかしかなく、型推論も効きません。
let numbers = ["one": 1, "two": 2, "three": 3, "four": 4]
let evenOnly = numbers.lazy.filter { (_, value) in value % 2 == 0 }
// ループで詰め直すしかない
var viaIteration: [String: Int] = [:]
for (key, value) in evenOnly {
viaIteration[key] = value
}
また、キーが重複する可能性のあるシーケンスを扱う場合、値をどう合成するかを指定する手段もありませんでした。
キーがまだ無いときの初期化が面倒
キーに対応する値を更新していく処理(たとえば文字の出現回数を数える)では、毎回 nil チェックと強制アンラップを書く必要がありました。
let source = "how now brown cow"
var frequencies: [Character: Int] = [:]
for c in source {
if frequencies[c] == nil {
frequencies[c] = 1
} else {
frequencies[c]! += 1
}
}
添字アクセスの戻り値が Optional<Value> であるために、頻出パターンであるにもかかわらずボイラープレートが生まれていました。
map と filter が Dictionary を返さない
Dictionary に対して標準の map / filter を呼ぶと、(Key, Value) のタプルの配列が返ってきます。Dictionary のままで欲しいことが多いのに、毎回ペアの配列を作ってから Dictionary に詰め直すことになり、非効率かつ冗長でした。さらに、Dictionary の map は必ずキーと値のペアを受け取るため、値だけを変換したい場合でも (k, v) を受け取って (k, transform(v)) を返すクロージャを書く必要がありました。
Dictionary のcapacityが見えない・制御できない
Dictionary に要素を追加していくと、内部のストレージが自動で拡張されます。この拡張は全要素のハッシュ再計算を伴う重い処理ですが、init(minimumCapacity:) 以外にcapacityを予約する手段がありませんでした。また、既存のindexが無効化されない範囲(=capacityが変わらない範囲)を把握する手段もなく、事前にサイズが分かっているケースでも最適化できませんでした。
シーケンスを「キー別にグルーピング」する定番処理がない
「リストを、ある基準でグループ化してキー別の配列にまとめる」という操作は非常によく書くものですが、標準ライブラリには用意されておらず、毎回手でループを書くことになっていました。
02 どのように解決されるのか
Dictionary と Set に、上述の不足を埋める一連のAPIを追加します。Swift 4.0 で導入されました。
シーケンスからの初期化
Dictionary に、キーと値のタプルのシーケンスから初期化する2種類のイニシャライザが追加されます。
init(uniqueKeysWithValues:) は、キーがすべてユニークであることが分かっているときに使います。重複するキーがあった場合は実行時トラップします。
let names = ["Cagney", "Lacey", "Bensen"]
let indexed = Dictionary(uniqueKeysWithValues: names.enumerated().map { (i, v) in (i + 1, v) })
// [1: "Cagney", 2: "Lacey", 3: "Bensen"]
let letters = ["a", "b", "c", "d"]
let zipped = Dictionary(uniqueKeysWithValues: zip(letters, 1...4))
// ["a": 1, "b": 2, "c": 3, "d": 4]
キーが重複しうる場合は init(_:uniquingKeysWith:) を使い、重複時にどちらの値を採用するか(あるいはどう合成するか)をクロージャで指定します。
let duplicates = [("a", 1), ("b", 2), ("a", 3), ("b", 4)]
// 先に現れた値を優先
let firstWins = Dictionary(duplicates, uniquingKeysWith: { (first, _) in first })
// ["a": 1, "b": 2]
// 大きい方を採用
let maxWins = Dictionary(duplicates, uniquingKeysWith: max)
// ["a": 3, "b": 4]
// 値を合算して頻度表を作る
extension Sequence where Element: Hashable {
func frequencies() -> [Element: Int] {
return Dictionary(self.lazy.map { ($0, 1) }, uniquingKeysWith: +)
}
}
[1, 2, 2, 3, 1, 2, 4, 5, 3, 2, 3, 1].frequencies()
// [1: 3, 2: 4, 3: 3, 4: 1, 5: 1]
merge / merging
既存の Dictionary に別のキーと値のシーケンスを取り込むための、ミュータブル版 merge(_:uniquingKeysWith:) と非ミュータブル版 merging(_:uniquingKeysWith:) が追加されます。重複キーの扱いは初期化と同様にクロージャで指定します。
// デフォルト値の合成(既存の値を優先)
let defaults = ["foo": false, "bar": false, "baz": false]
var options = ["foo": true, "bar": false]
options.merge(defaults) { (old, _) in old }
// ["foo": true, "bar": false, "baz": false]
// 値を足し合わせる
var bugCounts = ["bees": 9, "ants": 112]
bugCounts.merge(["bees": 3, "flies": 5], uniquingKeysWith: +)
// ["bees": 12, "ants": 112, "flies": 5]
デフォルト値付きの添字アクセス
subscript(key:default:) が追加されます。キーが無ければデフォルト値が返るため、Optional を介さずに値を読み書きできます。
let source = "how now brown cow"
var frequencies: [Character: Int] = [:]
for c in source {
frequencies[c, default: 0] += 1
}
// ["h": 1, "o": 4, "w": 4, " ": 3, "n": 2, "b": 1, "r": 1, "c": 1]
getter としてアクセスしてもデフォルト値は Dictionary に保存されません。つまり次の2行は等価です。
let x = frequencies["a", default: 0]
let y = frequencies["a"] ?? 0
setter を経由した場合は、キーが存在しないときにデフォルト値を足して書き戻す形になります。上の += 1 の例のように、modify 操作として使うのが典型的な用途です。
Dictionary の mapValues と filter
値だけを変換したいときのために mapValues が追加されます。キーが変わらないため、内部ストレージのキー部分をコピーできるなど効率面でも有利です。
let numbers = ["one": 1, "two": 2, "three": 3, "four": 4]
let strings = numbers.mapValues(String.init)
// ["one": "1", "two": "2", "three": "3", "four": "4"]
filter は、Dictionary を返すオーバーロードになります。キーが衝突する心配がないため Dictionary を直接返せます。
let evens = numbers.filter { $0.value % 2 == 0 }
// ["two": 2, "four": 4]
capacity と reserveCapacity
Dictionary と Set に capacity プロパティと reserveCapacity(_:) メソッドが追加されます。capacity は、ストレージの再確保なしに格納できる要素数です。事前に必要量がわかっているときに予約しておくことで、再ハッシュを伴う拡張を避けられます。
var numbers = ["one": 1, "two": 2, "three": 3, "four": 4]
numbers.capacity // 6
numbers.reserveCapacity(20)
numbers.capacity // 24(少なくとも20以上、実装上は通常もっと大きい)
ハッシュ衝突の確率を下げるため余裕をもって確保されるので、capacity の値は実際のバッファサイズとは一致しません。reserveCapacity(_:) 後の capacity も引数以上にはなりますが、ぴったりその値とは限りません。
シーケンスのグルーピング
Dictionary(grouping:by:) イニシャライザが追加されます。クロージャでキーを計算し、同じキーに分類された要素を配列としてまとめます。
let names = ["Patti", "Aretha", "Anita", "Gladys"]
// 先頭文字でグループ化
Dictionary(grouping: names, by: { $0.first! })
// ["P": ["Patti"], "A": ["Aretha", "Anita"], "G": ["Gladys"]]
// 名前の長さでグループ化
Dictionary(grouping: names) { $0.utf16.count }
// [5: ["Patti", "Anita"], 6: ["Aretha", "Gladys"]]
Set への反映
Dictionary と実装を共有している Set にも、相当する変更が加えられます。
Setを返すfilter(_:)オーバーロード(述語に基づくintersectionのように使えます)capacityプロパティとreserveCapacity(_:)メソッド
なお、Dictionary や Set の filter の戻り値型が変わることでソース互換性に影響が出る箇所があります。Swift 3 モードでは従来の配列を返す filter が @available(swift, obsoleted: 4) として残され、Swift 4 以降で新しい同型を返す filter に切り替わります。