浮動小数点数

Basis
最終更新: タグ: Types

Simple Variable チェックポイントでは浮動小数点(floating-point)型を整数と並べて紹介し、より詳しい解説は後回しにすると予告した。このチェックポイントがその約束を果たす。読み終えるころには、Floatが内部でどのように数を表現しているか、適切なFloat型の選び方、NaNInf の本当の意味、見かけ上等しい二つのFloatがなぜ一致しないことがあるか、そしてFloatと整数型の間で値を安全に変換する方法が分かるようになる。

Floatの実体

整数(integer)は全体数をビットの固定パターンとして格納し、各ビットは2の累乗の桁値を持つ。Floatは異なる。一つのビットパターンの中に 符号(sign)指数(exponent)仮数(significand)mantissa とも呼ばれる)という三つの独立した情報を、2進数の科学的表記に似た形で符号化する。

支配的な標準は IEEE 754 であり、現代のハードウェアのほぼすべてが準拠しており、Zigもそれに従う。64ビットでのレイアウトは次の通りだ。

  • 1ビット:符号(0 = 正、1 = 負)
  • 11ビット:指数(数をスケールする2の累乗を符号化)
  • 52ビット:仮数(2進数での有効桁)
value=(1)sign×2exponent1023×1.significand\text{value} = (-1)^{\text{sign}} \times 2^{\text{exponent} - 1023} \times 1.\overline{\text{significand}}

この式を暗記する必要はない。重要なのはそこから導かれる帰結だ。Floatが表現できる値は有限の集合に限られる。そのグリッド点に正確に落ちない実数は、黙って最も近い表現可能な値に丸められる。この丸めが次のような古典的な驚きの源となる。

const std = @import("std");

pub fn main() void {
    const x: f64 = 0.1 + 0.2;
    std.debug.print("{d}\n", .{x}); // 0.30000000000000004 — 0.3 ではない!
}

0.10.2 も2進数では正確に表現できないため、それぞれ最近傍のグリッド点に丸められる。丸められた二つの値を加算すると、わずかにずれた結果になる。これはZigのバグではなく、あらゆる言語においてIEEE 754が動作する根本的な仕組みだ。

ZigのFloat型ファミリー

Zigは5つのFloat型を提供する。すべてIEEE 754またはその拡張に従う。

ビット幅仮数部ビット数有効十進桁数(概算)
f161610約3〜4桁
f323223約7桁
f646452約15〜16桁
f808064約18〜19桁
f128128112約34桁

各型をいつ選ぶかについて、いくつかの指針を示す。

  • f16 は「半精度(half precision)」。主に機械学習パイプラインやGPUシェーダーで、精度よりも帯域幅が重要な場面で使われる。表現可能な範囲は非常に限られており(概ね ±65 504)、有効十進桁数も3〜4桁しかないため、演算誤差が素早く蓄積される。
  • f32 は「単精度(single precision)」で、グラフィックス、ゲームエンジン、組み込みシステムの標準だ。多くのGPUシェーダー言語で float と呼ばれるものがこれにあたる。
  • f64 は「倍精度(double precision)」で、日常的な汎用型だ。Pythonの float、Cの double、Rustの f64 はすべてこれを指す。デフォルトでは f64 を使うこと。
  • f80 はx87拡張精度フォーマットで、中間計算に余裕を持たせる。x86上の科学計算で広く使われているが、すべてのプラットフォームで利用できるわけではない(ARMやWebAssemblyターゲットでは使用不可)。
  • f128 は「四倍精度(quad precision)」で、34桁の有効十進桁数を持つ。ハードウェアではなくソフトウェアで実装されることが多く、演算が数桁遅くなることがある。本当に極端な精度が必要なときにのみ使用すること。
const pi_f32:  f32  = 3.1415927;         // 約7桁 — 最後の桁は丸められる
const pi_f64:  f64  = 3.141592653589793;
const pi_f128: f128 = 3.14159265358979323846264338327950;

Floatリテラルの書き方

Floatリテラルには、小数点か指数(あるいは両方)が少なくとも一つ必要だ。なければコンパイラは整数として扱う。

const a: f64 = 3.0;    // 小数点あり → Floatリテラル
const b: f64 = 3e2;    // 指数のみ → Floatリテラル、値は 300.0
const c: f64 = 3.0e2;  // 両方あり → Floatリテラル、値は 300.0
// const d: f64 = 3;   // コンパイルエラー: 3 は整数リテラルであってFloatではない

Zigは16進数のFloatリテラルも受け付ける。正確な2進数表現が必要なときに役立つ。

const hex: f32 = 0x1.91eb86p1; // IEEE 754 バイナリで正確に 3.14

日常的な作業では十進リテラルの方が分かりやすい。16進Floatは低レベルの数値コード向けのニッチなツールだ。

整数リテラルと同様、素のFloatリテラルはコンパイル時専用の型 comptime_float を持ち、完全な十進精度を保持する。型付き変数に代入されたときに初めて具体的な型に絞り込まれる。

const tau = 6.283185307179586476925; // comptime_float — コンパイル時は完全精度
const tf: f32 = tau;                 // 約7桁の有効数字に絞り込まれる
const td: f64 = tau;                 // 約15〜16桁の有効数字に絞り込まれる

アンダースコアはFloatリテラルの任意の位置に置くことができ、コンパイラには無視される。

const planck: f64 = 6.626_070_15e-34; // プランク定数、読みやすくなる

Floatの算術演算

Floatは4つの算術演算子をサポートする。

演算子演算
+加算
-減算 / 単項否定
*乗算
/除算

Floatには % 剰余演算子がない。浮動小数点の剰余が必要な場合は標準ライブラリの @mod(a, b) を使う。これは除数と同じ符号の結果を返す(数学的なモジュロ)。整数の % は被除数の符号に従う点で異なる。

整数と同様、二項演算の両オペランドは同じ型でなければならない。Zigが暗黙的に一方を拡張・縮小することはない。

const a: f32 = 1.5;
const b: f64 = 2.5;
const c = a + b; // コンパイルエラー: 型の不一致 — f32 と f64 は別の型

整数除算と違い、Float除算は切り捨てしない。7.0 / 2.03.5 になる。Float値をゼロで割ってもパニックは起きない。代わりに特殊値が生成される(次節で解説)。

特殊値:NaN と Inf

IEEE 754 は通常の数直線の外にある値のために特定のビットパターンを予約している。

  • Inf(正の無限大) — オーバーフロー、または正の非ゼロ値をゼロで割ると生成される。
  • -Inf(負の無限大) — その負の対応値。
  • NaN(非数、Not a Number)0.0 / 0.0 や負数の平方根のような未定義の演算で生成される。
const std = @import("std");

pub fn main() void {
    // ランタイム変数を使うことで、除算のコンパイル時評価を回避する。
    var zero: f64 = 0.0;
    _ = &zero;

    std.debug.print("1.0 / 0.0  = {}\n", .{1.0 / zero});   // inf
    std.debug.print("-1.0 / 0.0 = {}\n", .{-1.0 / zero});  // -inf
    std.debug.print("0.0 / 0.0  = {}\n", .{0.0 / zero});   // nan

    const inf: f64 = 1.0 / zero;
    std.debug.print("inf + 1    = {}\n", .{inf + 1.0});     // inf
    std.debug.print("inf - inf  = {}\n", .{inf - inf});     // nan

    const nan: f64 = 0.0 / zero;
    std.debug.print("nan == nan = {}\n", .{nan == nan});    // false!
}

最後の行が重要だ。NaN は自分自身を含む何とも等しくない。これはIEEE 754 が意図的に定めたルールであり、== でNaNを検出することはできない。代わりに std.math.isNan(x) を使うこと。

const std = @import("std");

pub fn checkNan(x: f64) void {
    if (std.math.isNan(x)) {
        std.debug.print("got NaN!\n", .{});
    }
}

整数との非対称性に注意しよう。Zigでは整数をゼロで割るとパニックになる(セーフビルドの場合)。Floatをゼロで割ってもパニックにはならず、InfNaN が生成される。ある問題に対して整数とFloat型のどちらを選ぶかを決める際に、この違いを念頭に置いておくこと。

精度と比較に関する落とし穴

すべてのFloat演算はわずかな丸め誤差を生じさせる可能性があるため、Floatを == で比較するのはほぼ常に誤ったアプローチだ。

const a: f64 = 0.1 + 0.2;
const b: f64 = 0.3;

if (a == b) {
    // このブランチはほぼ確実に実行されない。
}

慣用的な修正は イプシロン比較(epsilon comparison) だ。絶対差が許容誤差の範囲内かどうかを確認する。

const std = @import("std");

pub fn main() void {
    const a: f64 = 0.1 + 0.2;
    const b: f64 = 0.3;
    const eps: f64 = 1e-9; // 10億分の1 — 精度要件に合わせて調整する

    if (@abs(a - b) < eps) {
        std.debug.print("close enough\n", .{});
    }
}

適切なイプシロンの選び方は、値の大きさと丸め誤差が何回の演算で蓄積されたかによる。値が何桁にもわたる場合、絶対イプシロンよりも相対イプシロンの方が堅牢だ。@abs(a - b) 単独ではなく @abs(a - b) / @abs(b) で比較する。

標準ライブラリの std.math には二つの便利なヘルパーが用意されている。

const std = @import("std");

pub fn main() void {
    const a: f64 = 0.1 + 0.2;
    const b: f64 = 0.3;

    // 絶対許容誤差: |a - b| < tolerance
    const abs_close = std.math.approxEqAbs(f64, a, b, 1e-9);

    // 相対許容誤差: |a - b| < tolerance * |b|
    const rel_close = std.math.approxEqRel(f64, a, b, 1e-9);

    std.debug.print("abs: {}\n", .{abs_close}); // true
    std.debug.print("rel: {}\n", .{rel_close}); // true
}

値のおおよそのスケールが分かっている場合は approxEqAbs を使う。値が非常に大きかったり小さかったりして相対精度が重要な場合は approxEqRel を使う。

型キャスト

Zigは型を暗黙的に変換しない。すべての変換は組み込み関数を使って明示的に書かなければならない。

Float型間のキャスト

@as(T, value) はFloatをより広い型に拡張する。拡張は常に安全で、情報は失われない。

const x: f32 = 3.14;
const y: f64 = @as(f64, x); // 拡張: f32 の値の範囲では常に正確

@floatCast(value) はFloatをより小さな型に縮小する。精度は黙って失われ、結果はターゲット型で最も近い表現可能な値に丸められる。ランタイムパニックは起きない。

const x: f64 = 3.141592653589793;
const y: f32 = @floatCast(x); // y は約 3.1415927 — 精度が失われる

@floatCast は精度の損失を意識的に受け入れる場合にのみ書くこと。値がターゲット型の表現可能な範囲(f32 では約 ±3.4 × 10³⁸)を超える可能性がある場合、結果は Inf になる。

Floatと整数間のキャスト

@floatFromInt(value) は整数をFloatに変換する。小さな整数では正確だが、大きな整数では最も近い表現可能なFloatに丸められることがある。

const n: i32 = 42;
const f: f64 = @floatFromInt(n); // 42.0 — この程度の値では正確

const big: i64 = 9_007_199_254_740_993; // 2^53 + 1
const g: f64 = @floatFromInt(big);      // 丸められる: f64 はすべての i64 を表現できない

@intFromFloat(value) はFloatを整数に変換する際にゼロ方向に切り捨てる。小数部は丸めではなく切り捨てられる。Debug および ReleaseSafe ビルドでは、Floatが NaNInf、またはターゲットの整数型の範囲外の場合にパニックになる。

const f: f64 = 3.9;
const n: i32 = @intFromFloat(f); // n = 3 — 小数部が切り捨てられる

const g: f64 = -3.9;
const m: i32 = @intFromFloat(g); // m = -3 — ゼロ方向に切り捨て、-4 ではない

const bad: f64 = 1.0e18;
// const x: i32 = @intFromFloat(bad); // ランタイムパニック: i32 の範囲外

キャスト早見表

状況ツール
Floatをより広い型に拡張@as(T, value)
Floatをより小さな型に縮小(精度損失あり)@floatCast(value)
整数 → Float@floatFromInt(value)
Float → 整数(ゼロ方向に切り捨て、NaN/Inf/オーバーフロー時はパニック)@intFromFloat(value)

Floatの出力

このガイドで使ってきた {} プレースホルダーはFloatにも使えるが、デフォルトの表現で出力される。Zigのフォーマット指定子を使えばより細かく制御できる。

const std = @import("std");

pub fn main() void {
    const x: f64 = 3.141592653589793;

    std.debug.print("{}\n",     .{x}); // 3.141592653589793       (デフォルト)
    std.debug.print("{d}\n",    .{x}); // 3.141592653589793       (十進数)
    std.debug.print("{e}\n",    .{x}); // 3.141592653589793e+00   (科学的表記)
    std.debug.print("{d:.2}\n", .{x}); // 3.14                    (小数点以下2桁)
    std.debug.print("{d:.6}\n", .{x}); // 3.141593                (小数点以下6桁)
}

{d:.N} 指定子は出力を小数点以下 N 桁に制限する。これは値の表示方法にのみ影響し、変数に格納されている精度は変わらない。

まとめ

  • Floatは IEEE 754 標準に従い、符号・指数・仮数を固定ビット数に符号化する。表現できる値は有限の集合に限られ、ほとんどの実数は最も近い表現可能な近似値として格納される。
  • Zigは5つのFloat型を提供する: f16f32f64f80f128。デフォルトでは f64 を使う。メモリや帯域幅が逼迫している場合は f32 を使う。f128 はパフォーマンスコストを許容でき、かつ極端な精度が真に必要な場合にのみ使う。
  • Floatリテラルには小数点か指数(または両方)が必要だ。素のFloatリテラルは comptime_float 型を持ち、コンパイル時は任意精度で、代入時に具体的な型に絞り込まれる。アンダースコアは可読性のために使える。
  • 算術演算(+-*/)では両オペランドが同じ型でなければならない。Floatに % はなく、@mod を使う。Floatのゼロ除算はパニックではなく InfNaN を生成する。
  • NaN は自分自身を含む何とも等しくない。 検査には std.math.isNan(x) を使う。
  • Floatを == で比較するのはほぼ常に間違いだ。蓄積された丸め誤差があるためだ。@abs(a - b) < eps によるイプシロン比較、または std.math.approxEqAbs / std.math.approxEqRel ヘルパーを使う。
  • キャスト: 拡張には @as(T, v)、縮小(精度が黙って失われる)には @floatCast(v)、整数からFloatへは @floatFromInt(v)、FloatからFloatへは @intFromFloat(v)(ゼロ方向に切り捨て、セーフビルドでは NaNInf・範囲外でパニック)。