浮動小数点数の書式化と丸め
printf("%.2f", value)のような書式化は、浮動小数点数を単に文字列へ置き換える処理ではありません。2進浮動小数点数として保持された値を10進数へ変換し、指定された桁数に収めるため、そこで新たな丸めが発生します。
この結果をログの表示だけに使うなら、最下位桁の違いは問題にならないこともあります。一方、CSV、シリアライズ、ハッシュ、テストの期待値、外部システムとの交換形式にすると、Cランタイムの更新や実装の違いがアプリケーションの互換性へ直接影響します。特にOSやコンパイラ、Cランタイムのバージョンが変更された場合には、同じコードでも異なる結果となり問題として顕在化する可能性があります。
本記事では、浮動小数点数の書式化における丸めの発生段階、C標準の規定、型ごとの有効桁数、丸め方式の違い、UCRTでの変更点などを解説します。
実際のビット表現や丸め方向ごとの差は、IEEE 754 浮動小数点エミュレーターで確認できます。10進入力を正確な値として扱い、float(binary32)とdouble(binary64)へ丸めた結果を比較できます。
書式化までに丸めが発生する段階
浮動小数点数を表示するまでには、3つの異なる段階で丸めが発生する可能性があります。
- 入力時の丸め:
2.35のような10進リテラルを、有限桁の2進浮動小数点数へ変換します。 - 演算時の丸め:加減乗除などの結果を
floatやdoubleで表現可能な値へ丸めます。 - 書式化時の丸め:保持されている2進浮動小数点数を、
%.1fなどで指定された10進桁数へ丸めます。
有限桁の2進数で正確に表せる小数は、既約分数にしたときの分母が2の累乗になる数に限られます。たとえば10進数のはであり、分母に5を含むため、2進数では次のように循環して無限に続きます。
有限のビット数しか持たないdoubleは、この無限に続く2進数を途中で丸め、最も近い表現可能な値を格納します。一般的なIEEE 754 binary64で格納された値を10進数で正確に表すと、次の数になります。
2.350000000000000088817841970012523233890533447265625
数学的な2.35の2進表現は無限に続きますが、格納されたbinary64値は有限の2進数です。そのため、その格納値の正確な10進表記も長い有限小数となり、上記の桁で終わります。
これは数学的に正確な2.35より約大きい値です。そのため、printf("%.1f", 2.35)が2.4になる結果には、書式化時の丸めだけでなく入力時の丸めも影響しています。保持されている値は、書式化が始まる前から2.3と2.4の正確な中間点よりわずかに大きいためです。
浮動小数点数そのものの誤差については精度と誤差の発生要因も参照してください。
型によって異なる有効桁数
float、double、long doubleでは、保持できる仮数のビット数と10進の有効桁数が異なります。書式化で多くの桁を指定しても、元の型が保持していない情報は復元できません。逆に、桁数が少なすぎると、文字列を同じ型へ読み戻したときに元の値へ戻らない場合があります。
一般的なIEEE 754形式では、桁数の目安は次のとおりです。
| 型 | 一般的な形式 | 仮数の精度 | *_DIG | *_DECIMAL_DIG |
|---|---|---|---|---|
float | binary32 | 24ビット | 6桁 | 9桁 |
double | binary64 | 53ビット | 15桁 | 17桁 |
long double | 処理系とABIに依存 | 処理系に依存 | LDBL_DIGで確認 | LDBL_DECIMAL_DIGで確認 |
FLT_DIG、DBL_DIG、LDBL_DIGは、指定された桁数の10進数をその型へ変換して再び10進数へ戻したときに保持できる桁数の目安です。一方、FLT_DECIMAL_DIG、DBL_DECIMAL_DIG、LDBL_DECIMAL_DIGは、その型の値を10進文字列へ変換して同じ型へ読み戻すために必要な桁数です。後者はログや永続化で元の浮動小数点値を再現したい場合に重要です。
printfのprecisionは型の有効桁数を自動的には選びません。既定値は%fと%eでは小数点以下6桁、%gでは有効数字6桁です。%.17fの17は小数点以下の桁数ですが、%.17gの17は有効桁数であり、同じ17でも意味が異なります。
Cの可変長引数ではfloatがdoubleへ拡張されるため、printfの%f、%e、%gはdoubleを受け取ります。しかし、拡張前のfloatが持っていなかった精度まで増えるわけではありません。long doubleには%Lf、%Le、%Lgを使用します。
long doubleの実体には特に注意が必要です。MSVCではlong doubleとdoubleの表現、範囲、精度が同じですが、ほかのコンパイラやABIでは80ビット拡張精度やbinary128など、doubleより広い形式の場合があります。サイズだけで判断せず、<float.h>の定数を使用し、対象環境ごとに確認してください。
丸め方式の違い
IEEE 754には、最も近い値を選ぶ方式と、常に一定の方向へ丸める方式があります。最も近い2つの値までの距離が同じになる中間値では、どちらを選ぶかという追加の規則が必要です。IEEE 754では次の丸め方向が定義されています。
- roundTiesToEven:最も近い値を選び、中間値では末尾が偶数になる側を選ぶ既定の方式
- roundTiesToAway:最も近い値を選び、中間値ではゼロから遠い側を選ぶ方式
- roundTowardPositive:常に正の無限大方向へ丸める方式
- roundTowardNegative:常に負の無限大方向へ丸める方式
- roundTowardZero:常にゼロ方向へ丸める方式
CのFE_TONEARESTは通常roundTiesToEvenに対応し、FE_UPWARD、FE_DOWNWARD、FE_TOWARDZEROは3つの方向丸めに対応します。次の表では、日常語との比較のためhalf-upも加えています。half-upはIEEE 754で定義された丸め方向の名称ではありません。
| 丸め方式 | 2.4 | 2.5 | 3.5 | -2.4 | -2.5 | 考え方 |
|---|---|---|---|---|---|---|
| 最近接、同距離なら偶数(ties-to-even) | 2 | 2 | 4 | -2 | -2 | 最も近い値を選び、同距離なら結果の末尾が偶数になる側 |
| 最近接、同距離ならゼロから遠い側(ties-away-from-zero) | 2 | 3 | 4 | -2 | -3 | 正の値では日常的な四捨五入と同じ。負の中間値は絶対値を大きくする側 |
| 最近接、同距離なら正の無限大側(half-up) | 2 | 3 | 4 | -2 | -2 | 正の値では日常的な四捨五入と同じ。負の中間値は数直線上の大きい側 |
正の無限大方向(FE_UPWARD) | 3 | 3 | 4 | -2 | -2 | 中間値に限らず、常に正の無限大側 |
負の無限大方向(FE_DOWNWARD) | 2 | 2 | 3 | -3 | -3 | 中間値に限らず、常に負の無限大側 |
ゼロ方向(FE_TOWARDZERO) | 2 | 2 | 3 | -2 | -2 | 常に絶対値を小さくする側 |
日常的に正の数へ使う「四捨五入」は、最も近い値を選び、ちょうど半分なら大きい側へ丸める方式です。正の値だけならhalf-upとties-away-from-zeroのどちらとも同じ結果になります。
一方、負の値では「上」を数直線上の正の方向と考えるか、絶対値を大きくする方向と考えるかで結果が異なります。たとえば-2.5はhalf-upでは-2、ties-away-from-zeroでは-3です。そのため、正負を扱う技術仕様では単に「四捨五入」と書かず、丸め方式を明示する必要があります。
Cのroundは中間値をゼロから遠い側へ丸めます。一方、rintとnearbyintは現在の丸めモードに従います。関数名が似ていても、round(value)とprintf("%.0f", value)が常に同じ規則になるとは限りません。
最近接偶数丸めの利点
中間値を常に上側またはゼロから遠い側へ送ると、中間値が繰り返し現れるデータでは結果が一方向へ偏ります。最近接偶数丸めは、末尾が偶数になる側と奇数になる側へ交互に分散させることで、この系統的な偏りを抑えます。
たとえば、0.5、1.5、2.5、3.5を個別に整数へ丸めてから合計します。
- ties-away-from-zero:
1 + 2 + 3 + 4 = 10 - ties-to-even:
0 + 2 + 2 + 4 = 8 - 丸め前の合計:
8
この例では最近接偶数丸めだけが元の合計を保ちます。ただし、最近接偶数丸めがあらゆる入力分布で必ず無偏りになるわけではありません。利点は、正確な中間値を常に同じ方向へ送る規則に比べ、繰り返し処理で生じる上方または外向きの偏りを抑えやすいことです。ただし、値の分布や入力の偏りによっては、依然として統計的な偏りが生じる可能性がありますので注意が必要です。
C標準が規定する範囲
ここまで説明した書式、精度、丸め方式のうち、C標準がどこまで共通動作を要求するかを確認します。
C標準はprintf系の%e、%f、%gについて、表記形式、precisionの意味、省略時の桁数、指定された桁数へ丸めることを定めています。ただし、10進変換のすべてについて、通常のC処理系へ一律に最近接偶数丸めを義務付けているわけではありません。公開されているC11委員会草案N1570では、規定の強さが次のように異なります。
| 対象 | 規定の概要 |
|---|---|
%e、%f、%g | 表記形式とprecisionに従い、適切な桁数へ丸めることを要求 |
%a、%A | FLT_RADIXが2の累乗なら、正しく丸めた結果を要求 |
%e、%f、%gの10進変換 | DECIMAL_DIG以下で正しく丸めることは、通常規定では推奨事項 |
| IEC 60559対応を宣言する処理系のAnnex F | 対象となる2進・10進変換を、現在の丸めモードに従って正しく丸めることを要求 |
ここで「正しく丸める」とは、無限の精度で求めた結果から、現在の丸めモードに従って最適な表現を選ぶことです。したがって、既定のFE_TONEARESTでは最近接偶数になりますが、FE_UPWARD、FE_DOWNWARD、FE_TOWARDZEROではそれぞれの方向に従います。
書式指定子とprecisionは移植可能でも、10進変換の最下位桁やfesetroundの反映まで完全に同じとは限りません。C++のprintf系もC標準ライブラリ由来であり、実際の結果はリンクされるCランタイムに依存します。
UCRTで変更された丸め
Windows 10 Version 2004 (build 19041)のUCRTでは、printfファミリーの浮動小数点書式が変更されました。この新しい動作はVisual Studio 2019 Version 16.2以降でビルドされたプログラムから選択されます。
正確に表現可能な中間値を既定の丸めモードで出力すると、違いが分かります。
#include <stdio.h>
int main(void)
{
printf("%.0f %.0f\n", 1.5, 2.5);
}
| UCRTの動作 | 出力 |
|---|---|
| 従来 | 2 3 |
| build 19041以降の新動作 | 2 2 |
従来のUCRTは、正確に表現できて末尾が5となる値を常に上側へ丸めていました。これは、私たちが日常的に使う四捨五入の感覚に近い動作です。一方で、新しいUCRTはIEEE 754の最近接偶数丸めを使うため、2.5は偶数の2になりますので、日常的な四捨五入の感覚とは異なる結果になる場合があります。また、新しい動作ではfesetroundで設定した丸めモードも書式化へ反映されます。
Microsoftが互換用のlegacy_stdio_float_rounding.objを提供していることからも、この変更が既存アプリケーションへ影響し得るものとして扱われていることが分かります。このオブジェクトファイルをリンクすると旧動作を選択できますが、いつまで旧動作が互換性として維持されるかは不明です。特に新規実装では出力仕様を明確にして新動作へ対応することを優先するほうがよいでしょう。
UCRTのリンク方法とWindows SDKソースで確認した後続の丸め修正についてはUCRTの互換性に影響する変更を参照してください。
現在の丸めモードによる違い
1.25は2進数で正確に表現でき、%.1fでは正確な中間値になります。対応するランタイムでは次のコードで丸め方向の違いを確認できます。
#include <fenv.h>
#include <stdio.h>
#pragma STDC FENV_ACCESS ON
static void print_value(const char* label, int mode)
{
if (fesetround(mode) == 0)
{
volatile double positive = 1.25;
volatile double negative = -1.25;
printf("%-12s %.1f %.1f\n", label, positive, negative);
}
}
int main(void)
{
int const original_mode = fegetround();
print_value("nearest", FE_TONEAREST);
print_value("upward", FE_UPWARD);
print_value("downward", FE_DOWNWARD);
print_value("toward zero", FE_TOWARDZERO);
fesetround(original_mode);
}
期待される出力は次のとおりです。
nearest 1.2 -1.2
upward 1.3 -1.2
downward 1.2 -1.3
toward zero 1.2 -1.2
動的な丸めモードを使う場合、コンパイラが式を既定の丸めモードで定数畳み込みしない設定も必要です。GCCでは-frounding-mathが用意されていますが、GCCのドキュメントはこのオプションを実験的なものと説明しています。テスト対象の値をvolatileへ格納するだけで、すべての最適化上の問題を解決できるわけではありません。
ほかのCランタイム実装との比較
GCCやClangはコンパイラであり、通常はprintfそのものを実装しません。「GCCでの結果」は、実際にはリンクしたglibcやmuslなどのCランタイムによって決まります。同じGCCを使っても、Linuxディストリビューションやリンク先のlibcが違えば結果は同じとは限りません。
| 実装 | 通常の最近接モード | 有向丸め | 確認方法と注意点 |
|---|---|---|---|
| 旧UCRT | 正確な中間値の正数を上側へ丸める | printfはfesetroundを反映しない | Microsoft Learnに旧動作として記載 |
| 現在のUCRT | ties-to-even | fesetroundを反映 | Windows 10 build 19041とVS 2019 16.2以降の組み合わせ。互換オブジェクトで旧動作を選択可能 |
| glibc | ties-to-even | 現在の丸めモードを反映 | 現行ソースのprintf_fp.cと共通丸め処理で確認 |
| musl | ties-to-even | 現在の丸めモードを反映 | 現行ソースのvfprintf.cで、保持桁の奇偶とハードウェア丸めを利用 |
| Apple Libc | ties-to-even | 現在の丸めモードを反映 | 公開ソースのvfprintfとgdtoaで確認。特定OSに搭載されたバイナリとの一致は別途確認が必要 |
この表は各プロジェクトの現行資料または公開ソースに基づくもので、過去の全バージョンに同じ動作を保証するものではありません。また、long doubleの形式、DECIMAL_DIG、LDBL_DECIMAL_DIGは処理系やABIによって異なります。
書式化へ依存しない設計
ここまでの問題はC言語に限りません。数値を内部表現から文字列へ変換するすべての言語や実行環境で、既定の精度、丸め方式、ロケール、ライブラリの更新が出力へ影響する可能性があります。特定の書式化関数が偶然返した文字列へ依存せず、利用目的に応じた表現を仕様として定めることが重要です。
人間向けの表示
- 小数点以下の桁数または有効桁数と、丸め方式を仕様として決めます。
- 小数点記号、桁区切り、指数表記などを決めるロケールを明示します。
- 末尾のゼロを表示するか、非常に大きい値や小さい値を指数表記にするかも決めます。
- 業務上の丸め規則がある場合は、言語やライブラリの既定値へ任せず、アプリケーションの仕様として実装します。
永続化とシステム間連携
- 人間向けの表示文字列を、そのまま永続フォーマットにしないようにします。
- 10進文字列を使う場合は、往復変換に必要な有効桁数、指数形式、小数点記号、NaN、無限大、負のゼロの扱いを仕様化します。
- 異なる実装間で交換する場合は、送信側の書式化だけでなく受信側の解析規則でも同じ値へ戻ることを確認します。
- ビット列や16進浮動小数点表現を使う場合も、型の形式、バイト順、特殊値の扱いを明示します。
- 金額や法令で丸め方法が決まる値には、最小通貨単位の整数または10進型を使用します。
値を一度丸めてから少ない桁数で再び書式化すると、二重丸めによって直接書式化した場合と異なる結果になることがあります。丸める段階を1か所に決め、途中結果を必要以上に低い精度へ落とさないでください。
C/C++での具体例
Cのprintf系で人間向けに表示する場合は、%.2fや%.6gのようにprecisionを明示します。ただし、小数点記号は現在のロケールに依存し、最下位桁はCランタイムの丸め実装に依存する可能性があります。
doubleを10進文字列へ変換し、読み戻したときに同じ値を得るには、DBL_DECIMAL_DIG桁を使用できます。floatにはFLT_DECIMAL_DIG、long doubleにはLDBL_DECIMAL_DIGと%Lgを使用します。
#include <float.h>
#include <stdio.h>
int main(void)
{
char text[64];
double value = 2.35;
int length = snprintf(text, sizeof text, "%.*g", DBL_DECIMAL_DIG, value);
if (length < 0 || (size_t)length >= sizeof text)
{
return 1;
}
puts(text);
return 0;
}
この方法でも、小数点記号を固定した交換形式にするにはCロケールを保証する必要があります。C/C++間で2進値を正確に表現する用途では%aによる16進浮動小数点表現、C++ではロケールに依存しないstd::to_charsも選択肢になります。いずれの場合も、出力形式と読み戻し方法をプロトコルとして定義してください。
Cのroundで先に値を丸めてからprintfで再び桁数を制限すると、丸め方式の違いに加えて二重丸めが発生する可能性があります。表示だけが目的なら元の値を必要なprecisionで直接書式化し、業務規則として丸めた値が必要なら、その規則と処理段階を明示してください。
テストするときの注意点
- 書式化時の丸め方式だけを検証する場合は、
1.25や2.5など、2進数で正確に表現できる中間値を使います。2.35のように入力時点で丸められる値は、この目的には適しません。 - 実際の入力を想定したテストでは、
2.35のように10進数からの変換で正確な値よりわずかに大きくなる値と、2.15のようにわずかに小さくなる値の両方を含めます。 - 丸め境界を挟む直前と直後の表現可能な値を確認し、境界自体を正確に表現できる場合はその値も確認します。C/C++では
nextafterを使うと、指定した値に隣接する浮動小数点数を取得できます。 - 丸めによって桁上がりする境界と、
%gが固定小数点表記と指数表記を切り替える境界も確認します。これらは1 ULPの違いでも出力の複数桁や形式全体が変わる可能性があります。 float、double、long doubleを個別に確認し、それぞれの*_DIGと*_DECIMAL_DIGの境界付近をテストします。隣接値の生成には型に対応するnextafterf、nextafter、nextafterlを使用します。- 正負の値を分けて確認します。
FE_TONEARESTだけでなく、サポートするすべての丸めモードを確認します。%e、%f、%gと、実際に使用するprecisionを組み合わせます。- 0、非正規化数、非常に大きい値、NaN、正負の無限大も確認します。
/MDでは対象OSのUCRT、/MTではビルド時のUCRTを記録します。- Linuxではコンパイラ名だけでなく、glibcまたはmuslの種類とバージョンも記録します。
次の例では、数学的に正確な2.35を挟む2つのbinary64値を確認できます。2.35というリテラル自体は中間点よりわずかに大きく、その1つ前の値は中間点より小さくなります。
#include <math.h>
#include <stdio.h>
int main(void)
{
double above = 2.35;
double below = nextafter(above, -INFINITY);
printf("below: %a -> %.17g -> %.1f\n", below, below, below);
printf("above: %a -> %.17g -> %.1f\n", above, above, above);
}
最近接丸めを正しく実装するランタイムでは、最後の出力がそれぞれ2.3と2.4になります。%aも併記すると、表示対象となった正確な2進浮動小数点値を区別できます。
参考情報
- C11 committee draft N1570:
fprintf - C11 committee draft N1570: IEC 60559 binary-decimal conversion
- POSIX Issue 8:
fprintf - Microsoft Learn:
printf,_printf_l,wprintf,_wprintf_l - Microsoft Learn: CRT link options
- Microsoft Learn:
fegetround,fesetround - Microsoft Learn: Type
long double - Microsoft Learn: Data type constants
- glibc:
printf_fp.c - glibc: rounding mode helper
- musl:
vfprintf.c - Apple Libc:
vfprintf.c - Apple Libc:
gdtoa-dtoa.c - GCC:
-frounding-math - Oracle Numerical Computation Guide: IEEE Arithmetic
- David Goldberg: What Every Computer Scientist Should Know About Floating-Point Arithmetic