Search code examples
phppythondecimalfixed-pointbcmath

PHP bcmath versus Python Decimal


I am using PHP's bcmath library to perform operations on fixed-point numbers. I was expecting to get the same behaviour of Python's Decimal class but I was quite surprised to find the following behaviour instead:

// PHP:
$a = bcdiv('15.80', '483.49870000', 26);
$b = bcmul($a, '483.49870000', 26);
echo $b;  // prints 15.79999999999999999999991853

while using Decimals in Python I get:

# Python:
from decimal import Decimal
a = Decimal('15.80') / Decimal('483.49870000')
b = a * Decimal('483.49870000')
print(b)  # prints 15.80000000000000000000000000

Why is that? As I am using this to perform very sensitive operations, I would like to find a way to obtain in PHP the same result as in Python (i.e. (x / y) * y == x)


Solution

  • After some experimentation, I figured it out. This is an issue with rounding vs. truncation. Python, by default, uses ROUND_HALF_EVEN rounding, while PHP simply truncates at the specified precision. Python also has a default precision of 28, while you're using 26 in PHP.

    In [57]: import decimal
    In [58]: decimal.getcontext()
    Out[58]: Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999999, Emax=999999999, capitals=1, flags=[], traps=[InvalidOperation, Overflow, DivisionByZero])
    

    If you want to make Python imitate PHP's behavior of truncation, we just need to change the rounding property:

    In [1]: import decimal
    In [2]: decimal.getcontext().rounding = decimal.ROUND_DOWN
    In [3]: decimal.getcontext().prec = 28
    In [4]: a = decimal.Decimal('15.80') / decimal.Decimal('483.49870000')
    In [5]: b = a * decimal.Decimal('483.49870000')
    In [6]: print(b)
    15.79999999999999999999999999
    

    Making PHP behave like Python's default is a bit trickier. We need to create a custom function for division and multiplication, that rounds "half even" like Python:

    function bcdiv_round($first, $second, $scale = 0, $round=PHP_ROUND_HALF_EVEN)
    {
        return (string) round(bcdiv($first, $second, $scale+1), $scale, $round);
    }
    
    function bcmul_round($first, $second, $scale = 0, $round=PHP_ROUND_HALF_EVEN)
    {
        $rounded = round(bcmul($first, $second, $scale+1), $scale, $round);
    
        return (string) bcmul('1.0', $rounded, $scale);
    }
    

    Here's a demonstration:

    php > $a = bcdiv_round('15.80', '483.49870000', 28);
    php > $b = bcmul_round($a, '483.49870000', 28);
    php > var_dump($b);
    string(5) "15.80"