views:

1193

answers:

3

Because the float data type in PHP is inaccurate, and a FLOAT in MySQL takes up more space than an INT (and is inaccurate), I always store prices as INTs, multipling by 100 before storing to ensure we have exactly 2 decimal places of precision. However I believe PHP is misbehaving. Example code:

echo "<pre>";

$price = "1.15";
echo "Price = ";
var_dump($price);

$price_corrected = $price*100;
echo "Corrected price = ";
var_dump($price_corrected);


$price_int = intval(floor($price_corrected));
echo "Integer price = ";
var_dump($price_int);

echo "</pre>";

Produced output:

Price = string(4) "1.15"
Corrected price = float(115)
Integer price = int(114)

I was surprised. When the final result was lower than expected by 1, I was expecting the output of my test to look more like:

Price = string(4) "1.15"
Corrected price = float(114.999999999)
Integer price = int(114)

which would demonstrate the inaccuracy of the float type. But why is floor(115) returning 114??

+6  A: 

Try this as a quick fix:

$price_int = intval(floor($price_corrected + 0.5));

The problem you are experiencing is not PHP's fault, all programming languages using real numbers with floating point arithmetics have similar issues.

The general rule of thumb for monetary calculations is to never use floats (neither in the database nor in your script). You can avoid all kinds of problems by always storing the cents instead of dollars. The cents are integers, and you can freely add them together, and multiply by other integers. Whenever you display the number, make sure you insert a dot in front of the last two digits.

The reason why you are getting 114 instead of 115 is that floor rounds down, towards the nearest integer, thus floor(114.999999999) becomes 114. The more interesting question is why 1.15 * 100 is 114.999999999 instead of 115. The reason for that is that 1.15 is not exactly 115/100, but it is a very little less, so if you multiply by 100, you get a number a tiny bit smaller than 115.

Here is a more detailed explanation what echo 1.15 * 100; does:

  • It parses 1.15 to a binary floating point number. This involves rounding, it happens to round down a little bit to get the binary floating point number nearest to 1.15. The reason why you cannot get an exact number (without rounding error) is that 1.15 has infinite number of numerals in base 2.
  • It parses 100 to a binary floating point number. This involves rounding, but since 100 is a small integer, the rounding error is zero.
  • It computes the product of the previous two numbers. This also involves a little rounding, to find the nearest binary floating point number. The rounding error happens to be zero in this operation.
  • It converts the binary floating point number to a base 10 decimal number with a dot, and prints this representation. This also involves a little rounding.

The reason why PHP prints the surprising Corrected price = float(115) (instead of 114.999...) is that var_dump doesn't print the exact number (!), but it prints the number rounded to n - 2 (or n - 1) digits, where n digits is the precision of the calculation. You can easily verify this:

echo 1.15 * 100;  # this prints 115
printf("%.30f", 1.15 * 100);  # you 114.999....
echo 1.15 * 100 == 115.0 ? "same" : "different";  # this prints `different'
echo 1.15 * 100 < 115.0 ? "less" : "not-less";    # this prints `less'

If you are printing floats, remember: you don't always see all digits when you print the float.

See also the big warning near the beginning of the PHP float docs.

pts
$price_int = intval(floor($price_corrected + 0.5)) is the same as $price_int = intval(round($price_corrected)).
chaos
@pts, chaos is correct and actually my original code was like that. No difference. My question is, why does PHP *say* $price_converted is 115 when it's really 114.999999? (I do understand why it's 114.99999...)
Josh
@pts, I was wrong when I stated chaos was correct, I misread your code. That may work.
Josh
Ok, this does work. But which would be better, +0.5 or use of round()? What are the implications of using round()
Josh
Use round(), because it is self-contained and cleaner. The +0.5 may introduce more rounding.
pts
The reason why PHP prints `Corrected price = float(115)` is that var_dump doesn't print the exact number (!), but it prints the number rounded to n - 2 (or n - 1) digits, where n digits is the precision of the calculation.
pts
+2  A: 

PHP is doing rounding based on significant digits. It's hiding the inaccuracy (on line 2). Of course, when floor comes along, it doesn't know any better and lops it all the way down.

altCognito
@altCognito, do you know why it hides the inaccuracy on line 2? I think that's really what my question is.
Josh
I believe it has something to do with normalization, and the answer is in here: http://en.wikipedia.org/wiki/Floating_point#Multiplication :|
altCognito
+4  A: 

The other answers have covered the cause and a good workaround to the problem, I believe.

To aim at fixing the problem from a different angle:

For storing price values in MySQL, you should probably look at the DECIMAL type, which lets you store exact values with decimal places.

Chad Birch
+1, but I would add that I'm not sure how "inaccurate" a FLOAT type in mySQL is. It's a lot less inaccurate than performing any extra multiplication mojo, and leaves your data in a much better state for analysis. Anyway, what he said.
altCognito
@Chad Birch, doesn't INT use much less space than DECIMAL? I have always found that INT + logic to know how many decimal places to offset by to be more efficient than DECIMAL. Especially for things like prices. Effectively, the column is price_in_cents
Josh
No, not unless your DECIMAL has a lot of digits. According to the manual, INT uses 4 bytes (assuming you're using an actual INT and not a SMALLINT or anything), DECIMAL storage varies: "Each multiple of nine digits requires four bytes, and the 'leftover' digits require some fraction of four bytes". Full information: http://dev.mysql.com/doc/refman/5.1/en/storage-requirements.html
Chad Birch
That's very helpful. For some reason I thought DECIMAL was really just a CHAR() column... maybe that was in older versions of MySQL? Maybe I was just crazy... :-)
Josh