Permalink
97 lines (66 sloc) 3.97 KB

Introduce compactMapValues to Dictionary

Introduction

This proposal adds a combined filter/map operation to Dictionary, as a companion to the mapValues and filter methods introduced by SE-0165. The new compactMapValues operation corresponds to compactMap on Sequence.

Motivation

Swift 4 introduced two new Dictionary operations: the new method mapValues and a new version of filter. They correspond to the Sequence methods map and filter, respectively, but they operate on Dictionary values and return dictionaries rather than arrays.

However, SE-0165 left a gap in the API: it did not introduce a Dictionary-specific version of compactMap. We sometimes need to transform and filter values of a Dictionary at the same time, and Dictionary does not currently provide an operation that directly supports this.

For example, consider the task of filtering out nil values from a Dictionary of optionals:

let d: [String: String?] = ["a": "1", "b": nil, "c": "3"]
let r1 = d.filter { $0.value != nil }.mapValues { $0! }
let r2 = d.reduce(into: [String: String]()) { (result, item) in result[item.key] = item.value }
// r1 == r2 == ["a": "1", "c": "3"]

Or try running a failable conversion on dictionary values:

let d: [String: String] = ["a": "1", "b": "2", "c": "three"]
let r1 = d.mapValues(Int.init).filter { $0.value != nil }.mapValues { $0! }
let r2 = d.reduce(into: [String: Int]()) { (result, item) in result[item.key] = Int(item.value) }
// r == ["a": 1, "b": 2]

While mapValues and filter can be combined to solve this tasks, the solution needs multiple passes on the input dictionary, which is not particularly efficient. reduce(into:) provides a more efficient solution, but it is rather tricky to get right, and it obscures the intended meaning of the code with implementation details.

It seems worth adding an extra extension method to Dictionary for this operation; its obvious name is compactMapValues(_:), combining the precedents set by compactMap and mapValues.

let r3 = d.compactMapValues(Int.init)

Proposed solution

Add the following to Dictionary:

let d: [String: String?] = ["a": "1", "b": nil, "c": "3"]
let r4 = d.compactMapValues({$0})
// r4 == ["a": "1", "c": "3"]

Or,

let d: [String: String] = ["a": "1", "b": "2", "c": "three"]
let r5 = d.compactMapValues(Int.init)
// r5 == ["a": 1, "b": 2]

Detailed design

Add the following to Dictionary:

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

Source compatibility

This change is purely additive so has no source compatibility consequences.

Effect on ABI stability

This change is purely additive so has no ABI stability consequences.

Effect on API resilience

This change is purely additive so has no API resilience consequences.

Alternatives considered

We can simply omit this method from the standard library -- however, we already have mapValues and filter, and it seems reasonable to fill the API hole left between them with a standard extension.