KISS

Keep It Simple Stupid

A hack to compare doubles without epsilon in swift

| comments

Some time ago I worked on a proof-of-concept which used a type-safe units conversion library in swift (that had been before Foundation introduced their own units support). The library had measurements composed of a Double amount and a unit, e.g. “1.5 meters”. To compare measurements in tests with XCTAssertEqual, it’s necessary to conform to Equatable and implement the == function. But how do you specify the accuracy to compare doubles in this case?

Warning: this is a quick and dirty hack that shouldn’t really be used in production. Try to come up with a better design for your code to avoid it if possible.

My test looked something like this:

1
2
3
4
5
6
7
8
9
func testAddingDistanceMeasurementsShouldProduceValidResult() {
    let m0 = Measurement(200.5, Distance.Centimeter.self)
    let m1 = Measurement(500, Distance.Inch.self)

    let expected = Measurement(14.705, Distance.Meter.self)
    let actual = m0 + m1

    XCTAssertEqual(actual, expected)
}

Looks reasonable, doesn’t it? The expected result is in meters because it’s the base distance type. However this test fails:

1
Tests.swift:42: error: -[Tests testAddingDistanceMeasurementsShouldProduceValidResult] : XCTAssertEqual failed: ("Measurement(14.704999999999998, Distance.Meter)") is not equal to ("Measurement(14.705, Distance.Meter)")

Here’s the conversion in lldb where we can see that the values aren’t represented exactly:

1
2
3
4
5
6
7
8
9
10
11
// 200.5 centimeters = 2.005 meters
(lldb) p 200.5/100
(Double) $R0 = 2.0049999999999999

// 500 inches = 12.7 meters
(lldb) p 500*0.0254
(Double) $R2 = 12.699999999999999

// the sum should be 14.705 meters
(lldb) p $R0 + $R2
(Double) $R4 = 14.704999999999998

The problem here is that == provided by Equatable accepts only the two parameters to compare, we can’t add another one for accuracy. So I came up with this quick hack to compare floating-point numbers without an explicit accuracy threshold (epsilon):

1
2
3
4
5
6
7
8
9
10
11
func == <T: Measurement> (lhs: T, rhs: T) -> Bool {
    let equalValues: (Double, Double) -> Bool = { x, y in
        x == y
            || x + x.ulp == y
            || x - x.ulp == y
    }

    let sameUnits = 
    return sameUnits
        && equalValues(lhs.amount, rhs.amount)
}

The trick here is that one ulp of a Double defines the distance to the next representable Double value, so we can compare the left and right values directly or compare one previous and one next left value with the right value. The above test now passes!

Again, this is a dirty hack which may not work in your situation. I recommend this article for many more details about floating-point numbers in swift: https://www.jessesquires.com/blog/floating-point-swift-ulp-and-epsilon/.

Comments