4
\$\begingroup\$

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?

\$\endgroup\$
  • \$\begingroup\$ Why do you think you need this? It doesn't make much sense as floating point numbers cannot represent all decimals. So your rounded numbers will usually again have many more decimals. Better to keep them as is for your calculations and only round when converting to strings for display. \$\endgroup\$ – Sven Oct 7 '16 at 21:56
6
\$\begingroup\$

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
\$\endgroup\$
  • \$\begingroup\$ weird how the reduce is faster than the pow \$\endgroup\$ – Knight0fDragon Sep 30 '16 at 19:28
  • \$\begingroup\$ In the last function, if you write let divisor: Self = then you don't need any other casts to Self. Also, I'd prefer something like 10 as Self to Self(10), because the latter interprets 10 as an Int and then casts it to Self. \$\endgroup\$ – Tim Vermeulen Oct 2 '16 at 21:19
  • \$\begingroup\$ @TimVermeulen: That is correct, however let divisor: Self = ... might obfuscate the fact that floating point numbers are multiplied, not integers. (Which btw turned out to be faster than multiplying integers and converting the final result to Self). \$\endgroup\$ – Martin R Oct 2 '16 at 21:24
  • 1
    \$\begingroup\$ I chose integers because FloatingPoint can be instantiated from integers (and integer literals) but not from doubles (or double literals). But your feedback made me think about it again, and BinaryFloatingPoint can be instantiated from doubles. The updated "rounded4" method is again a bit faster! \$\endgroup\$ – Martin R Oct 3 '16 at 10:56
  • 1
    \$\begingroup\$ @TruMan1: That seems to be an consequence of how $0 is handled in Swift 4 in closures with multiple parameters, and the error message is misleading. – I have updated the code with a version which compiles with both Swift 3 and 4. \$\endgroup\$ – Martin R Sep 24 '17 at 14:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.