Skip to content

Commit eb097ef

Browse files
committed
Add Key to combine closure
1 parent a5083f6 commit eb097ef

File tree

3 files changed

+51
-14
lines changed

3 files changed

+51
-14
lines changed

Guides/Keyed.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Duplicate keys will trigger a runtime error by default. To handle this, you can
2222
let fruits = ["Apricot", "Banana", "Apple", "Cherry", "Blackberry", "Avocado", "Coconut"]
2323
let fruitsByLetter = fruits.keyed(
2424
by: { $0.first! },
25-
uniquingKeysWith: { old, new in new } // Always pick the latest fruit
25+
uniquingKeysWith: { key, old, new in new } // Always pick the latest fruit
2626
)
2727
// Results in:
2828
[
@@ -40,7 +40,7 @@ The `keyed(by:)` method is declared as a `Sequence` extension returning `[Key: E
4040
extension Sequence {
4141
public func keyed<Key>(
4242
by keyForValue: (Element) throws -> Key,
43-
uniquingKeysWith combine: ((Element, Element) throws -> Element)? = nil
43+
uniquingKeysWith combine: ((Key, Element, Element) throws -> Element)? = nil
4444
) rethrows -> [Key: Element]
4545
}
4646
```

Sources/Algorithms/Keyed.swift

+33-10
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,41 @@ extension Sequence {
3030
@inlinable
3131
public func keyed<Key>(
3232
by keyForValue: (Element) throws -> Key,
33-
// TODO: pass `Key` into `combine`: (Key, Element, Element) throws -> Element
34-
uniquingKeysWith combine: ((Element, Element) throws -> Element)? = nil
33+
uniquingKeysWith combine: ((Key, Element, Element) throws -> Element)? = nil
3534
) rethrows -> [Key: Element] {
36-
// Note: This implementation is a bit convoluted, but it's just aiming to reuse the existing stdlib logic,
37-
// to ensure consistent behaviour, error messages, etc.
38-
// If this API ends up in the stdlib itself, it could just call the underlying `_NativeDictionary` methods.
39-
try withoutActuallyEscaping(keyForValue) { keyForValue in
40-
if let combine {
41-
return try Dictionary(self.lazy.map { (try keyForValue($0), $0) }, uniquingKeysWith: combine)
42-
} else {
43-
return try Dictionary(uniqueKeysWithValues: self.lazy.map { (try keyForValue($0), $0) } )
35+
var result = [Key: Element]()
36+
37+
if combine != nil {
38+
// We have a `combine` closure. Use it to resolve duplicate keys.
39+
40+
for element in self {
41+
let key = try keyForValue(element)
42+
43+
if let oldValue = result.updateValue(element, forKey: key) {
44+
// Can't use a conditional binding to unwrap this, because the newly bound variable
45+
// doesn't play nice with the `rethrows` system.
46+
let valueToKeep = try combine!(key, oldValue, element)
47+
48+
// This causes a second look-up for the same key. The standard library can avoid that
49+
// by calling `mutatingFind` to get access to the bucket where the value will end up,
50+
// and updating in place.
51+
// Swift Algorithms doesn't have access to that API, so we make due.
52+
// When this gets merged into the standard library, we should optimize this.
53+
result[key] = valueToKeep
54+
}
55+
}
56+
} else {
57+
// There's no `combine` closure. Duplicate keys are disallowed.
58+
59+
for element in self {
60+
let key = try keyForValue(element)
61+
62+
guard result.updateValue(element, forKey: key) == nil else {
63+
fatalError("Duplicate values for key: '\(key)'")
64+
}
4465
}
4566
}
67+
68+
return result
4669
}
4770
}

Tests/SwiftAlgorithmsTests/KeyedTests.swift

+16-2
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,30 @@ final class KeyedTests: XCTestCase {
3838
}
3939

4040
func testNonUniqueKeysWithMergeFunction() {
41+
var combineCallHistory = [(key: Character, current: String, new: String)]()
42+
let expectedCallHistory = [
43+
(key: "A", current: "Apple", new: "Avocado"),
44+
(key: "C", current: "Cherry", new: "Coconut"),
45+
]
46+
4147
let d = ["Apple", "Avocado", "Banana", "Cherry", "Coconut"].keyed(
4248
by: { $0.first! },
43-
uniquingKeysWith: { older, newer in "\(older)-\(newer)"}
49+
uniquingKeysWith: { key, older, newer in
50+
combineCallHistory.append((key, older, newer))
51+
return "\(older)-\(newer)"
52+
}
4453
)
4554

4655
XCTAssertEqual(d.count, 3)
4756
XCTAssertEqual(d["A"]!, "Apple-Avocado")
4857
XCTAssertEqual(d["B"]!, "Banana")
4958
XCTAssertEqual(d["C"]!, "Cherry-Coconut")
5059
XCTAssertNil(d["D"])
60+
61+
XCTAssertEqual(
62+
combineCallHistory.map(String.init(describing:)), // quick/dirty workaround: tuples aren't Equatable
63+
expectedCallHistory.map(String.init(describing:))
64+
)
5165
}
5266

5367
func testThrowingFromKeyFunction() {
@@ -66,7 +80,7 @@ final class KeyedTests: XCTestCase {
6680
let error = SampleError()
6781

6882
XCTAssertThrowsError(
69-
try input.keyed(by: { $0.first! }, uniquingKeysWith: { _, _ in throw error })
83+
try input.keyed(by: { $0.first! }, uniquingKeysWith: { _, _, _ in throw error })
7084
) { thrownError in
7185
XCTAssertIdentical(error, thrownError as? SampleError)
7286
}

0 commit comments

Comments
 (0)