Featured image of post コード探偵ロックの事件簿【Money Pattern】消えた1円の行方〜浮動小数点が生んだ幽霊通貨〜

コード探偵ロックの事件簿【Money Pattern】消えた1円の行方〜浮動小数点が生んだ幽霊通貨〜

浮動小数点数での金額計算による1円の誤差と、通貨コードの分離による異通貨加算バグを、Money Value Objectで構造的に解決する。Perl/Mooでの整数セント表現、演算子オーバーロード、通貨不一致検出まで実装付きで解説。

事務所への来客——予約どおりの15時

雑居ビルの三階。階段を上がって、ドアの前で立ち止まる。スマホの時刻は15:00。メールには「木曜の15時に来たまえ」とだけ書かれていた。差出人は「ロック」。署名もなければ、会社概要のURLもない。

社外の技術ブログのコメント欄でLCIの名前を見つけたのは、先月の障害の事後分析レポートを読み漁っていた夜のことだ。「この手の問題ならLCIに相談してみては」——それだけの情報を頼りにサイトを見つけ、メールを送った。返信は一行。

ドアをノックする。

「開いている」

押し開けると、デスクの上にCasioの関数電卓——fx-991——の筐体と基板が並んでいた。男がひとり、虫眼鏡を片手に基板のチップを覗き込んでいる。くたびれたジャケット。エナジードリンクの缶は1本だけ。飲みかけ。

「このチップはBCD演算ユニットだ。10進数をそのまま扱う。浮動小数点を使わない。だから電卓は 0.1 + 0.2 を正確に 0.3 と表示できる」

虫眼鏡から目を離さずに言う。

「——一方、君のサーバーは?」

「あの、15時のお約束で伺いました。決済チームの——」

「ああ、メールの」ようやく虫眼鏡を置いた。「金額が1円ずれる件と、通貨が混ざる件だね。——見せたまえ、ワトソン君」

一拍、間が空いた。

「……それ、やめていただけますか」

ロックさんは気にする素振りすらなく、椅子をくるりと回してデスクに向かった。

「コードを見せたまえ、と言っているんだ」

まあ、呼び方より中身だ。バッグからノートPCを取り出す。

2つの障害を説明する。淡々と、だが語尾に力がこもる。

「先月、請求金額が1円ずれる障害が本番で出ました。原因は税率計算の浮動小数点丸め誤差です。修正はしましたが、対症療法です」

「修正の翌週、今度は別の箇所で、USD建ての送金額にJPY建ての手数料が加算されるバグが出ました。コード上は $amount + $fee で、どちらもただの数値です。通貨が別変数で管理されていて、足し算の時点ではチェックがありません」

「2つの障害の根っこが同じなのか別なのか、わたしにはわかりません。わかっているのは、金額の計算が信用できない状態だということです」

ロックさんはノートPCの画面をこちらに向けるよう促しながら、静かに言った。

「2つの事件は根が同じだ。金額がValue Objectになっていない——つまり、金はただの数字として放置されている。数字には名前がなく、通貨がなく、振る舞いがない。名前のない金は、幽霊と同じだよ」

容疑者の浮上——幽霊通貨の正体

ロックさんがノートPCの画面を覗き込む。わたしの決済モジュールのコードが映っている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package Payment::Invoice;
use v5.36;
use Moo;
use Types::Standard qw(Num Str ArrayRef);

has amount   => (is => 'ro', isa => Num, required => 1);
has currency => (is => 'ro', isa => Str, required => 1);
has items    => (is => 'ro', isa => ArrayRef, default => sub { [] });

sub total ($self) {
    my $sum = 0;
    for my $item ($self->items->@*) {
        $sum += $item->{price} * $item->{quantity};
    }
    return $sum;
}

sub add_tax ($self, $tax_rate) {
    return $self->total * (1 + $tax_rate);
}

sub add_fee ($self, $fee_amount) {
    return $self->total + $fee_amount;
}

1;

price は何型だね?」ロックさんが $sum += $item->{price} * $item->{quantity} の行を指でなぞる。

「Numです。浮動小数点数」

「そして currency は別のフィールドにいる。add_fee を見たまえ。$self->total + $fee_amount——この $fee_amount の通貨は誰が保証しているかね?」

「……呼び出し側の責任です。ドキュメントには『同一通貨で渡すこと』と書いてあります」

ロックさんがデスクの上の電卓基板をちらりと見た。

「ドキュメントで防ぐ。——先日の依頼人も同じことを言っていたよ」

何の話かわからなかったが、「ドキュメントで防ぐ」が不十分だという指摘は理解できた。

「容疑者は二人いる」ロックさんが言った。「第一の容疑者——浮動小数点数。証拠を見せよう」

ロックさんは自分のThinkPad——電卓の隣に置いてあった年代物——のターミナルを開いた。

1
2
$ perl -e 'print 0.1 + 0.2, "\n"'
0.30000000000000004

画面を凝視する。息が止まった。

「……知識としては知っていました。IEEE 754。でも——」

「知っていることと、自分のコードに当てはまると認識することは別の能力だよ、ワトソン君。もう1つ」

1
2
$ perl -e 'my $s = 0; $s += 0.01 for 1..100; print $s, "\n"'
0.999999999999998

「0.01を100回足して……1にならない」自分でも声のトーンが上がったのがわかった。

「税率8%を掛けた瞬間、結果は近似値になる。その近似値を sprintf '%.0f' で丸めたとき、1円上に行くか下に行くかは入力値の組み合わせで変わる。再現条件が複雑すぎて人間には追えない。——だが整数なら、1 + 1 は常に 2 だ」

「つまり、金額を最初から整数で……セント表現?」

「そうだ。$19.99は整数1999。¥1000はそのまま整数1000。日本円には小数がないからな。最小通貨単位の整数で保持すれば、加減算から浮動小数点が消える

ロックさんは一呼吸置いて、続けた。

「第二の容疑者——通貨の不在

わたしのコードの add_fee を指さす。

$self->total + $fee_amount。Perlはこの2つの数値を足す。通貨のことは何も知らない。金額と通貨が別の変数にいる限り、コードは通貨を無視する自由を持つ」

言葉を切って、こちらを見る。

「金額と通貨は、パスポートの写真と名前のようなものだ。剥がした瞬間、誰の顔かわからなくなる」

「だからMoneyオブジェクトで一体化する、ということですか」

「正確には、分離不可能にする。金額だけを取り出して裸で演算することを、構造的に不可能にするのだ」

鮮やかなリファクタリング——Money Value Object

ロックさんがThinkPadをわたしのノートPCの隣に並べた。

「Moneyクラスを作る。金額は整数。通貨は文字列。どちらも ro——不変だ」

ステップ1: 骨格

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package Money;
use v5.36;
use Moo;
use MooX::StrictConstructor;
use Types::Standard qw(Int Str);
use Carp qw(croak);

use overload
    '+'   => \&_add,
    '-'   => \&_subtract,
    '*'   => \&_multiply,
    '=='  => \&_equal,
    '!='  => \&_not_equal,
    '""'  => \&_stringify,
    '<=>' => \&_compare,
    'neg' => \&_negate,
    fallback => 0;

has amount => (
    is       => 'ro',
    isa      => Int,
    required => 1,
);

has currency => (
    is       => 'ro',
    isa      => Str,
    required => 1,
);

fallback => 0 って何ですか?」

「定義していない演算子が使われたとき、Perlが勝手に推測して動かすのを禁止する宣言だ。fallback => 1 なら $money / $money2 のような未定義の演算が暗黙的に動き得る。fallback => 0 なら即座にエラーだ。許可していない操作がエラーになることこそが安全だ

「ホワイトリスト方式ですね。許可した演算だけが動く」

ステップ2: 通貨チェックと加減算

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
sub _assert_same_currency ($self, $other) {
    croak sprintf(
        'Currency mismatch: cannot operate %s with %s',
        $self->currency, $other->currency
    ) unless $self->currency eq $other->currency;
}

sub _add ($self, $other, $swap) {
    croak 'Cannot operate with non-Money value'
        unless ref $other && $other->isa('Money');
    $self->_assert_same_currency($other);
    return Money->new(
        amount   => $self->amount + $other->amount,
        currency => $self->currency,
    );
}

sub _subtract ($self, $other, $swap) {
    croak 'Cannot operate with non-Money value'
        unless ref $other && $other->isa('Money');
    $self->_assert_same_currency($other);
    my ($left, $right) = $swap ? ($other, $self) : ($self, $other);
    return Money->new(
        amount   => $left->amount - $right->amount,
        currency => $self->currency,
    );
}

_add の冒頭を見たまえ。まず $other がMoneyオブジェクトかどうかを確認する。次に通貨が同じかを確認する。どちらかが満たされなければ、即座に死ぬ

$amount_usd + $fee_jpy を書いたら croak する」

「正確には、$usd_money + $jpy_money を書いたら croak する。裸の数値とは足せないし、異なる通貨とも足せない。二重の防壁だ」

あの通貨混合バグが頭をよぎった。USD建ての送金額にJPY建ての手数料を加算してしまったコード。もしMoneyオブジェクトを使っていたら——コードが動く前にエラーになっていた。

ステップ3: スカラー乗算

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
sub _multiply ($self, $other, $swap) {
    croak 'Cannot multiply Money by Money'
        if ref $other && $other->isa('Money');
    croak 'Multiplier must be a number'
        unless defined $other && !ref $other;
    return Money->new(
        amount   => int($self->amount * $other + 0.5),
        currency => $self->currency,
    );
}

「掛け算の $other がMoneyだったらエラーにしてる。Money × Moneyを禁止してるんですね」

「100円 × 200円は何になるかね? 20000 円円? ——意味がないだろう。Moneyの掛け算はスカラー倍だけが正当だ。3個買うなら $price * 3。税込み計算なら $price * 1.08許可する演算と禁止する演算を型で宣言するのがValue Objectの仕事だ

int($self->amount * $other + 0.5) は……四捨五入ですか?」

「最も単純な丸めだ。本番では丸め戦略を引数で受け取るか、別のメソッドに切り出すのが望ましい。Ward Cunninghamは『Moneyオブジェクト自体は丸めるべきではない。丸めはトランザクションのコンテキストで行うべき』と言っている。今回は構造の説明を優先して簡略化した」

ステップ4: 等価性と文字列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
sub _equal ($self, $other, $swap) {
    return 0 unless ref $other && $other->isa('Money');
    return $self->amount == $other->amount
        && $self->currency eq $other->currency;
}

sub _not_equal ($self, $other, $swap) {
    return !$self->_equal($other, $swap);
}

sub _compare ($self, $other, $swap) {
    croak 'Cannot compare non-Money'
        unless ref $other && $other->isa('Money');
    $self->_assert_same_currency($other);
    my ($left, $right) = $swap ? ($other, $self) : ($self, $other);
    return $left->amount <=> $right->amount;
}

sub _negate ($self, @) {
    return Money->new(
        amount   => -$self->amount,
        currency => $self->currency,
    );
}

sub _stringify ($self, @) {
    return sprintf '%s %d', $self->currency, $self->amount;
}

== は金額通貨の両方が一致して初めて true だ。USD 1999 と JPY 1999 は等しくない。——これがValue Objectの等価性だ」

属性の値で等しさを判定する。Entity vs Value Objectの概念——どこかで読んだ記憶がある。MoneyにIDはない。金額と通貨が同じなら、同じMoneyだ。

ステップ5: ファクトリメソッド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
my %CURRENCY_MINOR_UNIT = (
    JPY => 1,
    USD => 100,
    EUR => 100,
    GBP => 100,
);

sub from_major ($class, $major_amount, $currency) {
    my $unit = $CURRENCY_MINOR_UNIT{$currency}
        // croak "Unknown currency: $currency";
    return $class->new(
        amount   => int($major_amount * $unit + 0.5),
        currency => $currency,
    );
}

sub to_major ($self) {
    my $unit = $CURRENCY_MINOR_UNIT{$self->currency}
        // croak sprintf('Unknown currency: %s', $self->currency);
    return $self->amount / $unit;
}

1;

「人間は 19.99 と書きたがるし、表示も $19.99 でしてほしい。from_major は人間向けの数値を整数に変換し、to_major は逆方向だ。内部は常に整数、外部とのやりとりにだけ変換を使う

「JPYの minor_unit が1になってる。日本円は小数点以下がないから、そのまま」

「ISO 4217では通貨ごとに小数点以下の桁数が定義されている。日本円は0、米ドルは2、バーレーン・ディナールは3だ。この %CURRENCY_MINOR_UNIT が通貨ごとの違いを吸収する」

MooX::StrictConstructor にも目が止まった。タイプミスが即座にエラーになる仕組み——不変オブジェクトでは構築時の正しさが全て、という話をどこかで聞いたような気がする。

「Moneyは不変だ。$money->amount(500) とは書けない。金額を変えたければ、新しいMoneyを作る」ロックさんが言った。「不変性はMoneyの前提条件であって、オプションではない。100円が勝手に200円に変わる紙幣を信用する人間はいないだろう?」

確かに。金額が可変なら、共有されたMoneyオブジェクトの値がどこかで書き換えられるリスクがある。不変であれば安全に共有できる。

	classDiagram
    class Money {
        -Int amount
        -Str currency
        +amount() Int
        +currency() Str
        +from_major(amount, currency)$ Money
        +to_major() Num
        +_add(other) Money
        +_subtract(other) Money
        +_multiply(scalar) Money
        +_equal(other) Bool
        +_stringify() Str
    }

    class Invoice {
        +Money total_amount
        +add_tax(rate) Money
        +add_fee(Money fee) Money
    }

    Invoice --> Money : 金額はすべて Money
    note for Money "amount + currency 一体化\n整数セント表現\n演算子オーバーロード\n通貨不一致 = 即croak"

事件の終わり——テストが証明する金額の正体

ターミナルを開いた。ロックさんの指示でテストを書く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use Test::More;
use Test::Exception;

# === 整数精度テスト ===
my $a = Money->new(amount => 10, currency => 'USD');   # $0.10
my $b = Money->new(amount => 20, currency => 'USD');   # $0.20
my $c = $a + $b;
is $c->amount, 30, '整数加算: 10 + 20 = 30 (浮動小数点誤差なし)';

# === 通貨不一致テスト ===
my $usd = Money->new(amount => 1000, currency => 'USD');
my $jpy = Money->new(amount => 1000, currency => 'JPY');
dies_ok { $usd + $jpy } '異なる通貨の加算は例外';

# === Money × Money 禁止テスト ===
dies_ok { $usd * $jpy } 'Money同士の乗算は例外';

# === スカラー乗算テスト ===
my $price = Money->new(amount => 1999, currency => 'USD');
my $triple = $price * 3;
is $triple->amount, 5997, 'スカラー乗算: 1999 * 3 = 5997';

# === 不変性テスト ===
dies_ok { $price->amount(500) } '属性の書き換えは例外';
is $price->amount, 1999, '元のオブジェクトは不変';

# === 等価性テスト ===
my $same = Money->new(amount => 1999, currency => 'USD');
ok $price == $same, '同じ金額・同じ通貨は等しい';
my $diff_currency = Money->new(amount => 1999, currency => 'JPY');
ok $price != $diff_currency, '同じ金額でも通貨が違えば等しくない';

# === ファクトリメソッドテスト ===
my $from_dollars = Money->from_major(19.99, 'USD');
is $from_dollars->amount, 1999, 'from_major: $19.99 → 1999';
my $from_yen = Money->from_major(1000, 'JPY');
is $from_yen->amount, 1000, 'from_major: ¥1000 → 1000';

# === StrictConstructor テスト ===
dies_ok {
    Money->new(amout => 1000, currency => 'JPY');  # タイプミス
} 'タイプミスは例外';

done_testing;

テスト実行。全テストパス。グリーン。

モニターの All tests successful を見ながら、息を一つ吐いた。

「……全部通りました」

「通貨不一致はエラーになる。Money同士の掛け算もエラーになる。浮動小数点の丸め誤差は、そもそも発生しない」

ロックさんが静かに続けた。

犯人は裸の数値だった。名前のない金が、名前のあるMoneyになった。それだけのことだ」

「それだけのこと、ですか」わたしは画面を見つめたまま言った。「でも、それだけで2つの障害が両方消える」

「構造が正しければ、バグのカテゴリ全体が消える。1件のバグを潰すのではなく、バグが棲める場所を消すのだ」

探偵の調査結果——そしてドメインの深淵

ロックさんがThinkPadを閉じた。わたしもノートPCをバッグにしまいかける。

だが、一つだけ聞きたいことがあった。

「ロックさん。ドメインの深淵って、結局なんなんですか」

自分でもなぜそんな言葉が出たのかわからない。ただ、金額が「ただの数値」として放置されていた状況——名前もなく、境界もなく、振る舞いもない——それを「深淵」と呼びたくなった。

ロックさんは関数電卓の基板をデスクに戻しながら、少し間を置いた。

「型のない世界だよ。値が値としての権利——名前も、境界も、振る舞いも——を持たない世界だ。金額はただの数字。住所はただの文字列。注文はただのハッシュ」

それから、こちらを見た。

「ワトソン君、深淵を覗き返すには 型を与える 以外にないのだ」

数字に名前を与え、境界を定め、振る舞いを付与する。それはMoneyだけの話ではない。もっと大きな——うまく言葉にできないが、設計全体を貫く一本の線のようなものが見えた気がした。

「報酬は——そうだな」ロックさんがデスクの上を見渡す。「10進数で正確に表現できないドリンクを1本。0.1リットルのエスプレッソだ」

「……それ、普通のエスプレッソですよね」

「普通かどうかは浮動小数点に聞いてくれたまえ」

ドアに手をかけ、小さく苦笑した。

帰りの電車。スマホのメモアプリに Money->new(amount => 1999, currency => 'USD') と打ち込んでみる。

金額に名前がつくだけで、こんなに安心できるものなのか。数字が型になった瞬間、1円の幽霊は消えた。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
浮動小数点数での金額計算整数セント表現(最小通貨単位)加減算から丸め誤差が構造的に消滅
金額と通貨の分離管理Money Value Object(金額+通貨一体化)異なる通貨の演算が即座にエラー
裸の数値による暗黙的な型変換演算子オーバーロード+fallback => 0未定義の操作がエラーになる安全設計
ドキュメントによる通貨チェック_assert_same_currency による構造的保証実行時に通貨不一致を自動検出

推理のステップ

  1. 整数化: 金額を最小通貨単位の整数で保持する。USD $19.99 → 1999、JPY ¥1000 → 1000。通貨ごとの小数桁数(ISO 4217)に応じて変換係数を定義する
  2. 一体化: amount(整数)と currency(文字列)を1つのMoneyオブジェクトに封じ込める。is => 'ro' で不変性を保証する
  3. 演算子定義: overload+, -, *, ==, !=, <=> を定義する。加減算では通貨チェック、乗算ではMoney × Money禁止を組み込む
  4. 未定義操作の禁止: fallback => 0 で定義していない演算を即座にエラーにする
  5. ファクトリメソッド: from_major / to_major で人間向け数値との変換口を用意する。内部は常に整数
  6. StrictConstructor: MooX::StrictConstructor でコンストラクタのタイプミスを検出する。不変オブジェクトは構築時の正しさが全て

ロックより

金は、数字の中で最も嘘をつきやすい存在だ。小数点以下の幽霊が1円を食い、通貨の境界を持たない裸の数値が国境を無視する。

Value Objectとは、値に権利を与える行為だ。名前を与え、境界を定め、振る舞いを付与する。Moneyはその最も切実な実践であり——ドメインの深淵に型の光を差し込む最初の一歩だ。

ワトソン君、金を扱うなら、金に名前をつけたまえ。名前のない金は誰のものでもない。そして誰のものでもない金は、いつか必ず消える。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。