【実務・中級編】Dartの数値型における精度問題:doubleとnumの使い分けと丸め誤差の回避 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

開発チームの皆さん、コードレビューお疲れ様です。テクニカルリードの私だ。

今日のレビューで、また「あのバグ」の芽を見つけた。フロントエンドから渡ってきた金額データや、API連携で取得した小数をそのままUIにバインドし、計算処理を行っているコードだ。

「合計金額が `0.30000000000000004` になる」「消費税の計算で1円ズレる」
……まさか、君たちの書いたコードでこんな初歩的な不具合が起きていないだろうな?

Webエンジニアであれば、JavaScriptの `0.1 + 0.2 !== 0.3` のトラップは常識として知っているはずだ。だが、Dartの世界でも、浮動小数点演算の本質は何も変わらない。Dartの `double` はIEEE 754倍精度浮動小数点数であり、CPUレベルでそれを処理している以上、避けて通れない精度の限界(丸め誤差)が存在する。

今回は、Dartの数値型(`num`, `int`, `double`)の内部表現とコンパイラ・VMの挙動を解き明かし、実務の現場で絶対にバグを踏まないための「堅牢な数値設計パターン」を伝授する。

—

1. Dartの数値型アーキテクチャ:`num`, `int`, `double` の正体

まず、Dartの型階層をメモリとコンパイルの観点から正確に理解しよう。

num (抽象クラス)
/ \
int double

`num` は `int` と `double` の共通の親クラスだが、実務において `num` を安易に型注釈に使うのはバグの温床となる。なぜなら、`num` を使った瞬間、コンパイル後の値が整数(int)なのか浮動小数点(double)なのかが静的に確定せず、型チェックや実行時のボックス化(Boxing)のオーバーヘッド、あるいは予期せぬ暗黙の型昇格(`int` との演算による `double` 化)を引き起こすからだ。

AOT / JIT コンパイルにおける数値の現実

  • Dart VM (JIT / Flutter Debug): 数値はオブジェクトとして扱われるが、最適化コンパイラ(IC/Type Feedback)によってインライン展開される。
  • Dart2JS / Dartify (Web): Dartの `double` および `int` は、最終的にJavaScriptの `Number`(倍精度浮動小数点数)にコンパイルされる。つまり、Webターゲットでは、`int` であってもJavaScriptの安全な整数範囲(`Number.MAX_SAFE_INTEGER`: $2^{53} – 1$)を超えると精度が消失する。

このハードウェアとコンパイルの制約を無視して、「なんとなく動くから」と `double` で金融計算を書くのは、時限爆弾を抱えているのと同義だ。

—

2. 金融・決済・厳密な計算におけるアンチパターン

次のコードを見てほしい。コードレビューでこれを提出されたら、即座に差し戻しだ。

// 【絶対にしてはいけないアンチパターン】
double calculateTotal(double price, int quantity, double taxRate) {
// 小数同士の掛け算と足し算をそのまま行う
double subtotal = price quantity;
double tax = subtotal taxRate;
return subtotal + tax;
}

void main() {
// price = 100.0, quantity = 3, taxRate = 0.1 (10%)
print(calculateTotal(100.0, 3, 0.1));
// 期待値: 330.0
// 実際の出力: 330.00000000000006 (ファッ!?)
}

なぜこうなるのか? `0.1` という十進数は、二進浮動小数点数では無限小数になるため、正確に表現できず、演算の過程で微小な誤差が蓄積するからだ。これをそのままユーザーの画面に表示したり、DBに保存したりすれば、バックエンドとの整合性が崩れ、致命的なクレームに発展する。

—

3. 現場で使える堅牢な設計パターン:整数演算へのスケーリング

では、Dartでこの誤差を完全に回避し、堅牢な計算処理を実装するにはどうすればいいか?

答えはシンプルだ。「小数を扱わず、最小通貨単位(例: 円なら銭、ドルならセント)の整数(`int`)にスケールアップして計算する」。これがプロのバックエンド/フロントエンドエンジニアが取るべきアプローチだ。

以下のプロダクションコードを見てほしい。保守性が高く、イミュータブルで、かつ安全な金額ドメインモデルの例だ。

/// 【プロダクションコード例】
/// 浮動小数点の誤差を排除した安全な金額・数値演算クラス
class Money {
// 内部では常に「最小単位(例: 銭 / 1円の1/100)」の整数として保持する
final int _amountInCents;

const Money._(this._amountInCents);

/// 円単位のdoubleやintから生成(例: 100.50円 -> 10050銭)
factory Money.fromDouble(double amount) {
// 四捨五入して整数化することで、浮動小数点の初期ノイズを除去する
return Money._((amount 100).round());
}

/// 整数(最小単位)から直接生成
const Money.fromCents(int cents) : _amountInCents = cents;

/// 表示用のdouble値に変換(UI層でのバインド用)
double get toDouble => _amountInCents / 100.0;

/// 加算
Money operator +(Money other) {
return Money.fromCents(_amountInCents + other._amountInCents);
}

/// 減算
Money operator -(Money other) {
return Money.fromCents(_amountInCents – other._amountInCents);
}

/// 数量(int)との掛け算
Money multiply(int quantity) {
return Money.fromCents(_amountInCents quantity);
}

/// 税率(例: 10% -> 0.1)を掛け、四捨五入して新しいMoneyを返す
Money applyTax(double taxRate) {
// 税額を計算し、丸め処理(round)を必ず行う
final taxCents = (_amountInCents taxRate).round();
return Money.fromCents(_amountInCents + taxCents);
}

@override
String toString() => ‘¥${toDouble.toStringAsFixed(2)}’;

@bool
bool operator ==(Object other) =>
identical(this, other) ||
other is Money && runtimeType == other.runtimeType && _amountInCents == other._amountInCents;

@override
int get hashCode => _amountInCents.hashCode;
}

// — 実行例 —
void main() {
final price = Money.fromDouble(100.0); // 100.00円
final subtotal = price.multiply(3); // 300.00円
final total = subtotal.applyTax(0.1); // 10%込 -> 330.00円

print(total); // 出力: ¥330.00 (完全に正確!)
}

この設計が優れている理由

1. 誤差の完全隔離: コンストラクタの段階で `(amount 100).round()` を通すことで、APIから飛んできた不完全な `double` の浮動小数点ノイズを切り捨てている。
2. 型安全性: 単なる `double` ではなく `Money` 型としてカプセル化しているため、開発者が誤って `money1 money2`(金額同士の掛け算という無意味な演算)をコンパイルエラーで弾くことができる。
3. 演算の順序保証: 割り算や税率計算を行う際は、必ず最後に `.round()` や適切な丸め関数を通す設計を強制できる。

—

4. UI・表示層における丸め処理のベストプラクティス

計算は整数で行うべきだが、UIへの表示(フロントエンド)やAPIへの送信時にはどうしても `double` や文字列フォーマットが必要になる。

ここでDartの `double` が持つ標準メソッドの挙動に注意してほしい。

  • `toStringAsFixed(n)`: 指定した小数点以下桁数に四捨五入(正確には環境依存だが概ね四捨五入)して文字列にする。UI表示にはこれを使う。
  • `.round()`, `.floor()`, `.ceil()`: それぞれ四捨五入、切り捨て、切り上げ。

ただし、JavaScript環境(Web)で動作するDart2JSでは、IEEE 754の丸め誤差(いわゆる「銀行家の丸め」や無限小数の表示崩れ)が稀に顔を出す。
厳密な端数処理(切り捨て・切り上げ・四捨五入の厳密な指定)が必要な業務アプリケーションでは、前述したような 「整数演算ベースの自前丸めロジック」 を実装するのが最も堅牢だ。

—

テクニカルリードからの総括

  • `num` は使うな: 静的型付けの恩恵を受けるため、明示的に `int` または `double` を使い分けろ。
  • 金融・数量の計算に `double` を直使いするな: 浮動小数点演算の誤差は言語の仕様ではなく数学的・ハードウェア的限界だ。
  • スケーリング戦略を徹底せよ: 金額などは最小単位の `int` に変換し、ドメインモデル(Value Object)としてカプセル化せよ。

「動けばいい」ではなく、「なぜその型を使い、どうメモリとCPUが処理するか」を意識したコードを書くこと。それがプロのエンジニアだ。

次のプルリクエストでは、この設計思想が綺麗に反映されていることを期待している。レビューを終わる。

タイトルとURLをコピーしました