diff --git a/.ruby-version b/.ruby-version index 860487c..ff365e0 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.1 +3.1.3 diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/HashableByKeyPathPerformanceTests.xcbaseline/78031180-13A0-4520-92AA-B9A7A0AA4431.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/HashableByKeyPathPerformanceTests.xcbaseline/78031180-13A0-4520-92AA-B9A7A0AA4431.plist new file mode 100644 index 0000000..f415325 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcbaselines/HashableByKeyPathPerformanceTests.xcbaseline/78031180-13A0-4520-92AA-B9A7A0AA4431.plist @@ -0,0 +1,96 @@ + + + + + classNames + + EquatableByKeyPathPerformanceTests + + testEqualityPerformance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.016547 + baselineIntegrationDisplayName + Local Baseline + + + + HashableByKeyPathPerformanceTests + + testEqualityPerformance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.017176 + baselineIntegrationDisplayName + Local Baseline + + + testHashPerformance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.010787 + baselineIntegrationDisplayName + Local Baseline + + + + HashableByKeyPathTests + + testEqualityPerformance() + + com.apple.dt.XCTMetric_Clock.time.monotonic + + baselineAverage + 0.026650 + baselineIntegrationDisplayName + Local Baseline + + com.apple.dt.XCTMetric_Memory.physical + + baselineAverage + 3.276800 + baselineIntegrationDisplayName + Local Baseline + + com.apple.dt.XCTMetric_Memory.physical_peak + + baselineAverage + 0.000000 + baselineIntegrationDisplayName + Local Baseline + + + testHashPerformance() + + com.apple.dt.XCTMetric_Clock.time.monotonic + + baselineAverage + 0.029324 + baselineIntegrationDisplayName + Local Baseline + + com.apple.dt.XCTMetric_Memory.physical + + baselineAverage + 3.276800 + baselineIntegrationDisplayName + Local Baseline + + com.apple.dt.XCTMetric_Memory.physical_peak + + baselineAverage + 0.000000 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/HashableByKeyPathPerformanceTests.xcbaseline/Info.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/HashableByKeyPathPerformanceTests.xcbaseline/Info.plist new file mode 100644 index 0000000..f45b1d1 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcbaselines/HashableByKeyPathPerformanceTests.xcbaseline/Info.plist @@ -0,0 +1,33 @@ + + + + + runDestinationsByUUID + + 78031180-13A0-4520-92AA-B9A7A0AA4431 + + localComputer + + busSpeedInMHz + 0 + cpuCount + 1 + cpuKind + Apple M1 Pro + cpuSpeedInMHz + 0 + logicalCPUCoresPerPackage + 10 + modelCode + MacBookPro18,1 + physicalCPUCoresPerPackage + 10 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + arm64 + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/HashableByKeyPath.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/HashableByKeyPath.xcscheme index 7bad313..09595a6 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/HashableByKeyPath.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/HashableByKeyPath.xcscheme @@ -53,6 +53,16 @@ ReferencedContainer = "container:"> + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift index 3a13f87..b66c83c 100644 --- a/Package.swift +++ b/Package.swift @@ -9,5 +9,6 @@ let package = Package( targets: [ .target(name: "HashableByKeyPath"), .testTarget(name: "HashableByKeyPathTests", dependencies: ["HashableByKeyPath"]), + .testTarget(name: "HashableByKeyPathPerformanceTests", dependencies: ["HashableByKeyPath"]), ] ) diff --git a/Sources/HashableByKeyPath/EquatabilityKeyPathAggregator.swift b/Sources/HashableByKeyPath/EquatabilityKeyPathAggregator.swift deleted file mode 100644 index 02b6010..0000000 --- a/Sources/HashableByKeyPath/EquatabilityKeyPathAggregator.swift +++ /dev/null @@ -1,23 +0,0 @@ -internal struct EquatabilityKeyPathAggregator: EquatableKeyPathConsumer { - - private typealias EquateClosure = (_ lhs: Root, _ rhs: Root) -> Bool - - private var closures: [EquateClosure] = [] - - internal mutating func addEquatableKeyPath(_ keyPath: KeyPath) where KeyType: Equatable { - closures.append({ lhs, rhs in - return lhs[keyPath: keyPath] == rhs[keyPath: keyPath] - }) - } - - internal mutating func addCustomEquator(forKeyPath keyPath: KeyPath, equator: @escaping (KeyType, KeyType) -> Bool) where KeyType: Equatable { - closures.append { lhs, rhs in - return equator(lhs[keyPath: keyPath], rhs[keyPath: keyPath]) - } - } - - internal func evaluateEquality(lhs: Root, rhs: Root) -> Bool { - return closures.allSatisfy { $0(lhs, rhs) } - } - -} diff --git a/Sources/HashableByKeyPath/EquatableByKeyPath.swift b/Sources/HashableByKeyPath/EquatableByKeyPath.swift index f070f3f..c480bdb 100644 --- a/Sources/HashableByKeyPath/EquatableByKeyPath.swift +++ b/Sources/HashableByKeyPath/EquatableByKeyPath.swift @@ -2,22 +2,17 @@ A protocol that defines a single function that can be used to synthesise `Equatable` conformance. */ public protocol EquatableByKeyPath: Equatable { - /** Add key paths to `consumer` that will be used for `Equatable` conformance. - parameter consumer: The consumer to add the key paths to. */ static func addEquatableKeyPaths(to consumer: inout Consumer) where Consumer.Root == Self - } extension EquatableByKeyPath { - public static func == (lhs: Self, rhs: Self) -> Bool { - var aggregator = EquatabilityKeyPathAggregator() - addEquatableKeyPaths(to: &aggregator) - return aggregator.evaluateEquality(lhs: lhs, rhs: rhs) + var evaluator = EquatableByKeyPathEvaluator(lhs: lhs, rhs: rhs) + return evaluator.checkEquality() } - } diff --git a/Sources/HashableByKeyPath/EquatableByKeyPathEvaluator.swift b/Sources/HashableByKeyPath/EquatableByKeyPathEvaluator.swift new file mode 100644 index 0000000..abe2c11 --- /dev/null +++ b/Sources/HashableByKeyPath/EquatableByKeyPathEvaluator.swift @@ -0,0 +1,28 @@ +internal struct EquatableByKeyPathEvaluator: EquatableKeyPathConsumer { + private let lhs: Root + private let rhs: Root + private var hasFoundNotEqualProperty = false + + init(lhs: Root, rhs: Root) { + self.lhs = lhs + self.rhs = rhs + } + + internal mutating func checkEquality() -> Bool { + hasFoundNotEqualProperty = false + Root.addEquatableKeyPaths(to: &self) + return !hasFoundNotEqualProperty + } + + internal mutating func addEquatableKeyPath(_ keyPath: KeyPath) where KeyType: Equatable { + guard !hasFoundNotEqualProperty else { return } + + hasFoundNotEqualProperty = lhs[keyPath: keyPath] != rhs[keyPath: keyPath] + } + + internal mutating func addCustomEquator(forKeyPath keyPath: KeyPath, equator: @escaping (KeyType, KeyType) -> Bool) where KeyType: Equatable { + guard !hasFoundNotEqualProperty else { return } + + hasFoundNotEqualProperty = !equator(lhs[keyPath: keyPath], rhs[keyPath: keyPath]) + } +} diff --git a/Sources/HashableByKeyPath/EquatableKeyPathConsumer.swift b/Sources/HashableByKeyPath/EquatableKeyPathConsumer.swift index c5257c1..79c6157 100644 --- a/Sources/HashableByKeyPath/EquatableKeyPathConsumer.swift +++ b/Sources/HashableByKeyPath/EquatableKeyPathConsumer.swift @@ -2,7 +2,6 @@ A protocol that defines a function that can be used to add key paths from the `Root` type to `Equatable` properties. */ public protocol EquatableKeyPathConsumer { - /// The root type of the object that will be equated. associatedtype Root @@ -19,5 +18,4 @@ public protocol EquatableKeyPathConsumer { - parameter keyPath: The key to include when equating 2 instances of `Root`. */ mutating func addCustomEquator(forKeyPath keyPath: KeyPath, equator: @escaping (KeyType, KeyType) -> Bool) where KeyType: Equatable - } diff --git a/Sources/HashableByKeyPath/HashableByKeyPath.swift b/Sources/HashableByKeyPath/HashableByKeyPath.swift index 9b9a634..c59ba65 100644 --- a/Sources/HashableByKeyPath/HashableByKeyPath.swift +++ b/Sources/HashableByKeyPath/HashableByKeyPath.swift @@ -23,9 +23,9 @@ extension HashableByKeyPath { } public func hash(into hasher: inout Hasher) { - var hashableKeyPathAggregator = HashableKeyPathAggregator() - Self.addHashableKeyPaths(to: &hashableKeyPathAggregator) - return hashableKeyPathAggregator.hashValues(from: self, into: &hasher) + var keyPathHasher = KeyPathHasher(root: self, hasher: hasher) + Self.addHashableKeyPaths(to: &keyPathHasher) + hasher = keyPathHasher.hasher } } diff --git a/Sources/HashableByKeyPath/HashableKeyPathAggregator.swift b/Sources/HashableByKeyPath/HashableKeyPathAggregator.swift deleted file mode 100644 index bafe4ca..0000000 --- a/Sources/HashableByKeyPath/HashableKeyPathAggregator.swift +++ /dev/null @@ -1,36 +0,0 @@ -internal struct HashableKeyPathAggregator: HashableKeyPathConsumer { - - private var closures: [(_ root: Root, _ hasher: inout Hasher) -> Void] = [] - - private typealias EquateClosure = (_ lhs: Root, _ rhs: Root) -> Bool - - private var equateClosures: [EquateClosure] = [] - - internal init() {} - - internal mutating func addHashableKeyPath(_ keyPath: KeyPath) where KeyType: Hashable { - closures.append({ root, hasher in - return root[keyPath: keyPath].hash(into: &hasher) - }) - equateClosures.append { lhs, rhs in - return lhs[keyPath: keyPath] == rhs[keyPath: keyPath] - } - } - - internal mutating func addCustomEquator(forKeyPath keyPath: KeyPath, equator: @escaping (KeyType, KeyType) -> Bool) where KeyType : Hashable { - closures.append({ root, hasher in - return root[keyPath: keyPath].hash(into: &hasher) - }) - equateClosures.append { lhs, rhs in - return equator(lhs[keyPath: keyPath], rhs[keyPath: keyPath]) - } - } - - internal func hashValues(from root: Root, into hasher: inout Hasher) { - closures.forEach { $0(root, &hasher) } - } - - internal func evaluateEquality(lhs: Root, rhs: Root) -> Bool { - return equateClosures.allSatisfy { $0(lhs, rhs) } - } -} diff --git a/Sources/HashableByKeyPath/HashableKeyPathForwarder.swift b/Sources/HashableByKeyPath/HashableKeyPathForwarder.swift index f73142a..88e3773 100644 --- a/Sources/HashableByKeyPath/HashableKeyPathForwarder.swift +++ b/Sources/HashableByKeyPath/HashableKeyPathForwarder.swift @@ -1,5 +1,4 @@ internal struct HashableKeyPathForwarder: HashableKeyPathConsumer where Consumer.Root == Root { - internal typealias KeyPathListener = (_ keyPath: KeyPath) -> Void internal var equatableKeyPathConsumer: Consumer @@ -8,12 +7,13 @@ internal struct HashableKeyPathForwarder(_ keyPath: KeyPath) where KeyType: Hashable { equatableKeyPathConsumer.addEquatableKeyPath(keyPath) } + @inlinable internal mutating func addCustomEquator(forKeyPath keyPath: KeyPath, equator: @escaping (KeyType, KeyType) -> Bool) where KeyType: Hashable { equatableKeyPathConsumer.addCustomEquator(forKeyPath: keyPath, equator: equator) } - } diff --git a/Sources/HashableByKeyPath/KeyPathHasher.swift b/Sources/HashableByKeyPath/KeyPathHasher.swift new file mode 100644 index 0000000..13a1cc2 --- /dev/null +++ b/Sources/HashableByKeyPath/KeyPathHasher.swift @@ -0,0 +1,19 @@ +internal struct KeyPathHasher: HashableKeyPathConsumer { + internal private(set) var hasher: Hasher + + private let root: Root + + internal init(root: Root, hasher: Hasher) { + self.root = root + self.hasher = hasher + } + + @inlinable + internal mutating func addHashableKeyPath(_ keyPath: KeyPath) where KeyType: Hashable { + hasher.combine(root[keyPath: keyPath]) + } + + internal mutating func addCustomEquator(forKeyPath keyPath: KeyPath, equator: @escaping (KeyType, KeyType) -> Bool) where KeyType: Hashable { + // `KeyPathHasher` is never used for equality. + } +} diff --git a/Tests/HashableByKeyPathPerformanceTests/EquatableByKeyPathPerformanceTests.swift b/Tests/HashableByKeyPathPerformanceTests/EquatableByKeyPathPerformanceTests.swift new file mode 100644 index 0000000..0ecd55e --- /dev/null +++ b/Tests/HashableByKeyPathPerformanceTests/EquatableByKeyPathPerformanceTests.swift @@ -0,0 +1,52 @@ +import XCTest +import HashableByKeyPath + +final class EquatableByKeyPathPerformanceTests: XCTestCase { + func testEqualityPerformance() { + final class Foo: EquatableByKeyPath { + static func addEquatableKeyPaths(to consumer: inout Consumer) where Consumer.Root == Foo, Consumer: EquatableKeyPathConsumer { + consumer.addEquatableKeyPath(\.propertyA) + consumer.addEquatableKeyPath(\.propertyB) + consumer.addEquatableKeyPath(\.propertyC) + consumer.addEquatableKeyPath(\.propertyD) + consumer.addEquatableKeyPath(\.propertyE) + consumer.addEquatableKeyPath(\.propertyF) + consumer.addEquatableKeyPath(\.propertyG) + consumer.addEquatableKeyPath(\.child) + } + + let propertyA: String + let propertyB: String + let propertyC: String + let propertyD: String + let propertyE: String + let propertyF: String + var propertyG: String + let child: Foo? + + init(level: Int = 0) { + self.propertyA = "propertyA\(level)" + self.propertyB = "propertyB\(level)" + self.propertyC = "propertyC\(level)" + self.propertyD = "propertyD\(level)" + self.propertyE = "propertyE\(level)" + self.propertyF = "propertyF\(level)" + self.propertyG = "propertyG\(level)" + if level >= 5 { + child = nil + } else { + child = Foo(level: level + 1) + } + } + } + + let foo1 = Foo() + let foo2 = Foo() + + measure { + for _ in 0..<1000 { + _blackHole(foo1 == foo2) + } + } + } +} diff --git a/Tests/HashableByKeyPathPerformanceTests/HashableByKeyPathPerformanceTests.swift b/Tests/HashableByKeyPathPerformanceTests/HashableByKeyPathPerformanceTests.swift new file mode 100644 index 0000000..3dbedaa --- /dev/null +++ b/Tests/HashableByKeyPathPerformanceTests/HashableByKeyPathPerformanceTests.swift @@ -0,0 +1,99 @@ +import XCTest +import HashableByKeyPath + +final class HashableByKeyPathPerformanceTests: XCTestCase { + func testHashPerformance() { + final class Foo: HashableByKeyPath { + static func addHashableKeyPaths(to consumer: inout Consumer) where Consumer.Root == Foo, Consumer: HashableKeyPathConsumer { + consumer.addHashableKeyPath(\.propertyA) + consumer.addHashableKeyPath(\.propertyB) + consumer.addHashableKeyPath(\.propertyC) + consumer.addHashableKeyPath(\.propertyD) + consumer.addHashableKeyPath(\.propertyE) + consumer.addHashableKeyPath(\.propertyF) + consumer.addHashableKeyPath(\.propertyG) + consumer.addHashableKeyPath(\.child) + } + + let propertyA: String + let propertyB: String + let propertyC: String + let propertyD: String + let propertyE: String + let propertyF: String + let propertyG: String + private let child: Foo? + + init(level: Int = 0) { + self.propertyA = "propertyA\(level)" + self.propertyB = "propertyB\(level)" + self.propertyC = "propertyC\(level)" + self.propertyD = "propertyD\(level)" + self.propertyE = "propertyE\(level)" + self.propertyF = "propertyF\(level)" + self.propertyG = "propertyG\(level)" + if level >= 5 { + child = nil + } else { + child = Foo(level: level + 1) + } + } + } + + let foo1 = Foo() + + measure { + for _ in 0..<1000 { + _blackHole(foo1.hashValue) + } + } + } + + func testEqualityPerformance() { + final class Foo: HashableByKeyPath { + static func addHashableKeyPaths(to consumer: inout Consumer) where Consumer.Root == Foo, Consumer: HashableKeyPathConsumer { + consumer.addHashableKeyPath(\.propertyA) + consumer.addHashableKeyPath(\.propertyB) + consumer.addHashableKeyPath(\.propertyC) + consumer.addHashableKeyPath(\.propertyD) + consumer.addHashableKeyPath(\.propertyE) + consumer.addHashableKeyPath(\.propertyF) + consumer.addHashableKeyPath(\.propertyG) + consumer.addHashableKeyPath(\.child) + } + + let propertyA: String + let propertyB: String + let propertyC: String + let propertyD: String + let propertyE: String + let propertyF: String + var propertyG: String + let child: Foo? + + init(level: Int = 0) { + self.propertyA = "propertyA\(level)" + self.propertyB = "propertyB\(level)" + self.propertyC = "propertyC\(level)" + self.propertyD = "propertyD\(level)" + self.propertyE = "propertyE\(level)" + self.propertyF = "propertyF\(level)" + self.propertyG = "propertyG\(level)" + if level >= 5 { + child = nil + } else { + child = Foo(level: level + 1) + } + } + } + + let foo1 = Foo() + let foo2 = Foo() + + measure { + for _ in 0..<1000 { + _blackHole(foo1 == foo2) + } + } + } +} diff --git a/Tests/HashableByKeyPathPerformanceTests/_blackHole.swift b/Tests/HashableByKeyPathPerformanceTests/_blackHole.swift new file mode 100644 index 0000000..9f95050 --- /dev/null +++ b/Tests/HashableByKeyPathPerformanceTests/_blackHole.swift @@ -0,0 +1,9 @@ +/// An empty function used to prevent the compiler optimising away the value +/// passed in to it. +@inline(never) +func _blackHole(_ x: Int) {} + +/// An empty function used to prevent the compiler optimising away the value +/// passed in to it. +@inline(never) +func _blackHole(_ x: Bool) {}