0

I was testing some simple solution for my app, and I ran into some case where question comes up in my head... "Why one floating number is represented in JSON correctly (as I expect) and other one not...?"

in this case conversion from String to Decimal and then to JSON of number: "98.39" is perfectly predictable from human point of view, but number: "98.40" doesn't look so beautiful...

And my question is, could someone explain please to me, why conversion from String to Decimal works as I expect for one floating number, but for another it is not.

I have red a lot about Floating Point number error, but I can't figure it out how the proces from String ->... binary based conversion stuff...-> to Double has different precision for both cases.


My playground code:

struct Price: Encodable {
    let amount: Decimal
}

func printJSON(from string: String) {
    let decimal = Decimal(string: string)!
    let price = Price(amount: decimal)

    //Encode Person Struct as Data
    let encodedData = try? JSONEncoder().encode(price)

    //Create JSON
    var json: Any?
    if let data = encodedData {
        json = try? JSONSerialization.jsonObject(with: data, options: [])
    }

    //Print JSON Object
    if let json = json {
        print("Person JSON:\n" + String(describing: json) + "\n")
    }
}

let stringPriceOK =     "98.39"
let stringPriceNotOK =  "98.40"
let stringPriceNotOK2 = "98.99"

printJSON(from: stringPriceOK)
printJSON(from: stringPriceNotOK)
printJSON(from: stringPriceNotOK2)
/*
 ------------------------------------------------
 // OUTPUT:
 Person JSON:
 {
 amount = "98.39";
 }

 Person JSON:
 {
 amount = "98.40000000000001";
 }

 Person JSON:
 {
 amount = "98.98999999999999";
 }
 ------------------------------------------------
 */

I was looking/trying to figure it out what steps has been performed by the logical unit to convert: "98.39" -> Decimal -> String - with result of "98.39" and with the same chain of conversion: "98.40" -> Decimal -> String - with result of "98.40000000000001"

Many thanks for all responses!

Robert
  • 3,790
  • 1
  • 27
  • 46
  • 2
    [Related](https://stackoverflow.com/q/588004/335858): this is not exactly a duplicate, but the concepts it explains are exactly what's creating "the mystery." – Sergey Kalinichenko Aug 29 '18 at 20:13
  • 3
    Learn some basics of floating-point arithmetic. [Oracle docs example](https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html) – kkiermasz Aug 29 '18 at 20:28
  • 1
    @kkiermasz: The basics of floating-point arithmetic do not reveal what is happening here. Both 98.39 and 98.40 are not exactly representable in binary floating-point. Both are rounded when converted from decimal to binary. Yet one is displayed with merely four digits and the other with 16. That is a result of some implementation choices about default formatting, not just the nature of floating-point. – Eric Postpischil Aug 29 '18 at 21:04
  • I hypothesize the JSON encoder formats floating-point numbers using an algorithm such as “Convert the number to decimal with 16 significant digits, then remove trailing zeros.” For 93.89, the nearest representable value is 98.3900000000000005684341886080801486968994140625. When this is converted to 16 decimal digits, the result is “98.39000000000000”. When trailing zeros are removed, the result is “93.39”. For 98.40, the nearest representable value is 98.400000000000005684341886080801486968994140625. Converting produces “98.40000000000001”, and there are no trailing zeros to remove. – Eric Postpischil Aug 29 '18 at 21:09
  • (When reading the above, note there are a different number of zeros in 98.3900000000000005684341886080801486968994140625 and 98.400000000000005684341886080801486968994140625 despite the identity of the trailing digits. In the latter number, the non-zeros start one position earlier.) – Eric Postpischil Aug 29 '18 at 21:11
  • Have a look [here](https://stackoverflow.com/a/52006266/2907715). It's a common question touching on the subject of binary trying to represent a number. – ielyamani Aug 29 '18 at 21:44
  • @EricPostpischil You're on the right track, but it's not the JSON encoder that formats with 16 digits. It's `-[NSNumber description]`, which gets involved when Robert calls `String(describing: json)`. – rob mayoff Aug 29 '18 at 22:17

3 Answers3

4

It seems that at some point, the JSON representation is storing the value as binary floating point.

In particular, the closest double (IEEE binary64) value to 98.40 will is 98.400000000000005684341886080801486968994140625, which, when rounded to 16 significant figures is 98.40000000000001.

Why 16 significant figures? That's a good question, since 16 significant digits aren't enough to uniquely identify all floating point values, e.g. 0.056183066649934776 and 0.05618306664993478 are the same to 16 significant digits, but correspond to distinct values. What's bizarre is that your code now prints

["amount": 0.056183066649934998]

for both, which is 17 significant figures, but actually a completely wrong value, off by 32 units in the last place. I have no idea what is going on there.

See https://www.exploringbinary.com/number-of-digits-required-for-round-trip-conversions/ for more details on the necessary number of digits for binary-decimal conversions.

Simon Byrne
  • 7,694
  • 1
  • 26
  • 50
4

This is purely an artifact of how an NSNumber prints itself.

JSONSerialization is implemented in Objective-C and uses Objective-C objects (NSDictionary, NSArray, NSString, NSNumber, etc.) to represent the values it deserializes from your JSON. Since the JSON contains a bare number with decimal point as the value for the "amount" key, JSONSerialization parses it as a double and wraps it in an NSNumber.

Each of these Objective-C classes implements a description method to print itself.

The object returned by JSONSerialization is an NSDictionary. String(describing:) converts the NSDictionary to a String by sending it the description method. NSDictionary implements description by sending description to each of its keys and values, including the NSNumber value for the "amount" key.

The NSNumber implementation of description formats a double value using the printf specifier %0.16g. (I checked using a disassembler.) About the g specifier, the C standard says

Finally, unless the # flag is used, any trailing zeros are removed from the fractional portion of the result and the decimal-point wide character is removed if there is no fractional portion remaining.

The closest double to 98.39 is exactly 98.3900 0000 0000 0005 6843 4188 6080 8014 8696 8994 1406 25. So %0.16g formats that as %0.14f (see the standard for why it's 14, not 16), which gives "98.39000000000000", then chops off the trailing zeros, giving "98.39".

The closest double to 98.40 is exactly 98.4000 0000 0000 0056 8434 1886 0808 0148 6968 9941 4062 5. So %0.16g formats that as %0.14f, which gives "98.40000000000001" (because of rounding), and there are no trailing zeros to chop off.

So that's why, when you print the result of JSONSerialization.jsonObject(with:options:), you get lots of fractional digits for 98.40 but only two digits for 98.39.

If you extract the amounts from the JSON object and convert them to Swift's native Double type, and then print those Doubles, you get much shorter output, because Double implements a smarter formatting algorithm that prints the shortest string that, when parsed, produces exactly the same Double.

Try this:

import Foundation

struct Price: Encodable {
    let amount: Decimal
}

func printJSON(from string: String) {
    let decimal = Decimal(string: string)!
    let price = Price(amount: decimal)

    let data = try! JSONEncoder().encode(price)
    let jsonString = String(data: data, encoding: .utf8)!
    let jso = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
    let nsNumber = jso["amount"] as! NSNumber
    let double = jso["amount"] as! Double

    print("""
    Original string: \(string)
        json: \(jsonString)
        jso: \(jso)
        amount as NSNumber: \(nsNumber)
        amount as Double: \(double)

    """)
}

printJSON(from: "98.39")
printJSON(from: "98.40")
printJSON(from: "98.99")

Result:

Original string: 98.39
    json: {"amount":98.39}
    jso: ["amount": 98.39]
    amount as NSNumber: 98.39
    amount as Double: 98.39

Original string: 98.40
    json: {"amount":98.4}
    jso: ["amount": 98.40000000000001]
    amount as NSNumber: 98.40000000000001
    amount as Double: 98.4

Original string: 98.99
    json: {"amount":98.99}
    jso: ["amount": 98.98999999999999]
    amount as NSNumber: 98.98999999999999
    amount as Double: 98.99

Notice that both the actual JSON (on the lines labeled json:) and the Swift Double versions use the fewest digits in all cases. The lines that use -[NSNumber description] (labeled jso: and amount as NSNumber:) use extra digits for some values.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
0
#include <stdio.h>
int main ( void )
{
    float f;
    double d;

    f=98.39F;
    d=98.39;

    printf("%f\n",f);
    printf("%lf\n",d);
    return(1);
}
98.389999
98.390000

its not really a mystery at all as Simon pointed out. its just how computers work you are using a base 2 machine to do base 10 stuff. Just like 1/3 is a very simple number but in base ten it is 0.3333333. forever, not accurate nor pretty but in base 3 it would be something like 0.1 nice and clean. base 10 numbers dont go well with base 2 1/10th for example.

float fun0 ( void )
{
    return(98.39F);
}
double fun1 ( void )
{
    return(98.39);
}
00000000 <fun0>:
   0:   e59f0000    ldr r0, [pc]    ; 8 <fun0+0x8>
   4:   e12fff1e    bx  lr
   8:   42c4c7ae    sbcmi   ip, r4, #45613056   ; 0x2b80000

0000000c <fun1>:
   c:   e59f0004    ldr r0, [pc, #4]    ; 18 <fun1+0xc>
  10:   e59f1004    ldr r1, [pc, #4]    ; 1c <fun1+0x10>
  14:   e12fff1e    bx  lr
  18:   c28f5c29    addgt   r5, pc, #10496  ; 0x2900
  1c:   405898f5    ldrshmi r9, [r8], #-133 ; 0xffffff7b

42c4c7ae  single
405898f5c28f5c29  double

0 10000101 10001001100011110101110
0 10000000101 1000100110001111010111000010100011110101110000101001

10001001100011110101110
1000100110001111010111000010100011110101110000101001

just looking at the mantissas between them clearly this is not going to resolve to an exact number, so then rounding and formatted printing with more rounding comes into play...

old_timer
  • 69,149
  • 8
  • 89
  • 168