Skip to content

Commit 8d18d1a

Browse files
committed
[test] checkHashable: Check that unequal values produce different hashes
This is safe to do with hash(into:), because random hash collisions can be eliminated with awesome certainty by trying a number of different hash seeds. (Unless there is a weakness in SipHash.) In some cases, we intentionally want hashing to produce looser equivalency classes than equality — to let those cases keep working, add an optional hashEqualityOracle parameter. Review usages of checkHashable and add hash oracles as needed.
1 parent d55b14b commit 8d18d1a

File tree

2 files changed

+152
-21
lines changed

2 files changed

+152
-21
lines changed

stdlib/private/StdlibUnittest/StdlibUnittest.swift

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2397,24 +2397,55 @@ public func checkEquatable<T : Equatable>(
23972397
checkEquatable(
23982398
[lhs, rhs],
23992399
oracle: { expectedEqual || $0 == $1 }, message(),
2400-
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line), showFrame: false)
2400+
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line),
2401+
showFrame: false)
2402+
}
2403+
2404+
internal func hash<H: Hashable>(_ value: H, seed: Int? = nil) -> Int {
2405+
var hasher = _Hasher()
2406+
if let seed = seed {
2407+
hasher.combine(seed)
2408+
}
2409+
hasher.combine(value)
2410+
return hasher.finalize()
2411+
}
2412+
2413+
/// Test that the elements of `instances` satisfy the semantic requirements of
2414+
/// `Hashable`, using `equalityOracle` to generate equality and hashing
2415+
/// expectations from pairs of positions in `instances`.
2416+
public func checkHashable<Instances: Collection>(
2417+
_ instances: Instances,
2418+
equalityOracle: (Instances.Index, Instances.Index) -> Bool,
2419+
allowBrokenTransitivity: Bool = false,
2420+
_ message: @autoclosure () -> String = "",
2421+
stackTrace: SourceLocStack = SourceLocStack(),
2422+
showFrame: Bool = true,
2423+
file: String = #file, line: UInt = #line
2424+
) where Instances.Iterator.Element: Hashable {
2425+
checkHashable(
2426+
instances,
2427+
equalityOracle: equalityOracle,
2428+
hashEqualityOracle: equalityOracle,
2429+
allowBrokenTransitivity: allowBrokenTransitivity,
2430+
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line),
2431+
showFrame: false)
24012432
}
24022433

24032434
/// Test that the elements of `instances` satisfy the semantic
24042435
/// requirements of `Hashable`, using `equalityOracle` to generate
2405-
/// equality expectations from pairs of positions in `instances`.
2406-
public func checkHashable<Instances : Collection>(
2436+
/// equality expectations from pairs of positions in `instances`,
2437+
/// and `hashEqualityOracle` to do the same for hashing.
2438+
public func checkHashable<Instances: Collection>(
24072439
_ instances: Instances,
24082440
equalityOracle: (Instances.Index, Instances.Index) -> Bool,
2441+
hashEqualityOracle: (Instances.Index, Instances.Index) -> Bool,
24092442
allowBrokenTransitivity: Bool = false,
2410-
24112443
_ message: @autoclosure () -> String = "",
24122444
stackTrace: SourceLocStack = SourceLocStack(),
24132445
showFrame: Bool = true,
24142446
file: String = #file, line: UInt = #line
24152447
) where
24162448
Instances.Iterator.Element : Hashable {
2417-
24182449
checkEquatable(
24192450
instances,
24202451
oracle: equalityOracle,
@@ -2426,11 +2457,51 @@ public func checkHashable<Instances : Collection>(
24262457
let x = instances[i]
24272458
for j in instances.indices {
24282459
let y = instances[j]
2460+
let predicted = hashEqualityOracle(i, j)
2461+
expectEqual(
2462+
predicted, hashEqualityOracle(j, i),
2463+
"bad hash oracle: broken symmetry between indices \(i), \(j)",
2464+
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line))
24292465
if x == y {
2466+
expectTrue(
2467+
predicted,
2468+
"""
2469+
bad hash oracle: equality must imply hash equality
2470+
lhs (at index \(i)): \(x)
2471+
rhs (at index \(j)): \(y)
2472+
""",
2473+
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line))
2474+
}
2475+
if predicted {
2476+
expectEqual(
2477+
hash(x), hash(y),
2478+
"""
2479+
hash(into:) expected to match, found to differ
2480+
lhs (at index \(i)): \(x)
2481+
rhs (at index \(j)): \(y)
2482+
""",
2483+
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line))
24302484
expectEqual(
24312485
x.hashValue, y.hashValue,
2432-
"lhs (at index \(i)): \(x)\nrhs (at index \(j)): \(y)",
2433-
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line))
2486+
"""
2487+
hashValue expected to match, found to differ
2488+
lhs (at index \(i)): \(x)
2489+
rhs (at index \(j)): \(y)
2490+
""",
2491+
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line))
2492+
} else {
2493+
// Try a few different seeds; at least one of them should discriminate
2494+
// between the hashes. It is extremely unlikely this check will fail
2495+
// all ten attempts, unless the type's hash encoding is not unique,
2496+
// or unless the hash equality oracle is wrong.
2497+
expectTrue(
2498+
(0..<10).contains { hash(x, seed: $0) != hash(y, seed: $0) },
2499+
"""
2500+
_hash(into:) expected to differ, found to match
2501+
lhs (at index \(i)): \(x)
2502+
rhs (at index \(j)): \(y)
2503+
""",
2504+
stackTrace: stackTrace.pushIf(showFrame, file: file, line: line))
24342505
}
24352506
}
24362507
}

validation-test/stdlib/AnyHashable.swift.gyb

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,13 @@ AnyHashableTests.test("AnyHashable(mixed minimal hashables)/Hashable") {
175175
% end
176176
% end
177177

178-
checkHashable(xs, equalityOracle: { $0 / 2 == $1 / 2 })
178+
checkHashable(
179+
xs,
180+
equalityOracle: { $0 / 2 == $1 / 2 },
181+
// FIXME: Types that hash the same way will produce hash collisions when
182+
// converted to AnyHashable. Arguably, the type id should be used as a hash
183+
// discriminator.
184+
hashEqualityOracle: { $0 / 2 % 6 == $1 / 2 % 6 })
179185
}
180186

181187
% for (kw, name) in [
@@ -575,18 +581,27 @@ let interestingBitVectorArrays: [[UInt8]] = [
575581
AnyHashableTests.test("AnyHashable(CFBitVector)/Hashable, .base") {
576582
let bitVectors: [CFBitVector] =
577583
interestingBitVectorArrays.map(CFBitVector.makeImmutable)
584+
let hashEqualityOracle: (Int, Int) -> Bool = {
585+
// CFBitVector returns its count as the hash.
586+
interestingBitVectorArrays[$0].count == interestingBitVectorArrays[$1].count
587+
}
578588
let arrays = bitVectors.map { $0.asArray }
579589
func isEq(_ lhs: [[UInt8]], _ rhs: [[UInt8]]) -> Bool {
580590
return zip(lhs, rhs).map { $0 == $1 }.reduce(true, { $0 && $1 })
581591
}
582592
expectEqualTest(interestingBitVectorArrays, arrays, sameValue: isEq)
583-
checkHashable(bitVectors, equalityOracle: { $0 == $1 })
593+
checkHashable(
594+
bitVectors,
595+
equalityOracle: { $0 == $1 },
596+
hashEqualityOracle: hashEqualityOracle)
584597

585598
do {
586599
expectEqual(.foreignClass, SwiftRuntime.metadataKind(of: bitVectors.first!))
587600

588601
let anyHashables = bitVectors.map(AnyHashable.init)
589-
checkHashable(anyHashables, equalityOracle: { $0 == $1 })
602+
checkHashable(anyHashables,
603+
equalityOracle: { $0 == $1 },
604+
hashEqualityOracle: hashEqualityOracle)
590605

591606
let v = anyHashables.first!.base
592607
expectTrue(type(of: v) is CFBitVector.Type)
@@ -600,7 +615,9 @@ AnyHashableTests.test("AnyHashable(CFBitVector)/Hashable, .base") {
600615
SwiftRuntime.metadataKind(of: bitVectorsAsAnyObjects.first!))
601616

602617
let anyHashables = bitVectorsAsAnyObjects.map(AnyHashable.init)
603-
checkHashable(anyHashables, equalityOracle: { $0 == $1 })
618+
checkHashable(anyHashables,
619+
equalityOracle: { $0 == $1 },
620+
hashEqualityOracle: hashEqualityOracle)
604621

605622
let v = anyHashables.first!.base
606623
expectTrue(type(of: v) is CFBitVector.Type)
@@ -612,20 +629,30 @@ AnyHashableTests.test("AnyHashable(CFMutableBitVector)/Hashable, .base") {
612629
// CFBitVector.
613630
let bitVectors: [CFMutableBitVector] =
614631
interestingBitVectorArrays.map(CFMutableBitVector.makeMutable)
632+
let hashEqualityOracle: (Int, Int) -> Bool = {
633+
// CFBitVector returns its count as the hash.
634+
interestingBitVectorArrays[$0].count == interestingBitVectorArrays[$1].count
635+
}
615636
let arrays = bitVectors.map { $0.asArray }
616637
func isEq(_ lhs: [[UInt8]], _ rhs: [[UInt8]]) -> Bool {
617638
return zip(lhs, rhs).map { $0 == $1 }.reduce(true, { $0 && $1 })
618639
}
619640
expectEqualTest(interestingBitVectorArrays, arrays, sameValue: isEq)
620-
checkHashable(bitVectors, equalityOracle: { $0 == $1 })
641+
checkHashable(
642+
bitVectors,
643+
equalityOracle: { $0 == $1 },
644+
hashEqualityOracle: hashEqualityOracle)
621645

622646
do {
623647
expectEqual(
624648
.foreignClass,
625649
SwiftRuntime.metadataKind(of: bitVectors.first!))
626650

627651
let anyHashables = bitVectors.map(AnyHashable.init)
628-
checkHashable(anyHashables, equalityOracle: { $0 == $1 })
652+
checkHashable(
653+
anyHashables,
654+
equalityOracle: { $0 == $1 },
655+
hashEqualityOracle: hashEqualityOracle)
629656

630657
let v = anyHashables.first!.base
631658
expectTrue(type(of: v) is CFMutableBitVector.Type)
@@ -634,14 +661,20 @@ AnyHashableTests.test("AnyHashable(CFMutableBitVector)/Hashable, .base") {
634661
let bitVectorsAsAnyObjects: [NSObject] = bitVectors.map {
635662
($0 as AnyObject) as! NSObject
636663
}
637-
checkHashable(bitVectorsAsAnyObjects, equalityOracle: { $0 == $1 })
664+
checkHashable(
665+
bitVectorsAsAnyObjects,
666+
equalityOracle: { $0 == $1 },
667+
hashEqualityOracle: hashEqualityOracle)
638668

639669
expectEqual(
640670
.objCClassWrapper,
641671
SwiftRuntime.metadataKind(of: bitVectorsAsAnyObjects.first!))
642672

643673
let anyHashables = bitVectorsAsAnyObjects.map(AnyHashable.init)
644-
checkHashable(anyHashables, equalityOracle: { $0 == $1 })
674+
checkHashable(
675+
anyHashables,
676+
equalityOracle: { $0 == $1 },
677+
hashEqualityOracle: hashEqualityOracle)
645678

646679
let v = anyHashables.first!.base
647680
expectTrue(type(of: v) is CFMutableBitVector.Type)
@@ -717,10 +750,14 @@ AnyHashableTests.test("AnyHashable(MinimalHashableRCSwiftError)/Hashable") {
717750
.caseC(LifetimeTracked(2)), .caseC(LifetimeTracked(2)),
718751
]
719752
expectEqual(.enum, SwiftRuntime.metadataKind(of: xs.first!))
720-
checkHashable(xs, equalityOracle: { $0 / 2 == $1 / 2 })
753+
checkHashable(
754+
xs,
755+
equalityOracle: { $0 / 2 == $1 / 2 },
756+
hashEqualityOracle: { $0 / 4 == $1 / 4 })
721757
checkHashable(
722758
xs.map(AnyHashable.init),
723-
equalityOracle: { $0 / 2 == $1 / 2 })
759+
equalityOracle: { $0 / 2 == $1 / 2 },
760+
hashEqualityOracle: { $0 / 4 == $1 / 4 })
724761
}
725762

726763
AnyHashableTests.test("AnyHashable(MinimalHashableRCSwiftError).base") {
@@ -763,8 +800,16 @@ AnyHashableTests.test("AnyHashable(Wrappers)/Hashable") {
763800
return lhs == rhs
764801
}
765802

766-
checkHashable(values, equalityOracle: equalityOracle,
767-
allowBrokenTransitivity: true)
803+
func hashEqualityOracle(_ lhs: Int, _ rhs: Int) -> Bool {
804+
// Elements in [0, 3] hash the same, as do elements in [4, 7].
805+
return lhs / 4 == rhs / 4
806+
}
807+
808+
checkHashable(
809+
values,
810+
equalityOracle: equalityOracle,
811+
hashEqualityOracle: hashEqualityOracle,
812+
allowBrokenTransitivity: true)
768813
}
769814

770815
AnyHashableTests.test("AnyHashable(Set)/Hashable") {
@@ -789,8 +834,10 @@ AnyHashableTests.test("AnyHashable(Set)/Hashable") {
789834
}
790835
}
791836

792-
checkHashable(values, equalityOracle: equalityOracle,
793-
allowBrokenTransitivity: true)
837+
checkHashable(
838+
values,
839+
equalityOracle: equalityOracle,
840+
allowBrokenTransitivity: true)
794841
}
795842

796843
AnyHashableTests.test("AnyHashable(Array)/Hashable") {
@@ -902,6 +949,12 @@ AnyHashableTests.test("AnyHashable(_SwiftNativeNSError(MinimalHashableRCSwiftErr
902949
.caseC(LifetimeTracked(1)), .caseC(LifetimeTracked(1)),
903950
.caseC(LifetimeTracked(2)), .caseC(LifetimeTracked(2)),
904951
]
952+
checkHashable(
953+
swiftErrors,
954+
equalityOracle: { $0 / 2 == $1 / 2 },
955+
hashEqualityOracle: { $0 / 4 == $1 / 4 },
956+
allowBrokenTransitivity: false)
957+
905958
let nsErrors: [NSError] = swiftErrors.flatMap {
906959
swiftError -> [NSError] in
907960
let bridgedNSError = swiftError as NSError
@@ -938,16 +991,23 @@ AnyHashableTests.test("AnyHashable(_SwiftNativeNSError(MinimalHashableRCSwiftErr
938991
return false
939992
}
940993

994+
func hashEqualityOracle(_ lhs: Int, _ rhs: Int) -> Bool {
995+
// Every NSError that has the same domain and code hash the same way.
996+
return lhs / 8 == rhs / 8
997+
}
998+
941999
// FIXME: transitivity is broken because pure `NSError`s can compare equal to
9421000
// Swift errors with payloads just based on the domain and code, and Swift
9431001
// errors with payloads don't compare equal when payloads differ.
9441002
checkHashable(
9451003
nsErrors,
9461004
equalityOracle: equalityOracle,
1005+
hashEqualityOracle: hashEqualityOracle,
9471006
allowBrokenTransitivity: true)
9481007
checkHashable(
9491008
nsErrors.map(AnyHashable.init),
9501009
equalityOracle: equalityOracle,
1010+
hashEqualityOracle: hashEqualityOracle,
9511011
allowBrokenTransitivity: true)
9521012

9531013
// FIXME(id-as-any): run `checkHashable` on an array of mixed

0 commit comments

Comments
 (0)