Upgrade to Pro — share decks privately, control downloads, hide ads and more …

PHPでお金を扱う時、終わりのない 謎の1円調査の旅にでなくて済む方法

NAKKA-K
March 22, 2025

PHPでお金を扱う時、終わりのない 謎の1円調査の旅にでなくて済む方法

PHPerKaigi2025 - 03/22(土) - Track C 13:00 ~

## 想定聴講者
* 会計システムや EC サイトなどでお金を扱う開発をしている人
* PHPのstring, float, intがどのように相互変換されるのか挙動に興味がある人
* 設計に興味がある人

## 話すこと
* PHPでstring, float, intを相互変換するとどのような問題が起きるのか、どのように実行されているのか
* センシティブな数値を扱う時、どのように扱うべきなのか

## 話さないこと
* 既存の技術選定について
* 既存システムの苦悩と戦いについて

## 説明
普段開発している時はあまり意識せずに数値を型変換することがあると思いますが、そこには思いもよらぬ潜在的なバグに繋がる挙動が潜んでいます。

会計システムを作る時にPHPの数値仕様をしっかり理解した上で作らないと、後々大変なことになってしまう可能性があります。
小数点以下の誤差によって1円が消えたり増えたりしてしまうことがあり、1円の行方を巡って終わりのない、解決もしない調査の旅に身を投じることになるでしょう。
それが今なのか、いつなのかは分かりませんが、知っていれば防げる問題でもあります。

本セッションでは数値にはどんな問題があり、扱う時に何を気をつける必要があって、さらに扱いやすくするためにおすすめの方法をお話しします。

NAKKA-K

March 22, 2025
Tweet

Other Decks in Programming

Transcript

  1. 株式会社fluct テックリード なっかー @konsent_nakka 略歴 • 新卒でCARTA HOLDINGS⼊社 fluctに配属され5年⽬ •

    1年⽬はパブリッシャー向け広告配信システム全体開発を担当 • 2年⽬から数年は1名で会計システムを保守管理 • 広告配信設定システム/ツール開発チームのテックリード 役割/領域 management engineering Front Server Data Cloud Intra
  2. $depositReportUSD = [ // 海外からの入金レポート (10行) 0.1, 0.1, 0.1, 0.1,

    0.1, 0.1, 0.1, 0.1, 0.1, 0.1 ]; $sumUSD = 0.0; foreach ($depositReportUSD as $priceUSD) { $sumUSD += $priceUSD; } 0.1ドルを10回足してみる
  3. $depositReportUSD = [ // 海外からの入金レポート (10行) 0.1, 0.1, 0.1, 0.1,

    0.1, 0.1, 0.1, 0.1, 0.1, 0.1 ]; $sumUSD = 0.0; foreach ($depositReportUSD as $priceUSD) { $sumUSD += $priceUSD; } 0.1ドルを10回足してみる 四則演算を使っている
  4. $depositReportUSD = [ // 海外からの入金レポート (10行) 0.1, 0.1, 0.1, 0.1,

    0.1, 0.1, 0.1, 0.1, 0.1, 0.1 ]; $sumUSD = 0.0; foreach ($depositReportUSD as $priceUSD) { $sumUSD += $priceUSD; } var_dump($sumUSD); // `float(0.9999999999999999)` 合計が1ドルにならない 1ドルを想像するがそうはなってない
  5. $yenConversionRate = 150.0; $sumJPY = $sumUSD * $yenConversionRate; // 円に変換

    $floorSumJPY = floor($sumJPY); // 支払いに利用するため切り捨てる 1ドルを円に変換すると150円になるはず
  6. $yenConversionRate = 150.0; $sumJPY = $sumUSD * $yenConversionRate; // 円に変換

    var_dump($sumJPY); // float(149.99999999999999) $floorSumJPY = floor($sumJPY); // 支払いに利用するため切り捨てる var_dump($floorSumJPY); // `int(149)` 1円消えた…… 150円を想定していたはず
  7. コンピューターでは表現しきれない世界 • コンピューターは2進数 ◦ 表現できない数が存在する • 今回は0.1を表現できず、無限に続く小数(循環小数)として扱われる ◦ 0.1 =

    0.0 0011 0011 0011…… ◦ 小数の規格は 浮動小数点数(IEEE754) を参照 • 何も気にせず扱うと、意図しない数字になる はじまり
  8. BCMath • PHPではBCMathという任意精度演算が拡張で用意されている ◦ bcadd - 2つの任意精度の数値を加算する ◦ bcsub -

    任意精度数値の減算を行う ◦ bcmul - 2つの任意精度数値の乗算を行う ◦ bcdiv - 2つの任意精度数値で除算を行う ◦ bcfloor - 任意精度数値を切り下げる • https://www.php.net/manual/ja/ref.bc.php 計算方法
  9. BCMath • BCMathはstringを引数として計算し、stringを返す ◦ bcadd(string $num1, string $num2, ?int $scale

    = null): string ◦ https://www.php.net/manual/ja/function.bcadd.php • 小数点第何位までの計算として欲しいのかはscaleで指定 計算方法
  10. $depositReportUSD = [ // 海外からの入金レポート (10行) 0.1, 0.1, 0.1, 0.1,

    0.1, 0.1, 0.1, 0.1, 0.1, 0.1 ]; $sumUSD = 0.0; foreach ($depositReportUSD as $priceUSD) { $sumUSD += $priceUSD; } $yenConversionRate = 150.0; $sumJPY = $sumUSD * $yenConversionRate; // 円に変換 $floorSumJPY = floor($sumJPY); // 支払いに利用するため切り捨てる 計算方法が良くない
  11. $sumUSD = '0.0'; foreach ($depositReportUSD as $priceUSD) { $sumUSD =

    bcadd($sumUSD, $priceUSD, 2); } $yenConversionRate = '150.0'; $sumJPY = bcmul($sumUSD, $yenConversionRate, 2); // 円に変換 $floorSumJPY = bcfloor($sumJPY); // 支払いに利用するため切り捨てる BCMathを利用したコード BCMath関数を使う
  12. $sumUSD = '0.0'; foreach ($depositReportUSD as $priceUSD) { $sumUSD =

    bcadd($sumUSD, $priceUSD, 2); } var_dump($sumUSD); // `string(1.00)` $yenConversionRate = '150.0'; $sumJPY = bcmul($sumUSD, $yenConversionRate, 2); // 円に変換 var_dump($sumJPY); // `string(150.00)` $floorSumJPY = bcfloor($sumJPY); // 支払いに利用するため切り捨てる var_dump($floorSumJPY); // `string(150)` BCMathを利用したコード 想定通り150円
  13. 注意点 - PHP8.3以前 • BCMathはPHP8.3以前は使えない関数がある ◦ bcceil - 任意精度数値を切り上げる ◦

    bcround - 任意精度数値を丸める ◦ bcfloor - 任意精度数値を切り下げる ◦ bcdivmod - 任意精度数値の商と剰余を取得する 計算方法
  14. 注意点 - 速度の低下 • 速度は少しだけ遅くなる ◦ 正確性を犠牲にしてどこまで速くするべきなのかは疑問 ◦ パフォーマンスを犠牲にしてでも正確性が重要な場合は多い •

    PHPはバージョンが上がるごとに速くなっている ◦ BCMathは計算アルゴリズムの変更で数倍速くなっている ◦ https://qiita.com/SakiTakamachi/items/8510ff59b09592ceb51d 計算方法
  15. 1000万回の雑計測で1.5倍程度 [PHP8.4] for($i = 0; $i < 10000000; $i++) {

    "2.34" * (string) $i; } /** * 0m 0.486s */ for($i = 0; $i < 10000000; $i++) { bcmul("2.34", (string) $i); } /** * 0m 0.718s */ BCMath 算術演算
  16. $price1 = 0.1 + 0.2; $price2 = 0.3; var_dump($price1); var_dump($price2);

    var_dump($price1 === $price2); BCMathを使わない場合は 等価 にならない
  17. $price1 = 0.1 + 0.2; $price2 = 0.3; var_dump($price1); //

    0.30000000000000004 var_dump($price2); // 0.3 var_dump($price1 === $price2); // false BCMathを使わない場合は 等価 にならない 等価にならない
  18. $price1 = bcadd('0.1', '0.2', 2); $price2 = '0.3'; var_dump($price1); //

    `string(0.30)` var_dump($price2); // `string(0.3)` var_dump($price1 === $price2); // false '0.30' === '0.3' BCMathで計算しても 等価 にならない 通常文字列比較だと小数点以下の 0有無が違う
  19. bccomp(string $num1, string $num2, ?int $scale = null): int •

    bccompは第一引数と第二引数の比較をする関数 ◦ 第一引数が大きければ 1 ◦ 第二引数が大きければ -1 ◦ 等価であれば 0 • scaleにより等価かどうか変わる点は注意 ◦ bccomp('1.001', '1', 2); // 0 ◦ bccomp('1.001', '1', 3); // 1 比較方法
  20. $price1 = bcadd('0.1', '0.2', 2); $price2 = '0.3'; var_dump($price1); //

    `string(0.30)` var_dump($price2); // `string(0.3)` var_dump($price1 === $price2); // false '0.30' === '0.3' var_dump(bccomp($price1, $price2, 2) === 0); // true 数値としての比較 想定通り等価として扱われる
  21. (中略)ここまでは既存と一緒 $sumUSD = 0.0; foreach ($depositReportUSD as $priceUSD) { $sumUSD

    += $priceUSD; } var_dump($sumUSD); // 0.9999999999999999 var_dump((string) $sumUSD); var_dump((int) $sumUSD); 合計は0.9999の循環小数に
  22. (中略)ここまでは既存と一緒 $sumUSD = 0.0; foreach ($depositReportUSD as $priceUSD) { $sumUSD

    += $priceUSD; } var_dump($sumUSD); // 0.9999999999999999 var_dump((string) $sumUSD); // 1 var_dump((int) $sumUSD); // 0 float / string / int で乖離
  23. PHPにおける浮動小数点数の型変換 • PHPは利用者に扱いやすい変換を提供しようとしてくれる ◦ それが仇となって難しい挙動を生み出している? • 注意として小数をstringにする過程でイプシロンは丸められる ◦ 場合によっては 1.0E-6

    のようなE表記になってしまう ◦ var_dump((string)0.000001); // 1.0E-6 ◦ E表記はBCMathの引数として渡すと怒られる • そもそも不要な型変換をしないのがベスト ◦ BCMathで計算する数値は基本的にstringとして扱う 型変換
  24. $price1 = bcadd('0.1', '0.2', 2); $price2 = '0.3'; bccomp($price1, $price2,

    2) === 0; // true 比較方法の章で使ったコード
  25. function 二つの値を計算して比較する関数 ( string $expected, string $actualLeftOperand, string $actualRightOperand, ):

    bool { $actual = bcadd($actualLeftOperand, $actualRightOperand, 2); return bccomp($actual, $expected, 2) === 0; } 二つの値を計算して比較する関数 ('0.3', '0.1', '0.2'); メッセージを出力し、比較する関数
  26. プリミティブ型 • stringにはじまりPHPには多くのプリミティブ型が存在する ◦ Javaでは byte / char / short

    / long ◦ JavaScriptでは symbol / undefined / null ◦ Golangでは complex64 / rune / uintptr • プリミティブ(=原始的)というように汎用的な型 ◦ 汎用的で何でもできて容易に壊せる ◦ 壊さないように使い方を脳に留めておく必要があり辛い 型宣言
  27. BCMath\Number • BCMathはPHP8.4でBCMath\Numberというクラスが登場 ◦ new BCMath\Number(‘0.1’) ◦ string | int

    を引数として作れるBCMath用の型 ◦ BCMathが提供している計算関数が実装されたクラス • 数値の型を限定して、それ以外のstringと区別するべき ◦ 数値も通常文字列もstringなのは緩すぎる ◦ 静的解析でも数字になっているかどうかは指摘されない ◦ 実行時エラーになる 型宣言
  28. function 二つの値を計算して比較する関数 ( \BCMath\Number $expected, \BCMath\Number $actualLeftOperand, \BCMath\Number $actualRightOperand, ):

    bool { $actual = $actualLeftOperand->add($actualRightOperand, 2); return $expected->compare($actual, 2) === 0; } BCMathを使って書き直す
  29. $最終円金額 = 今月の最終円金額を計算する ( $金額, $税率); $最終ドル金額 = 最終円金額をドルに変換する (

    $最終円金額, $ドル変換レート); return [$最終円金額, $最終ドル金額]; 本当に正しく動いているのかは分からない
  30. function 今月の最終円金額を計算する ( \BCMath\Number $金額, \BCMath\Number $税率): \BCMath\Number { $最終円金額

    = $金額->mul($税率, 2)->floor(); return $最終円金額; } function 最終円金額をドルに変換する ( \BCMath\Number $最終円金額, \BCMath\Number $ドル変換レート): \BCMath\Number { return $最終円金額->div($ドル変換レート, 2); } 本当に正しく動いているのかは分からない
  31. 表現の限界 • 汎用的な型だけでは表現に限界がある ◦ プリミティブ型、BCMath\Number • 汎用的な型だけではメソッドを呼び出す時に取り違えられる ◦ 正しい使い方しかできないようになってない •

    ドメインが伝わらず用途を推測できない ◦ ある値がドメインとして存在可能な値か分からない ◦ 例えばマイナスはダメなど • 引数に同じ型が横一列に並んでいると視認/保守性が悪い 独自型定義
  32. final readonly class PriceJPY { public function __construct(public \BCMath\Number $value)

    {} // applyTax は税込金額を含めたValueObjectを返す // 課税、非課税、免税などに対応するため外から税率を渡す public function applyTax(TaxRate $taxRate): TotalPriceTaxinJPY { return new TotalPriceTaxinJPY($this, $taxRate); } } ValueObjectはこんなの
  33. $最終円金額 = 今月の最終円金額を計算する ( new PriceJPY($金額), new TaxRate($税率)); $最終ドル金額 =

    最終円金額をドルに変換する ( $最終円金額, new DollarConvertRate($ドル変換レート)); // 円とドルの金額を両方 DBに保存したいのでまとめて返す return new TotalPriceTaxin($最終円金額, $最終ドル金額); ValueObjectを使った関数を呼び出してみる
  34. function 今月の最終円金額を計算する ( PriceJPY $金額, TaxRate $税率, ): TotalPriceTaxinJPY {

    return $金額->applyTax($税率); } function 最終円金額をドルに変換する ( TotalPriceTaxinJPY $最終円金額, DollarConvertRate $ドル変換レート): TotalPriceTaxinUSD { return new TotalPriceTaxinUSD($最終円金額, $ドル変換レート); } ValueObjectを使った関数の実装
  35. $最終円金額 = 今月金額と前月金額から最終円金額を計算する ( new CurrentPriceJPY($今月金額), new TaxRate($今月税率), new PrevPriceJPY($前月金額),

    new TaxRate($前月税率)); $最終ドル金額 = 最終円金額をドルに変換する ( $最終円金額, new DollarConvertRate($ドル変換レート)); return new TotalPriceTaxin($最終円金額, $最終ドル金額); 前月金額が出てきても可読性が悪くなりづらい
  36. function 今月金額と前月金額から最終円金額を計算する ( CurrentPriceJPY $今月金額, TaxRate $今月税率, PrevPriceJPY $前月金額, TaxRate

    $前月税率, ): TotalPriceTaxinJPY { return new TotalPriceTaxinJPY( $今月金額->applyTax($今月税率), $前月金額->applyTax($前月税率), ); } 処理もやれることをやるだけ
  37. どうして縛るのか? • 引用 ◦ 問題領域の知識を活用して固有の型を作ることで、取りえる組み 合わせを大幅に減らせる ◦ 「出来てはならぬことを禁じる」のではなく、はじめから「出来 ていいことだけを出来るようにする」と考えるのです ◦

    状態だけでなく「ふるまい」もカプセル化する 独自型定義 【89】関数の「サイズ」を小さくする 【103】見知らぬ人ともうまくやるには 【31】状態だけでなく「ふるまい」もカプセル化する
  38. No day but TODAY CARTA HOLDINGSについて 約 53% 電 通

    (株) VOYAGE GROUP 約 47% 既存株主 事業例 サービス例 CARTA HOLDINGSについて
  39. Q & A • ドメインの中にBCMathみたいな外部ライブラリ入るのが気になる ◦ BCMathは純正拡張なので保守されなくなるような痛みは少ない ◦ 出来ることもシンプルでGMPなどへの入れ替えも大変ではない •

    BCMathによる計算時間増加が気になることもある ◦ 前処理(ELT/ETL)でまとめておく可能性も考える ◦ ただ、例えばsnowflakeはコンピュータ誤差は発生する ◦ そもそもintで全部扱い小数が発生しないならBCMathも必要ない まとめ