HiveBrain v1.2.0
Get Started
← Back to all entries
patternswiftMinor

Swift FloatingPoint rounded to places

Submitted by: @import:stackexchange-codereview··
0
Viewed 0 times
swiftroundedfloatingpointplaces

Problem

I'm playing a bit with extensions for default protocols in Swift 3.0.

I would like to implement rounded(toPlaces places: Int) -> Self for Double, Float.

Both of these structs refer to FloatingType protocol.
I wrote small extension for FloatingType protocol:

extension FloatingPoint {

    typealias Exponent = Int // is this okay? how can I write where `Exponent: Int` ?

    public func rounded(toPlaces places: Int) -> Self {
        guard places >= 0 else { return self }
        let divisor = Self(Int(pow(10.0, Double(places))))
        //let divisor = Self(sign: .plus, exponent: places, significand: Self(Int(pow(5, Double(places)))))
        return (self * divisor).rounded() / divisor
    }
}


However I can't find the most easiest and fastest method. Self(Int(pow(5, Double(places)))) so much conversions... Ugh...

The problem is that there is no pow for floating point type, so I need to do lots of conversions.

At 1m calls it loses about 0.04s for the Double extension, which doesn't do Self(Int(...)). Is it okay for such method or there is another approach?

Solution

First of all one should be aware that most decimal fractions cannot
be represented exactly by a binary floating point number. For example,

let y = Double.pi.rounded(toPlaces: 3)


does not assign the number 3.142 to y but the closest number
which is representable in the IEEE 754 format for double precision
floating point numbers, which happens to be

3.141999999999999904076730672386474907398223876953125


If the purpose of rounding the number is to present a result to the
user up to a certain precision, then it is better to use a number
formatter instead, for example

print(String(format: "%.3f", Double.pi)) // 3.142


or NumberFormatter which is very versatile and can do the conversion
according to the user's locale settings.

In other cases (like working with monetary values) it may be more
appropriate to work with integers only (or DecimalNumber) to avoid all rounding issues.

Having said that, here are some remarks and possible improvements
of your method.

I have named different implementations rounded1, rounded2, ... so that all can be tested in the same
program.

I am not sure what the effect of

typealias Exponent = Int


in the protocol extension is, but the proper way to restrict an extension
is a where clause:

extension FloatingPoint where Exponent == Int {

    public func rounded1(toPlaces places: Int) -> Self {
        guard places >= 0 else { return self }
        let divisor = Self(sign: .plus, exponent: places, significand: Self(Int(pow(5, Double(places)))))
        return (self * divisor).rounded() / divisor
    }
}


Of course you don't need a restriction for your other variant:

extension FloatingPoint {

    public func rounded2(toPlaces places: Int) -> Self {
        guard places >= 0 else { return self }
        let divisor = Self(Int(pow(10.0, Double(places))))
        return (self * divisor).rounded() / divisor
    }
}


The first one looks a bit obfuscated, and relies on the representation
of the numbers as a binary mantissa with an exponent.
I would use it only if it gives a clear performance advantage.

So let's benchmark it! I used this simple
test code which rounds 10,000,000 numbers in the range 0.0 .. 10.0
to 1 .. 8 decimal digits:

let start = Date()
var sum = 0.0
for i in 1 ... 10_000_000 {
    let x = Double(i)/1_000_000.0
    for p in 1 ... 8 {
        let y = x.rounded(toPlaces: p)
        sum += y
    }
}
let end = Date()
let time = end.timeIntervalSince(start)
print(sum, time)


The results are added to prevent the compiler from removing function
calls whose result is unused, and to verify that all methods give
exactly the same results.

On a 3,5 GHz iMac, with the code compiled in Release mode, I got
the following timings:

rounded1: 2.66 sec
rounded2: 2.52 sec

so the "obfuscated" method is actually a tiny bit slower.

But we can improve the performance. As you said, there is a lot of type conversions in

let divisor = Self(Int(pow(10.0, Double(places))))


This can be improved a bit if we restrict the extension method to
the BinaryFloatingPoint protocol instead. This is not a severe
restriction, because all current floating point types (Float, Double,
CGFloat) conform to BinaryFloatingPoint. The advantage is that
a value can be initialized from a Double:

extension BinaryFloatingPoint {

    public func rounded3(toPlaces places: Int) -> Self {
        guard places >= 0 else { return self }
        let divisor = Self(pow(10.0, Double(places)))
        return (self * divisor).rounded() / divisor
    }
}


That makes it a bit faster:

rounded1: 2.34 sec

The bottleneck seems to be the pow function. Since places
will usually be a small integer, it might be worth to use
iterated multiplication instead:

extension BinaryFloatingPoint {
    public func rounded4(toPlaces places: Int) -> Self {
        guard places >= 0 else { return self }
        let divisor = Self((0..<places).reduce(1.0) { (accum, _) in 10.0 * accum })
        return (self * divisor).rounded() / divisor
    }
}


Here Double values are multiplied and the result converted to
the type of Self. This turned out to be faster than multiplying
integers or instances of Self.

This is more than 4x faster compared to rounded3 and needs only
a single type conversion:

rounded4: 0.49 sec

Code Snippets

let y = Double.pi.rounded(toPlaces: 3)
3.141999999999999904076730672386474907398223876953125
print(String(format: "%.3f", Double.pi)) // 3.142
typealias Exponent = Int
extension FloatingPoint where Exponent == Int {

    public func rounded1(toPlaces places: Int) -> Self {
        guard places >= 0 else { return self }
        let divisor = Self(sign: .plus, exponent: places, significand: Self(Int(pow(5, Double(places)))))
        return (self * divisor).rounded() / divisor
    }
}

Context

StackExchange Code Review Q#142748, answer score: 6

Revisions (0)

No revisions yet.