PHP 任意精度四則演算クラス

PHPには以前より[BCMath](https://www.php.net/manual/ja/book.bc.php)という任意精度数学関数がありますが、利用できるよう自由に設定することができないケースもあるかと思いますので、そういった場合の代用として作成しました。
BCMathほど多くのことはできませんが、とりあえず桁数制限のない小数を含む四則演算と剰余が使用できます。

###スクリプト###

~~~php:decimalcalc.php
<?php
class DecimalCalc {
    // 内部保存値
    private $result;
    // 商の小数部の取得桁数デフォルト
    private $divDecimalLength = 20;
    // 商の小数部丸め時の切り捨てモードデフォルト
    private $divTruncate = 0;

    private $sl1;
    private $sl2;

    public function __construct($result = '0') {
        $this->result = $this->toNumericString($result);
        $this->sl1 = strlen(PHP_INT_MAX) - 4;
        if($this->sl1 < 1) $this->sl1 = 1;
        $this->sl2 = strlen((int)sqrt(PHP_INT_MAX)) - 4;
        if($this->sl2 < 1) $this->sl2 = 1;
    }

    // 内部保存値の設定
    public function init($num = '0') {
        $this->result = $this->toNumericString($num);
        return $this;
    }

    // 加算
    public function add($num = '0') {
        $a = $this->result;
        $b = $this->toNumericString($num);
        $p = max($this->decimalLength($a), $this->decimalLength($b));

        // bが0ならaをそのまま返す
        if($b === '0') return $a;

        // 減算引き渡し用
        $a_ = $a;
        $b_ = $b;

        $a = $this->decimalShift($a, $p);
        $b = $this->decimalShift($b, $p);

        $aSign = $bSign = '';
        if($a[0] === '-') {
            $a = substr($a, 1);
            $aSign = '-';
        }
        if($b[0] === '-') {
            $b = substr($b, 1);
            $bSign = '-';
        }

        // bのみマイナスの場合はbのマイナス符号を削除して減算へ
        if($aSign === '' && $bSign === '-') {
            return $this->sub(substr($b_, 1));
        }
        // aのみマイナスの場合はbにマイナス符号を付加して減算へ
        if($aSign === '-' && $bSign === '') {
            return $this->sub("-$b_");
        }

        $sLength = $this->sl1;
        $sNumber = 10 ** $sLength;
        $sFormat = "%0{$sLength}s";

        $ra = [];
        $l = strlen($a) / $sLength;
        for($i = 0; $i < $l; ++$i) {
            $ra[] = (int)substr($a, -$sLength);
            $a = substr($a, 0, -$sLength);
        }
        $rb = [];
        $l = strlen($b) / $sLength;
        for($i = 0; $i < $l; ++$i) {
            $rb[] = (int)substr($b, -$sLength);
            $b = substr($b, 0, -$sLength);
        }

        $l = max(count($ra), count($rb));
        // 加算演算
        for($i = 0; $i < $l; ++$i) {
            if(!isset($ra[$i])) $ra[$i] = 0;
            if(!isset($rb[$i])) $rb[$i] = 0;
            $ra[$i] += $rb[$i];
            if($ra[$i] >= $sNumber) {
                if(!isset($ra[$i + 1])) $ra[$i + 1] = 0;
                $ra[$i + 1] += (int)($ra[$i] / $sNumber);
                $ra[$i] %= $sNumber;
            }
        }

        for($i = 0; $i < count($ra) - 1; ++$i) $ra[$i] = sprintf($sFormat, $ra[$i]);

        $result = implode('', array_reverse($ra));
        $result = $this->decimalShift($result, -$p);
        if($aSign === '-' && $bSign === '-') $result = '-'. $result;

        return $this->result = $result;
    }

    // 減算
    public function sub($num = '0') {
        $a = $this->result;
        $b = $this->toNumericString($num);
        $p = max($this->decimalLength($a), $this->decimalLength($b));

        // bが0ならaをそのまま返す
        if($b === '0') return $a;

        // 加算引き渡し用
        $a_ = $a;
        $b_ = $b;

        $a = $this->decimalShift($a, $p);
        $b = $this->decimalShift($b, $p);

        $aSign = $bSign = '';
        if($a[0] === '-') {
            $a = substr($a, 1);
            $aSign = '-';
        }
        if($b[0] === '-') {
            $b = substr($b, 1);
            $bSign = '-';
        }

        // bのみマイナスの場合はbのマイナス符号を削除して加算へ
        if($aSign === '' && $bSign === '-') {
            return $this->add(substr($b_, 1));
        }
        // aのみマイナスの場合はbにマイナス符号を付加して加算へ
        if($aSign === '-' && $bSign === '') {
            return $this->add("-$b_");
        }

        // 戻り値用符号
        $resultSign = '';

        // 比較用文字列生成
        $mLength = max(strlen($a), strlen($b));
        $f = "%0{$mLength}s";
        $aCmp = sprintf($f, $a);
        $bCmp = sprintf($f, $b);

        // ab入れ替えの場合の戻り値用符号反転
        if($aSign === '' && $aCmp < $bCmp || $aSign === '-' && $aCmp > $bCmp) $resultSign = '-';

        // bのほうが大きければaとbを入れ替え
        if($aCmp < $bCmp) [$a, $b] = [$b, $a];

        $sLength = $this->sl1;
        $sNumber = 10 ** $sLength;
        $sFormat = "%0{$sLength}s";

        $ra = [];
        $l = strlen($a) / $sLength;
        for($i = 0; $i < $l; ++$i) {
            $ra[] = (int)substr($a, -$sLength);
            $a = substr($a, 0, -$sLength);
        }
        $rb = [];
        $l = strlen($b) / $sLength;
        for($i = 0; $i < $l; ++$i) {
            $rb[] = (int)substr($b, -$sLength);
            $b = substr($b, 0, -$sLength);
        }

        $l = max(count($ra), count($rb));
        // 減算演算
        for($i = 0; $i < $l; ++$i) {
            if(!isset($ra[$i])) $ra[$i] = 0;
            if(!isset($rb[$i])) $rb[$i] = 0;
            $ra[$i] -= $rb[$i];
            if($ra[$i] < 0) {
                $ra[$i] += $sNumber;
                if(isset($ra[$i + 1])) --$ra[$i + 1];
            }
        }

        for($i = 0; $i < count($ra) - 1; ++$i) $ra[$i] = sprintf($sFormat, $ra[$i]);

        $result = implode('', array_reverse($ra));
        $result = $this->decimalShift($result, -$p);
        $result = preg_replace('/^0+/', '', $result);
        $result = preg_replace('/^$/', '0', $result);
        $result = preg_replace('/^\./', '0.', $result);

        return $this->result = $resultSign. $result;
    }

    // 乗算
    public function mul($num = '0') {
        $a = $this->result;
        $b = $this->toNumericString($num);
        $p = $this->decimalLength($a) + $this->decimalLength($b);

        // bが0なら0を返す
        if($b === '0') return $this->result = '0';

        $a = str_replace('.', '', $a);
        $b = str_replace('.', '', $b);

        $aSign = $bSign = '';
        if($a[0] === '-') {
            $a = substr($a, 1);
            $aSign = '-';
        }
        if($b[0] === '-') {
            $b = substr($b, 1);
            $bSign = '-';
        }

        $sLength = $this->sl2;
        $sNumber = 10 ** $sLength;
        $sFormat = "%0{$sLength}s";

        $ra = [];
        $l = strlen($a) / $sLength;
        for($i = 0; $i < $l; ++$i) {
            $ra[] = (int)substr($a, -$sLength);
            $a = substr($a, 0, -$sLength);
        }
        $rb = [];
        $l = strlen($b) / $sLength;
        for($i = 0; $i < $l; ++$i) {
            $rb[] = (int)substr($b, -$sLength);
            $b = substr($b, 0, -$sLength);
        }

        $c = [];
        // 乗算演算
        for($i = 0; $i < count($ra); ++$i) {
            for($j = 0; $j < count($rb); ++$j) {
                $dp = $i + $j;
                if(!isset($c[$dp])) $c[$dp] = 0;
                $c[$dp] += $ra[$i] * $rb[$j];
                if($c[$dp] >= $sNumber) {
                    if(!isset($c[$dp + 1])) $c[$dp + 1] = 0;
                    $c[$dp + 1] += (int)($c[$dp] / $sNumber);
                    $c[$dp] %= $sNumber;
                }
            }
        }
        for($i = 0; $i < count($c) - 1; ++$i) $c[$i] = sprintf($sFormat, $c[$i]);

        $result = implode('', array_reverse($c));
        $result = $this->decimalShift($result, -$p);
        $result = preg_replace('/^0+$/', '0', $result);

        $resultSign = ($aSign !== $bSign) ? '-' : '';
        return $this->result = $resultSign.$result;
    }

    // 除算
    public function div($num = '0', $m = null, $truncate = null) {
        if(is_null($truncate)) $truncate = $this->divTruncate;
        if(is_null($m)) $m = $this->divDecimalLength;
        if($m < 0) $m = 0;
        $m = (int)$m;

        $a = $this->result;
        $b = $this->toNumericString($num);
        $p = max($this->decimalLength($a), $this->decimalLength($b));
        if($b === '0') {
            try {
                throw new Exception(__METHOD__. ': Division by zero');
            } catch (Exception $e) {
                return $e->getmessage();
            }
        }

        $a = $this->decimalShift($a, $p + $m + 2);
        $b = $this->decimalShift($b, $p);

        $aSign = $bSign = '';
        if($a[0] === '-') {
            $a = substr($a, 1);
            $aSign = '-';
        }
        if($b[0] === '-') {
            $b = substr($b, 1);
            $bSign = '-';
        }

        $b_ = $b;
        $b = preg_replace('/^0+/', '', $b);
        $ol = strlen($b_) - strlen($b);
        if($ol) $a .= str_repeat('0', $ol);

        $k = strlen($b);
        $a_ = substr($a, 0, strlen($b));

        $c = [0];
        $sp = 0;
        $f = '%0'. (strlen($b) + 1). 's';
        $spb = sprintf($f, $b);
        // 除算演算
        do {
            if(!isset($c[$sp])) $c[$sp] = 0;
            while(sprintf($f, $a_) >= $spb) {
                $a_ = $this->init($a_)->sub($b);
                if(++$c[$sp] > 9) break;
            }
            ++$sp;
            $a_ .= isset($a[$k]) ? $a[$k] : '0';
        } while(++$k < strlen($a));

        $c = $this->decimalShift(implode('', $c), -($m + 1 + $ol));

        $nd = explode('.', $c);
        $n = $nd[0];
        $d = (isset($nd[1]) ? $nd[1] : '0'). str_repeat('0', $m);
        $n = preg_replace('/^0+/', '', $n);
        $n = preg_replace('/^$/', '0', $n);

        $dd = $d[$m];
        $nr = str_split($n);
        $dr = str_split(substr($d, 0, $m));

        if($truncate == 0) {
            if($dd >= 5) {
                if($m > 0) ++$dr[count($dr) - 1];
                else ++$nr[count($nr) - 1];
            }
            for($i = count($dr) - 1; $i > 0; --$i) {
                if($dr[$i] > 9) {
                    ++$dr[$i - 1];
                    $dr[$i] %= 10;
                }
            }
            if($dr[0] > 9) {
                $dr[0] %= 10;
                ++$nr[count($nr) - 1];
            }
            for($i = count($nr) - 1; $i > 0; --$i) {
                if($nr[$i] > 9) {
                    ++$nr[$i - 1];
                    $nr[$i] %= 10;
                }
            }
        }

        $n = preg_replace('/^0+/', '', implode($nr));
        if($n === '') $n = '0';
        $d = preg_replace('/0+$/', '', implode($dr));

        $resultSign = $aSign !== $bSign ? '-' : '';
        if(preg_match('/^[0]+$/', "$n$d")) $resultSign = '';
        $result = $resultSign. $n. ($d !== '' ? ".$d" : '');

        return $this->result = $result;
    }

    // 剰余
    public function mod($num = '0') {
        $a = $this->result;
        $b = $this->toNumericString($num);
        if($b === '0') {
            try {
                throw new Exception(__METHOD__. ': Division by zero');
            } catch (Exception $e) {
                return $e->getmessage();
            }
        }
        $q = $this->div($b, 0, 1);
        $this->init($b);
        $p = $this->mul($q);
        $this->init($a);
        $d = $this->sub($p);
        return $this->result = $d;
    }

    private function toNumericString($s) {
        $s = (is_string($s) || is_numeric($s)) ? "$s" : '0';
        if(!is_numeric($s)) {
            $s = '0';
        }
        // 指数表記パース
        $s = preg_replace_callback('/([\d.]*)([eE])([-+]?\d+)/', function($m){
            if($m[3] == 0) {
                return $m[1];
            }
            elseif(!preg_match('/[1-9]/', $m[1])) {
                return '0';
            }
            elseif($m[3] > 0) {
                $d = explode('.', $m[1]);
                if(!isset($d[1])) $d[1] = str_repeat('0', $m[3]);
                else $d[1] .= str_repeat('0', $m[3]);
                $d[0] .= substr($d[1], 0, $m[3]);
                $d[1] = preg_replace('/0+$/', '', substr($d[1], $m[3]));
                return $d[0]. ($d[1] !== '' ? '.'. $d[1] : '');
            }
            $d = explode('.', $m[1]);
            if(!isset($d[1])) $d[1] = '0';
            $d[0] = str_repeat('0', abs($m[3])). $d[0];
            $d[1] = substr($d[0], $m[3]). $d[1];
            $d[0] = substr($d[0], 0, $m[3]);
            return $d[0]. '.'. $d[1];
        }, $s);
        // +符号除去
        $s = str_replace('+', '', $s);
        // 0調整
        $s = preg_replace('/^(-)?0+/', '${1}', $s);
        $s = preg_replace('/^(-)?\./', '${1}0.', $s);
        $s = preg_replace('/^[-0.]+$/', '0', $s);
        $s = preg_replace('/(\.\d*?)0*$/', '$1', $s);
        $s = preg_replace('/\.$/', '', $s);
        $s = preg_replace('/^$/', '0', $s);
        return $s;
    }

    private function decimalLength($s) {
        $tmp = explode('.', $s);
        return isset($tmp[1]) ? strlen($tmp[1]) : 0;
    }
    private function decimalShift($s, $p) {
        if($p === 0) return $s;
        $tmp = explode('.', $s);
        if(!isset($tmp[1])) $tmp[1] = '';
        if($p > 0) {
            $tmp[1] .= str_repeat('0', $p - strlen($tmp[1]));
            $tmp[0] = preg_replace('/^(-)?0+/', '$1', $tmp[0]);
            return implode('', $tmp);
        }
        $sign = '';
        if($tmp[0][0] === '-'){
            $sign = '-';
            $tmp[0] = substr($tmp[0], 1);
        }
        if(strlen($tmp[0]) < abs($p)) {
            $tmp[0] = str_repeat('0', abs($p) - strlen($tmp[0])). $tmp[0];
        }
        $tmp[1] = substr($tmp[0], $p). $tmp[1];
        $tmp[0] = substr($tmp[0], 0, $p);
        $tmp[0] = preg_replace('/^0+/', '', $tmp[0]);
        if($tmp[0] === '') $tmp[0] = '0';
        $tmp[1] = preg_replace('/0+$/', '', $tmp[1]);
        return $sign. ($tmp[1] !== '' ? implode('.', $tmp) : $tmp[0]);
    }
    // 商の小数部の取得桁数設定
    public function setDivDecimalLength($num = 20) {
        if($num < 0) $num = 0;
        $num = (int)$num;
        $this->divDecimalLength = $num;
        return $this;
    }
    // 商の小数部丸め時の切り捨てモード設定
    public function setDivTruncate($num = 0) {
        if($num !== 0) $num = 1;
        $this->divTruncate = $num;
        return $this;
    }
}
~~~

###使用例###

####インスタンス生成####

`new DecimalCalc([$num]);`

各演算の元となる内部値を初期値として設定できます。
数値でも指定できますが、値によっては渡す時点で精度が失われている場合もあるので、文字列での指定推奨です。
省略時のデフォルト値は'0'です。

~~~php:example
$d = new DecimalCalc('123456789.012');
$d = new DecimalCalc();
~~~

####メソッド####

####加算####

`add($num)`

内部値に対して加算を行なった結果を返します。
内部値は加算後の値に更新されます。

~~~php:example
$d = new DecimalCalc('123456789.0123');
echo $d->add('4567.8901234567');
// 123461356.9024234567
~~~

####減算####

`sub($num)`

内部値に対して減算を行なった結果を返します。
内部値は減算後の値に更新されます。

~~~php:example
$d = new DecimalCalc('123456789.0123');
echo $d->sub('4567.8901234567');
// 123452221.1221765433
~~~

####乗算####

`mul($num)`

内部値に対して乗算を行なった結果を返します。
内部値は乗算後の値に更新されます。

~~~php:example
$d = new DecimalCalc('123456789.0123');
echo $d->mul('4567.8901234567');
// 563937047202.96281105481741
~~~

####除算####

`div($num [,$length [,$truncateMode]])`

内部値に対して除算を行なった結果を返します。
内部値は除算後の値に更新されます。
`$length`は小数を第何位まで取得するかを指定します。
省略時のデフォルトは20です。
`$truncateMode`は小数部の捨てられる部分の丸めモードを指定します。
省略時のデフォルトは0(丸め)、0以外を指定で切り捨てになります。

~~~php:example
$a = '123456789.0123';
$b = '4567.8901234567';
$d = new DecimalCalc($a);
echo $d->div($b);       // 27027.09252535073003630397
$d = new DecimalCalc($a);
echo $d->div($b, 7);    // 27027.0925254
$d = new DecimalCalc($a);
echo $d->div($b, 7, 1); // 27027.0925253
~~~

####剰余####

`mod($num)`

内部値に対して除算を行ない剰余を取得します。
内部値は剰余で更新されます。

~~~php:example
$d = new DecimalCalc('123456789.0123');
echo $d->mod('4567.8901234567');
// 422.6456357691
~~~

####内部値設定####

`init($num)`

内部値を更新します。

~~~php:example
$d = new DecimalCalc();
echo $d->init('123456789.0123')->add('4567.8901234567'); // 123461356.9024234567
echo $d->init('1111.1111')->add('4567.8901234567'); // 5679.0012234567
~~~

####商の小数取得桁数設定####

`setDivDecimalLength($divDecimalLength)`

除算時の商の小数部の取得桁数のデフォルト値を設定します。
`divメソッド`に第2引数を指定している場合はそちらが優先されます。

~~~php:example
$d = new DecimalCalc();
$d->setDivDecimalLength(0);
echo $d->init('1234')->div('567'); // 2
echo $d->init('1234')->div('567', 5); // 2.17637
~~~

####商の小数丸めモード設定####

`setDivTruncate($divTruncate)`

除算時の商の小数部の丸めモードデフォルト値を設定します。
`divメソッド`に第3引数を指定している場合はそちらが優先されます。

~~~php:example
$d = new DecimalCalc();
$d->setDivTruncate(1);
echo $d->init('1234')->div('567', 5);    // 2.17636
echo $d->init('1234')->div('567', 5, 0); // 2.17637
~~~

####関数風に使うラッパーのサンプル####

~~~php:wrapper.php
<?php
// wrapper sample
require_once('./decimalcalc.php');

function add($a, $b) {
    return (new DecimalCalc($a))->add($b);
}

function sub($a, $b) {
    return (new DecimalCalc($a))->sub($b);
}

function mul($a, $b) {
    return (new DecimalCalc($a))->mul($b);
}

function div($a, $b, $int = false) {
    return (new DecimalCalc($a))
        ->div($b, $int ? 0 : null, $int ? 1 : null);
}

function mod($a, $b) {
    return (new DecimalCalc($a))->mod($b);
}

$a = '123456789.0123456';
$b = '456789.012345678';
echo add($a, $b), "\n"; // 123913578.024691278
echo sub($a, $b), "\n"; // 122999999.999999922
echo mul($a, $b), "\n"; // 56393704720318.0983387568023168
echo div($a, $b), "\n"; // 270.27092525360239422787
echo div($a, $b, true), "\n"; // 270
echo mod($a, $b), "\n"; // 123755.67901254
~~~

####演算結果の信頼性について###
ランダムに設定したAとBの値で当スクリプトとBCMathとで演算を行い結果を比較、という内容で数十万回ほど確認してみたところ結果に差異は出ませんでしたが、それでも絶対に計算ミスが出ないということを保証するものではありませんのでご了承ください。

####検証スクリプト(参考)####
実行にはBCMathが必要です。
実行中はそこそこ負荷がかかりますのでご注意ください。

~~~php:test.php
<?php
require_once('./decimalcalc.php');

$d = new DecimalCalc();
$scale = 100;
bcscale($scale);

$v = false; // true:実行内容表示 / false:進捗のみ

for($i = 0; $i < 100000; ++$i) {
    if(!$v) echo "$i\r";
    else echo str_repeat('#', 64), " $i\n\n";

    $a = mkRand();
    $b = mkRand();
    if($v) echo "A: $a\nB: $b\n\n";

    $r1 = $d->init($a)->add($b);
    $r2 = preg_replace('/\.$/', '', preg_replace('/0+$/', '', bcadd($a, $b)));
    test($v, 'add', $a, $b, $r1, $r2);

    $r1 = $d->init($a)->sub($b);
    $r2 = preg_replace('/\.$/', '', preg_replace('/0+$/', '', bcsub($a, $b)));
    test($v, 'sub', $a, $b, $r1, $r2);

    $r1 = $d->init($a)->mul($b);
    $r2 = preg_replace('/\.$/', '', preg_replace('/0+$/', '', bcmul($a, $b)));
    test($v, 'mul', $a, $b, $r1, $r2);

    $r1 = $d->init($a)->div($b, $scale, 1);
    $r2 = preg_replace('/\.$/', '', preg_replace('/0+$/', '', bcdiv($a, $b)));
    test($v, 'div', $a, $b, $r1, $r2);

    $r1 = $d->init($a)->mod($b);
    $r2 = preg_replace('/\.$/', '', preg_replace('/0+$/', '', bcmod($a, $b, 50)));
    test($v, 'mod', $a, $b, $r1, $r2);
}
echo "\n";

function mkRand() {
    $n = [];
    $d = [];
    $r = mt_rand(0, 3);
    for($i = 0 ; $i < $r; ++$i)
        $n[] = mt_rand(0, 10) ? mt_rand() : (mt_rand(0, 1) ? '9999999999999999' : '0000000000000000');
    $r = mt_rand(0, 3);
    for($i = 0 ; $i < $r; ++$i)
        $d[] = mt_rand(0, 10) ? mt_rand() : (mt_rand(0, 1) ? '9999999999999999' : '0000000000000000');
    $n = implode($n);
    $d = implode($d);
    if($n === '') $n = '0';
    if($d === '') $d = '0';
    if(preg_match('/^0+$/', "$n$d")) $n = '1';
    if(mt_rand(0, 1)) $n = "-$n";
    return mt_rand(0, 1) ? "$n.$d" : "$n$d";
}

function test($v, $l, $a, $b, $r1, $r2) {
    if($v) {
        echo "> $l\n",
            $r1,"\n", $r2,"\n",
            $r1 === $r2 ? 'true' : '**** false ****', "\n\n";
    }
    else {
        if($r1 !== $r2) {
            echo "$l   \n A: $a\n B: $b\n R1: $r1\n R2: $r2\n\n";
        }
    }
}
~~~