4.8 — 浮点数

整数非常适合计算整数,但有时我们需要存储非常大(正数或负数)的数字,或带有小数部分的数字。浮点类型变量是可以保存带有小数部分的数字的变量,例如 4320.0、-3.33 或 0.01226。名称“浮点”中的“浮动”部分指的是小数点可以“浮动”——也就是说,它可以支持小数点前后可变位数的数字。浮点数据类型始终是有符号的(可以保存正值和负值)。

提示

在代码中编写浮点数时,小数点分隔符必须是小数点。如果您来自使用十进制逗号的国家/地区,则需要习惯使用小数点。

C++ 浮点类型

C++ 有三种基本的浮点数据类型:单精度 float、双精度 double 和扩展精度 long double。与整数一样,C++ 没有定义这些类型的实际大小。

类别C++ 类型典型大小
浮点数float

4 字节
double8 字节
long double8、12 或 16 字节

在现代架构上,浮点类型通常使用 IEEE 754 标准中定义的浮点格式之一实现(参见 https://en.wikipedia.org/wiki/IEEE_754)。因此,float 几乎总是 4 字节,double 几乎总是 8 字节。

另一方面,long double 是一种奇怪的类型。在不同的平台上,它的大小可以在 8 到 16 字节之间变化,并且它可能使用也可能不使用 IEEE 754 兼容格式。我们建议避免使用 long double

提示

本教程系列假设您的编译器正在为 floatdouble 使用 IEEE 754 兼容格式。

您可以使用以下代码查看您的浮点类型是否与 IEEE 754 兼容

#include <iostream>
#include <limits>

int main()
{
    std::cout << std::boolalpha; // print bool as true or false rather than 1 or 0
    std::cout << "float: " << std::numeric_limits<float>::is_iec559 << '\n';
    std::cout << "double: " << std::numeric_limits<double>::is_iec559 << '\n';
    std::cout << "long double: " << std::numeric_limits<long double>::is_iec559 << '\n';
}

致进阶读者

float 几乎总是使用 4 字节 IEEE 754 单精度格式实现。
double 几乎总是使用 8 字节 IEEE 754 双精度格式实现。

然而,用于实现 long double 的格式因平台而异。常见的选择包括:

  • 8 字节 IEEE 754 双精度格式(与 double 相同)。
  • 80 位(通常填充到 12 或 16 字节)x87 扩展精度格式(与 IEEE 754 兼容)。
  • 16 字节 IEEE 754 四精度格式。
  • 16 字节双双精度格式(与 IEEE 754 不兼容)。

浮点变量和字面量

以下是浮点变量的一些定义

float f;
double d;
long double ld;

使用浮点字面量时,始终包含至少一个小数点(即使小数部分为 0)。这有助于编译器理解该数字是浮点数而不是整数。

int a { 5 };      // 5 means integer
double b { 5.0 }; // 5.0 is a floating point literal (no suffix means double type by default)
float c { 5.0f }; // 5.0 is a floating point literal, f suffix means float type

int d { 0 };      // 0 is an integer
double e { 0.0 }; // 0.0 is a double

请注意,默认情况下,浮点字面量默认为 double 类型。f 后缀用于表示 float 类型的字面量。

最佳实践

始终确保字面量的类型与它们被赋值或用于初始化的变量的类型匹配。否则将导致不必要的转换,可能会导致精度损失。

打印浮点数

现在考虑这个简单的程序

#include <iostream>

int main()
{
	std::cout << 5.0 << '\n';
	std::cout << 6.7f << '\n';
	std::cout << 9876543.21 << '\n';

	return 0;
}

这个看似简单的程序的结果可能会让你感到惊讶

5
6.7
9.87654e+06

在第一种情况下,std::cout 打印了 5,即使我们输入的是 5.0。默认情况下,如果小数部分为 0,std::cout 将不打印数字的小数部分。

在第二种情况下,数字按我们预期的方式打印。

在第三种情况下,它以科学记数法打印数字(如果您需要复习科学记数法,请参阅第 4.7 课 — 科学记数法简介)。

浮点范围

格式范围精度
IEEE 754 单精度(4 字节)±1.18 x 10-38 到 ±3.4 x 1038 和 0.06-9 位有效数字,通常为 7 位
IEEE 754 双精度(8 字节)±2.23 x 10-308 到 ±1.80 x 10308 和 0.015-18 位有效数字,通常为 16 位
x87 扩展精度(80 位)±3.36 x 10-4932 到 ±1.18 x 104932 和 0.018-21 位有效数字
IEEE 754 四精度(16 字节)±3.36 x 10-4932 到 ±1.18 x 104932 和 0.033-36 位有效数字

致进阶读者

80 位 x87 扩展精度浮点类型有点历史异常。在现代处理器上,这种类型的对象通常被填充到 12 或 16 字节(这是处理器处理起来更自然的尺寸)。这意味着这些对象有 80 位的浮点数据,其余内存是填充物。

80 位浮点类型与 16 字节浮点类型具有相同范围可能看起来有点奇怪。这是因为它们用于指数的位数相同——然而,16 字节的数字可以存储更多的有效数字。

浮点精度

考虑分数 1/3。这个数字的十进制表示是 0.33333333333333… 3 一直延续到无穷大。如果你把这个数字写在一张纸上,你的手臂会在某个时候感到疲倦,你最终会停止书写。你剩下的数字会接近 0.3333333333….(3 一直延续到无穷大),但并不完全相同。

在计算机上,无限精度数字需要无限内存来存储,而我们通常每个值只有 4 或 8 字节。这种有限的内存意味着浮点数只能存储一定数量的有效数字——任何额外的有效数字要么丢失,要么表示不精确。实际存储的数字可能接近所需数字,但不精确。我们将在下一节中展示一个示例。

浮点类型的精度定义了它可以在不丢失信息的情况下表示多少有效数字。

浮点类型具有的精度位数取决于大小(浮点数的精度低于双精度数)和存储的特定值(某些值可以比其他值更精确地表示)。

例如,浮点数有 6 到 9 位精度。这意味着浮点数可以精确地表示任何具有最多 6 位有效数字的数字。具有 7 到 9 位有效数字的数字可能精确也可能不精确,具体取决于特定值。而具有超过 9 位精度的数字肯定无法精确表示。

双精度值具有 15 到 18 位精度,大多数双精度值至少有 16 位有效数字。长双精度数最小精度为 15、18 或 33 位有效数字,具体取决于它占用的字节数。

关键见解

浮点类型只能精确表示一定数量的有效数字。使用有效数字多于最小值的数字可能会导致该值存储不精确。

输出浮点值

输出浮点数时,std::cout 的默认精度为 6——也就是说,它假定所有浮点变量都只有 6 位有效数字(浮点数的最小精度),因此它会截断其后的任何内容。

以下程序显示 std::cout 截断为 6 位数字

#include <iostream>

int main()
{
    std::cout << 9.87654321f << '\n';
    std::cout << 987.654321f << '\n';
    std::cout << 987654.321f << '\n';
    std::cout << 9876543.21f << '\n';
    std::cout << 0.0000987654321f << '\n';

    return 0;
}

此程序输出

9.87654
987.654
987654
9.87654e+006
9.87654e-005

请注意,每个数字只有 6 位有效数字。

另请注意,在某些情况下,std::cout 将切换到以科学记数法输出数字。根据编译器,指数通常会填充到最小位数。不要担心,9.87654e+006 与 9.87654e6 相同,只是有一些填充的 0。显示的最小指数位数是编译器特定的(Visual Studio 使用 3,其他一些编译器根据 C99 标准使用 2)。

我们可以使用名为 std::setprecision()输出操纵器函数来覆盖 std::cout 显示的默认精度。输出操纵器改变数据的输出方式,并在 iomanip 头文件中定义。

#include <iomanip> // for output manipulator std::setprecision()
#include <iostream>

int main()
{
    std::cout << std::setprecision(17); // show 17 digits of precision
    std::cout << 3.33333333333333333333333333333333333333f <<'\n'; // f suffix means float
    std::cout << 3.33333333333333333333333333333333333333 << '\n'; // no suffix means double

    return 0;
}

输出

3.3333332538604736
3.3333333333333335

由于我们将精度设置为 17 位(使用 std::setprecision()),因此上述每个数字都以 17 位打印。但是,正如您所看到的,这些数字的精度肯定不足 17 位!而且由于浮点数的精度低于双精度数,因此浮点数具有更大的误差。

提示

输出操纵器(和输入操纵器)是“粘性的”——这意味着如果您设置它们,它们将保持设置状态。

唯一的例外是 std::setw。某些 IO 操作会重置 std::setw,因此每次需要时都应使用 std::setw

精度问题不仅影响小数,还影响任何有效数字过多的数字。让我们考虑一个大数字

#include <iomanip> // for std::setprecision()
#include <iostream>

int main()
{
    float f { 123456789.0f }; // f has 10 significant digits
    std::cout << std::setprecision(9); // to show 9 digits in f
    std::cout << f << '\n';

    return 0;
}

输出

123456792

123456792 大于 123456789。值 123456789.0 有 10 位有效数字,但浮点值通常有 7 位精度(并且 123456792 的结果只有 7 位有效数字的精度)。我们失去了一些精度!当数字无法精确存储而导致精度丢失时,这称为舍入误差

因此,在使用需要比变量所能容纳的精度更高的浮点数时,必须小心。

最佳实践

除非空间非常宝贵,否则优先使用 double 而不是 float,因为 float 的精度不足通常会导致不准确。

舍入误差使浮点比较变得棘手

由于二进制(数据存储方式)和十进制(我们思考方式)数字之间存在不明显的差异,浮点数很难处理。考虑分数 1/10。在十进制中,这很容易表示为 0.1,我们习惯于将 0.1 视为一个具有 1 位有效数字的易于表示的数字。然而,在二进制中,十进制值 0.1 由无限序列表示:0.00011001100110011… 因此,当我们为浮点数赋值 0.1 时,我们将遇到精度问题。

您可以在以下程序中看到其影响

#include <iomanip> // for std::setprecision()
#include <iostream>

int main()
{
    double d{0.1};
    std::cout << d << '\n'; // use default cout precision of 6
    std::cout << std::setprecision(17);
    std::cout << d << '\n';

    return 0;
}

这输出

0.1
0.10000000000000001

在顶部行,std::cout 打印 0.1,正如我们所预期的。

在底部行,我们让 std::cout 显示 17 位精度,我们看到 d 实际上不完全是 0.1!这是因为 double 由于其有限的内存而不得不截断近似值。结果是一个精确到 16 位有效数字的数字(double 类型保证的),但该数字并不完全是 0.1。舍入误差可能会使数字稍微变小或稍微变大,具体取决于截断发生的位置。

舍入误差可能产生意想不到的后果

#include <iomanip> // for std::setprecision()
#include <iostream>

int main()
{
    std::cout << std::setprecision(17);

    double d1{ 1.0 };
    std::cout << d1 << '\n';
	
    double d2{ 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 }; // should equal 1.0
    std::cout << d2 << '\n';

    return 0;
}
1
0.99999999999999989

虽然我们可能预期 d1d2 应该相等,但我们看到它们不相等。如果我们在程序中比较 d1d2,程序可能不会按预期执行。因为浮点数往往不精确,所以比较浮点数通常是有问题的——我们在第 6.7 课——关系运算符和浮点比较 中会更详细地讨论这个问题(以及解决方案)。

关于舍入误差的最后一点:数学运算(例如加法和乘法)往往会使舍入误差增大。因此,即使 0.1 在第 17 位有效数字处有舍入误差,当我们将 0.1 加十次时,舍入误差已经蔓延到第 16 位有效数字。持续的运算将导致此误差变得越来越重要。

关键见解

当数字无法精确存储时,就会发生舍入误差。即使是简单的数字,例如 0.1,也可能发生这种情况。因此,舍入误差可以并且确实一直发生。舍入误差不是例外——它们是常态。切勿假设您的浮点数是精确的。

这条规则的一个推论是:谨慎使用浮点数来表示金融或货币数据。

相关内容

要更深入了解浮点数在二进制中的存储方式,请查看 float.exposed 工具。
要了解更多关于浮点数和舍入误差的信息,floating-point-gui.defabiensanglard.net 有关于该主题的易懂指南。

NaN 和 Inf

IEEE 754 兼容格式还支持一些特殊值

  • Inf,表示无穷大。Inf 是有符号的,可以是正数 (+Inf) 或负数 (-Inf)。
  • NaN,表示“不是数字”。NaN 有几种不同的类型(我们在此不讨论)。
  • 带符号的零,意味着“正零”(+0.0)和“负零”(-0.0)有单独的表示。

不兼容 IEEE 754 的格式可能不支持其中一些(或任何)值。在这种情况下,使用或生成这些特殊值的代码将产生实现定义的行为。

这是一个显示所有三个的程序

#include <iostream>

int main()
{
    double zero { 0.0 };
    
    double posinf { 5.0 / zero }; // positive infinity
    std::cout << posinf << '\n';

    double neginf { -5.0 / zero }; // negative infinity
    std::cout << neginf << '\n';

    double z1 { 0.0 / posinf }; // positive zero
    std::cout << z1 << '\n';

    double z2 { -0.0 / posinf }; // negative zero
    std::cout << z2 << '\n';

    double nan { zero / zero }; // not a number (mathematically invalid)
    std::cout << nan << '\n';

    return 0;
}

以及使用 Clang 的结果

inf
-inf
0
-0
nan

请注意,打印 InfNaN 的结果是平台特定的,因此您的结果可能有所不同(例如,Visual Studio 将最后一个结果打印为 -nan(ind))。

最佳实践

避免除以 0.0,即使您的编译器支持它。

总结

总而言之,您应该记住关于浮点数的两件事

  1. 浮点数对于存储非常大或非常小的数字很有用,包括带有小数部分的数字。
  2. 浮点数通常存在很小的舍入误差,即使数字的有效数字少于精度。很多时候,这些误差因其太小而未被注意,也因为输出时数字被截断。然而,浮点数的比较可能无法给出预期的结果。对这些值执行数学运算会导致舍入误差增大。
guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
671 评论
最新
最早 最多投票
内联反馈
查看所有评论