Slide 1

Slide 1 text

PHPでお金を扱う時、終わりのない 謎の1円調査の旅にでなくて済む方法 CARTA HOLDINGS fluct 開発本部 テックリード なっかー (@konsent_nakka) PHPerKaigi 2025 2025.03.22

Slide 2

Slide 2 text

株式会社fluct テックリード なっかー @konsent_nakka 略歴 ● 新卒でCARTA HOLDINGS⼊社 fluctに配属され5年⽬ ● 1年⽬はパブリッシャー向け広告配信システム全体開発を担当 ● 2年⽬から数年は1名で会計システムを保守管理 ● 広告配信設定システム/ツール開発チームのテックリード 役割/領域 management engineering Front Server Data Cloud Intra

Slide 3

Slide 3 text

お⾦の計算といえば会計システム

Slide 4

Slide 4 text

会計システムを開発してきた経験から 気付きづらい問題が潜むところを話します

Slide 5

Slide 5 text

ドルの⼊⾦レポートから、円の⽀払い⾦額を計算

Slide 6

Slide 6 text

$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回足してみる

Slide 7

Slide 7 text

$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回足してみる 四則演算を使っている

Slide 8

Slide 8 text

$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ドルを想像するがそうはなってない

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

$yenConversionRate = 150.0; $sumJPY = $sumUSD * $yenConversionRate; // 円に変換 var_dump($sumJPY); // float(149.99999999999999) $floorSumJPY = floor($sumJPY); // 支払いに利用するため切り捨てる var_dump($floorSumJPY); // `int(149)` 1円消えた…… 150円を想定していたはず

Slide 11

Slide 11 text

コンピューターでは表現しきれない世界 ● コンピューターは2進数 ○ 表現できない数が存在する ● 今回は0.1を表現できず、無限に続く小数(循環小数)として扱われる ○ 0.1 = 0.0 0011 0011 0011…… ○ 小数の規格は 浮動小数点数(IEEE754) を参照 ● 何も気にせず扱うと、意図しない数字になる はじまり

Slide 12

Slide 12 text

1円消えるとどうなる?

Slide 13

Slide 13 text

問い合わせ ● スプシで確認した金額とズレ はじまり 〜後日〜

Slide 14

Slide 14 text

複雑なドメイン処理のなか 調査は厳しく推測に終わった

Slide 15

Slide 15 text

お⾦の様な正確性が求められる処理で 算術演算⼦を利⽤しない

Slide 16

Slide 16 text

AGENDA A 正しく計算する 1. 計算⽅法 2. ⽐較⽅法 3. 型変換 出来ていいことだけを出来るようにする 1. 型宣⾔ 2. 独⾃型定義 B

Slide 17

Slide 17 text

AGENDA A 正しく計算する 01 計算⽅法 02 ⽐較⽅法 03 型変換 01

Slide 18

Slide 18 text

BCMathを使う

Slide 19

Slide 19 text

小数の罠 ● 算術演算子を利用した小数計算はコンピューター誤差の影響を受ける ○ 算術演算子とは + - * / など ○ 例えば 0.1 + 0.2 ≠ 0.3 ● 誤差が発生しない仕組みが必要 計算方法

Slide 20

Slide 20 text

BCMath ● PHPではBCMathという任意精度演算が拡張で用意されている ○ bcadd - 2つの任意精度の数値を加算する ○ bcsub - 任意精度数値の減算を行う ○ bcmul - 2つの任意精度数値の乗算を行う ○ bcdiv - 2つの任意精度数値で除算を行う ○ bcfloor - 任意精度数値を切り下げる ● https://www.php.net/manual/ja/ref.bc.php 計算方法

Slide 21

Slide 21 text

BCMath ● BCMathはstringを引数として計算し、stringを返す ○ bcadd(string $num1, string $num2, ?int $scale = null): string ○ https://www.php.net/manual/ja/function.bcadd.php ● 小数点第何位までの計算として欲しいのかはscaleで指定 計算方法

Slide 22

Slide 22 text

$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); // 支払いに利用するため切り捨てる 計算方法が良くない

Slide 23

Slide 23 text

$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関数を使う

Slide 24

Slide 24 text

$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円

Slide 25

Slide 25 text

注意点 - BCMathの型 ● BCMathでは基本的に計算対象の数値をstringとして扱う ○ 戻り値もstring ● 数値もstringで扱う必要がある 計算方法

Slide 26

Slide 26 text

注意点 - PHP8.3以前 ● BCMathはPHP8.3以前は使えない関数がある ○ bcceil - 任意精度数値を切り上げる ○ bcround - 任意精度数値を丸める ○ bcfloor - 任意精度数値を切り下げる ○ bcdivmod - 任意精度数値の商と剰余を取得する 計算方法

Slide 27

Slide 27 text

注意点 - 速度の低下 ● 速度は少しだけ遅くなる ○ 正確性を犠牲にしてどこまで速くするべきなのかは疑問 ○ パフォーマンスを犠牲にしてでも正確性が重要な場合は多い ● PHPはバージョンが上がるごとに速くなっている ○ BCMathは計算アルゴリズムの変更で数倍速くなっている ○ https://qiita.com/SakiTakamachi/items/8510ff59b09592ceb51d 計算方法

Slide 28

Slide 28 text

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 算術演算

Slide 29

Slide 29 text

AGENDA A 正しく計算する 01 計算⽅法 02 ⽐較⽅法 03 型変換 02

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

$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有無が違う

Slide 33

Slide 33 text

比較の罠 - 数値と数字 ● 小数の比較は意図しない結果になることもある ○ BCMathを使っていない計算では特に ● BCMathを使った計算結果でも比較演算子による比較が失敗しうる ○ 小数末尾の0の有無などで厳密な比較(===)で失敗する ○ “0.1” ≠ “0.10” と判断される 比較方法

Slide 34

Slide 34 text

比較の罠 - 数値と数字 ● それを防ぐためにBCMathで計算し、BCMathで比較する ○ BCMathで比較すれば同一の”数値”として判断してくれる ● お金など、厳密な計算を伴うところは基本的にはすべてBCMathを使 うべき 比較方法

Slide 35

Slide 35 text

bccompで⽐較する

Slide 36

Slide 36 text

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 比較方法

Slide 37

Slide 37 text

$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 数値としての比較 想定通り等価として扱われる

Slide 38

Slide 38 text

計算と⽐較、両⽅揃って初めて想定通りになる

Slide 39

Slide 39 text

AGENDA A 正しく計算する 01 計算⽅法 02 ⽐較⽅法 03 型変換 03

Slide 40

Slide 40 text

型変換の挙動を実感するために 算術演算⼦を使ったサンプル

Slide 41

Slide 41 text

(中略)ここまでは既存と一緒 $sumUSD = 0.0; foreach ($depositReportUSD as $priceUSD) { $sumUSD += $priceUSD; } 0.1ドルを10回足すコード

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

(中略)ここまでは既存と一緒 $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 で乖離

Slide 44

Slide 44 text

PHPにおける浮動小数点数の型変換 ● PHPは利用者に扱いやすい変換を提供しようとしてくれる ○ それが仇となって難しい挙動を生み出している? ● 注意として小数をstringにする過程でイプシロンは丸められる ○ 場合によっては 1.0E-6 のようなE表記になってしまう ○ var_dump((string)0.000001); // 1.0E-6 ○ E表記はBCMathの引数として渡すと怒られる ● そもそも不要な型変換をしないのがベスト ○ BCMathで計算する数値は基本的にstringとして扱う 型変換

Slide 45

Slide 45 text

ここまでで計算の正確性は担保された

Slide 46

Slide 46 text

次はお⾦計算のミスを防ぐ

Slide 47

Slide 47 text

例えばBCMathを使い忘れたり、 数値を取り違えたり、 使い⽅を間違えたりする

Slide 48

Slide 48 text

間違いやすいロジックはお⾦の調査を困難にする

Slide 49

Slide 49 text

出来ていいことだけ 出来るようにしよう

Slide 50

Slide 50 text

AGENDA A 出来ていいことだけを出来るようにする 01 型宣⾔ 02 独⾃型定義 01 B

Slide 51

Slide 51 text

プリミティブ型とBCMathを使ったサンプル

Slide 52

Slide 52 text

$price1 = bcadd('0.1', '0.2', 2); $price2 = '0.3'; bccomp($price1, $price2, 2) === 0; // true 比較方法の章で使ったコード

Slide 53

Slide 53 text

関数にしてみる

Slide 54

Slide 54 text

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'); メッセージを出力し、比較する関数

Slide 55

Slide 55 text

全部stringだけど 数値として扱いたくないだろうか?

Slide 56

Slide 56 text

そもそもbcmathを忘れずに 全ての箇所で使⽤できるだろうか?

Slide 57

Slide 57 text

プリミティブ型 ● stringにはじまりPHPには多くのプリミティブ型が存在する ○ Javaでは byte / char / short / long ○ JavaScriptでは symbol / undefined / null ○ Golangでは complex64 / rune / uintptr ● プリミティブ(=原始的)というように汎用的な型 ○ 汎用的で何でもできて容易に壊せる ○ 壊さないように使い方を脳に留めておく必要があり辛い 型宣言

Slide 58

Slide 58 text

引数の型を狭くして ⽤途を絞ろう

Slide 59

Slide 59 text

BCMath\Number ● BCMathはPHP8.4でBCMath\Numberというクラスが登場 ○ new BCMath\Number(‘0.1’) ○ string | int を引数として作れるBCMath用の型 ○ BCMathが提供している計算関数が実装されたクラス ● 数値の型を限定して、それ以外のstringと区別するべき ○ 数値も通常文字列もstringなのは緩すぎる ○ 静的解析でも数字になっているかどうかは指摘されない ○ 実行時エラーになる 型宣言

Slide 60

Slide 60 text

function 二つの値を計算して比較する関数 ( \BCMath\Number $expected, \BCMath\Number $actualLeftOperand, \BCMath\Number $actualRightOperand, ): bool { $actual = $actualLeftOperand->add($actualRightOperand, 2); return $expected->compare($actual, 2) === 0; } BCMathを使って書き直す

Slide 61

Slide 61 text

二つの値を計算して比較する関数 ( new \BCMath\Number('0.3'), new \BCMath\Number('0.1'), new \BCMath\Number('0.2'), ); 呼び出しコードに数値しか渡せなくなった

Slide 62

Slide 62 text

型を狭くする利点 ● 仕方なくstringとして扱っていた数字が正しく数値になった ● BCMath\Numberを使うとメソッドが自動補完される ○ 任意精度演算を忘れない 型宣言

Slide 63

Slide 63 text

二つの値を計算して比較する関数 ( '0.3', '0.1', '0.2', ); 型が決まっていると数値であるべきことが明確 二つの値を計算して比較する関数 ( new \BCMath\Number('0.3'), new \BCMath\Number('0.1'), new \BCMath\Number('0.2'), );

Slide 64

Slide 64 text

次は、型にコードの意味を持たせる

Slide 65

Slide 65 text

AGENDA A 出来ていいことだけを出来るようにする 01 型宣⾔ 02 01 独⾃型定義 B

Slide 66

Slide 66 text

独⾃型を使って意図が分かりやすく、 使い⽅も分かりやすいものを提供する

Slide 67

Slide 67 text

汎⽤的な型のみの場合はどうなるか?

Slide 68

Slide 68 text

ユースケースの説明

Slide 69

Slide 69 text

今⽉の⽀払⾦額を計算→ドルに変換する

Slide 70

Slide 70 text

今⽉の⾦額に税率をかけて⽀払⾦額を計算 ⽀払⾦額に為替レートをかけてドルに変換 円とドル両⽅の⽀払⾦額を返す

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

表現の限界 ● 汎用的な型だけでは表現に限界がある ○ プリミティブ型、BCMath\Number ● 汎用的な型だけではメソッドを呼び出す時に取り違えられる ○ 正しい使い方しかできないようになってない ● ドメインが伝わらず用途を推測できない ○ ある値がドメインとして存在可能な値か分からない ○ 例えばマイナスはダメなど ● 引数に同じ型が横一列に並んでいると視認/保守性が悪い 独自型定義

Slide 74

Slide 74 text

どう表現するか?

Slide 75

Slide 75 text

プリミティブ型より ドメイン固有の型を 【62】プリミティブ型よりドメイン固有の型を

Slide 76

Slide 76 text

ValueObjectを使う

Slide 77

Slide 77 text

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はこんなの

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

使い⽅を間違えない

Slide 81

Slide 81 text

型が違うとちゃんと怒られる 型が違う

Slide 82

Slide 82 text

追加要望

Slide 83

Slide 83 text

前⽉からの繰越⾦額を扱う必要が出てきた

Slide 84

Slide 84 text

例えば 1000円に満たなければ 翌⽉の⽀払いとするような場合

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

function 今月金額と前月金額から最終円金額を計算する ( CurrentPriceJPY $今月金額, TaxRate $今月税率, PrevPriceJPY $前月金額, TaxRate $前月税率, ): TotalPriceTaxinJPY { return new TotalPriceTaxinJPY( $今月金額->applyTax($今月税率), $前月金額->applyTax($前月税率), ); } 処理もやれることをやるだけ

Slide 87

Slide 87 text

伝わりやすさ重視でコードを書いたが、 本来はもっと複雑なコードなはず

Slide 88

Slide 88 text

前⽉の繰越⾦額が出てきてたり マージンの概念が出たり 配信障害などの補填をしたくなったり ⼊⾦レポートが会社ごとに違うフォーマットだったり 買い切りでの取引が始まったり 暫定で⽀払いがしたくなったり 暫定の差額を計算する必要が出てきたり 計上科⽬を区別したくなったり 請求が出てきたり インボイスが出てきたりインボイスが出てきたり

Slide 89

Slide 89 text

コードが正しいかどうかに⾃信を持てるのは⼤事

Slide 90

Slide 90 text

⾃分⾃⾝のことは⾃⾝で答えられるようにする

Slide 91

Slide 91 text

どうして縛るのか? ● 引用 ○ 問題領域の知識を活用して固有の型を作ることで、取りえる組み 合わせを大幅に減らせる ○ 「出来てはならぬことを禁じる」のではなく、はじめから「出来 ていいことだけを出来るようにする」と考えるのです ○ 状態だけでなく「ふるまい」もカプセル化する 独自型定義 【89】関数の「サイズ」を小さくする 【103】見知らぬ人ともうまくやるには 【31】状態だけでなく「ふるまい」もカプセル化する

Slide 92

Slide 92 text

どこまで縛るか ● 間違った処理を書かないために型を分ける ○ ドメインとして明確に処理を区別したい場合は型を分ける ○ スコープが小さくない変数も制約があった方が安全 ● 重要でないロジックを細かく型を分ける必要はないと思っている ○ コードを書く時のコストの方が辛くなってくる ○ 例えばログメッセージ、スコープが2, 3行だけの変数など 独自型定義

Slide 93

Slide 93 text

全体の設計として どこでBCMath\Numberにして、 どこでValueObjectにするのか

Slide 94

Slide 94 text

型を狭くする利点 型宣言 string BCMath\Number CurrentPriceJPY

Slide 95

Slide 95 text

まとめ

Slide 96

Slide 96 text

No day but TODAY CARTA HOLDINGSについて 約 53% 電 通 (株) VOYAGE GROUP 約 47% 既存株主 事業例 サービス例 CARTA HOLDINGSについて

Slide 97

Slide 97 text

まとめ ● 基本的には精度を求められる小数計算全てにBCMathを使う ○ BCMathを使う場合、数値はstringで扱うことを第一に考える ● 変数の型は用途を狭く限定する ○ スコープも狭くし、広い場合は型をフルに使って限定する ● PHPのバージョンは上げる分だけメリットが増える ○ BCMathの関数 ○ BCMath\Number ○ readonly ○ 速度 まとめ

Slide 98

Slide 98 text

Q & A ● ドメインの中にBCMathみたいな外部ライブラリ入るのが気になる ○ BCMathは純正拡張なので保守されなくなるような痛みは少ない ○ 出来ることもシンプルでGMPなどへの入れ替えも大変ではない ● BCMathによる計算時間増加が気になることもある ○ 前処理(ELT/ETL)でまとめておく可能性も考える ○ ただ、例えばsnowflakeはコンピュータ誤差は発生する ○ そもそもintで全部扱い小数が発生しないならBCMathも必要ない まとめ