Mapping Dictionary keys

It would be nice to have mapKeys<T> and compactMapKeys<T> functions for dictionaries, just as we now have mapValues<T> and compactMapValues<T> in Swift 4.0.

The motivation behind this is that there are concrete cases where you may need to modify the keys of a dictionary, leaving values intact, and to do this today requires an unnecessarily complex reduce function.

An example of this is working with NSAttributedString in UIKit.

Say that we have [NSAttributedStringKey: Any]dictionaries encapsulating styles defined in a global constants file that we access regularly to generate attributed strings with theNSAttributedString(string: String, attributes: [NSAttributedStringKey: Any]?) initializer.

When we want to use these dictionaries to, say, set the defaultTextAttributes on a UITextField instance, we run into the problem that UITextField.defaultTextAttributes expects [String: Any] as the dictionary type.

To adapt this attributes dictionary today, a possible approach is to use the reduce function:

var attributes: [NSAttributedStringKey: Any] = [
.font: UIFont.systemFont(ofSize: 15), 
.foregroundColor: UIColor.white
]

attributes.reduce(into: [String: Any](), { result, x in
    result[x.key.rawValue] = x.value
})

Granted in this example this is a bit trivial but you can see how the complexity can dramatically increase if we want to transform the key in more elaborate ways.

A simple extension to Dictionary to introduce map-type functions could be as simple as:

extension Dictionary {
    func compactMapKeys<T>(_ transform: ((Key) throws -> T?)) rethrows -> Dictionary<T, Value> {
        return try self.reduce(into: [T: Value](), { (result, x) in
            if let key = try transform(x.key) {
                result[key] = x.value
            }
        })
    }
}

yet this simplifies our function to:

var attributes: [NSAttributedStringKey: Any] = [
    .font: UIFont.systemFont(ofSize: 15), 
	  .foregroundColor: UIColor.white
]

attributes.compactMapKeys { $0.rawValue }

Thoughts as to whether or not this is worthwhile addition to the standard library is much appreciated!

2 Likes

This is not as strongly motivated as mapValues, and as such, does not make an appropriate addition to the standard library IMO.

In designing this API, you've made a decision: colliding keys should be silently coalesced. That isn't always the right decision. Sometimes colliding keys would indicate a programmer error and should trap. Sometimes you might need to do something else like deconflict them, or coalesce their values.

mapValues OTOH doesn't have these issues. And while the mapValues can be trivially composed, it has the benefit of potential optimization, since the dictionary does not have to rehash/bucket the values. That doesn't apply to mapKeys.

p.s. if you find the reduce version verbose, you can simplify it:

attributes.reduce(into: [:]) { result, x in
  result[x.key.rawValue] = x.value
}

// or alternatively, including an assertion for duplicate keys

Dictionary(uniqueKeysWithValues: attributes.map({ 
  (k,v) in (k.rawValue,v) 
}))
14 Likes

I need this a lot. I bet other people do too and they just don't know they do, because they're using for loops instead. Overloads are the way to go!

public extension Dictionary {
  /// Same values, corresponding to `map`ped keys.
  ///
  /// - Parameter transform: Accepts each key of the dictionary as its parameter
  ///   and returns a key for the new dictionary.
  /// - Postcondition: The collection of transformed keys must not contain duplicates.
  func mapKeys<Transformed>(
    _ transform: (Key) throws -> Transformed
  ) rethrows -> [Transformed: Value] {
    .init(
      uniqueKeysWithValues: try map { (try transform($0.key), $0.value) }
    )
  }

  /// Same values, corresponding to `map`ped keys.
  ///
  /// - Parameters:
  ///   - transform: Accepts each key of the dictionary as its parameter
  ///     and returns a key for the new dictionary.
  ///   - combine: A closure that is called with the values for any duplicate
  ///     keys that are encountered. The closure returns the desired value for
  ///     the final dictionary.
  func mapKeys<Transformed>(
    _ transform: (Key) throws -> Transformed,
    uniquingKeysWith combine: (Value, Value) throws -> Value
  ) rethrows -> [Transformed: Value] {
    try .init(
      map { (try transform($0.key), $0.value) },
      uniquingKeysWith: combine
    )
  }

  /// `compactMap`ped keys, with their values.
  ///
  /// - Parameter transform: Accepts each key of the dictionary as its parameter
  ///   and returns a potential key for the new dictionary.
  /// - Postcondition: The collection of transformed keys must not contain duplicates.
  func compactMapKeys<Transformed>(
    _ transform: (Key) throws -> Transformed?
  ) rethrows -> [Transformed: Value] {
    .init(
      uniqueKeysWithValues: try compactMap { key, value in
        try transform(key).map { ($0, value) }
      }
    )
  }
}

I haven't found a need for the remaining compactMapKeys overload yet, but I think it should be in the standard library anyway.

11 Likes

This may not be worth including in the standard library, but it's something I've found useful more than a few times. Riffing on Jessy's answer a little bit:

extension Dictionary {

    /// Transforms dictionary keys without modifying values.
    /// Deduplicates transformed keys.
    ///
    /// Example:
    /// ```
    /// ["one": 1, "two": 2, "three": 3, "": 4].mapKeys({ $0.first }, uniquingKeysWith: { max($0, $1) })
    /// // [Optional("o"): 1, Optional("t"): 3, nil: 4]
    /// ```
    ///
    /// - Parameters:
    ///   - transform: A closure that accepts each key of the dictionary as
    ///   its parameter and returns a transformed key of the same or of a different type.
    ///   - combine:A closure that is called with the values for any duplicate
    ///   keys that are encountered. The closure returns the desired value for
    ///   the final dictionary.
    /// - Returns: A dictionary containing the transformed keys and values of this dictionary.
    func mapKeys<T>(_ transform: (Key) throws -> T, uniquingKeysWith combine: (Value, Value) throws -> Value) rethrows -> [T: Value] {
        try .init(map { (try transform($0.key), $0.value) }, uniquingKeysWith: combine)
    }

    /// Transforms dictionary keys without modifying values.
    /// Drops (key, value) pairs where the transform results in a nil key.
    /// Deduplicates transformed keys.
    ///
    /// Example:
    /// ```
    /// ["one": 1, "two": 2, "three": 3, "": 4].compactMapKeys({ $0.first }, uniquingKeysWith: { max($0, $1) })
    /// // ["o": 1, "t": 3]
    /// ```
    ///
    /// - Parameters:
    ///   - transform: A closure that accepts each key of the dictionary as
    ///   its parameter and returns an optional transformed key of the same or of a different type.
    ///   - combine: A closure that is called with the values for any duplicate
    ///   keys that are encountered. The closure returns the desired value for
    ///   the final dictionary.
    /// - Returns: A dictionary containing the non-nil transformed keys and values of this dictionary.
    func compactMapKeys<T>(_ transform: (Key) throws -> T?, uniquingKeysWith combine: (Value, Value) throws -> Value) rethrows -> [T: Value] {
        try .init(compactMap { (try transform($0.key), $0.value) as? (T, Value) }, uniquingKeysWith: combine)
    }

}
4 Likes

Thanks for the clean extension. A mapValues counterpart makes perfect sense. My case I needed to serialize/deserialize strongly-typed dictionaries with hashable enum keys in and out of UserDefaults.

2 Likes

I believe there's room for improvement here over the current API. Right now you'd probably combine map()/compactMap() with init(_:uniquingKeysWith:):

let originalDictionary: [Int: Potato] = [...]
let kvPairs = originalDictionary.map { (String(describing: $0.key), $0.value) }
let newDictionary = [String: Potato](kvPairs, uniquingKeysWith: { ... })

This pattern could probably be improved. It's complex enough that the "no one-liners" rule doesn't apply (IMHO) and it's not obvious at a glance what the code is supposed to do. I suspect in some cases new API would allow the stdlib to limit hashing overhead, though I haven't thought too much about it.

However I also suspect it means we'd need mapKeys() to take an additional uniquingKeysWith closure or to provide some other mechanism to ensure keys are unique in the output sequence. At that point, it's no longer really a map() operation in the functional programming sense, so I don't know if mapKeys() is then the right name for the function.

1 Like

dude this is perfect!

1 Like

+1 on this

Could it be added to swift-algorithms then? Seems like better fit than swift-collections.

2 Likes