This post is about float values, some basic computer science, and the PHP-specific handling of floats. It’s geared for the beginner, but please read on even if you feel you’re above it, as I tried to include some interesting bits at the end.
As a programmer, I have read through more fat books than I care to think about. That doesn’t mean I’ve retained it – quite the opposite. I’ve probably forgotten at least ten facts for any one I remember. That said, there are times when I’m working on something that I have that “Oh yeahhhh… a-Dur! *self thwap*” moment when something I should know pops up and says hello. Recently, I had a moment like that when I was (foolishly) trying to check a float value for equality in PHP.
Allow me to back the truck up for a moment. In case you don’t know, float values are numbers with a decimal component, e.g. 1.234 . In PHP, you end up with a float any time you explicitly create a decimal, like so:
$x = 2.234;
Or, for example, if you divide two integers, like this:
$x = 2 / 3;
You can end up with a float value many other ways, but you get the idea. The trouble with floats is this: the value of a float is always an estimate!. That might not seem like a surprise to you when considering the decimal representation of, say, 2/3. However, it can be annoying if you (as I did) stop thinking of floats as what they are for a moment in the context of what you’re doing.
Before I babble any further, here’s a stripped down code example, illustrating the basic problem I encountered:
1 2 3 4 5 6 7 8 9 10 11 12 | <?php $x = .6 + .3 + .1; var_dump($x); // value of $x shows as "float(1)" if($x == 1) { echo "$x = 1. All is right in the world\n"; } else { echo "$x != 1? The hell you say?!?\n"; } ?> |
Computers don’t store numbers using base 10. That is, a computer doesn’t think of .6 as six tenths. It tries to store that fraction as best it can using what essentially boils down to ones and zeroes. In that conversion, rounding occurs. It is similar to when we have to represent a fraction like 2/3 as a decimal. Without “cheating” and putting a line over the repeating digit, the best we can do is something like 0.66666667, where the final 7 is the point at which we got tired of writing sixes, gave up, and wrote a 7. We round up, figure that’s close enough, and move on with our lives. The computer does the same thing, but some times it’s not as obvious.
In the above script, when the value of $x is dumped to the screen, it is shown as “float(1)”. That really only adds to the confusion, because it’s clearly not equal to 1. So what is $x? The var_dump function is actually rounding off the value. However, in PHP, you can output the value of a float with forced precision using the printf function, using some formatting directives. Here’s an example:
printf("%.30f", $x);
The string “%.30f” is what’s called a *conversion specifier* that tells the computer to represent the argument (the $x) as a float with a precision of 30 decimal places. See the documentation for more information. When we add that to the example script and run it, things become a little easier to understand:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?php $x = .6 + .3 + .1; echo "var_dump of \$x: \n"; var_dump($x); // value of $x shows as "float(1)" echo "\n more precise version of \$x:"; printf("%.30f\n\n", $x); // value of $x shows as 0.999999999999999822364316059975 if($x == 1) { echo "$x = 1. All is right in the world\n"; } else { echo "$x != 1? The hell you say?!?\n"; } ?> |
Here’s the final revised code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?php $x = .6 + .3 + .1; echo "var_dump of \$x: \n"; var_dump($x); // value of $x shows as "float(1)" echo "\n more precise version of \$x:\n"; printf("%.30f\n\n", $x); // value of $x shows as 0.999999999999999822364316059975 if(round($x, 5) == 1) { echo "$x = 1. All is right in the world\n"; } else { echo "$x != 1? The hell you say?!?\n"; } ?> |
That does it for the basic bit, and ends the “beginner” part of this post. For the truly masochistic: Here’s a description of the scenario:
I was working on something that involved percentage weights for a list of values. Basically, each value had a weight reflecting its relevance that was factored in when all of the values were added. The sum of the weights was 100%, and were 60%, 30%, and 10% for the 3 values. In essence, here’s how it played out:
$adjusted_value = $value1 * .6 + $value2 * .3 + $value3 * .1;
So far so good. However, I also needed to account for the possibility that all three values might not be present. For the requirements of the problem (and I’m keeping the example much more simplified than the actual code involved), I needed to ’scale’ the adjusted_value by the total weights of the values that were present. For example, if only the first two values were present, the total weight would be 60% + 30% = 90%, so I would have to divide my adjusted_value by the total weight, or:
$total_weight = .6 + .3;
$adjusted_value = $adjusted_value / $total_weight;
In the code, I had a check for each value being included, and if it existed (was not null), added the weight to the $total_weight. At this point, everything was groovy. The problem I encountered was when I was checking the value of the $total_weight, and firing code based on whether or not it totaled 100%. What I wanted the code to do at that point is immaterial to the bug, what’s important is that the code was never being fired. Here’s a stripped down version of the code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php $weights = array(.6, .3, .1); $values = array(75, 62, 80); $count = 0; $total_weight = 0; $adjusted_value = 0; foreach ($values as $v) { if (isset($v)) { $adjusted_value += $v * $weights[$count]; $total_weight += $weights[$count]; } $count++; } if ($total_weight == 0) { // covers case to avoid divide by zero $adjusted_value = 0; } elseif($total_weight != 1) { // error: this *always* gets fired! $adjusted_value /= $total_weight; } else { // code that never gets fired, even at 100% } ?> |
} elseif($total_weight != 1) {
Changing the comparison to round the float value being compared gives me the result I expected:
} elseif(round($total_weight, 5) != 1) {
Once again, all is right in the world.
