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

Dartにおける数値演算のパラダイム:IEEE 754の呪縛とコンパイラ最適化の境界線

Dartランタイムの深部、すなわちDart VMのJIT/AOTパイプラインや、JavaScriptへのトランスパイル先であるWebターゲットにおいて、数値型(`num`, `int`, `double`)の扱いは単なる「数学的な計算」を超えたハードウェアとランタイムの契約である。

シニアエンジニアであれば、`0.1 + 0.2 != 0.3` という普遍的な浮動小数点数の罠に一度は直面したことがあるだろう。しかし、それを単なる「仕様だから仕方ない」で片付けるか、Dartの型システム、VMの表現形式(Untagged Pointer vs Tagged SMI)、そしてクロスプラットフォーム実行時のセマンティクスの違いまで理解してコードを書くかによって、金融システムや暗号学的処理の堅牢性は根本から変わる。

本稿では、Dartにおける数値型の低レイヤ表現、IEEE 754倍精度浮動小数点の構造、そして丸め誤差を完全に回避するための実践的なアーキテクチャ設計について、妥協なきコードと共に解説する。

—

1. Dart VMにおける数値のメモリ表現:`int` と `double` の二面性

Dartのすべての値はオブジェクトである。しかし、パフォーマンスの観点から、整数値や浮動小数点数がすべてJavaの`java.lang.Integer`のような冗長なヒープオブジェクトとしてアロケーションされていたのでは、Flutterの60fps/120fpsのレンダリングパイプラインは秒速でGC(ガベージコレクション)の嵐に溺れることになる。

64bitアーキテクチャとTagged Pointer(SMI)

Dart VM(Flutterのネイティブ実行環境)では、64bitアーキテクチャ上において、整数の最適化に SMI(Small Integer) 方式を採用している。

  • SMI(Small Integer): 値の最下位ビット(LSB)を `0` にすることで、それがポインタではなく即値(Immediate value)であることを示す。これにより、64bitレジスタ内(実際には63bit符号付き)に収まる整数は、ヒープ上のオブジェクトヘッダを一切持たず、CPUレジスタ上で直接演算される。
  • Heap Number / Double: SMIの範囲を超える整数、またはすべての `double` 型の値は、VMのヒープ上にアロケーションされるか、あるいはAOTコンパイルされたコード内においてSIMDレジスタ(FPU)上で直接処理される。

しかし、ここで重大な問題が生じる。Web(dart2js / DDC)にコンパイルされた場合、Dartの `int` も `double` もすべてJavaScriptの単一の数値型である「倍精度浮動小数点数(IEEE 754 `double`)」に丸められるという点だ。

void examineRuntimeTypes() {
num a = 10;
num b = 10.0;

// ネイティブVMでは挙動が異なるが、Webターゲットでは同一視されるリスクがある
print(a is int); // true (Native)
print(b is double); // true (Native)
}

この「プラットフォームによる数値表現の揺らぎ」を理解していないと、クロスプラットフォーム(Flutter Mobile + Flutter Web)でビット単位の正確性が求められる処理(暗号学的ハッシュの計算、厳密な金融勘定など)を実装した際に、予期せぬバグの温床となる。

—

2. IEEE 754倍精度浮動小数点数の限界と精度の壁

`double`(およびDartの `num` が内包する浮動小数点表現)は、IEEE 754規格に基づく64ビット浮動小数点数である。

  • 符号部 (Sign): 1ビット
  • 指数部 (Exponent): 11ビット
  • 仮数部 (Mantissa / Fraction): 52ビット(暗黙の最上位ビット `1` を含めると実質53ビット)

53ビットの仮数部が表現できる最大の整数値(安全な整数範囲)は $2^{53} – 1$ すなわち `9,007,199,254,740,991`(約9京) である。これを超える整数や、10進数の少数を2進数で厳密に表現できない値(例: `0.1`)を扱う場合、必ず丸め誤差(Rounding Error)が発生する。

誤差の発生メカニズム

void demonstratePrecisionLoss() {
double a = 0.1;
double b = 0.2;
double c = a + b;

print(c); // 出力: 0.30000000000000004
print(c == 0.3); // false!
}

この現象は、`0.1`($1/10$)という10進数が、2進小数において無限巡回小数($0.0001100110011…_2$)になることに起因する。限られた52ビットの仮数部に押し込める段階で丸められるため、演算後に微小な誤差が蓄積される。

—

3. 金融計算における極限の防御:Fixed-Point(固定小数点数)の自社実装

金融システム(決済、利息計算、税金計算)において、`double`型をそのまま用いることは「システム障害を引き起こすリスクを放置している」と同義である。

これに対するシニアエンジニアの回答は、「整数(`int`)を用いた固定小数点演算(Fixed-Point Arithmetic)」への置き換えである。通貨の最小単位(日本円なら1円、米ドルならセント、あるいはサトシ単位)を `int` で保持し、計算の最後に適切な丸め処理を行う。

以下のプロダクションクオリティの堅牢な `Money` クラスを見てほしい。これは浮動小数点の呪縛から完全に解放された安全な数値ドメインモデルの模範解答である。

class Money implements Comparable {
// すべての内部計算は最小通貨単位(例: セント、銭)の「整数」で行う
// 例: $10.50 -> 1050 単位
final int _units;

const Money._(this._units);

/// 浮動小数点数からの生成は丸め誤差のリスクを明示的に断つため、
/// ファクトリコンストラクタでスケール(小数点以下の桁数)を指定させる。
factory Money.fromDecimal(double amount, {int scale = 2}) {
final factor = _pow10(scale);
// 四捨五入を適用して整数化
return Money._((amount factor).round());
}

factory Money.fromInt(int units) => Money._(units);

static int _pow10(int power) {
int result = 1;
for (int i = 0; i < power; i++) { result = 10; } return result; } Money operator +(Money other) => Money._(_units + other._units);
Money operator -(Money other) => Money._(_units – other._units);

Money multiply(double factor) {
// 乗算時はdoubleを受け入れるが、最終結果は整数に丸める
return Money._((_units factor).round());
}

/// 厳密な割り算と端数処理(銀行家の丸め / Half-Even Roundingを模範とする)
Money divide(int divisor) {
if (divisor == 0) throw ArgumentError(‘Division by zero’);
// 整数割り算における四捨五入のロジック
final quotient = _units / divisor;
return Money._(quotient.round());
}

@override
int compareTo(Money other) => _units.compareTo(other._units);

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

@override
int get hashCode => _units.hashCode;

@override
String toString() {
// 表示の時のみ小数点の位置を復元する
final dollars = _units ~/ 100;
final cents = (_units % 100).abs().toString().padLeft(2, ‘0’);
return ‘\$$dollars.$cents’;
}
}

void main() {
// 安全な金融計算のデモ
final m1 = Money.fromDecimal(0.1);
final m2 = Money.fromDecimal(0.2);
final sum = m1 + m2;

print(‘Sum: $sum’); // 出力: $0.30
print(‘Equals 30 cents? ${sum == Money.fromDecimal(0.3)}’); // true
}

—

4. `num`, `int`, `double` の適切な使い分け基準

アーキテクチャ設計において、どの型を選択すべきかの指針は以下の通りである。

1. `int` を選択すべきケース

  • ループカウンタ、インデックス、ID、ビット演算、そしてすべての金融・カウンタ値。
  • プラットフォーム間で一貫した64bit整数演算が保証される(VM/AOT)。ただしWebでの扱いに注意(JSの安全な整数は $2^{53}-1$ まで)。

2. `double` を選択すべきケース

  • 3Dグラフィックス座標(Flutterのレイアウト、OpenGL/Impellerとの連携)、物理演算、音声・画像処理、機械学習のテンソル計算など、「多少の誤差が結果に致命的な影響を与えない、あるいはパフォーマンスが最優先される」領域。

3. `num` を選択すべきケース

  • ジェネリクスや共通の数学ユーティリティ関数(例: `T clamp(T val, T min, T max)`)において、`int` と `double` の両方を受け入れたい場合のみに使用する。
  • ビジネスロジックの引数や戻り値に `num` を露出させるのは設計上のアンチパターンである。型安全性がボケ、呼び出し元で不必要な型キャストや潜在的バグを生む。

—

5. まとめ:ランタイムの制約を支配するエンジニアへ

Dartは、シンプルでモダンな構文の裏側で、強力なコンパイラ最適化とネイティブ/Webのデュアルランタイム環境を支える複雑なコンテキストを持っている。

浮動小数点の誤差問題は、「Dartの仕様の欠陥」ではない。それはハードウェアレベルでの数学的限界であり、言語やランタイムがそれを完全に隠蔽することは原理的に不可能である。

真に優れたアーキテクトとは、ランタイムやハードウェアの限界(IEEE 754の制約、VMのメモリ表現、ターゲットプラットフォームごとの挙動の違い)を熟知し、その上で破綻のない堅牢な型設計とデータ構造(Fixed-Point等)を構築できる者のことである。

コードの1行、数値型の選定の1つにまで意図を持たせよ。それこそが、プロダクションの荒波を生き抜く唯一の道である。

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