二十七、非常大和非常小的数字
即使是最长的long long
也不能代表真正的大数,比如阿伏伽德罗数(6.02×10 23 )或者极小的数,比如一个电子的质量(9.1×10–31kg)。科学家和工程师使用科学记数法,它由一个尾数(如 6.02 或 9.1)和一个指数(如 23 或–31)组成,相对于基数(10)。
计算机使用类似的表示法来表示非常大和非常小的数字,称为浮点。我知道你们中的许多人一直在急切地等待这一探索,因为你们可能已经厌倦了只使用整数,所以让我们开始吧。
浮点数
计算机使用浮点数表示非常大和非常小的值。通过牺牲精度,你可以获得一个大大扩展的范围。但是,千万不要忘记,范围和精度是有限的。浮点数与数学实数不同,尽管它们通常可以作为实数的有用近似值。
像科学记数法一样,浮点数也有尾数、符号和指数。尾数和指数使用共同的基数或基数。尽管 C++ 中的整数总是以二进制表示,但浮点数可以使用任何基数。二进制是一种流行的基数,但有些计算机使用 16 甚至 10 作为基数。精确的细节总是依赖于实现。换句话说,每个 C++ 实现都使用其本机浮点格式来获得最佳性能。
浮点值通常有多种形式。C++ 提供单精度、双精度和扩展精度,分别称为float
、double
和long double
。不同的是,float
通常比double
精度低,量程小,double
通常比long double
精度低,量程小。作为交换,long double
通常比double
需要更多的内存和计算时间,而double
通常比float
消耗更多的内存和计算时间。另一方面,实现可以自由地对所有三种类型使用相同的表示。
使用double
,除非有理由不使用。当内存非常珍贵,您可以承受失去精度时,请使用float
;当您绝对需要额外的精度或范围,并且可以承受放弃内存和性能时,请使用long double
。
浮点数的一种常见二进制表示是 IEC 60559 标准,它更为人所知的名称是 IEEE 754。很可能,你的桌面系统有实现 IEC 60559 标准的硬件。为了方便起见,下面的讨论只描述 IEC 60559;然而,不要忘记 C++ 允许许多浮点表示。例如,大型机和 DSP 可能使用其他表示法。
一个 IEC 60559 binary32
(C++ float
)占用 32 位,其中 23 位构成尾数,8 位构成指数,剩下一位用于尾数的符号。基数是 2,所以一个 IEC 60559 binary32
的范围大致是 2–127到 2 127 或者 10–38到 10 38 。(我撒谎了。更小的数字是可能的,但是细节与 C++ 没有密切关系。如果你很好奇,在你最喜欢的计算机科学参考书中查找 subnormal 。)
IEC 60559 标准为特殊值保留了一些位模式。特别是,如果指数全是 1 位,尾数全是 0 位,则该值被认为是“无穷大”它不完全是数学上的无穷大,但它尽力假装。例如,将任意有限值与无穷大相加,得到无穷大的答案。正无穷大总是大于任何有限值,负无穷大总是小于有限值。
如果指数全是 1 位,尾数不全是 0 位,则该值被视为非数字,或 NaN 。NaN 有两种类型:安静型和信号型。带有安静 NaN 的算术总是产生 NaN 结果。使用信号 NaN 会导致机器中断。这个中断在你的程序中如何表现取决于实现。一般来说,你应该预料到你的程序会突然终止。请查阅您的编译器文档以了解详细信息。某些没有意义结果的算术运算也会产生 NaN,例如将正无穷大加到负无穷大。
通过调用std::isnan
(在<cmath>
中声明)测试一个值是否为 NaN。存在类似的函数来测试浮点数的无穷大和其他属性。
一个double
(IEC 60559 binary64
)在结构上类似于一个float
,除了它占用 64 位:52 位用于尾数,11 位用于指数,1 位符号位。double
也可以有无穷大和 NaN 值,具有相同的结构表示(即,指数全为 1)。
A long double
甚至比double
还要长。IEC 60559 标准允许至少需要 79 位的扩展双精度格式。许多桌面和工作站系统使用 80 位(尾数 63 位、指数 16 位和 1 位符号位)实现扩展精度浮点数。
浮点文字
任何带小数点或十进制指数的数字文字都代表浮点数。不管语言环境如何,小数点始终是'.'
。指数以字母e
或E
开始,可以带符号。数字文本中不允许有空格,例如:
3.1415926535897
31415926535897e-13
0.000314159265e4
默认情况下,浮点文字的类型为double
。要写一个float
字面值,在数字后面加上字母f
或F
。对于long double
,使用字母l
或L
,如下例所示:
3.141592f
31415926535897E-13l
0.000314159265E+420L
与long int
文字一样,我更喜欢大写的L
,以避免与数字1
混淆。你可以随意使用f
或F
,但我建议你选择一个并坚持使用。为了和L
统一,我更喜欢用F
。
如果浮点文字超出了类型的范围,编译器会告诉你。如果你要求一个比该类型支持的精度更高的值,编译器会自动给你尽可能高的精度。另一种可能是,您请求了一个该类型无法精确表示的值。在这种情况下,编译器会给出下一个更高或更低的值。
例如,你的程序可能有字面量0.2F
,它看起来像一个完美的实数,但是作为一个二进制浮点值,它没有精确的表示。而是大约为 0.0011001100 2 。十进制值和内部值之间的差异会导致意想不到的结果,其中最常见的是当您期望两个数字相等而它们不相等时。读取清单 27-1 预测胜负。
#include <cassert>
int main()
{
float a{0.03F};
float b{10.0F};
float c{0.3F};
assert(a * b == c);
}
Listing 27-1.Floating-Point Numbers Do Not Always Behave As You Expect
你的预测是什么?
实际结果如何?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
问题是 0.03 和 0.3 在二进制中没有精确的表示,所以如果你的浮点格式是二进制(大多数都是),计算机使用的值是真实值的近似值。将 0.03 乘以 10 得到的结果非常接近 0.3,但二进制表示与将 0.3 转换为二进制得到的结果不同。(在 IEC 60559 单精度格式中,0.03 * 10.0 给出 0.01111001100110011001002,0.3 是 0.0111100110011001100110002。这两个数字非常接近,但在第 22 个有效位上有所不同。
一些程序员错误地认为浮点运算因此是“不精确的”相反,浮点运算是精确的,但与实数运算不一样。问题只在于程序员的预期,如果你预期浮点运算要遵循实数运算的规则。如果您意识到编译器将您的十进制文字转换为其他值,并使用这些其他值进行计算,并且如果您了解处理器在使用这些值执行有限精度算术运算时使用的规则,您就可以确切地知道结果会是什么。如果这种详细程度对您的应用程序至关重要,那么您必须花时间来执行这种程度的分析。
然而,我们其他人可以继续假装浮点数和算术几乎是真实的,而不必过多担心差异。只是不要为了完全相等而比较浮点数。(如何比较数字近似相等,不在本书讨论范围之内。请访问网站获取链接和参考资料。)
浮点特征
您可以查询numeric_limits
来揭示浮点类型的大小和限制。您还可以确定该类型是允许无穷大还是 NaN。清单 27-2 显示了一些显示浮点类型信息的代码。
import <cmath>;
import <iostream>;
import <limits>;
import <locale>;
int main()
{
std::cout.imbue(std::locale{""});
std::cout << std::boolalpha;
// Change float to double or long double to learn about those types.
using T = float;
std::cout << "min=" << std::numeric_limits<T>::min() << '\n'
<< "max=" << std::numeric_limits<T>::max() << '\n'
<< "IEC 60559? " << std::numeric_limits<T>::is_iec559 << '\n'
<< "max exponent=" << std::numeric_limits<T>::max_exponent << '\n'
<< "min exponent=" << std::numeric_limits<T>::min_exponent << '\n'
<< "mantissa places=" << std::numeric_limits<T>::digits << '\n'
<< "radix=" << std::numeric_limits<T>::radix << '\n'
<< "has infinity? " << std::numeric_limits<T>::has_infinity << '\n'
<< "has quiet NaN? " << std::numeric_limits<T>::has_quiet_NaN << '\n'
<< "has signaling NaN? " << std::numeric_limits<T>::has_signaling_NaN << '\n';
if (std::numeric_limits<T>::has_infinity)
{
T zero{0};
T one{1};
T inf{std::numeric_limits<T>::infinity()};
if (std::isinf(one/zero))
std::cout << "1.0/0.0 = infinity\n";
if (inf + inf == inf)
std::cout << "infinity + infinity = infinity\n";
}
if (std::numeric_limits<T>::has_quiet_NaN)
{
// There's no guarantee that your environment produces quiet NaNs for
// these illegal arithmetic operations. It's possible that your compiler's
// default is to produce signaling NaNs, or to terminate the program
// in some other way.
T zero{};
T inf{std::numeric_limits<T>::infinity()};
std::cout << "zero/zero = " << zero/zero << '\n';
std::cout << "inf/inf = " << inf/inf << '\n';
}
}
Listing 27-2.Discovering the Attributes of a Floating-Point Type
修改程序,使其打印关于double
的信息。运行它。再次为long double
修改,运行。结果符合你的期望吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
浮点输入输出
读取和写入浮点值取决于区域设置。在传统语言环境中,输入格式与整数或浮点文字相同。在本地语言环境中,您必须根据语言环境的规则编写输入。特别是,小数点分隔符必须是区域设置的分隔符。千位分隔符是可选的,但是如果使用它们,必须使用特定于区域设置的字符和正确的位置。
输出更复杂。
除了字段宽度和填充字符,浮点输出还取决于精度(小数点后的位数)和格式,格式可以是定点(无指数)、科学(有指数)或通用(仅在必要时使用指数)。默认为常规。根据语言环境的不同,数字可能包含千人组的分隔符。
在科学格式和固定格式(使用同名的操纵器指定)中,精度是小数点后的位数。在一般格式中,它是有效数字的最大数量。用precision
成员函数或setprecision
操纵器设置流的精度。默认精度是 6。像往常一样,不带参数的操纵器在<ios>
中声明,所以你可以用<iostream>
免费获得它们,但是setprecision
要求你导入<iomanip>
。在格式说明符中设置小数点后的精度。精度必须在最小宽度和类型字母之间。
double const pi{3.141592653589793238L};
std::cout.precision(12);
std::cout << pi << '\n';
std::cout << std::setprecision(4) << pi << '\n';
std::cout << std::format("{0:.12}\n{0:.4}\n", pi);
在scientific
格式中,指数用小写的'e'
(或者'E'
,如果你使用uppercase
操纵器),后面跟着以 10 为底的指数。指数总是有一个符号(+
或-
)和至少两位数,即使指数为零。尾数写在小数点前一位。精度决定小数点后的位数。
在fixed
格式中,不打印指数。根据需要,在小数点前打印尽可能多的数字。精度决定小数点后的位数。始终打印小数点。
默认格式是通用格式,这意味着在不牺牲信息的情况下很好地打印数字。如果指数小于或等于–4,或者大于精度,则以科学格式打印数字。否则,打印时没有指数。然而,与传统的定点输出不同,小数点后的尾随零被删除。如果删除尾随零后,小数点成为最后一个字符,它也将被删除。
必要时,将值四舍五入以符合分配的精度。
C++ 11 中引入的一种格式是hexfloat
(格式类型'a'
)。该值以十六进制形式打印,这使您可以在以二进制或十六进制表示的系统上找到准确的值。因为字母'e'
是有效的十六进制值,所以指数用字母'p'
或'P'
标记。
指定特定输出格式的最简单方法是使用操纵器:scientific
、fixed
或hexfloat
。像精度一样,格式保持在流的状态,直到您更改它。(只有宽度在输出操作后复位。)不幸的是,一旦设置了格式,就很难恢复到默认的通用格式。为此,您必须使用一个成员函数,而且是一个笨拙的函数,如下所示:
std::cout << std::scientific << large_number << '\n';
std::cout << std::fixed << small_number << '\n';
std::cout.unsetf(std::ios_base::floatfield);
std::cout << number_in_general_format << '\n';
std::cout << std::format("{:e}\n{:f}\n{}\n",
large_number, small_number, number_in_general_format);
在格式说明符中,使用类型'e'
表示指数型或科学型,'f'
表示固定型,'g'
表示常规型,'a'
表示十六进制型。使用大写字母获得大写的'E'
或'P'
作为指数。
完成 表 27-1 ,精确显示每个值如何在经典语言环境中以每种格式打印。为了方便你,我填了第一排。
表 27-1。
浮点输出
|价值
|
精确
|
科学的
|
固定的;不变的
|
六进位浮点
|
一般
|
| — | — | — | — | — | — |
| One hundred and twenty-three thousand four hundred and fifty-six point seven eight nine | six | 1.234568e5
| 123456.789000
| 0x1.e240cap+16
| 123457
|
| 1.23456789 | four | ___________ | _____________ | _______________ | _______________ |
| One hundred and twenty-three million four hundred and fifty-six thousand seven hundred and eighty-nine | Two | ___________ | _____________ | _______________ | _______________ |
| –1234.5678 e9 | five | ___________ | _____________ | _______________ | _______________ |
在你把你的预测填入表格后,写一个程序来测试你的预测,然后运行它,看看你做得有多好。将你的程序与清单 27-3 进行比较。
import <format>;
import <iostream>;
/// Print a floating-point number in three different formats.
/// @param precision the precision to use when printing @p value
/// @param value the floating-point number to print
void print(int precision, float value)
{
std::cout.precision(precision);
std::cout << std::scientific << value << '\t'
<< std::fixed << value << '\t'
<< std::hexfloat << value << '\t';
// Set the format to general.
std::cout.unsetf(std::ios_base::floatfield);
std::cout << value << '\n';
std::cout << std::format("{0:.{1}e}\n{0:.{1}f}\n{0:.{1}a}\n{0:.{1}g}\n",
value, precision);
}
/// Main program.
int main()
{
print(6, 123456.789F);
print(4, 1.23456789F);
print(2, 123456789.F);
print(5, -1234.5678e9F);
}
Listing 27-3.Demonstrating Floating-Point Output
根据浮点表示形式的不同,不同系统的精确值可能会有所不同。例如,大多数系统上的float
不支持九个十进制数字的完全精度,因此打印结果的最低有效数字可能会有些模糊。换句话说,除非你想坐下来做一些严肃的二进制计算,否则你无法准确预测每种情况下的输出。表 27-2 显示了在典型的 IEC 60559 兼容系统上运行时,清单 27-3 的输出。
表 27-2。
打印浮点数的结果
|价值
|
精确
|
科学的
|
固定的;不变的
|
六进位浮点
|
一般
|
| — | — | — | — | — | — |
| One hundred and twenty-three thousand four hundred and fifty-six point seven eight nine | six | 1.234568e+05
| 123456.789062
| 0x1.e240cap+16
| 123457
|
| 1.23456789 | four | 1.2346e+00
| 1.2346
| 0x1.3c0ca4p+0
| 1.235
|
| One hundred and twenty-three million four hundred and fifty-six thousand seven hundred and eighty-nine | Two | 1.23e+08
| 123456792.00
| 0x1.d6f346p+26
| 1.2e+08
|
| –1234.5678 e9 | five | -1.23457e+12
| -1234567823360.00000
| -0x1.1f71fap+40
| -1.2346e+12
|
有些应用程序从来不需要使用浮点数;其他人非常需要它们。例如,科学家和工程师依赖浮点算术和数学函数,必须理解使用这些数字的微妙之处。C++ 拥有计算密集型编程所需的一切。尽管细节超出了本书的范围,感兴趣的读者应该参考一下关于<cmath>
头和它提供的先验函数和其他函数的参考资料。<cfenv>
头包含函数和相关声明,让您调整舍入模式和浮点环境的其他方面。如果在 C++ 参考中找不到关于<cfenv>
的信息,请查阅 C 99 参考中的<fenv.h>
头文件。
接下来的探索将会涉及一个完全不同的主题,解释我在一些程序中使用的奇怪的注释——额外的斜线(///
)和星号(/**
)。
二十八、文件
这次探险和其他的有点不同。它没有涵盖 C++ 标准的一部分,而是研究了一个名为 Doxygen 的第三方工具。请随意跳过这一探索,但要明白这是我解释您有时在代码清单中看到的奇怪注释的地方。
注释
Doxygen 是一个免费的(有费用和许可)工具,它可以读取您的源代码,寻找遵循特定结构的注释,并从注释和代码中提取信息以生成文档。它产生多种格式的输出:HTML、RTF(富文本格式)、LaTeX、UNIX 手册页和 XML。
Java 程序员可能熟悉一种叫做 javadoc 的类似工具。javadoc 工具是 Java 软件开发工具包中的标准,而 Doxygen 与 C++ 标准或任何 C++ 供应商都没有关系。C++ 缺乏结构化文档的标准,所以你可以随心所欲。例如,微软为注释中的 XML 标记定义了自己的约定,如果您打算完全在微软内部工作,这很好。NET 环境。对于其他程序员,我建议使用具有更广泛和可移植用途的工具。最流行的解决方案是 Doxygen,我认为每个 C++ 程序员都应该了解它,即使您决定不使用它。这就是为什么我在书中包括了这个探索。
结构化注释
Doxygen 注意遵循特定格式的注释:
-
单行注释以额外的斜线或感叹号开始:
///
或//!
-
多行注释以一个额外的星号或感叹号开始:
/**
或/*!
此外,Doxygen 认识到一些普遍的注释惯例和修饰。例如,它忽略一行斜线。
//
多行注释可以以一行星号开始。
/*****************************************************************************
like this
*****************************************************************************/
多行注释中的一行可以以星号开始。
/****************************************************************************
* This is a structured comment for Doxygen. *
****************************************************************************/
在结构化注释中,您可以记录程序中的各种实体:函数、类型、变量等等。
惯例是紧接在声明或定义之前的注释适用于被声明或定义的实体。有时,您希望将注释放在声明之后,例如对变量的单行描述。为此,在注释的开头使用“小于”(<
)符号。
double const c = 299792458.0; ///< speed of light in m/sec
文档标签和降价
Doxygen 有自己的标记语言,利用了标签。标签可以以反斜杠字符(\return
)或“at 符号”(@return
)开始。有些标签有参数,有些没有。除了它自己的标签之外,你还可以使用 HTML 或 Markdown(一种易于读写的类似 wiki 的语法)的子集。最有用的标签、标记和降价如下:
@b 字
将一词标为黑体。您还可以使用 HTML 标记、<b>
短语 </b>
,这在短语包含空格时很有用,或者使用 Markdown,将文本括在星号:* 短语 *中。
@brief 一句话描述
简要描述一个实体。实体有简短而详细的文件。根据您如何配置 Doxygen,简要文档可以是实体完整文档的第一句话,或者您可以要求一个显式的@brief
标记。无论哪种情况,注释的其余部分都是实体的详细文档。
@c 字
将单词视为代码片段,并将其设置为固定间距字体。您也可以使用 HTML 标记、<tt>
短语 </tt>
,或者使用反斜线进行 Markdown、*短语*
。
字
用斜体强调一词。也可以使用 HTML 标签、<em>
短语 </em>
,或者下划线进行 Markdown: _
短语 _
。
@file 文件名
呈现源文件的概述。详细描述可以描述文件的用途、修订历史和其他全局文档。文件名是可选的;没有它,Doxygen 使用文件的真实名称。
@link 实体文本 @endlink
创建一个到命名的实体的超链接,比如一个文件。我在我的@mainpage
上使用@link
来为项目中最重要的文件或唯一的文件创建一个目录。Markdown 提供了多种创建链接的方式,比如 文本 。
@mainpage 标题
为索引或封面开始整个项目的概述。你可以把@mainpage
放在任何源文件中,或者甚至为注释留出一个单独的文件。在小项目中,我将@mainpage
放在与main
函数相同的源文件中,但是在大项目中,我使用一个单独的文件,比如 main.dox 。
@p 姓名
将名称设置为固定间距字体,以区别于函数参数。
@par 标题
开始一个新段落。如果你提供一行标题,它将成为段落标题。空行也可以分隔段落。
@param 名称说明
记录一个名为名为的函数参数。如果您想在函数文档的其他地方引用该参数,请使用@p
名称。
@post 后置条件
记录函数的后置条件。后置条件是一个布尔表达式,当函数返回时,您可以假设它为真(假设所有前提条件都为真)。C++ 缺乏任何强制后置条件的正式机制(除了assert
),所以记录后置条件是至关重要的,尤其是对库作者来说。
@pre 前置条件
记录函数的前提条件。前提条件是一个布尔表达式,在函数被调用之前必须为真,否则不能保证函数正常工作。C++ 缺乏任何强制实施前提条件的正式机制(除了assert
),所以记录前提条件是至关重要的,尤其是对库作者来说。
@return 描述
记录函数返回的内容。
见外部参照
插入对名为 xref 的实体的交叉引用。Doxygen 在结构化注释中查找对其他文档实体的引用。当它找到一个时,它插入一个超链接(或文本交叉引用,取决于输出格式)。然而,有时您必须插入对文档中没有命名的实体的显式引用,因此您可以使用@see
。
您可以通过在名称前加上%
来禁止自动创建超链接。
@&, @@, @, @%, @<
对文字字符(&
、@
、\
、%
或<
)进行转义,以防止被 Doxygen 或 HTML 解释。
Doxygen 非常灵活,您有很多方法使用原生 Doxygen 标签、HTML 或 Markdown 来格式化您的注释。这本书的网站有到 Doxygen 主页的链接,在那里你可以找到更多关于该工具的信息并下载该软件。大多数 Linux 用户已经有了 Doxygen 其他用户可以为他们喜欢的平台下载 Doxygen。
清单 28-1 展示了使用 Doxygen 的许多方法中的一些。
/** @file
* @brief Tests strings to see whether they are palindromes.
*
* Reads lines from the input, strip non-letters, and checks whether
* the result is a palindrome. Ignores case differences when checking.
* Echoes palindromes to the standard output.
*/
/** @mainpage Palindromes
* Tests input strings to see whether they are palindromes.
*
* A _palindrome_ is a string that reads the same forward and backward.
* To test for palindromes, this program needs to strip punctuation and
* other non-essential characters from the string, and compare letters without
* regard to case differences.
*
* This program reads lines of text from the standard input and echoes
* to the standard output those lines that are palindromes.
*
* Source file: list2801.cpp
*
* @date 27-March-2020
* @author Ray Lischner
* @version 3.0
*/
import <algorithm>;
import <iostream>;
import <ranges>;
import <locale>;
import <ranges>;
import <string>;
import <string_view>;
/** @brief Tests for non-letter.
*
* Tests the character @p ch in the global locale.
* @param ch the character to test
* @return true if @p ch is not a letter
*/
bool non_letter(char ch)
{
return not std::isalnum(ch, std::locale{});
}
/** @brief Converts to lowercase.
*
* All conversions use the global locale.
*
* @param ch the character to test
* @return the character converted to lowercase
*/
char lowercase(char ch)
{
return std::tolower(ch, std::locale{});
}
/** @brief Compares two characters without regard to case.
*
* @param a one character to compare
* @param b the other character to compare
* @return `true` if the characters are the same in lowercase,
* `false` if they are different.
*/
bool is_same_char(char a, char b)
{
return lowercase(a) == lowercase(b);
}
/** @brief Determines whether @p str is a palindrome.
*
* Only letter characters are tested. Spaces and punctuation don't count.
* Empty strings are not palindromes because that's just too easy.
* @param str the string to test
* @return `true` if @p str is the same forward and backward and
* `not str.empty()`
*/
bool is_palindrome(std::string_view str)
{
auto filtered_str{ str | std::views::filter(lowercase) };
return std::ranges::equal(filtered_str, filtered_str|std::views::reverse,
is_same_char);
}
/** @brief Main program.
* Sets the global locale to the user's native locale.
* Then imbues the I/O streams with the native locale.
*/
int main()
{
std::locale::global(std::locale{""});
std::cin.imbue(std::locale{});
std::cout.imbue(std::locale{});
for (std::string line{}; std::getline(std::cin, line); /*empty*/)
if (is_palindrome(line))
std::cout << line << '\n';
}
Listing 28-1.Documenting Your Code with Doxygen
图 28-1 显示了网页浏览器中的主页。
图 28-1。
回文文档的主页
使用 Doxygen
Doxygen 没有采用大量的命令行参数,而是使用一个配置文件,通常命名为Doxyfile
,您可以将所有有趣的信息放入其中。配置文件中的信息包括项目的名称、要检查注释的文件、要生成的输出格式以及可以用来调整输出的各种选项。
由于选项太多,Doxygen 附带了一个向导doxywizard
,来帮助生成一个合适的配置文件,或者您可以使用-g
开关运行命令行doxygen
实用程序,来生成一个默认的配置文件,其中有很多注释来帮助您理解如何定制它。
一旦配置了 Doxygen,运行程序就变得简单了。简单地运行doxygen
,它就走了。Doxygen 在解析 C++ 方面做得很好,c++ 是一种复杂且难以解析的语言,但它有时会混淆。注意错误消息,看看源文件是否有问题。
配置文件规定了输出的位置。通常,每种输出格式都位于自己的子目录中。例如,默认配置文件将 HTML 输出存储在html
目录中。在您喜欢的浏览器中打开html/index.html
文件,查看结果。
在您的系统上下载并安装 Doxygen。
将 Doxygen 注释添加到您的程序中。配置并运行 Doxygen。
未来的程序将继续零星地使用 Doxygen 注释,当我认为这些注释有助于你理解程序是做什么的时候。不过,总的来说,我尽量避免在书中提到它们,因为文本通常会足够好地解释事情,我不想浪费任何空间。然而,本书附带的程序有更完整的 Doxygen 注释。
二十九、项目 1:身体质量指数
项目时间到了!身体质量指数(身体质量指数)是一些卫生保健专业人员用来确定一个人是否超重,如果是,超重多少的测量方法。为了计算身体质量指数,你需要一个人的体重(公斤)和身高(米)。身体质量指数就是体重/身高 2 ,转换成无单位值。
你的任务是编写一个程序,读取记录,打印记录,并计算一些统计数据。这个程序应该从要求一个阈值身体质量指数开始。仅打印身体质量指数大于或等于阈值的记录。每个记录需要包含一个人的姓名(可以包含空格)、体重(以千克为单位)、身高(以厘米为单位,而不是米)和性别('M'
或'F'
)。让用户以大写或小写输入性别。要求用户以厘米为单位输入高度,这样您就可以使用整数来计算身体质量指数。你必须调整公式,把米改为厘米。
阅读每个人的记录后,立即打印其身体质量指数。在收集了每个人的信息后,根据数据打印两张表——一张是男性的,一张是女性的。在身体质量指数评级后使用星号来标记数量达到或超过阈值的记录。在每张表格后,打印平均值和身体质量指数中值。(中位数是一半身体质量指数值小于中位数,一半大于中位数的值。如果用户输入偶数条记录,则取中间两个值的平均值。)将单个身体质量指数值计算为整数。以浮点数形式计算身体质量指数值的平均值和中值,并打印小数点后有一位的平均值。
清单 29-1 显示了一个示例用户会话。用户输入以粗体显示。
$ bmi
This program computes Bogus Metabolic Indicator (BMI) values.
Enter threshold BMI: 25
Enter name, height (in cm), and weight (in kg) for each person:
Name 1: Ray Lischner
Height (cm): 180
Weight (kg): 90
Sex (m or f): m
BMI = 27
Name 2: A. Nony Mouse
Height (cm): 120
Weight (kg): 42
Sex (m or f): F
BMI = 29
Name 3: Mick E. Mouse
Height (cm): 30
Weight (kg): 2
Sex (m or f): M
BMI = 22
Name 4: A. Nony Lischner
Height (cm): 150
Weight (kg): 55
Sex (m or f): m
BMI = 24
Name 5: No One
Height (cm): 250
Weight (kg): 130
Sex (m or f): f
BMI = 20
Name 6: ^Z
Male data
Ht(cm) Wt(kg) Sex BMI Name
180 90 M 27* Ray Lischner
30 2 M 22 Mick E. Mouse
150 55 M 24 A. Nony Lischner
Mean BMI = 24.3
Median BMI = 24
Female data
Ht(cm) Wt(kg) Sex BMI Name
120 42 F 29* A. Nony Mouse
250 130 F 20 No One
Mean BMI = 24.5
Median BMI = 24.5
Listing 29-1.Sample User Session with the BMI Program
暗示
如果你需要的话,这里有一些提示:
-
在单独的向量中记录数据,例如,
heights
、weights
、sexes
、names
、bmis
。 -
对所有输入和输出使用本地语言环境。
-
将程序分成函数,例如,
compute_bmi
根据体重和身高计算身体质量指数。 -
你可以只使用我们到目前为止介绍的技术来编写这个程序,但是如果你知道其他的技术,请随意使用它们。下一组探索将呈现语言特性,这将极大地方便编写这类程序。
-
我的解决方案的完整源代码可以在本书附带的其他文件中找到,但是在您自己编写程序之前不要偷看。
三十、自定义类型
C++ 的关键设计目标之一是,您应该能够定义外观和行为都与内置类型相似的全新类型。需要三态逻辑吗?自己写tribool
类型。需要任意精度的算术?自己写bigint
类型。更好的是,让别人来写,你用普通int
的方式使用bigint
。本文介绍了一些允许您定义自定义类型的语言特性。随后的探索对这些主题进行了更深入的研究。
定义新类型
让我们考虑一个场景,其中您想要定义一个类型rational
,来表示有理数(分数)。有理数有一个分子和一个分母,都是整数。理想情况下,您将能够以与内置数值类型相同的方式对有理数进行加、减、乘、除操作。您还应该能够在同一个表达式中混合有理数和其他数字类型。(我们的rational
类型不同于std::ratio
类型,它代表一个编译时常量;我们的rational
类型可以在运行时改变值。)
I/O 流应该能够以某种合理的方式读写有理数。输入操作符应该接受输出操作符产生的任何东西作为有效输入。I/O 操作符应该留意流的标志和相关设置,例如字段宽度和填充字符,这样您就可以像在 Exploration 8 中处理整数一样,格式化整齐对齐的有理数列。
您应该能够将任何数值赋给一个rational
变量,并将一个rational
值转换成任何内置的数值类型。自然地,将有理数转换为整数变量会导致截断为整数。有人可能认为转换应该是自动的,类似于从浮点到整数的转换。一个相反的论点是,丢弃信息的自动转换在最初的 C 语言设计中是一个错误,不应该被复制。相反,丢弃信息的转换应该清晰明了。我更喜欢后一种方法。
这一次要处理的事情很多,所以让我们慢慢开始。
第一步是决定如何存储一个有理数。你必须存储一个分子和一个分母,都是整数。负数呢?选择一个约定,比如分子得到整个值的符号,分母总是正的。清单 30-1 显示了一个基本的rational
类型定义。
/// Represent a rational number.
struct rational
{
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
Listing 30-1.Defining a Custom rational Type
定义从关键字struct
开始。c 程序员认为这是一个结构定义——但是等等,还有更多的内容。
rational
类型的内容看起来像是名为numerator
和denominator
的变量的定义,但是它们的工作方式略有不同。记住清单 30-1 显示了一个类型定义。换句话说,编译器记得rational
命名了一个类型,但是它没有为一个对象、numerator
或denominator
分配任何内存。用 C++ 的说法,numerator
和denominator
被称为数据成员;其他一些语言称之为实例变量或字段。
注意右大括号后面的分号。类型定义不同于复合语句。如果你忘记了分号,编译器会提醒你,有时会很粗鲁地提醒你,同时指出分号所属行之外的行号。
当定义一个类型为rational
的对象时,该对象存储了numerator
和denominator
成员。使用点(.
)操作符来访问成员(在本书中你一直在这么做),如清单 30-2 所示。
import <iostream>;
/// Represent a rational number.
struct rational
{
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{};
pi.numerator = 355;
pi.denominator = 113;
std::cout << "pi is approximately " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-2.Using a Custom Type and Its Members
那不是非常令人兴奋的,是吗?类型只是坐在那里,毫无生气。你知道标准库中很多类型都有成员函数,比如std::ostream
的width
成员函数,允许你写std::cout.width(4)
。下一节将展示如何编写自己的成员函数。
成员函数
让我们给rational
添加一个成员函数,用分子和分母的最大公约数来减少它们。清单 30-3 显示了带有reduce()
成员函数的示例程序。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Reduce the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{};
pi.numerator = 1420;
pi.denominator = 452;
pi.reduce();
std::cout << "pi is approximately " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-3.Adding the reduce Member Function
注意reduce()
成员函数看起来就像一个普通的函数,除了它的定义出现在rational
类型定义中。它调用gcd
(最大公约数)函数,该函数在<numeric>
中声明。还要注意reduce()
如何引用rational
的数据成员。当调用reduce()
成员函数时,必须提供一个对象作为点(.
)操作符的左操作数(如清单 30-3 中的pi
)。当reduce()
函数引用一个数据成员时,该数据成员取自左边的操作数。于是,numerator = numerator / div
就有了pi.numerator = pi.numerator / div
的效果。
成员函数也可以调用在同一类型中定义的其他成员函数。自己试试:添加 assign()成员函数,该函数以一个numerator
和denominator
为两个参数,赋给各自的数据成员,并调用reduce()
。这为rational
的用户节省了额外的步骤(以及忽略对reduce()
调用的可能错误)。设返回类型为void
。在这里写你的成员函数:
清单 30-4 展示了整个程序,我的assign
成员函数用粗体显示。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Assigns a numerator and a denominator, then reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{};
pi.assign(1420, 452);
std::cout << "pi is approximately " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-4.Adding the assign Member Function
注意现在的main
程序是多么简单。隐藏细节,比如reduce()
,有助于保持代码的整洁、可读性和可维护性。
注意另一个微妙的细节:assign()
的定义在reduce()
之前,尽管它调用了reduce()
。我们需要对这个规则做一个小的调整,编译器必须至少看到一个名字的声明,然后你才能使用这个名字。struct
的成员可以引用其他成员,而不考虑类型中声明的顺序。在所有其他情况下,您必须在使用前提供声明。
能够在一个步骤中分配分子和分母是对rational
类型的一个很好的补充,但更重要的是能够初始化一个rational
对象。回想一下 Exploration 5 中我对确保所有对象都被正确初始化的告诫。下一节演示如何给rational
添加对初始化器的支持。
构造器
如果能够初始化一个带有分子和分母的rational
对象,并自动对它们进行适当的缩减,这不是很好吗?你可以通过编写一个特殊的成员函数来实现,这个函数看起来和行为有点像assign
,除了名字和类型的名字一样(rational
),而且这个函数没有返回类型或者返回值。清单 30-5 展示了如何编写这个特殊的成员函数。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Constructs a rational object, given a numerator and a denominator.
/// Always reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
/// Assigns a numerator and a denominator, then reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{1420, 452};
std::cout << "pi is about " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-5.Adding the Ability to Initialize a rational Object
注意pi
对象的定义。变量在其初始化器中接受参数,这两个参数以与函数参数相同的方式传递给特殊的初始化器函数。这个特殊的成员函数被称为构造器。
构造器看起来很像普通的函数,除了它没有返回类型。此外,您不能选择名称,但必须使用类型名称。还有一行是以冒号开头的。这段额外的代码初始化数据成员的方式与初始化变量的方式相同。在所有的数据成员被初始化之后,构造器的主体以与任何成员函数主体相同的方式运行。
初始化列表是可选的。没有它,数据成员就没有初始化——这是一件坏事,所以不要这样做。
**修改 rational 类型,使其接受负分母。**如果分母为负,将其改为正,同时改变分子的符号。因此,rational{-710, -227}
将具有与rational{710, 227}
相同的值。
您可以选择在许多地方中的任何一个进行修改。良好的软件设计实践表明,变更应该恰好发生在一个点上,所有其他功能都应该调用那个点。因此,我建议修改reduce()
,如清单 30-6 所示。
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
if (denominator < 0)
{
denominator = -denominator;
numerator = -numerator;
}
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
Listing 30-6.Modifying the reduce Member Function to Accept a Negative Denominator
重载构造器
可以像重载普通函数一样重载构造器。所有重载的构造器都有相同的名称(即类型的名称),并且它们的参数数量或类型必须不同。例如,您可以添加一个接受单个整数参数的构造器,隐式使用 1 作为分母。
编写重载构造器有两种方法可供选择。你可以让一个构造器调用另一个,或者让构造器初始化所有成员,就像你写的第一个构造器一样。若要调用另一个构造器,请使用初始值设定项列表中的类型名。
rational(int num) : rational{num, 1} {}
或者直接初始化每个成员。
rational(int num)
: numerator{num}, denominator{1}
{}
当您希望多个构造器共享一个共同的行为时,将工作委托给一个共同的构造器非常有意义。例如,你可以有一个单独的构造器调用reduce()
,其他所有的构造器都可以调用那个构造器,从而确保无论你如何构造rational
对象,你都知道它已经被减少了。
另一方面,当分母为 1 时,不需要调用reduce()
,所以您可能更喜欢第二种形式,直接初始化数据成员。选择权在你。
调用另一个构造器的构造器被称为委托构造器,因为它将其工作委托给另一个构造器。
我相信你能看到rational
型目前状态的很多不足。它有几个你可能也错过了。挺住;下一次探索开始改进类型。例如,您可能想通过比较两个rational
对象来测试您的修改,看看它们是否相等。然而,要做到这一点,您必须编写一个定制的==
操作符,这是下一个探索的主题。
三十一、重载运算符
这一探索继续了对编写自定义类型的研究。使自定义类型与内置类型无缝运行的一个重要方面是确保自定义类型支持所有预期的运算符——算术类型必须支持算术运算符,可读和可写类型必须支持 I/O 运算符,等等。幸运的是,C++ 允许你重载操作符,就像重载函数一样。
比较有理数
在之前的探索中,你开始写一个rational
类型。在对它进行修改之后,一个重要的步骤是测试修改后的类型,内部测试的一个重要方面是等号(==
)操作符。C++ 允许您为几乎每个操作符定义一个自定义实现,前提是至少有一个操作数具有自定义类型。换句话说,您不能重新定义整数除法来产生一个rational
结果,但是您可以定义一个整数除以一个rational
数,反之亦然。
要实现一个定制的操作符,编写一个普通的函数,但是对于函数名,使用operator
关键字,后跟操作符符号,如清单 31-1 所示。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Constructs a rational object, given a numerator and a denominator.
/// Always reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
/// Assigns a numerator and a denominator, then reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
if (denominator < 0)
{
denominator = -denominator;
numerator = -numerator;
}
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
/// Compares two rational numbers for equality.
/// @pre @p a and @p b are reduced to normal form
bool operator==(rational const& a, rational const& b)
{
return a.numerator == b.numerator and a.denominator == b.denominator;
}
/// Compare two rational numbers for inequality.
/// @pre @p a and @p b are reduced to normal form
bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
int main()
{
rational pi1{355, 113};
rational pi2{1420, 452};
if (pi1 == pi2)
std::cout << "success\n";
else
std::cout << "failure\n";
}
Listing 31-1.Overloading the Equality Operator
减少所有rational
数字的好处之一是,这使得比较更容易。构造器没有检查3/3
是否与6/6
相同,而是将两个数字都简化为1/1
,所以这只是比较分子和分母的问题。另一个诀窍是用==
来定义!=
。为自己做额外的工作是没有意义的,所以将比较rational
对象的实际逻辑限制在一个函数中,并从另一个函数中调用它。如果您担心调用额外的一层函数的性能开销,可以使用关键字inline
,如清单 31-2 所示。
/// Compares two rational numbers for equality.
/// @pre @p a and @p b are reduced to normal form
bool operator==(rational const& a, rational const& b)
{
return a.numerator == b.numerator and a.denominator == b.denominator;
}
/// Compares two rational numbers for inequality.
/// @pre @p a and @p b are reduced to normal form
inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
Listing 31-2.Using inline for Trivial Functions
关键字inline
是对编译器的一个暗示,你希望函数在调用点被扩展。如果编译器决定听从您的意愿,那么结果程序中不会有任何名为operator!=
的可识别函数。相反,每一个使用带有rational
对象的!=
操作符的地方,函数体都在那里被扩展,导致对operator==
的调用。
要实现<
操作符,您需要一个公分母。一旦实现了operator<
,就可以根据<
实现所有其他的关系操作符。您可以选择任意一个关系运算符(<
、>
、<=
、>=
)作为基本运算符,并根据基本运算符实现其他运算符。惯例是从<
开始。清单 31-3 演示了<
和<=
。
/// Compares two rational numbers for less-than.
bool operator<(rational const& a, rational const& b)
{
return a.numerator * b.denominator < b.numerator * a.denominator;
}
/// Compares two rational numbers for less-than-or-equal.
inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
Listing 31-3.Implementing the < Operator for rational
执行>和> =根据<。
将您的运算符与清单 31-4 进行比较。
/// Compares two rational numbers for greater-than.
inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
/// Compares two rational numbers for greater-than-or-equal.
inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
Listing 31-4.Implementing the > and >= Operators in Terms of <
**然后编写一个测试程序。**为了帮助您编写测试,下载test.cpp
文件并将import test
添加到您的程序中。根据需要多次调用TEST()
函数,传递一个布尔表达式作为唯一参数。如果参数为真,则测试通过。如果参数为假,则测试失败,并且TEST
函数打印一条合适的消息。因此,您可以编写测试,如下所示:
TEST(rational{2, 2} == rational{5, 5});
TEST(rational{6,3} > rational{10, 6});
全大写的名字TEST
,告诉你TEST
不同于一个普通的函数。特别是,如果测试失败,测试文本将作为失败消息的一部分打印出来。TEST
函数如何工作超出了本书的范围,但是有它在身边还是很有用的;您将在未来的测试工具中使用它。将你的测试程序与清单 31-5 进行比较。
#include <cassert>
import <iostream>;
import <numeric>;
import test;
... struct rational omitted for brevity ...
int main()
{
rational a{60, 5};
rational b{12, 1};
rational c{-24, -2};
TEST(a == b);
TEST(a >= b);
TEST(a <= b);
TEST(b <= a);
TEST(b >= a);
TEST(b == c);
TEST(b >= c);
TEST(b <= c);
TEST(a == c);
TEST(a >= c);
TEST(a <= c);
rational d{109, 10};
TEST(d < a);
TEST(d <= a);
TEST(d != a);
TEST(a > d);
TEST(a >= d);
TEST(a != d);
rational e{241, 20};
TEST(e > a);
TEST(e >= a);
TEST(e != a);
TEST(a < e);
TEST(a <= e);
TEST(a != e);
}
Listing 31-5.Testing the rational Comparison Operators
算术运算符
比较没问题,但是算术运算符要有趣得多。您可以重载任何或所有算术运算符。二元运算符有两个参数,一元运算符有一个参数。你可以选择任何有意义的返回类型。清单 31-6 显示了二元加法运算符和一元否定运算符。
rational operator+(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator + rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
rational operator-(rational const& r)
{
return rational{-r.numerator, r.denominator};
}
Listing 31-6.Addition Operator for the rational Type
编写其他算术运算符:-、*和/ 。暂时忽略被零除的问题。将你的功能与我的功能进行比较,我的功能在清单 31-7 中列出。
rational operator-(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator - rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
rational operator*(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.numerator, lhs.denominator * rhs.denominator};
}
rational operator/(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator, lhs.denominator * rhs.numerator};
}
Listing 31-7.Arithmetic Operators for the rational Type
用rational
数字做加法、减法等等都没问题,但是更有趣的是混合类型的问题。比如3 * rational(1, 3)
的值是多少?试试看。收集带有所有操作符的rational
类型的定义,并编写一个main()
函数来计算该表达式并将其存储在某个地方。为结果变量选择一种对你有意义的类型,然后决定如何最好地将该值打印到std:
:cout
。
你希望表达式编译时没有错误吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
表达式的结果类型是什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
你希望结果是什么样的?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
解释你的观察结果。
原来,rational
的单参数构造器告诉编译器,它可以在任何需要的时候从一个int
构造一个rational
。这是自动完成的,所以编译器会看到整数3
以及一个int
和一个rational
对象的乘积。它知道两个rational
之间的operator*
,并且它知道它不能使用带有rational
操作数的内置*
操作符。因此,编译器决定它的最佳响应是将int
转换为rational
(通过调用rational{3}
),然后它可以应用将两个rational
对象相乘的自定义operator*
,产生一个rational
结果,即rational{1, 1}
。它会代表您自动完成所有这些工作。清单 31-8 展示了编写测试程序的一种方法。
#include <cassert>
import <iostream>;
import <numeric>;
... struct rational omitted for brevity ...
int main()
{
rational result{3 * rational{1, 3}};
std::cout << result.numerator << '/' << result.denominator << '\n';
}
Listing 31-8.Test Program for Multiplying an Integer and a Rational Number
能够从一个int
自动构造一个rational
对象是非常方便的。您可以轻松地编写对整数和有理数执行运算的代码,而无需一直关注类型转换。当混合整数和浮点数时,您会发现同样的便利。例如,您可以编写1+2.0
,而不必执行类型转换:static_cast<double>(1)+2.0
。
另一方面,所有这些便利可能都太方便了。尝试编译下面的代码样本,看看你的编译器报告了什么:
int a(3.14); // which one is okay,
int b{3.14}; // and which is an error?
我总是用花括号来初始化变量,但是只要你至少提供一个参数,圆括号也可以。你也为安全付出了代价。当您使用括号进行初始化时,编译器允许丢失信息的转换,如从浮点到整数的转换,但当您使用花括号时,它会报告错误。
这种差异对于rational
型来说至关重要。使用圆括号中的浮点数初始化有理数会将该数截断为整数,并使用构造器的单参数形式。这根本不是你想要的。相反,初始化rational{3.14}
应该产生与rational{314, 100}
相同的结果。
编写从浮点到分数的高质量转换超出了本书的范围。相反,让我们选择一个合理的 10 的幂作为分母。假设我们选择 100,000,那么rational{3.14159}
将被视为rational{static_cast<int>(3.14159 * 100000), 100000}
。写一个浮点数的构造器。我建议使用委托构造器;也就是说,编写浮点构造器,以便它调用另一个构造器。
将你的结果与我在清单 31-9 中的结果进行比较。一个更好的解决方案是使用numeric_limits
来确定double
可以支持的精度的十进制位数,并试图保持尽可能多的精度。一个更好的解决方案是使用浮点实现的基数,而不是使用基数 10。
struct rational
{
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
rational(double r)
: rational{static_cast<int>(r * 100000), 100000}
{}
... omitted for brevity ...
int numerator;
int denominator;
};
Listing 31-9.Constructing a Rational Number from a Floating-Point Argument
如果您想为特定的参数类型优化特定的函数,您也可以通过利用普通的函数重载来实现。不过,你最好确保这项额外的工作是值得的。请记住int
操作数可以是右操作数或左操作数,所以您必须重载这两种形式的函数,如列表 31-10 所示。
rational operator*(rational const& rat, int mult)
{
return rational{rat.numerator * mult, rat.denominator};
}
inline rational operator*(int mult, rational const& rat)
{
return rat * mult;
}
Listing 31-10.Optimizing Operators for a Specific Operand Type
在这样一个简单的例子中,为了避免一点额外的运算而增加麻烦是不值得的。然而,在更复杂的情况下,例如除法,您可能需要编写这样的代码。
数学函数
C++ 标准库提供了许多数学函数,比如计算绝对值的std::abs
(你已经猜到了)。如你所见,一些数学函数,如gcd
,在<numeric>
中。大部分都在<cmath>
里,因为这是从 C 语言继承来的,所以必须用#include
代替import
。C++ 标准禁止重载这些标准函数来操作自定义类型,但是您仍然可以编写执行类似操作的函数。在 Exploration 71 中,您将了解名称空间,这将使您能够使用真正的函数名。每当编写自定义数值类型时,都应该考虑应该提供哪些数学函数。在这种情况下,绝对值非常有意义。写一个处理有理数的绝对值函数。称之为 absval
。
您的absval
函数应该通过值接受一个rational
参数,并返回一个rational
结果。与我编写的算术运算符一样,您可以选择对参数使用引用调用。如果是这样,请确保将引用声明为const
。清单 31-11 展示了我对absval
的实现。
rational absval(rational const& r)
{
return rational{std::abs(r.numerator), r.denominator};
}
Listing 31-11.Computing the Absolute Value of a Rational Number
那很简单。其他用于计算平方根的数学函数,比如sqrt
,又是如何呢?对于浮点参数,大多数其他函数都是重载的。如果编译器知道如何自动将有理数转换为浮点数,您可以简单地将一个rational
参数传递给任何现有的浮点函数,而无需做进一步的工作。那么,您应该使用哪种浮点类型呢?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
这个问题没有简单的答案。合理的首选可能是double
,这是“默认”的浮点类型(例如,浮点文字具有类型double
)。另一方面,如果有人真的想要long double
提供的额外精度呢?或者那个人不需要太多精度,更喜欢用float
怎么办?
解决方案是放弃自动转换为浮点类型的可能性,而是提供三个函数来显式计算有理数的浮点值。写 as_float,as_double,as_long_double 。这些成员函数中的每一个都计算并返回有理数的浮点近似值。函数名标识了返回类型。您必须使用static_cast
将分子和分母转换为所需的浮点类型,正如您在 Exploration 26 中学到的那样。清单 31-12 展示了我是如何编写这些函数的,其中有一个示例程序演示了它们的用法。
#include <cassert>
import <iostream>;
import <numeric>;
struct rational
{
float as_float()
{
return static_cast<float>(numerator) / denominator;
}
double as_double()
{
return numerator / static_cast<double>(denominator);
}
long double as_long_double()
{
return static_cast<long double>(numerator) /
static_cast<long double>(denominator);
}
... omitted for brevity ...
};
int main()
{
rational pi{355, 113};
rational bmi{90*100*100, 180*180}; // Bogus-metabolic indicator of 90kg, 180cm
double circumference{0}, radius{10};
circumference = 2 * pi.as_double() * radius;
std::cout << "circumference of circle with radius " << radius << " is about "
<< circumference << '\n';
std::cout << "bmi = " << bmi.as_float() << '\n';
}
Listing 31-12.Converting to Floating-Point Types
如您所见,如果/
(或任何其他算术或比较运算符)的一个参数是浮点的,则另一个操作数被转换为匹配。您可以转换两个操作数,或者只转换其中一个。挑选最适合自己的风格,坚持下去。
还有一项任务会使编写测试程序更容易:重载 I/O 操作符。这是下一个探索的主题。
三十二、自定义 I/O 运算符
能直接读写有理数不是很好吗,比如std::cout << rational{355, 113}
?事实上,C++ 拥有你所需要的一切,尽管这项工作比想象中的要复杂一些。本文介绍了实现这一目标所需的一些部分。
输入运算符
I/O 操作符就像 C++ 中的其他操作符一样,你可以像重载其他操作符一样重载它们。输入操作符,也称为提取器(因为它从流中提取数据),将std::istream&
作为第一个参数。它必须是非const
引用,因为函数会修改流对象。第二个参数也必须是非const
引用,因为您将在那里存储输入值。按照惯例,返回类型是std::istream&
,返回值是第一个参数。这使您可以在单个表达式中组合多个输入操作。(返回清单 17-3 查看示例。)
函数体必须完成读取输入流、解析输入以及决定如何解释输入的工作。正确的错误处理是困难的,但是基本的很容易。每个流都有一个跟踪错误的状态掩码。表 32-1 列出了可用的状态标志(在<ios>
中声明)。
表 32-1。
输入输出状态标志
|旗
|
描述
|
| — | — |
| badbit
| 不可恢复的错误 |
| eofbit
| 文件结尾 |
| failbit
| 无效的输入或输出 |
| goodbit
| 没有错误 |
如果输入无效,输入函数将failbit
设置为流的错误状态。当调用者测试流是否正常时,它测试错误状态。如果failbit
被设置,检查失败。(如果出现不可恢复的错误,如硬件故障,测试也会失败,但这与当前主题无关。)
现在您必须决定有理数的格式。这种格式应该足够灵活,便于人们读写,但又足够简单,便于函数快速读取和解析。输入格式必须能够读取输出格式,也可能能够读取其他格式。
我们把格式定义为一个整数,一个斜杠(/
),再一个整数。空白可以出现在这些元素的前面或后面,除非在输入流中禁用了空白标志。如果输入包含后面没有斜杠的整数,则该整数成为结果值(即隐式分母为 1)。输入操作符必须“不读”这个字符,这对程序的其余部分可能很重要。unget()
成员函数正是这样做的。整数的输入操作符也会做同样的事情:读取尽可能多的字符,直到读取一个不属于整数的字符,然后是最后一个字符unget
。
将所有这些片段放在一起需要一点小心,但并不那么困难。清单 32-1 给出了输入操作符。将该操作符添加到您在 Exploration 31 中编写的rational
类型的其余部分。
... copy the rational class from Exploration 31
std::istream& operator>>(std::istream& in, rational& rat)
{
int n{0}, d{0};
char sep{'\0'};
if (not (in >> n >> sep))
// Error reading the numerator or the separator character.
in.setstate(std::cin.failbit);
else if (sep != '/')
{
// Read numerator successfully, but it is not followed by /.
// Push sep back into the input stream, so the next input operation
// will read it.
in.unget();
rat.assign(n, 1);
}
else if (in >> d)
// Successfully read numerator, separator, and denominator.
rat.assign(n, d);
else
// Error reading denominator.
in.setstate(std::cin.failbit);
return in;
}
Listing 32-1.Input Operator
请注意,直到函数成功地从流中读取分子和分母,才会修改rat
。目标是确保如果流进入错误状态,函数不会改变rat
。
输入流自动处理空白。默认情况下,输入流在每个输入操作中跳过前导空白,这意味着rational
输入操作符跳过分子、斜杠分隔符和分母之前的空白。如果程序关闭了ws
标志,输入流不会跳过空白,并且所有三个部分必须是连续的。
输出运算符
编写输出操作符,或插入器(这样命名是因为它将文本插入到输出流中),由于过多的格式标志,有许多障碍。您希望留意所需的字段宽度和对齐方式,并且必须根据需要插入填充字符。像任何其他输出操作符一样,您希望重置字段宽度,但不更改任何其他格式设置。
编写复杂输出操作符的一种方法是使用一个临时输出流,将其文本存储在一个string
中。在<sstream>
模块中声明了std::ostringstream
类型。像使用任何其他输出流一样使用ostringstream
,比如cout
。当你完成时,str()
成员函数返回完成的string
。
要为一个rational
数字编写输出操作符,创建一个ostringstream
,然后编写分子、分隔符和分母。接下来,将结果字符串写入实际的输出流。让流本身在写入字符串时处理宽度、对齐和填充问题。如果您将分子、斜杠和分母直接写入输出流,宽度将只应用于分子,对齐将是错误的。类似于输入操作符,第一个参数的类型是std::ostream&
,这也是返回类型。返回值是第一个参数。第二个参数可以使用 call-by-value,或者您可以传递一个对const
的引用,如清单 32-2 所示。将这段代码添加到清单 32-1 和您正在定义的rational
类型的其余部分。
std::ostream& operator<<(std::ostream& out, rational const& rat)
{
std::ostringstream tmp{};
tmp << rat.numerator;
if (rat.denominator != 1)
tmp << '/' << rat.denominator;
out << tmp.str();
return out;
}
Listing 32-2.Output Operator
错误状态
下一步是编写测试程序。理想情况下,测试程序应该能够在遇到无效输入错误时继续运行。所以现在是一个很好的时机来仔细看看一个 I/O 流是如何跟踪错误的。
正如您在前面的探索中了解到的,每个流都有一个错误标志掩码(见表 32-1 )。您可以测试、设置或清除这些标志。然而,测试标志有点不寻常,所以要注意。
本书中大多数程序测试流错误条件的方法是使用流本身或输入操作作为条件。如您所知,输入操作符函数返回流,因此这两种方法是等效的。如果设置了failbit
或badbit
,流通过返回其fail()
函数的反函数转换为bool
结果,该函数返回true
。
在输入循环的正常过程中,程序一直前进,直到输入流用完为止。当流到达输入流的末尾时,流设置eofbit
。流的状态仍然是好的,因为fail()
返回 false,所以循环继续。但是,下一次您试图从流中读取时,它会发现没有更多的输入可用,设置failbit
,并返回一个错误条件。循环条件为false
,循环退出。
如果流包含无效输入,例如整数输入的非数字字符,循环也可能退出;如果输入流中有硬件错误(例如磁盘故障),循环也可能退出。直到现在,本书中的程序都懒得测试为什么循环会退出。然而,要编写一个好的测试程序,你必须知道原因。
首先,您可以通过调用bad()
成员函数来测试硬件或类似的错误,如果设置了badbit
,该函数将返回 true。这意味着文件发生了可怕的事情,而程序无法修复这个问题。
接下来,通过调用eof()
成员函数来测试正常的文件结束,只有当eofbit
被设置时它才是true
。如果bad()
和eof()
都是false
并且fail()
是true
,这意味着该流包含无效输入。您的程序应该如何处理输入失败取决于您的特定环境。一些程序必须立即退出;其他人可能会尝试继续。例如,您的测试程序可以通过调用clear()
成员函数来重置错误状态,然后继续运行。输入失败后,您可能不知道流的位置,所以您不知道流下一步准备读取什么。这个简单的测试程序跳到下一行。
清单 32-3 展示了一个测试程序,它会一直循环直到文件结束或者一个不可恢复的错误发生。如果问题仅仅是无效输入,错误状态被清除,循环继续。
#include <cassert>
import <iostream>;
import <numeric>;
import <sstream>;
... omitted for brevity ...
/// Tests for failbit only
bool iofailure(std::istream& in)
{
return in.fail() and not in.bad();
}
int main()
{
rational r{0};
while (std::cin)
{
if (std::cin >> r)
// Read succeeded, so no failure state is set in the stream.
std::cout << r << '\n';
else if (not std::cin.eof())
{
// Only failbit is set, meaning invalid input. Clear the state,
// and then skip the rest of the input line.
std::cin.clear();
std::cin.ignore(std::numeric_limits<int>::max(), '\n');
}
}
if (std::cin.bad())
std::cerr << "Unrecoverable input failure\n";
}
Listing 32-3.Testing the I/O Operators
rational
型快完成了。下一个探索处理赋值操作符,并试图改进构造器。
三十三、复制和初始化
完成这个阶段的最后一步是编写赋值操作符和改进构造器。原来 C++ 为您做了一些工作,但是您经常想要微调这些工作。让我们找出方法。
赋值运算符
到目前为止,所有的rational
操作符都是自由函数。赋值运算符是不同的。C++ 标准要求它是一个成员函数。清单 33-1 显示了编写这个函数的一种方法。
struct rational
{
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
rational& operator=(rational const& rhs)
{
numerator = rhs.numerator;
denominator = rhs.denominator;
reduce();
return *this;
}
int numerator;
int denominator;
};
Listing 33-1.First Version of the Assignment Operator
有几点需要进一步解释。当您将运算符实现为自由函数时,每个操作数需要一个参数。因此,二元运算符需要一个双参数函数,而一元运算符需要一个单参数函数。成员函数则不同,因为对象本身就是一个操作数(总是左操作数),对象对所有成员函数都是隐式可用的;因此,您需要少一个参数。二元操作符需要一个参数(如清单 33-1 所示),一元操作符不需要参数(示例如下)。
赋值运算符的约定是返回对封闭类型的引用。要返回的值是对象本身。可以用表达式*this
( this
是保留关键字)获取对象。
因为*this
是对象本身,所以引用成员的另一种方式是使用点运算符(例如(*this).numerator
),而不是不加修饰的numerator
。(*this).numerator
的另一种写法是this->numerator
。意思是一样的;备选语法主要是为了方便。对于这些简单的函数来说,编写this->
并不是必须的,但这通常是个好主意。当你读取一个成员函数时,你很难区分成员和非成员,这是一个信号,你必须通过在所有成员名前使用this->
来帮助读者。清单 33-2 显示了明确使用this->
的赋值操作符。
rational& operator=(rational const& that)
{
this->numerator = that.numerator;
this->denominator = that.denominator;
reduce();
return *this;
}
Listing 33-2.Assignment Operator with Explicit Use of this->
右边的操作数可以是你想要的任何东西。例如,您可能想要优化一个整数到一个rational
对象的赋值。赋值操作符与编译器的自动转换规则一起工作的方式是,编译器将这样的赋值(例如,r = 3
)视为临时rational
对象的隐式构造,随后是一个rational
对象到另一个对象的赋值。
编写一个赋值操作符,它带有一个 int
参数。将您的解决方案与我的进行比较,如清单 33-3 所示。
rational& operator=(int num)
{
this->numerator = num;
this->denominator = 1; // no need to call reduce()
return *this;
}
Listing 33-3.Assignment of an Integer to a rational
如果你没有写赋值操作符,编译器会为你写一个。在简单的rational
类型的情况下,结果是编译器编写了一个与清单 32-2 中的完全一样的类型,所以实际上没有必要自己编写(除了教学目的)。当编译器为您编写代码时,读者很难知道实际定义了哪些函数。此外,更难记录隐式函数。所以 C++ 让你明确地声明你希望编译器为你提供一个特殊的函数,方法是在声明(不是定义)后面加上=default
而不是函数体。
rational& operator=(rational const&) = default;
构造器
编译器还会自动编写一个构造器,特别是通过从另一个rational
对象复制所有数据成员来构造一个rational
对象的构造器。这被称为复制构造器。每当您通过值向函数传递一个rational
参数时,编译器使用复制构造器将参数值复制到参数中。任何时候你定义一个rational
变量并用另一个rational
值初始化它,编译器通过调用复制构造器来构造变量。
与赋值操作符一样,编译器的默认实现正是我们自己编写的,所以没有必要编写复制构造器。与赋值运算符一样,您可以明确声明希望编译器提供其默认的复制构造器。
rational(rational const&) = default;
复制构造器的参数类型是引用。好好想想。当通过值传递参数时,编译器使用复制构造器,因此如果复制构造器使用通过值调用,程序将在第一次尝试复制对象时无限递归。所以复制构造器的参数必须是一个引用。几乎总是引用一个const
对象。
如果你没有为一个类型写任何构造器,编译器也会创建一个不带参数的构造器,叫做默认构造器。当您定义自定义类型的变量并且没有为它提供初始值设定项时,编译器使用默认构造器。编译器对默认构造器的实现只是为每个数据成员调用默认构造器。如果数据成员具有内置类型,则该成员保持未初始化状态。换句话说,如果我们没有为rational
编写任何构造器,任何rational
变量都将是未初始化的,因此它的分子和分母将包含垃圾值。这很糟糕——非常糟糕。所有的操作者都假设rational
对象已经被简化为正常形式。如果您向它们传递一个未初始化的rational
对象,它们就会失败。解决方案很简单:不要让编译器编写它的默认构造器。相反,你写一个。
你所要做的就是写一个构造器。这将阻止编译器编写自己的默认构造器。(它仍然会编写自己的复制构造器。)
早期,我们为rational
类型编写了一个构造器,但它不是默认的构造器。因此,您不能定义一个rational
变量并不初始化它或者用空括号初始化它。(您可能在编写自己的测试程序时遇到过这个问题。)未初始化的数据是个坏主意,拥有默认构造器是个好主意。所以写一个默认的构造器来确保一个没有初始化器的rational
变量仍然有一个定义良好的值。你应该使用什么值?我推荐零,这符合string
和vector
等类型的默认构造器的精神。为 rational
写一个默认构造器,将值初始化为零。
将你的解决方案与我的进行比较,我的解决方案在清单 33-4 中给出。
rational()
: rational{0, 1}
{}
Listing 33-4.Overloaded Constructors for rational
把这一切放在一起
在我们离开之前的rational
式(只是暂时的;我们会回来),让我们把所有的碎片放在一起,这样你就可以看到你在过去的四次探索中完成了什么。清单 33-5 显示了rational
和相关操作符的完整定义。
#include <cassert>
#include <cmath>
import <iostream>;
import <numeric>;
import <sstream>;
import test;
/// Represent a rational number (fraction) as a numerator and denominator.
struct rational
{
rational()
: rational{0}
{/*empty*/}
rational(int num)
: numerator{num}, denominator{1}
{/*empty*/}
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
rational(double r)
: rational{static_cast<int>(r * 10000), 10000}
{/*empty*/}
rational& operator=(rational const& that)
{
this->numerator = that.numerator;
this->denominator = that.denominator;
return *this;
}
float as_float()
{
return static_cast<float>(numerator) / denominator;
}
double as_double()
{
return static_cast<double>(numerator) / denominator;
}
long double as_long_double()
{
return static_cast<long double>(numerator) / denominator;
}
/// Assign a numerator and a denominator, then reduce to normal form.
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduce the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
if (denominator < 0)
{
denominator = -denominator;
numerator = -numerator;
}
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator;
int denominator;
};
/// Absolute value of a rational number.
rational abs(rational const& r)
{
return rational{std::abs(r.numerator), r.denominator};
}
/// Unary negation of a rational number.
rational operator-(rational const& r)
{
return rational{-r.numerator, r.denominator};
}
/// Add rational numbers.
rational operator+(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator + rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
/// Subtraction of rational numbers.
rational operator-(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator - rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
/// Multiplication of rational numbers.
rational operator*(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.numerator, lhs.denominator * rhs.denominator};
}
/// Division of rational numbers.
/// TODO: check for division-by-zero
rational operator/(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator, lhs.denominator * rhs.numerator};
}
/// Compare two rational numbers for equality.
bool operator==(rational const& a, rational const& b)
{
return a.numerator == b.numerator and a.denominator == b.denominator;
}
/// Compare two rational numbers for inequality.
inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
/// Compare two rational numbers for less-than.
bool operator<(rational const& a, rational const& b)
{
return a.numerator * b.denominator < b.numerator * a.denominator;
}
/// Compare two rational numbers for less-than-or-equal.
inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
/// Compare two rational numbers for greater-than.
inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
/// Compare two rational numbers for greater-than-or-equal.
inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
/// Read a rational number.
/// Format is @em integer @c / @em integer.
std::istream& operator>>(std::istream& in, rational& rat)
{
int n{0}, d{0};
char sep{'\0'};
if (not (in >> n >> sep))
// Error reading the numerator or the separator character.
in.setstate(in.failbit);
else if (sep != '/')
{
// Push sep back into the input stream, so the next input operation
// will read it.
in.unget();
rat.assign(n, 1);
}
else if (in >> d)
// Successfully read numerator, separator, and denominator.
rat.assign(n, d);
else
// Error reading denominator.
in.setstate(in.failbit);
return in;
}
/// Write a rational numbers.
/// Format is @em numerator @c / @em denominator.
std::ostream& operator<<(std::ostream& out, rational const& rat)
{
std::ostringstream tmp{};
tmp << rat.numerator << '/' << rat.denominator;
out << tmp.str();
return out;
}
int main()
{
TEST(rational{1} == rational{2,2});
... Add tests, lots of tests
}
Listing 33-5.Complete Definition of rational and Its Operators
我鼓励你向清单 33-5 中的程序添加测试,以测试rational
类的所有最新特性。确保一切都按您预期的方式运行。然后将rational
放在一边,进行下一次探索,更深入地了解编写定制类型的基础。
三十四、编写类
rational
类型是类的一个例子。既然您已经看到了编写自己的类的具体示例,那么是时候了解管理所有类的一般规则了。这个探索和接下来的四个探索为 C++ 编程的这个重要方面奠定了基础。
剖析一个班级
一个类有一个名字和成员——数据成员、成员函数,甚至成员类型定义和嵌套类。用关键字struct
开始一个类定义。(您可能想知道为什么不用关键字class
开始一个类定义。请耐心等待;一切都将在探索中变得清晰 36 。)用花括号把类定义体括起来,定义以分号结束。在花括号中,您列出了所有成员。以类似于局部变量定义的方式声明数据成员。编写成员函数的方式与编写自由函数的方式相同。清单 34-1 显示了一个只包含数据成员的简单类定义。
struct point
{
double x;
double y;
};
Listing 34-1.Class Definition for a Cartesian Point
清单 34-2 展示了 C++ 如何让你在一个声明中列出多个数据成员。除了琐碎的类,这种风格并不常见。我更喜欢单独列出每个成员,这样我就可以包含一个注释来解释这个成员,它的用途,什么约束适用于它,等等。即使没有评论,一点额外的澄清也大有帮助。
struct point
{
double x, y;
};
Listing 34-2.Multiple Data Members in One Declaration
与 C++ 源文件中的任何其他名称一样,在使用类名之前,编译器必须看到它的声明或定义。您可以在自己的定义中使用类名。
使用类名作为类型名,定义局部变量、函数参数、函数返回类型,甚至其他数据成员。编译器从类定义的最开始就知道类名,所以你可以在类定义中使用它的名字作为类型名。
当您使用类类型定义变量时,编译器会留出足够的内存,以便变量可以存储该类的每个数据成员的副本。例如,定义一个类型为point
的对象,该对象包含x
和y
成员。定义另一个类型为point
的对象,该对象包含自己独立的x
和y
成员。
使用点(.
)运算符来访问成员,就像您在本书中一直做的那样。对象是左边的操作数,成员名是右边的操作数,如清单 34-3 所示。
import <iostream>;
struct point
{
double x;
double y;
};
int main()
{
point origin{}, unity{};
origin.x = 0;
origin.y = 0;
unity.x = 1;
unity.y = 1;
std::cout << "origin = (" << origin.x << ", " << origin.y << ")\n";
std::cout << "unity = (" << unity.x << ", " << unity.y << ")\n";
}
Listing 34-3.Using a Class and Its Members
成员函数
除了数据成员,您还可以拥有成员函数。成员函数定义看起来非常类似于普通的函数定义,只是您将它们定义为类定义的一部分。此外,成员函数可以调用同一类的其他成员函数,并且可以访问同一类的数据成员。清单 34-4 展示了添加到类point
中的一些成员函数。
#include <cmath> // for sqrt and atan2
struct point
{
/// Distance to the origin.
double distance()
{
return std::sqrt(x*x + y*y);
}
/// Angle relative to x-axis.
double angle()
{
return std::atan2(y, x);
}
/// Add an offset to x and y.
void offset(double off)
{
offset(off, off);
}
/// Add an offset to x and an offset to y
void offset(double xoff, double yoff)
{
x = x + xoff;
y = y + yoff;
}
/// Scale x and y.
void scale(double mult)
{
this->scale(mult, mult);
}
/// Scale x and y.
void scale(double xmult, double ymult)
{
this->x = this->x * xmult;
this->y = this->y * ymult;
}
double x;
double y;
};
Listing 34-4.Member Functions for Class point
对于每个成员函数,编译器都会生成一个名为this
的隐藏参数。当调用成员函数时,编译器将对象作为隐藏参数传递。在成员函数中,可以用表达式*this
访问对象。C++ 语法规则规定成员操作符(.
)的优先级高于*
操作符,因此需要在*this
(例如(*this).x
)两边加上括号。为了语法上的方便,编写相同表达式的另一种方式是this-
> x
,您可以在清单 34-4 中看到几个例子。
编译器足够聪明,知道何时使用成员名,所以使用this->
是可选的。如果一个名字没有局部定义,并且是一个成员的名字,编译器会认为你想使用这个成员。为了清晰起见,一些程序员喜欢总是包含this->
——在一个大程序中,您很容易忘记哪些名称是成员名称。其他程序员发现额外的this->
很杂乱,只在必要时才使用。我的推荐是后者。您需要学习阅读 C++ 类,其中一项必要的技能是能够阅读类定义,找到成员名称,并在阅读类定义时跟踪这些名称。
许多程序员使用一种更微妙的技术,包括使用特殊的前缀或后缀来表示数据成员名称。例如,一种常见的技术是对所有数据成员使用前缀m_
(“m”是成员的缩写)。另一种常见的技术没有那么麻烦:使用普通的下划线(_
)后缀。比起前缀,我更喜欢后缀,因为后缀比前缀干扰少,所以它们不会模糊名字的重要部分。从现在开始,我将采用在每个数据成员名称后添加下划线的做法。
NO LEADING UNDERSCORE
如果您只想使用下划线来表示成员,请将其用作后缀,而不是前缀。C++ 标准将某些名称搁置一边,并禁止您使用它们。实际的规则有些冗长,因为 C++ 从 C 标准库中继承了许多限制。例如,您不应该使用任何以E
开头、后跟数字或大写字母的名称。(这条规则看起来很神秘,但是 C 标准库为数学函数中的范围错误定义了几个错误代码名,比如ERANGE
。该规则允许库在将来添加新名称,并允许那些实现库的人添加特定于供应商的名称。)
我喜欢简单,所以我遵循三个基本原则。这些规则比正式的 C++ 规则稍微严格一些,但并不繁琐:
-
不要使用包含两个连续下划线(
like__this
)的任何名称。 -
不要使用任何以下划线(
_like_this
)开头的名称。 -
不要使用全大写的名称(
LIKE_THIS
)。
使用保留名称会导致未定义的行为。编译器可能不会抱怨,但结果是不可预测的。通常,标准的库实现必须为其内部使用发明许多额外的名称。通过定义应用程序程序员不能使用的某些名称,C++ 确保了库作者可以在库中使用这些名称。如果您不小心使用了与内部库名冲突的名称,结果可能是混乱或者仅仅是函数实现中的细微变化。
构造器
正如你在 Exploration 30 中学到的,构造器是一个特殊的成员函数,它初始化一个对象的数据成员。您已经看到了如何编写构造器的几种变体,现在是时候再学习一些了。
当你声明一个数据成员时,你也可以提供一个初始化器。初始化器是一个缺省值,编译器在构造器没有初始化成员时使用。使用正常的初始化语法,在花括号中提供一个或多个值。
struct point {
int x = 1;
int y;
point() {} // initializes x to 1 and y to 0
};
仅当特定成员需要所有或几乎所有构造器中的单个值时,才使用这种方式初始化数据成员。通过将初始值从构造器中分离出来,使得构造器更难阅读和理解。人类读者必须阅读构造器和数据成员声明,才能知道对象是如何初始化的。另一方面,使用默认初始化器是确保内置类型的数据成员(如int
)总是被初始化的一个好方法。
回想一下,构造器可以重载,编译器根据初始化器中的参数选择调用哪个构造器。我喜欢用花括号初始化一个对象。花括号中的值以与普通函数的函数参数相同的方式传递给构造器。事实上,C++ 03 使用圆括号来初始化对象,所以初始化式看起来非常像函数调用。C++ 的更高版本仍然允许这种风格的初始化式,但是在几乎所有其他情况下,花括号更好。探索 31 演示了花括号提供了更好的类型安全性。
花括号的另一个关键区别是,你可以用花括号中的一系列值初始化一个容器,比如一个vector
,如下所示:
std::vector<int> data{ 1, 2, 3 };
这就引入了一个问题。vector 类型有几个构造器。例如,双参数构造器允许您用单个值的多个副本初始化一个向量。例如,一个有十个零的向量可以初始化如下:
std::vector<int> ten_zeroes(10, 0);
请注意,我使用了括号。如果我用花括号呢?试试看。会发生什么?
向量用两个整数初始化:10 和 0。规则是容器将花括号视为一系列用来初始化容器内容的值。大括号还可以用在其他一些情况下,比如复制一个容器,但是如果构造器的参数看起来像容器值,那么编译器可能会这样解释它们,或者发出一个关于不明确的错误消息。
用与普通成员函数几乎相同的方式编写构造器,但有一些不同:
-
省略返回类型。
-
使用普通的
return;
(不返回值的返回语句)。 -
使用类名作为函数名。
-
在冒号后添加一个初始化列表来初始化数据成员。初始化器也可以调用另一个构造器,将参数传递给那个构造器。将构造委托给一个公共构造器是确保所有构造器都正确执行规则的一个好方法。
清单 34-5 展示了几个添加到类point
的构造器的例子。
struct point
{
point()
: point{0.0, 0.0}
{}
point(double x, double y)
: x_{x}, y_{y}
{}
point(point const& pt)
: point{pt.x_, pt.y_}
{}
double x_;
double y_;
};
Listing 34-5.Constructors for Class point
初始化是类类型和内置类型的主要区别之一。如果你定义一个没有初始化器的内置类型的对象,你会得到一个垃圾值,但是类类型的对象总是通过调用一个构造器来初始化。您总是有机会初始化对象的数据成员。内置类型和类类型之间的区别在 C++ 用来初始化构造器中的数据成员的规则中也很明显。
构造器的初始化列表是可选的,但是我建议你总是提供它,除非每个数据成员都有一个初始化列表。初始值设定项列表出现在冒号之后,冒号跟在构造器参数列表的右括号之后;它按照在类定义中声明的顺序初始化每个数据成员,忽略初始化列表中的顺序。为了避免混淆,总是按照与数据成员相同的顺序编写初始化列表。成员初始值设定项用逗号分隔,可以根据需要任意多行。每个成员初始化器提供单个数据成员的初始值,或者使用类名调用另一个构造器。列出成员名,后面用花括号括起它的初始化式。初始化数据成员与初始化变量相同,遵循相同的规则。
如果你没有为你的类写任何构造器,编译器会写它自己的默认构造器。编译器的默认构造器就像一个省略了初始化列表的构造器。
struct point {
point() {} // x_ is initialized to 0, and y_ is uninitialized
double x_{};
double y_;
};
编译器给你写构造器的时候,构造器是隐式。如果编写任何构造器,编译器会取消隐式默认构造器。如果你想要一个默认的构造器,你必须自己写。
在某些应用程序中,您可能希望避免初始化point
的数据成员的开销,因为您的应用程序会立即为point
对象分配一个新值。然而,大多数时候,谨慎是最好的。
一个复制构造器接受一个与类相同类型的参数,通过引用传递。当您通过值将对象传递给函数时,或者当函数返回对象时,编译器会自动生成对复制构造器的调用。还可以用另一个point
对象的值初始化一个point
对象,编译器生成代码来调用复制构造器。
point pt1; // default constructor
point p2{pt1}; // copy constructor
如果你不写自己的复制构造器,编译器会为你写一个。自动复制构造器调用每个数据成员的复制构造器,就像清单 34-5 中的一样。因为我写了一个和编译器隐式写的一模一样的,所以没有理由显式写。让编译器完成它的工作。
为了帮助你可视化编译器如何调用构造器,请阅读清单 34-6 。注意它是如何为每次构造器的使用打印一条消息的。
import <iostream>;
struct demo
{
demo() : demo{0} { std::cout << "default constructor\n"; }
demo(int x) : x_{x} { std::cout << "constructor(" << x << ")\n"; }
demo(demo const& that)
: x_{that.x_}
{
std::cout << "copy constructor(" << x_ << ")\n";
}
int x_;
};
demo addone(demo d)
{
++d.x_;
return d;
}
int main()
{
demo d1{};
demo d2{d1};
demo d3{42};
demo d4{addone(d3)};
}
Listing 34-6.Visual Constructors
预测运行清单 34-6 中程序的输出。
检查你的预测。你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
当向函数传递参数和接受返回值时,编译器会执行一些小的优化。例如,C++ 标准不是将一个demo
对象复制到addone
返回值,然后复制返回值来初始化d4
,而是指导编译器移除对复制构造器的不必要调用。当我运行这个程序时,我得到了这个:
constructor(0)
default constructor
copy constructor(0)
constructor(42)
copy constructor(42)
copy constructor(43)
默认和删除的构造器
如果不提供任何构造器,编译器将隐式编写一个默认构造器和一个复制构造器。如果你写了至少一个任意类型的构造器,编译器不会隐式地写一个默认的构造器,但是如果你自己没有写,它仍然会给你一个复制构造器。
您可以控制编译器的隐式行为,而无需编写任何自己的构造器。为构造器写一个不带体的函数头,用=default
得到编译器的隐式定义。使用=delete
抑制该功能。例如,如果您不希望任何人创建类的副本,请注意以下几点:
struct dont_copy
{
dont_copy(dont_copy const&) = delete;
};
更常见的是让编译器编写它的复制构造器,但是明确地告诉人类读者。随着你对 C++ 了解的越来越多,你会发现编译器为你写构造器的规则,以及何时写构造器的规则,比我目前所介绍的要复杂得多。当你要求编译器隐式地提供一个构造器时,我敦促你养成声明的习惯,即使这看起来很明显。
struct point
{
point() = default;
point(point const&) = default;
int x, y;
};
那很容易。接下来的探索从一个真正的挑战开始。