C 语言中的浮点数类型

2.1.4 处理的是整数在 C 里的转换规则,本节是它的浮点版本。两者的结构完全平行:同样有”宽度”和”符号性”之外的第三个维度——编码族(整数族与浮点族),而跨族的转换是本节全部陷阱的来源。

一条贯穿全节的判据:转换会不会丢东西,只看目标类型的有效位数够不够装下源类型的全部信息。

机制

类型与常量

类型典型实现有效位十进制有效数字
floatIEEE 754 binary3224约 7
doubleIEEE 754 binary6453约 15~16
long double平台相关(x86 上常为 80 位扩展精度)64约 18~19

C 标准并不强制浮点必须是 IEEE 754(只有定义了 __STDC_IEC_559__ 的实现才作此承诺),但408 与所有主流平台都按 IEEE 754 处理。

浮点常量默认是 double:

float  f = 3.14;      /* 3.14 是 double,赋值时被转成 float,精度丢失 */
float  g = 3.14f;     /* 后缀 f 才是 float 常量 */
double d = 1.0 / 3;   /* 3 先转 double,除法在 double 域完成 */

1.0/3 与 1.0f/3 结果不同,这是常量后缀被忽略造成的典型偏差。

表达式中的转换

浮点的通常算术转换比整数简单,没有”无符号传染”这种反直觉规则,只是向精度高的一方看齐:整型int + float 的结果是 float;float + double 的结果是 double。整型一律先转成浮点再运算。

一个后果:int a=1, b=3; float x = a/b; 得到 0.0,不是 0.333——除法在整型域就已完成并截断,转成 float 是之后的事。要正确必须写 (float)a / b。

整数与浮点互转:四条路径

这是本节的核心,四条路径的行为各不相同:

转换会不会丢精度会不会溢出原因
int → float会不会int 需 31 位有效位,float 只有 24 位
int → double不会不会double 有 53 位 位,完全精确
float → int会(截断小数)会(超范围是 UB)向零截断
double → float会会(可能变 )53 位压到 24 位;范围也变窄

int → float 会丢精度这一条最反直觉,因为两者都是 32 位。但 float 的 32 位里有 8 位给了阶码,有效位只剩 24 位,所以 的整数无法精确表示:

int i = 16777217;              /* 2²⁴ + 1 */
float f = i;                   /* 变成 16777216.0f,末位丢失 */
printf("%d\n", (int)f == i);   /* 0 */

由此得到两条必须记住的恒等式判断:

一般不成立恒成立(位)

float → int 的截断方向是”向零”,与 double → int 相同,也与整数除法一致:(int)3.9 == 3,(int)(-3.9) == -3。注意它与算术右移的向负无穷取整仍然不同。

转换不是位型重解释

float f = 1.0f;
int a = (int)f;        /* a == 1:值转换 */
int b = *(int*)&f;     /* b == 1065353216:位型重解释,0x3F800000 */

(int)f 让编译器生成一条真正的转换指令(x86 上是 cvttss2si),*(int*)&f 一条指令都不生成,只是换了个类型标签去读同一块内存。

这是 2.1.4 同名边界在浮点上的体现,也是位型题与数值题的分界线:题目问”把这个 float 赋给 int 得到什么”是值转换;问”把这段内存按 int 读出来是什么”是位型重解释。

浮点比较

== 对浮点不可靠,因为参与比较的两个值可能各自带着不同的舍入误差:

if (0.1 + 0.2 == 0.3) { /* 不成立 */ }

三个数各自在编译期就被舍入过一次(2.1.1),0.1+0.2 又在运算时舍入一次,最终位串与 0.3 的位串相差一个最低位。

正确写法是比较差的绝对值:

if (fabs(a - b) < 1e-9) { /* 认为相等 */ }

但这个 不能是固定常数——浮点误差是相对的,在 附近,double 的相邻可表示值就相差约 ,用 1e-9 判等等于用 ==。严谨的做法是用相对误差:。

两个例外,浮点比较是可靠的:

  • 精确表示的值之间:、、小于 的整数在 float 里都是精确的,if (f == 1.0f) 在 f 由精确运算得来时可靠。
  • 与 0 比较符号:if (f > 0) 没有精度问题。

NaN 的比较行为

为假为真

NaN 与任何值(包括自己)的所有比较都返回假,!= 除外。 因此判断 NaN 的标准写法就是 x != x(或 isnan(x))。

这条还导致一个连带后果:if (a < b) ... else ... 与 if (a >= b) ... else ... 在 a 或 b 为 NaN 时走的分支不同,两者不是简单的取反关系。

默认实参提升

传给 printf 这类可变参数函数时,float 会被自动提升为 double:

float f = 1.5f;
printf("%f\n", f);     /* f 被提升为 double,%f 对应 double,正确 */

所以 printf 没有专门的 float 格式符——%f 一直就是 double 的格式符。这也是 scanf 必须区分 %f(float*)和 %lf(double*)而 printf 不必区分的原因:指针不会被提升。

边界

哪些恒等式成立

这是最典型的选择题形式,逐条给出结论(设 i 为 int,f 为 float,d 为 double,均非 NaN/无穷):

表达式成立?理由
(float)(double)f == f成立float → double 无损,转回来一模一样
(double)(float)d == d不成立53 位压到 24 位,一般有损
(int)(float)i == i不成立 时丢精度
(int)(double)i == i成立53 位足以装下 32 位 int
f + d 的类型double向高精度看齐
d * d >= 0成立平方非负,浮点乘法保号
d1 + d2 == d2 + d1成立交换律成立
(d1+d2)+d3 == d1+(d2+d3)不成立结合律不成立
d1 + d2 - d2 == d1不成立大数吃小数
d / 0.0不报错得 (整数除 0 才是异常)

d/0.0 与 i/0 的差别值得单独记:浮点除以零由 IEEE 754 定义为 ,不触发任何异常;整数除以零在 x86 上触发 #DE 异常。

精度损失发生在赋值还是运算

float a = 1.0f/3.0f;    /* 运算在 float 域,误差较大 */
float b = 1.0/3.0;      /* 运算在 double 域(常量是 double),赋值时才压成 float */

b 的误差更小,因为它多在 double 里精算了一步。这解释了为什么中间变量用 double、最终结果再转 float 是好做法。

x87 的历史遗留还带来一个更隐蔽的现象:早期 x86 的浮点寄存器是 80 位,中间结果精度高于 double,同一段代码开不开优化可能得到不同结果。现代编译器用 SSE 后这个问题基本消失,但它说明”浮点结果依赖实现”不是空话。

C 标准并不承诺 IEEE 754

严格地说,C 标准只要求浮点类型能表示某个范围、有某个精度,没有强制 IEEE 754 的位布局、舍入模式和特殊值行为。

做题时的判据:题目说”按 IEEE 754”就套 IEEE 754;题目问”C 标准是否保证”,答案往往是”不保证”。这与整数那边”C 不强制补码(C23 起才强制)“是同一类问题。

对照速查

转换精度范围
int → float可能丢安全
int → double精确安全
float → double精确安全
double → float可能丢可能溢出
float/double → int截断小数(向零)可能 UB
记忆点
浮点常量默认类型double
printf 的 %f对应 double(float 被提升)
判断 NaNx != x
浮点除以 0得 ,不异常
唯一成立的定律交换律

考点

  • int → double 精确,int → float 可能丢精度(float 有效位只有 24)
  • (int)(double)i == i 恒成立;(int)(float)i == i 不成立
  • (float)(double)f == f 成立;(double)(float)d == d 不成立
  • 浮点常量默认是 double,3.14f 才是 float
  • int 除法先截断再转浮点:(float)(1/3) == 0.0
  • == 不可用于浮点判等; 应取相对误差
  • x != x 当且仅当 x 是 NaN
  • 浮点除以零得 ,整数除以零触发异常
  • 浮点只有交换律,没有结合律和分配律
  • float 传入可变参数会被提升为 double

链接