PHP BC Math library ignore rounding rules

1.9k Views Asked by At

I'm using bcdiv function from PHP to calculate some things, but result is different than it should be. Here is sample code:

$val1 = 599.60;
$val2 = 60;

var_dump(bcdiv($val1, $val2, 0));
// result string(1) "9"
// should be "10"

var_dump(bcdiv($val1, $val2, 2));
// result string(4) "9.99"
// result ok, but

var_dump(bcdiv($val1, $val2, 1));
// result string(4) "9.9"
// should be "10" too

Results from first var_dump is very strange for me, as it should be 10 not 9.

Same results are for other BCMath functions:

$val1 = 599.99;
$val2 = 1;

var_dump(bcmul($val1, $val2, 0));
// result string(3) "599"
// should be "600"

var_dump(bcadd($val1, $val2, 0));
// result string(3) "600"
// should be "601"

var_dump(bcsub($val1, $val2, 0));
// result string(3) "598"
// should be "599"

I have a lot of float calculations in my app and now I'm not sure how to handle them properly, normal math calculations have floating point problems, but that from bc math are not the best thing I should use.

So, here are my questions:

  1. How can I handle float calculations, considering that BCMath results are wrong, when you think about regular mathematics rounding rules?
  2. How do You (other PHP programmers) calculate float numbers? Converting them to integers is not possible in my app.
  3. What do you think about php-decimal?
2

There are 2 best solutions below

0
ptyskju On BEST ANSWER

Thank you Christos Lytras for pointing what I did wrong. Because I'm using BCMath calculations in multiple classes and I don't have enough time to rewrite all places with floats to integers, I decided to create simple trait. It solves all my problems with rounded values. Here is trait code:

trait FloatCalculationsTrait
{

    /**
     * Default precision for function results
     *
     * @var integer
     */
    protected $scale = 2;

    /**
     * Default precision for BCMath functions
     *
     * @var integer
     */
    protected $bcMathScale = 10;

    /**
     * Rounding calculation values, based on https://stackoverflow.com/a/60794566/3212936
     *
     * @param string $valueToRound
     * @param integer|null $scale
     * @return float
     */
    protected function round(string $valueToRound, ?int $scale = null): float
    {
        if ($scale === null) {
            $scale = $this->scale;
        }

        $result = $valueToRound;

        if (strpos($valueToRound, '.') !== false) {
            if ($valueToRound[0] != '-') {
                $result = bcadd($valueToRound, '0.' . str_repeat('0', $scale) . '5', $scale);
            } else {
                $result = bcsub($valueToRound, '0.' . str_repeat('0', $scale) . '5', $scale);
            }
        }

        return $result;
    }

    /**
     * Add floats
     *
     * @param float|null $firstElement
     * @param float|null $secondElement
     * @param integer|null $scale
     * @return float
     */
    protected function add(?float $firstElement, ?float $secondElement, ?int $scale = null): float
    {
        $result = bcadd($firstElement, $secondElement, $this->bcMathScale);

        return $this->round($result, $scale);
    }

    /**
     * Substract floats
     *
     * @param float|null $firstElement
     * @param float|null $secondElement
     * @param integer|null $scale
     * @return float
     */
    protected function substract(?float $firstElement, ?float $secondElement, ?int $scale = null): float
    {
        $result = bcsub($firstElement, $secondElement, $this->bcMathScale);

        return $this->round($result, $scale);
    }

    /**
     * Alias for `substract` function
     *
     * @param float|null $firstElement
     * @param float|null $secondElement
     * @param integer|null $scale
     * @return float
     */
    protected function sub(?float $firstElement, float $secondElement, ?int $scale = null): float
    {
        return $this->substract($firstElement, $secondElement, $scale);
    }

    /**
     * Multiply floats
     *
     * @param float|null $firstElement
     * @param float|null $secondElement
     * @param integer|null $scale
     * @return float
     */
    protected function multiply(?float $firstElement, ?float $secondElement, ?int $scale = null): float
    {
        $result = bcmul($firstElement, $secondElement, $this->bcMathScale);

        return $this->round($result, $scale);
    }

    /**
     * Alias for `multiply` function
     *
     * @param float|null $firstElement
     * @param float|null $secondElement
     * @param integer|null $scale
     * @return float
     */
    protected function mul(?float $firstElement, ?float $secondElement, ?int $scale = null): float
    {
        return $this->multiply($firstElement, $secondElement, $scale);
    }

    /**
     * Divide floats
     *
     * @param float|null $firstElement
     * @param float|null $secondElement
     * @param integer|null $scale
     * @return float
     */
    protected function divide(?float $firstElement, ?float $secondElement, ?int $scale = null): float
    {
        $result = bcdiv($firstElement, $secondElement, $this->bcMathScale);

        return $this->round($result, $scale);
    }

    /**
     * Alias for `divide` function
     *
     * @param float|null $firstElement
     * @param float|null $secondElement
     * @param integer|null $scale
     * @return float
     */
    protected function div(?float $firstElement, ?float $secondElement, ?int $scale = null): float
    {
        return $this->divide($firstElement, $secondElement, $scale);
    }
}

And here you can check results: http://sandbox.onlinephpfunctions.com/code/5b602173a1825a2b2b9f167a63646477c5105a3c

9
Christos Lytras On

bcdiv

bcdiv ( string $dividend , string $divisor [, int $scale = 0 ] ) : string

Parameters

  • dividend
    The dividend, as a string.
  • divisor
    The divisor, as a string.
  • scale
    This optional parameter is used to set the number of digits after the decimal place in the result. If omitted, it will default to the scale set globally with the bcscale() function, or fallback to 0 if this has not been set.

As you can see, bcdiv 3rd parameter is not for rounding, but for scale, which means it just keeps that number of digits.

There is a nice Q/A by Alix Axel on this specific problem which you can see here "How to ceil, floor and round bcmath numbers?".

In his answer, he has a custom function bcround which will do the rounding as you expect:

function bcround($number, $precision = 0)
{
  if (strpos($number, '.') !== false) {
    if ($number[0] != '-')
      return bcadd($number, '0.' . str_repeat('0', $precision) . '5', $precision);
    return bcsub($number, '0.' . str_repeat('0', $precision) . '5', $precision);
  }
  return $number;
}

$val1 = 599.60;
$val2 = 60;

var_dump(bcround(bcdiv($val1, $val2, 10), 0));
// string(2) "10"

var_dump(bcround(bcdiv($val1, $val2, 10), 2));
// string(4) "9.99"

var_dump(bcround(bcdiv($val1, $val2, 10), 1));
// string(4) "10.0"

Proper way to handle currency numbers

I am not sure if your numbers are referring to price and currencies, but if that's the case, then the best way to handle currency calculations, is to have all the values to cents and do the math using integers.

For your example:

$val1 = 59960; // 59960 cents == 599.60
$val2 = 6000;  // 6000 cents == 60.00

var_dump($val1 / $val2);
// float(9.9933333333333)

var_dump(round($val1 / $val2, 0));
// float(10)

var_dump(round($val1 / $val2, 2));
// float(9.99)

var_dump(round($val1 / $val2, 1));
// float(10)