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!

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) 
}))
10 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.

3 Likes
Terms of Service

Privacy Policy

Cookie Policy