浮動小数点型

前の章では、整数の算術演算は使いやすいものの、オーバーフローや切り捨てによるプログラミングエラーが発生しやすいことを学んだ。また、整数は1.25のように小数部分を持つ値を持つことができないことも学んだ。

浮動小数点型は、小数部分をサポートするように設計されている。この名前の"点"は、整数部分と小数部分を区切る基数点からきており、"浮動"は、これらの型が実装される方法の詳細、つまり小数点が適宜左右に移動することを指している。(この詳細は、これらの型を使用する場合、重要ではない。)

この章でも、重要な詳細について説明しなければならない。その前に、浮動小数点型の興味深い点をいくつか挙げておこう。

浮動小数点型は、場合によってはより便利だが、すべてのプログラマが知っておくべき特殊性がある。整数と比較すると、小数点を扱うことを主な目的としているため、切り捨てを回避するのに非常に優れている。他の型と同様、特定のビット数に基づいているため、オーバーフローが発生しやすいが、整数と比較すると、サポートできる値の範囲は広くなっている。さらに、オーバーフローが発生した場合、何も表示されないのではなく、正の無限大および負の無限大という特別な値が割り当てられる。

念のため、浮動小数点型は次の通りである。

ビット数初期値
float32float.nan
double64double.nan
real少なくとも64、場合によってはそれ以上
(例:80、ハードウェアのサポートに依存)
real.nan
浮動小数点型のプロパティ

浮動小数点型には、他の型よりも多くのプロパティがある。

浮動小数点型のその他のプロパティは、あまり一般的ではない。これらのプロパティはすべて、dlang.orgの"浮動小数点型のプロパティ"で確認できる。

浮動小数点型のプロパティとその関係は、次のような数直線上に表すことができる。

   +     +─────────+─────────+   ...   +   ...   +─────────+─────────+     +
   │   -max       -1         │         0         │         1        max    │
   │                         │                   │                         │
-infinity               -min_normal          min_normal               infinity

2つの特別な無限大値を除き、上の線は縮尺である。min_normalと1の間で表現できる値の数は、1とmaxの間で表現できる値の数と同じだ。これは、min_normalと1の間の値の小数部の精度が非常に高いことを意味する。(負の側についても同様だ。)

.nan

これは浮動小数点変数のデフォルト値であることはすでに説明した。.nanは、意味のない浮動小数点式の結果としても現れることがある。例えば、次のプログラムの浮動小数点式はすべてdouble.nanとなる。

import std.stdio;

void main() {
    double zero = 0;
    double infinity = double.infinity;

    writeln("any expression with nan: ", double.nan + 1);
    writeln("zero / zero            : ", zero / zero);
    writeln("zero * infinity        : ", zero * infinity);
    writeln("infinity / infinity    : ", infinity / infinity);
    writeln("infinity - infinity    : ", infinity - infinity);
}
D
floating_point.1

.nanは、初期化されていない値を示すというだけではない。計算を通じて伝播されるため、エラーを早期に、かつ容易に検出できるという点で有用だ。

浮動小数点値の指定

浮動小数点値は、123のような小数点のない整数値から構築することも、123.0のように小数点を使って直接作成することもできる。

浮動小数点値は、1.23e+4のように、特別な浮動小数点構文を使用して指定することもできる。この構文のe+部分は、"10の累乗"と読み替えることができる。この読み方によると、前の値は"1.23×10の4乗"となり、"1.23×104"と同じになる。これは1.23×10000と同じで、12300に等しくなる。

eの後の値が負の場合、5.67e-3のように、これは"10の累乗で割る"と読む。したがって、この例は"5.67を103で割った"となり、これは5.67/1000と同じで、0.00567に等しい。

3つの浮動小数点型のプロパティを出力する次のプログラムの出力では、浮動小数点形式が明らかだ。

import std.stdio;

void main() {
    writeln("Type                    : ", float.stringof);
    writeln("Precision               : ", float.dig);
    writeln("Minimum normalized value: ", float.min_normal);
    writeln("Minimum value           : ", -float.max);
    writeln("Maximum value           : ", float.max);
    writeln();

    writeln("Type                    : ", double.stringof);
    writeln("Precision               : ", double.dig);
    writeln("Minimum normalized value: ", double.min_normal);
    writeln("Minimum value           : ", -double.max);
    writeln("Maximum value           : ", double.max);
    writeln();

    writeln("Type                    : ", real.stringof);
    writeln("Precision               : ", real.dig);
    writeln("Minimum normalized value: ", real.min_normal);
    writeln("Minimum value           : ", -real.max);
    writeln("Maximum value           : ", real.max);
}
D
floating_point.2

私の環境でのプログラムの出力は次の通りだ。realはハードウェアに依存するため、異なる出力が得られる可能性がある:

float
float
精度6
最小正規化値1.17549e-38
最小値-3.40282e+38
最大値3.40282e+38
double
double
精度15
最小正規化値2.22507e-308
最小値-1.79769e+308
最大値1.79769e+308
real
real
精度18
最小正規化値3.3621e-4932
最小値-1.18973e+4932
最大値1.18973e+4932

注釈: doubleおよびrealfloatよりも精度が高いが、writelnはすべての浮動小数点値を6桁の精度で出力する。(精度については後で説明する。)

観察

前の章で覚えていると思うが、ulongの最大値は20桁で、18,446,744,073,709,551,616だ。この値は、最小の浮動小数点型と比較しても小さいように見える。floatは、1038の範囲までの値、例えば、340,282,000,000,000,000,000,000,000,000,000,000,000までの値を持つことができる。realの最大値は104932の範囲であり、4900桁以上の値になる。

別の例として、doubleが15桁の精度で表現できる最小値を見てみよう。

    0.000...(ここにさらに300個のゼロがある)...0000222507385850720
オーバーフローは無視されない

非常に大きな値を取ることができるにもかかわらず、浮動小数点型もオーバーフローしやすい。この点では、オーバーフローは無視されないため、浮動小数点型は整数型よりも安全だ。正の側でオーバーフローした値は.infinityになり、負の側でオーバーフローした値は‑.infinityになる。これを確認するために、.maxの値を10%増やしよう。値はすでに最大値なので、10%増やすとオーバーフローする。

import std.stdio;

void main() {
    real value = real.max;

    writeln("Before         : ", value);

    // 1.1を掛けることは10%を加えることと同じだ
    value *= 1.1;
    writeln("Added 10%      : ", value);

    // 値を半分に分割して、その値を減らしてみよう
    value /= 2;
    writeln("Divided in half: ", value);
}
D
floating_point.3

値がオーバーフローしてreal.infinityになると、半分に分割してもその値のままになる。

その前1.18973e+4932
10%を追加したinf
半分に分割されたinf
精度

精度とは、日常生活の中でよく目にするが、あまり話題には上らない概念である。精度とは、値を指定する際に使用する桁数のことだ。例えば、100の3分の1は33だが、33は2桁なので、精度は2だ。値を33.33とより正確に指定すると、精度は4桁になる。

各浮動小数点型が持つビット数は、その最大値だけでなく、精度にも影響する。ビット数が多いほど、値の精度が高くなる。

除算では切り捨てはない

前の章で見たように、整数除算では結果の小数部を保持できない:

int first = 3;
int second = 2;
writeln(first / second);
D

出力:

1

浮動小数点型にはこの切り捨ての問題はない。浮動小数点型は、小数部分を保持するように特別に設計されている。

double first = 3;
double second = 2;
writeln(first / second);
D

出力:

1.5

小数部の精度は、型の精度によって異なる。realは精度が最も高く、floatは精度が最も低い。

使用する型の選択

特に理由がない限り、浮動小数点値にはdoubleを選択。floatは精度が低いが、他の型よりもサイズが小さいため、メモリが限られている場合に便利である。一方、一部のハードウェアでは、realの精度はdoubleよりも高いため、高精度の計算にはrealが適している。

すべての値を表現できない

日常生活では、特定の値を表現できないことがある。私たちが日常的に使用している 10進法では、小数点前の数字は1、10、100などを表し、小数点後の数字は10分の1、100分の1、1000分の1などを表す。

これらの値を組み合わせることで、あらゆる値を正確に表現することができる。例えば、0.23という値は2の10分の1と3の100分の1で構成されているため、正確に表現することができる。一方、1/3という値は、10進数では、いくら指定しても桁数が不足するため、正確に表現することができない。

浮動小数点型の場合も、状況は非常によく似ている。これらの型は、特定のビット数に基づいているため、すべての値を正確に表現することはできない。

コンピュータが使用する2進法との違いは、小数点前の桁は1、2、4などであり、小数点後の桁は0.5、0.25、0.125などであることである。これらの桁の正確な組み合わせの値のみが正確に表現できる。

コンピュータが使用する2進数では、10セントの0.1という値は正確に表現できない。この値は10進数では正確に表現できるが、2進数では4桁が0.0001100110011...と無限に繰り返される。(この値は10進数ではなく2進数で記述されていることに注意。) 使用する浮動小数点型の精度に応じて、あるレベルでは常に不正確になる。

次のプログラムは、この問題を示している。変数の値が、ループで1000回0.001ずつ増加している。驚くべきことに、結果は1にならない。

import std.stdio;

void main() {
    float result = 0;

    // 1000回ごとに0.001を追加する:
    int counter = 1;
    while (counter <= 1000) {
        result += 0.001;
        ++counter;
    }

    if (result == 1) {
        writeln("As expected: 1");

    } else {
        writeln("DIFFERENT: ", result);
    }
}
D
floating_point.4

0.001を正確に表現できないため、その不正確さが各反復で結果に影響を与えるからだ:

異なる: 0.999991

注釈:上記の変数counterはループカウンタだ。この目的のために変数を明示的に定義することは推奨されない。代わりに、一般的なアプローチとしては、後で説明するforeachループを使う方法がある。

順序の無さ

整数で説明したのと同じ比較演算子は、浮動小数点型でも使用される。ただし、.nanは無効な浮動小数点値を表す特別な値であるため、.nanを他の値と比較することは意味がない。例えば、.nan1のどちらが大きいかを比較することは意味がない。

そのため、浮動小数点値には別の比較概念、つまり順序性がないことが導入されている。順序性がないということは、少なくとも一方の値が.nanであることを意味する。

次の表は、すべての浮動小数点比較演算子を一覧表示している。これらはすべて二項演算子(2つのオペランドを取る)であり、left == rightと同じように使用される。falsetrueを含む列は、比較演算の結果を示す。

最後の列は、オペランドの一方が.nanの場合に演算が意味を持つかどうかを示している。例えば、式1.2 < real.nanの結果はfalseだが、オペランドの一方がreal.nanであるため、この結果は意味を持たない。逆の比較real.nan < 1.2の結果も、falseとなる。lhsは左辺を表し、各演算子の左側にある式を示す。

演算子意味lhsがより大きい場合lhsが小さい場合が両方とも等しい場合の少なくとも一方が .nanの場合.nanを含む意味のある
==は等しいfalsefalsetruefalseはい
!=と等しくないtruetruefalsetrueはい
>より大きいtruefalsefalsefalseいいえ
>=以上truefalsetruefalseいいえ
<より小さいfalsetruefalsefalseない
<=以下falsetruetruefalseない

.nanと一緒に使用すると意味があるが、==演算子は、.nan値と一緒に使用すると、常にfalseを返す。これは、両方の値が.nanの場合でも同様である。

import std.stdio;

void main() {
    if (double.nan == double.nan) {
        writeln("equal");

    } else {
        writeln("not equal");
    }
}
D
floating_point.5

double.nanが自身と等しいと予想されるが、比較の結果はfalseとなる:

等しくない
isNaN() .nanの等価比較

前述のように、==演算子を使用して、浮動小数点変数の値が.nanであるかどうかを判断することはできない。

if (variable == double.nan) {    // ← 間違い
    // ...
}
D

std.mathモジュールにあるisNaN()関数は、値が.nanであるかどうかを判断するためのものだ。

import std.math;
// ...
    if (isNaN(variable)) {           // ← 正しい
        // ...
    }
D

同様に、値が.nanではないかどうかを判断するには、!isNaN()を使用する必要がある。そうしないと、!=演算子は常にtrueを返すからだ。

演習
  1. 上記のプログラムで、0.001を1000回加算する部分で、floatの代わりにdouble(またはreal)を使用しよう:
    double result = 0;
    D

    この演習は、浮動小数点数の等価比較がどれほど誤解を招きやすいかを示している。

  2. 前の章の計算機を、浮動小数点型に対応するように変更。変更後の計算機は、より正確に動作するはずだ。計算機を試す際には、1000、1.23、1.23e4など、さまざまな形式の浮動小数点値を入力してみよう。
  3. 入力から5つの浮動小数点値を読み込むプログラムを書いてみよう。このプログラムでは、各値を2回ずつ出力してから、各値の5分の1を出力するようにしよう。

    この演習は、次の章で説明する配列の概念の準備である。これまで学んだ内容でこのプログラムを書くと、配列をより理解しやすく、その重要性をより深く理解できるだろう。