Introduce Dictionary.mapValuesWithKeys
01 何が問題だったのか
Dictionary の値を、キーを使って変換したい場面はよくあります。たとえば通貨コードをキーに残高を値として持つ辞書に対し、「USD balance: 13」のような表示用文字列に変換したいといったケースです。
ところが Dictionary.mapValues(_:) はクロージャに値しか渡さないため、キーを参照したい変換では従来 init(uniqueKeysWithValues:) や reduce(into:) に頼るしかありませんでした。
// いずれもキーを使いたいがために遠回りになる
let new: [Key: NewValue] = .init(
uniqueKeysWithValues: old.lazy.map { ($0, transform(id: $0, payload: $1)) }
)
let new: [Key: NewValue] = old.reduce(into: [:]) {
$0[$1.key] = transform(id: $1.key, payload: $1.value)
}
これらはいずれも、変換後の辞書をゼロから構築し直す形になります。Dictionary は内部的にハッシュテーブルを持つため、同じキー集合をそのまま引き継げばよい場面でも、キーごとに再ハッシュと挿入処理が発生します。reduce(into:) の方は途中でストレージの再確保も挟まるぶんさらに不利で、キーを一切変更しない用途に対しては明らかに過剰なコストがかかっていました。
mapValues(_:) であれば元のハッシュテーブル構造をそのまま再利用でき、こうしたコストは発生しません。キーを参照したいというだけの理由でその最適化を諦めなければならない状態は、素直ではありません。
02 どのように解決されるのか
Dictionary の mapValues(_:) と compactMapValues(_:) に、クロージャがキーと値の両方を受け取るオーバーロードを追加します。既存のキーをそのまま流用するため、ハッシュテーブルの再構築は不要で、値の変換だけが走ります。
extension Dictionary {
public func mapValues<T, E>(
_ transform: (Key, Value) throws(E) -> T
) throws(E) -> Dictionary<Key, T>
public func compactMapValues<T, E>(
_ transform: (Key, Value) throws(E) -> T?
) throws(E) -> Dictionary<Key, T>
}
使い方は、既存の mapValues / compactMapValues に渡すクロージャの引数を2つにするだけです。
let balances: [Currency: Int64] = [.USD: 13, .EUR: 15]
// キーを使って表示用文字列に変換
let displayText: [Currency: String] = balances.mapValues { key, value in
"\(key.alpha3) balance: \(value)"
}
// キーに応じて値を捨てる/残す
let positiveUSD: [Currency: Int64] = balances.compactMapValues { key, value in
key == .USD && value > 0 ? value : nil
}
変換結果のキーは必ず元の辞書と同じなので、Dictionary はキーの配列とハッシュテーブルをそのまま引き継ぎ、値の配列だけを作り直します。init(uniqueKeysWithValues:) や reduce(into:) による書き換えで発生していた再ハッシュ・再確保のコストがなくなり、キーを使わない既存の mapValues / compactMapValues と同じオーダーで動きます。
既存の mapValues / compactMapValues との関係
引数が1つのクロージャ(値のみを受け取る形)を渡したときは、これまでどおり既存のオーバーロードが選ばれます。既存コードの挙動は変わりません。
当初の提案では mapValuesWithKeys という別名のメソッドを追加する案でしたが、レビューの結果、mapValues / compactMapValues のオーバーロードとして追加する形で受理されました。名前が一貫するぶん呼び出し側の見た目は素直になりますが、値がタプル型 (Key, T) の辞書に対してこれまで mapValues { $0 } のような書き方をしていたコードは、新しいオーバーロードに解決されるようになって挙動が変わり得ます。この場合はクロージャの引数を明示する(例: mapValues { (pair: (Key, T)) in pair })などの修正が必要になります。
今後の展望
swift-collections の OrderedDictionary にも同様のオーバーロードを追加することが自然な拡張として挙げられています。OrderedDictionary はキー配列とハッシュテーブルを別に保持しているため、キー側を丸ごと再利用できる今回の方式による性能向上はさらに大きくなる可能性があります(speculativeなもので、実現を約束するものではありません)。