基础设施:类型和声明
(Basic Facilities: Types and Declarations)
目录
6.1.2 (用于代码源文件的)基本源字符集(The Basic Source Character Set)
6.2.3.2 字符文字量(Character Literals)(呈现字符的值)
6.2.4.1 整数文字量(Integer Literals)(整数进制呈现的值)
6.3.2 声明多名称(Declaring Multiple Names)
6.4.2 对象生命周期(Lifetimes of Objects)
6.1 ISO C++ 标准
C++ 语言和标准库由其 ISO 标准定义:ISO/IEC 14882:2011。本书中对该标准的引用形式为 §iso.23.3.6.1。如果认为本书的文本不准确、不完整或可能错误,请参考该标准。但不要指望该标准是教程或非专家可以轻松理解。
严格遵守 C++ 语言和库标准本身并不能保证代码质量好甚至可移植。标准并没有规定一段代码是好是坏;它只是规定程序员可以依赖和不能依赖实现。编写完全符合标准的糟糕程序很容易,而且大多数现实世界的程序都依赖于标准无法保证可移植的特征。他们这样做是为了访问无法直接用 C++ 表达或需要依赖特定实现细节的系统接口和硬件功能(译注:比如某些需要使用汇编语言(C++不能直接操作处理器寄存器,但汇编语言可以)的接口或者硬件功能,因此不属于C++范畴)。
标准认为许多重要的事情都由实现定义(译注:即由实现的编译工具去确定默认值,编译器的实现又取决于具体的硬件的汇编指令集或其它硬件调用规范)。这意味着每个实现都必须为构造提供特定的、定义明确的行为,并且必须记录该行为。例如:
unsigned char c1 = 64; // 定义明确: 一个 char 类型至少有8个位,可以存储64
unsigned char c2 = 1256; // 实现定义: 若一个char 只有8位,则截断
c1 的初始化定义明确,因为char必须至少为 8 位。但是,c2 的初始化行为是实现定义的,因为char中的位数是实现定义的(译注:意思是说,如果某个编译器将char定义为16位,那么c2将不被截断,实际上最终由硬件确定,编译器只是根据处理器的指令集编译成所应的汇编代码)。如果字符只有 8 位,则值 1256 将被截断为 232(§10.5.2.1)。大多数实现定义的功能与运行程序所用的硬件的差异有关。
其他行为未指定;也就是说,一系列可能的行为都是可以接受的,但实现者没有义务指定实际发生的行为。通常,认为某事未指定的原因是,由于根本原因,确切的行为是不可预测的。例如,new 返回的确切值是未指定的。除非采用了某种同步机制来防止数据争用(§41.2),否则分配给两个线程的变量的值也是未指定的。
在编写实际程序时,通常需要依赖实现定义的行为。这种由实现定义的行为是我们为了具备在大量系统上有效运行的能力所付出的代价。例如,如果所有字符都是 8 位,所有指针都是 32 位,C++ 就会简单得多。然而,16 位和 32 位字符集并不罕见,具有 16 位和 64 位指针的机器被广泛使用。
为了最大限度地提高可移植性,明智的做法是明确说明我们依赖哪些实现定义的功能,并将更微妙的示例隔离在程序中明确标记的部分中。这种做法的一个典型示例是,在某些头文件中以常量和类型定义的形式呈现对硬件大小的所有依赖关系。为了支持这种技术,标准库提供了numeric_limits(§40.2)。对于许多有关实现定义的功能的假设(译注:即假设应用各种不同的平台,这些类型应该是何值),可以通过将它们声明为静态断言(§2.4.3.3)来检查。例如:
static_assert(4<=sizeof(int),"siz eof(int) too small");
(译注:static_assert在编译时检查,如果条件不成立,则提示后面的出错原因 。 )
未定义的行为更糟糕。如果实现不需要合理的行为,则标准认为构造未定义。通常,一些明显的实现技术会导致使用未定义特性的程序表现非常糟糕。例如:
const int size = 4∗1024;
char page[size];
void f()
{
page[size+size] = 7; // 未定义(数组大小必须明确,不能是变量)。
}
此代码段的可能结果包括覆盖不相关数据(译注:即越界,覆盖数组之外的其它数据)和触发硬件错误/异常。实现不需要在合理结果中进行选择。当使用强大的优化器时,未定义行为的实际影响可能变得非常难以预测。如果存在一组合理且易于实现的替代方案,则该功能被视为未指定或实现定义的,而不是未定义的。
值得花费大量时间和精力来确保程序不使用标准未指定或未定义的东西。在许多情况下,存在有助于实现这一点的工具。
6.1.1 (C++标准的)实现
C++ 实现可以是托管的(hosted),也可以是独立式的(freestanding)(§iso.17.6.1.3)。(译注:所谓托管实现,其依赖于操作系统,受操作系统约束;独立实现,最少依赖于操作系统甚至不依赖于操作系统;因此对于托管实现,C++ 标准所需的标准库头文件集比独立实现大得多,在独立实现的情况下,其代码可能在没有操作系统支持的环境下被编译运行。)托管实现包括标准(§30.2)和本书中描述的所有标准库设施。独立实现可以提供较少的标准库设施,只要提供以下内容即可:
独立实现适用于仅使用最少操作系统支持的代码。许多实现还提供了一个(非标准)选项,用于不使用异常,适用于真正最小的、接近硬件的程序。
6.1.2 (用于代码源文件的)基本源字符集(The Basic Source Character Set)
(译注:基本源字符集:源字符集是用于解释程序源文件的编码。它会被转换为内部表示,用作编译前预处理阶段的输入。然后,内部表示会转换为执行字符集,以将字符串和字符值存储在可执行文件中。C++ 在编译时和运行时使用的字符集是实现定义的。源文件被读取为物理字符集中的字符序列。读取源文件时,物理字符将映射到编译时字符集,这称为源字符集。映射是实现定义的,但许多实现使用相同的字符集。)
C++ 标准和本书中的示例都是使用基本源字符集编写的,该字符集由国际 7 位字符集 ISO 646-1983 的美国变体(称为 ASCII (ANSI3.4-1968))中的字母、数字、图形字符和空格字符组成。这可能会给在不同字符集的环境中用 C++ 的人带来问题:
ASCII 包含某些字符集中没有的标点符号和运算符号(例如 ]、{ 和 !)。
我们需要一种符号来表示那些没有便捷字符表示法的字符(比如换行符和“值为 17 的字符”)。
ASCII 不包含用于写作的语言面不是英语的那些字符(例如 ñ、Þ 和 Æ)。
要将扩展字符集用于源代码,编程环境可以通过多种方式将扩展字符集映射到基本源字符集,例如,使用通用字符名称(§6.2.3.2)。
6.2 类型
考虑:
x = y+f(2);
为了在 C++ 程序中使这一点有意义,必须适当地声明名称 x、y 和 f。也就是说,程序员必须指定名为 x、y 和 f 的实体存在,并且它们的类型分别为 =(赋值)、+(加法)和 ()(函数调用)有意义。
C++ 程序中的每个名称(标识符)都具有与其关联的类型。此类型决定了可对名称(即名称所指的实体)应用哪些操作以及如何解释这些操作。例如:
float x; // x是一个单精度浮点变量
int y = 7; // y 是一个整型变量且初始值为7
float f(int); // f 是一个取一个整型参数的函数并返回单精度浮点数
这些声明将使示例有意义。因为 y 被声明为 int,所以可以将其赋值、用作 + 的操作数等。另一方面,f 被声明为一个以 int 为参数的函数,因此可以给定整数 2 来调用它。
本章介绍基本类型(§6.2.1)和声明(§6.3)。其示例仅演示语言特性;它们不打算做任何实用的事情。更广泛和更现实的示例将保留在后面的章节中。本章仅提供构建 C++ 程序的最基本元素。您必须了解这些元素以及与之相关的术语和简单语法,才能用 C++ 完成一个真正的项目,尤其是阅读其他人编写的代码。但是,彻底理解本章中提到的每个细节并不是理解后续章节的必要条件。因此,您可能更愿意浏览本章,观察主要概念,然后在需要了解更多细节时再返回。
6.2.1 基本类型(Fundamental Types)
C++ 有一组基本类型,对应于计算机最常见的基本存储单元以及使用它们保存数据的最常见方式:
§6.2.2 布尔类型 (bool)
§6.2.3 字符类型 (如 char 和 wchar_t)
§6.2.4 整数类型 (如 int 和 long long)
§6.2.5 浮点类型 (如 double 和 long double)
§6.2.7 void 类型,用于表示信息缺失
基于这些类型,我们可以使用声明运算符构造其他类型:
§7.2 指针类型 (如 int∗)
§7.3 数组类型 (如 char[])
§7.7 引用类型 (如 double& 和 vector<int>&&)
此外,用户还可以定义其他类型:
§8.2 数据结构和类(第 16 章)
§8.4 用于表示特定值集的枚举类型(enum和enum类)
布尔类型、字符类型和整数类型统称为整数类型。整数和浮点类型统称为算术类型(译注:适用于加减乘法运算)。枚举和类(第 16 章)被称为用户定义类型,因为它们必须由用户定义,而不是像基本类型那样无需事先声明即可使用。相反,基本类型、指针和引用统称为内置类型。标准库提供了许多用户定义类型(第 4 章、第 5 章)。
整数和浮点类型有多种大小,以便程序员可以选择所消耗的存储量、精度和可用于计算的范围(§6.2.8)。C++ 假定计算机提供了用于保存字符的字节、用于保存和计算整数值的字、最适合浮点计算的某些实体以及用于引用这些实体的地址。C++ 基本类型以及指针和数组以合理的独立于实现的方式向程序员呈现这些机器级概念。
对于大多数应用程序,我们可以使用 bool 表示逻辑值,char 表示字符,int 表示整数值,double 表示浮点值。其余基本类型是优化、特殊需求和兼容性的变体,最好在出现此类需求之前忽略对其考量。
6.2.2 bool类型(Booleans)
布尔值 bool 可以有两个值 true 或 false。bool值用于表示逻辑运算的结果。例如
void f(int a, int b)
{
bool b1 {a==b};
// ...
}
如果 a 和 b 具有相同的值,则 b1 为true;否则,b1 为false。
(译注:在vs2022环境下,bool类型用1个字节存储,false用0表示,true用1表示,可以直接用0或1赋值bool变量而不会有任何警告。任何除0和1外的整数都可以赋值一个bool类型变量,不过可能会有截断的警告。例如:
bool b1 = 1;
00007FF7766D282D mov byte ptr [b1],1 ;可以看出系统存储bool类型为字节。
)
bool 的一个常见用途是作为测试某些条件(谓词)的函数结果的类型。
例如:
bool is_open(File∗);
bool greater(int a, int b) { return a>b; }
根据定义,true 转换为整数时值为 1,false 转换为整数时值为 0。相反,整数可以隐式转换为布尔值:非零整数转换为 true,0 转换为 false。例如:
bool b1 = 7; // 7!=0,因此成为 true (译注:可能有截断的警告!)
bool b2 {7}; // error : 收窄(§2.2.2, §10.5)
int i1 = true; // i1 成为 1
int i2 {true}; // i2 成为 1
如果您更喜欢使用 {}- 防止收容的初始化器,但仍想将 int 转换为 bool,则可以显式地这样做:
void f(int i)
{
bool b {i!=0};
// ...
};
在算术和逻辑表达式中,bool值被转换为整数;对转换后的值执行整数算术和逻辑运算。如果需要将结果转换回bool值,则 0 转换为 false,非零值转换为 true。例如:
bool a = true;
bool b = true;
bool x = a+b; // a+b is 2, so x becomes true
bool y = a||b; // a||b is 1, so y becomes true ("||" means "or")
bool z = a−b; // a-b is 0, so z becomes false
指针可以隐式转换为bool值(§10.5.2.5)。非空指针转换为 true;值为 nullptr 的指针转换为 false。例如:
void g(int∗ p)
{
bool b = p; // 对 true 或 false 的窄化
bool b2 {p!=nullptr}; // 显示检验 nullptr
if (p) { // 相当于 p!=nullptr
// ...
}
}
(译注:nullptr 在实现中实际上是0 。)
我更喜欢 if (p) 而不是 if (p!=nullptr),因为它更直接地表达了“如果p 有效”的概念,而且它更短。较短的形式导致出错的机会更少。
6.2.3 字符类型
目前使用的字符集和字符集编码有很多。C++ 提供了各种字符类型,反映了这种通常令人困惑的多样性:
char:默认字符类型,用于程序文本。char 用于实现字符集,通常为 8 位。
signed char:与 char 类似,但确保是有符号的,即能够保存正值和负值。
unsigned char:与 char 类似,但确保是无符号的。
wchar_t:用于保存较大字符集(如 Unicode)的字符(参见 §7.3.2.2)。
wchar_t 的大小由实现定义,足以保存实现的语言环境支持的最大字符集(第 39 章)。
char16_t:用于保存 16 位字符集(如 UTF-16)的类型。
char32_t:用于保存 32 位字符集(如 UTF-32)的类型。
这是六种不同的类型(尽管 _t 后缀通常用于表示别名;§6.5)。在每个实现中,char 类型等于signed char或unsigned char类型(译注:一般情况下,三种类型在实现时都占用1个字节,只是编译时解释的不同),但在应用的时候,这三个名称仍被视为不同的类型。
char变量可以保存实现的字符集的一个字符。例如:
char ch = 'a';
几乎普遍而言,一个字符有 8 位,因此它可以保存 256 个不同值中的一个。通常,字符集是 ISO-646 的变体,例如 ASCII,从而提供键盘上显示的字符。许多问题源于该字符集仅部分标准化。
支持不同自然语言的字符集之间以及以不同方式支持相同自然语言的字符集之间存在严重差异。在这里,我们只对这种差异如何影响 C++ 规则感兴趣。如何在多语言、多字符集环境中编程这个更大、更有趣的问题超出了本书的范围,尽管它在几个地方提到过(§6.2.3、§36.2.1、第 39 章)。
可以安全地假设实现字符集包括十进制数字、26 个英文字母字符和一些基本标点符号。但以下假设并不安全:
8 位字符集中的字符不超过 127 个(例如,某些字符集提供 255 个字符)。
字母字符不超过英语提供的字符(大多数欧洲语言提供更多字符,例如 æ、þ 和 ß)。
字母字符是连续的(EBCDIC 在“i”和“j”之间留有空隙)。
编写 C++ 时使用的每个字符都可用(例如,某些国家字符集不提供{ 、} 、[、]、| 和 \)。
一个char 适合 1 个字节。有些嵌入式处理器没有字节访问硬件,因此一个字符占 4 个字节。此外,可以合理地使用 16 位 Unicode 编码来表示基本字符。
只要有可能,我们就应该避免对物体的表现做出假设。这条一般规则甚至适用于字符。
每个字符在实现使用的字符集中都有一个整数值。例如,'b' 在 ASCII 字符集中的值为 98。下面是一个循环,输出您要输入的任何字符的整数值:
void intval()
{
for (char c; cin >> c; )
cout << "the value of '" << c << "' is " << int{c} << '\n';
}
符号 int{c} 给出字符 c 的整数值(“我们可以从 c 构造的 int”)。将 char 转换为整数的可能性提出了一个问题:char 是有符号的还是无符号的?8 位字节表示的 256 个值可以解释为 0 到 255 的值或-127 到 127 的值。不,不是人们可能期望的 -128 到 127:C++ 标准保留了硬件补码的可能性,这消除了一个值;因此,使用 -128 是不可移植的。不幸的是,对于普通 char,选择有符号还是无符号是由实现定义的。C++ 提供了两种类型,答案是肯定的:signed char,至少可以保存-127 到 127 的值,以及unsigned char,至少可以保存 0 到 255 的值。幸运的是,这种差异仅对 0 到 127 范围之外的值有影响,并且最常见的字符都在该范围内。(译注:决定用什么类型是程序员的责职,事实上计算机都存储为1个字节,就看你如何去解释它。)
存储在普通 char中的超出该范围的值可能会导致微妙的可移植性问题。如果您需要使用多种类型的 char或将整数存储在 char变量中,请参阅§6.2.3.1。请注意,字符类型是整数类型(§6.2.1)(译注:只不过,字符类型存储的是字符对应编码的整数值,但是在编译器中呈现出来的是字符,而在内存能直接看到其编码的整数值),因此适用于算术和按位逻辑运算(§10.3)。例如:
void digits()
{
for (int i=0; i!=10; ++i)
cout << static_cast<char>('0'+i);
}
这是将十位数字写入 cout 的一种方式。字符文字 '0' 被转换为其整数值并添加 i。然后将得到的 int 转换为 char 并写入 cout。简单的 '0'+i 是一个 int,因此如果我省略了 static_cast<char>,则输出将是 48、49 等,而不是 0、1 等。
6.2.3.1 字符类型
普通 char 是否被视为有符号或无符号是由实现定义的。这为一些令人讨厌的意外和实现依赖性打开了可能性。例如:
char c = 255; // 255 是 ‘‘全1,’ ’ 十六进制是 0xFF
int i = c;
i 的值是什么?不幸的是,答案是不确定的。在使用 8 位字节的实现中,答案取决于扩展为 int 时“全 1” char位模式的含义。在char无符号的机器上,答案是 255。在char有符号的机器上,答案是 −1。在这种情况下,编译器可能会对文字量 255 转换为字符值 −1提出警告。然而,C++ 不提供检测此类问题的通用机制。一种解决方案是避免使用普通char,而仅使用特定的char类型。不幸的是,一些标准库函数(如 strcmp())只接受普通字符(§43.4)。
char 的行为必须与signed char 或 unsigned char 相同。但是,这三种char 类型是不同的,因此不能混合指向不同 char 类型的指针。例如:
void f(char c, signed char sc, unsigned char uc)
{
char∗ pc = &uc; // 错误: 无指针转换
signed char∗ psc = pc; //错误: 无指针转换
unsigned char∗ puc = pc; //错误: 无指针转换
psc = puc; //错误: 无指针转换
}
三种 char 类型的变量可以自由地相互赋值。但是,将过大的值赋给unsigned char (§10.5.2.1)仍然未定义。例如:
void g(char c, signed char sc, unsigned char uc)
{
c = 255; // 若普通字符是有符号且为8位则由实现定义
c = sc; // OK
c = uc; //若普通字符是有符号且若uc的值太大则由实现定义
sc = uc; // 若uc的值太大则由实现定义
uc = sc; // OK: 隐式转换为无符号数
sc = c; // 若普通字符是有符号且若c的值太大则由实现定义
uc = c; // OK: 隐式转换为无符号数
}
为了具体化,不妨假设一个char型是8位:
signed char sc = −160; //译注:-160的补码是1111-1111-0110-0000,
//最高位为符号位,隐匿转换按有符号处理,截断高8位,余下的低8位0110-0000
//按有符号解释,最高位为0,表示这个数为正,所以 0110-0000解释为96
unsigned char uc = sc; // uc == 116 (因为 256-160==116)(译注:uc=96)
cout << uc; // print 't'
char count[256]; // 假设为8位字符
++count[sc]; // 可能导致灾难: 越界访问
++count[uc]; // OK
(译注:上述例子sc的取值不恰当,没能演示越界,因为 -160 对于signed char 而言越界了,高8位被截断,低八位按有符号解析为96,因此并未造成越界。比如,如果sc = −120 ,则上述举例成立,造成越界。)
如果您始终使用纯字符并避免使用负字符值,则不会发生这些潜在问题和混淆。
6.2.3.2 字符文字量(Character Literals)(呈现字符的值)
字符文字量是用单引号括起来的单个字符,例如“a”和“0”。字符文字量的类型为 char。字符文字量可以隐式地转换为运行 C++ 程序的机器的字符集中的整数值。例如,如果您在使用 ASCII 字符集的机器上运行,则“0”的值为 48。使用字符文字量表示字符而不是直接用十进制表示字符可以使程序更具可移植性。
一些字符具有标准名称,使用“\”(反斜杠)作为转义字符:
别被它们的表象迷惑,实际上它们都是单个字符。
我们可以将实现字符集中的字符表示为一位、两位或三位八进制数(\ 后跟八进制数字)或十六进制数(\x 后跟十六进制数字)。序列中的十六进制数数量没有限制。八进制或十六进制数字序列分别以第一个非八进制数或十六进制数的字符终止。例如:
这样就可以用机器的字符集表示每个字符,特别是可以将这些字符嵌入字符串中(参见 §7.3.2)。使用任何数表示字符都会使程序无法在具有不同字符集的机器之间移植(译注:意思是说,以上表示法对于不同字符集的机器并不通用,可能会引起问题)。
可以将多个字符括在字符文字量中,例如‘ab’。这种用法已经过时,依赖于实现,最好避免。这种多字符文字的类型为 int。(译注:大多数编译器并不支持这样么,单引号括起来的多个字符当成char虽然有的编译器并不报错,但是只会认最后一个字符。)
当使用八进制表示法将数值常量嵌入字符串时,明智的做法是始终使用三位数字表示数。这种表示法很难阅读,但不必担心常量后面的字符是否是数字。对于十六进制常量,请使用两位数字。请考虑以下示例:
char v1[] = "a\xah\129"; // 6 chars: 'a' '\xa' 'h' '\12' '9' '\0'
//(译注:h越出16进制,解析为另一个字符,9超出8进制,解析为另一个字符。)
char v2[] = "a\xah\127"; // 5 chars: 'a' '\xa' 'h' '\127' '\0'
char v3[] = "a\xad\127"; // 4 chars: 'a' '\xad' '\127' '\0'
char v4[] = "a\xad\0127"; // 5 chars: 'a' '\xad' '\012' '7' '\0'
宽字符文字的形式为 L'ab',类型为 wchar_t。引号之间的字符数及其含义由实现定义。
C++ 程序可以处理比 127 个字符的 ASCII 集更丰富的字符集,例如 Unicode。这种较大字符集的文字表示为四个或八个十六进制数字的序列,前面带有 U 或 u。例如:
U'\UFADEBEEF'
u'\uDEAD'
u'\xDEAD'
对于任何十六进制数字 X,较短的符号 u'\uXXXX' 等同于 U'\U0000XXXX'。十六进制数字的数量不是四或八则属于词汇错误。十六进制数字的含义由 ISO/IEC 10646 标准定义,此类值称为通用字符名称。在 C++ 标准中,通用字符名称在 §iso.2.2,§iso.2.3,§iso.2.14.3,§iso.2.14.5 和 §iso.E 中进行了描述。
6.2.4 整数类型
与 char 一样,每个整数类型都有三种形式:“普通”int,signed int和unsigned int。此外,整数有四种大小:short int,“普通”int ,long int 和long long int。long int可以称为普通long,long long int可以称为普通long long。同样,short是short int的同义词,unsigned是unsigned int的同义词,signed是signed int的同义词。没有与 int等价的long short int。
无符号整数类型非常适合将存储视为位数组(bit array)的用途。使用无符号而不是整数来获得多一位来表示正整数几乎从来都不是一个好主意。通过将变量声明为无符号来确保某些值为正的尝试通常会被隐式转换规则(§10.5.1、§10.5.2.1)所挫败。
与普通char不同,普通int始终是有符号的。有符号int类型只是普通int对应项的更明确的同义词,而不是不同的类型。
如果您需要对整数大小进行更详细的控制,可以使用 <cstdint> (§43.7) 中的别名,例如 int64_t(恰好 64 位的有符号整数),uint_fast16_t(恰好 8 位的无符号整数,据说是此类整数中最快的)和 int_least32_t(至少有 32 位的有符号整数,就像普通 int 一样)。普通整数类型具有明确定义的最小大小(§6.2.8),因此 <cstdint> 有时是多余的,可能会被过度使用。
除了标准整数类型之外,实现还可以提供扩展整数类型(有符号和无符号)。这些类型的行为必须与整数类似,并且在考虑转换和整数文字值时被视为整数类型,但它们通常具有更大的范围(占用更多空间)。
6.2.4.1 整数文字量(Integer Literals)(整数进制呈现的值)
整数文字量有三种形式:十进制、八进制和十六进制(译注:以三种进制呈现这个整数时,它们应该分别被表示成什么样的进制值)。十进制文字量是最常用的,并且看起来与您预期的一样:
7 1234 976 12345678901234567890
编译器应该对太长而无法表示的文字量发出警告,但只有 {} 初始化器(§6.3.5)才会出现编译错误。
以零开头,后跟 x 或 X(0x 或 0X)的文字量是十六进制(基数为 16)数字。以零开头但后跟 x 或 X 的文字是八进制(基数为 8)数字。例如:
字母 a,b,c,d,e 和 f 或它们的大写对应字母分别用于表示 10,11,12,13,14 和 15。八进制和十六进制表示法最适用于表达位模式。使用这些表示法来表达真正的数可能会导致意外结果。例如,在一台机器上将 int 表示为二进制补码 16 位整数,0xffff 是负十进制数 −1。如果使用更多位来表示整数,它就会是正十进制数 65535。
后缀 U 可用于显式表示unsigned文字量。类似地,后缀 L 可用于显式表示long文字量。例如,3 是 int,3U 是unsigned int,3L 是long int。允许使用后缀组合。例如:
cout << 0xF0UL << ' ' << 0LU << '\n';
如果没有提供后缀,编译器会根据整数字面量的值和实现的整数大小(§6.2.4.2)为其提供合适的类型。
将不明显的常量的使用限制于一些带有良好注释的 const (§7.5),constexpr (§10.4) 和枚举器 (§8.4) 初始化器是一个好主意。
6.2.4.2 整数文字量类型
一般来说,整数文字量的类型取决于其形式、值和后缀:
如果它是十进制且没有后缀,则它具有以下可以表示其值的类型中的第一个:int,long int,long long int。
如果它是八进制或十六进制且没有后缀,则它具有以下可以表示其值的类型中的第一个:int,unsigned int,long int,unsigned long int,long long int,unsigned long long int。
如果它带有 u 或 U 后缀,则它的类型是以下可以表示其值的类型中的第一个:unsigned int, unsigned long int, unsigned long long int。
如果它是十进制且带有 l 或 L 后缀,则它的类型是以下可以表示其值的类型中的第一个:long int, long long int。
如果它是八进制或十六进制并且带有后缀 l 或 L,则其类型是以下类型中的第一个,其中它的值可以表示为:long int, unsigned long int, long long int, unsigned long long int。
如果它是后缀 ul, lu, uL, Lu, Ul, lU, UL, 或 LU,则其类型是以下类型中的第一个,其中它的值可以表示为:unsigned long int, unsigned long long int。
如果它是十进制并且带有后缀 ll 或 LL,则其类型是 long long int。
如果它是八进制或十六进制并且带有后缀 ll 或 LL,则其类型是以下类型中的第一个,其中它的值可以表示为:long long int, unsigned long long int。
如果它以 llu, llU, ull, Ull, LLu, LLU, uLL, 或 ULL 为后缀,则其类型为 unsigned long long int。
例如,100000 在具有 32 位 int 的机器上为 int 类型,但在具有 16 位 int 和 32 位 long 的机器上为 long int 类型。类似地,0XA000 在具有 32 位 int 的机器上为 int 类型,但在具有 16 位 int 的机器上为 unsigned int 类型。可以使用后缀来避免这些实现依赖性:100000L 在所有机器上为 long int 类型,0XA000U 在所有机器上为 unsigned int 类型。
6.2.5 浮点类型
浮点类型表示浮点数。浮点数是使用固定内存量表示的实数的近似值。浮点类型有三种:float(单精度)、double(双精度)和 long double(扩展精度)。
单精度、双精度和扩展精度的确切含义由实现定义。为至关重要的选择问题选择正确的精度需要对浮点计算有深入的了解。如果您不具备这种理解,请寻求建议,花时间学习,或者使用双精度并希望获得最佳效果。
6.2.5.1 浮点文字量
在默认情况下,浮点文字量的类型为 double。同样,编译器应该警告那些太大而无法表示的浮点文字量。以下是一些浮点文字量:
1.23 .23 0.23 1. 1.0 1.2e10 1.23e−15
请注意,浮点文字量中间不能出现空格。例如,65.43 e−21 不是浮点文字量,而是四个单独的词汇标记(导致语法错误):
65.43 e – 21
如果需要 float 类型的浮点文字量,可以使用后缀 f 或 F 定义一个(译注:否则默认为double):
3.14159265f 2.0f 2.997925F 2.9e−3f
如果需要 long double 类型的浮点文字,可以使用后缀 l 或 L 定义一个:
6.2.6 前缀和后缀
有一个小的后缀群和几个前缀,用于表示文字量的类型:
请注意,这里的“string”是指“字符串文字量”(§7.3.2),而不是“std::string 类型”。
显然,我们也可以考虑将“ .” 和 e 视为中缀,将 R" 和 u8" 视为一组分隔符的第一部分。但是,我认为命名法并不重要,重要的是概述令人眼花缭乱的文字种类。
后缀 l 和 L 可以与后缀 u 和 U 组合来表示unsigned long。例如:
1LU //unsigned long
2UL //unsigned long
3ULL //unsigned long long
4LLU //unsigned long long
5LUL //error
后缀 l 和 L 可用于浮点文字量来表达 long double。例如:
1L //long int
1.0L //long double
允许使用 R,L 和 u 前缀的组合,例如 uR"∗∗(foo\(bar))∗∗"。请注意,字符(无符号)和字符串 UTF-32 编码的 U 前缀含义之间存在巨大差异(§7.3.2.2)。
此外,用户可以为用户定义类型定义新的后缀。例如,通过定义用户定义的文字量运算符(§19.2.6),我们可以得到
"foo bar"s // std::string 文字量类型
123_km // 距离文字量类型
不以 _ 开头的后缀是为标准库保留的。
6.2.7 void类型
从语法上来说,void 类型是一种基本类型。但是,它只能用作更复杂类型的一部分;没有 void 类型的对象。它用于指定函数不返回值,或用作指向未知类型对象的指针的基类型。例如:
void x; // 错误:没有 void 对象
void& r; // 错误:没有对 void 的引用
void f(); // 函数 f 不返回值 (§12.1.4)
void∗ pv; // 指向未知类型对象的指针 (§7.2.1)
声明函数时,必须指定返回值的类型。从逻辑上讲,您希望能够通过省略返回类型来指示函数没有返回值。但是,这会使语法变得混乱(§iso.A)。因此,void 用作“伪返回类型”,以指示函数不返回值。
6.2.8 数据类型的大小
C++ 基本类型的某些方面(例如 int 的大小)是由实现定义的(§6.1)。我指出了这些依赖关系,并经常建议避免它们或采取措施将它们的影响降至最低。你为什么要担心呢?在各种系统上编程或使用各种编译器的人非常关心,因为如果他们不这样做,他们就不得不浪费时间查找和修复模糊的错误。声称不关心可移植性的人通常是因为他们只使用一个系统,并且觉得他们可以承受“语言就是我的编译器实现的”这种态度。这是一种狭隘而短视的观点。如果你的程序成功了,它就会被移植,所以有人必须找到并修复与实现相关的功能相关的问题。此外,程序通常需要使用同一系统的其他编译器进行编译,甚至你最喜欢的编译器的未来版本也可能与当前版本有所不同。在编写程序时了解并限制实现依赖关系的影响要比事后试图理清混乱局面容易得多。
限制依赖于实现的语言特性的影响相对容易。限制依赖于系统的库工具的影响则困难得多。在可行的情况下使用标准库工具是一种方法。
提供多种整数类型、多种无符号类型和多种浮点类型的原因是允许程序员利用硬件特性。在许多机器上,不同种类的基本类型在内存要求、内存访问时间和计算速度方面存在显著差异。如果您了解一台机器,通常很容易为特定变量选择合适的整数类型。编写真正可移植的底级代码则更难(译注:比如汇编代码)。
下面是一组合理的基本类型和一个示例字符串文字量(§7.3.2)的图形表示:
按照同样的比例(.2 英寸对 1 字节),1 兆字节的内存将向右延伸约 3 英里(5 公里)。
C++ 对象的大小以 char 大小的倍数计,因此根据定义,char 的大小为 1。可以使用 sizeof 运算符(§10.3)获取对象或类型所占存储的大小。这是关于基本类型的大小的保证:
1 ≡ sizeof(char) ≤ sizeof(short) ≤ sizeof(int) ≤ sizeof(long) ≤ sizeof(long long)
1 ≤ sizeof(bool) ≤ sizeof(long)
sizeof(char) ≤ sizeof(wchar_t) ≤ sizeof(long)
sizeof(float) ≤ sizeof(double) ≤ sizeof(long double)
sizeof(N) ≤ sizeof(signed N) ≤ sizeof(unsigned N)
在最后一行中,N 可以是 char,short,int,long 或 long long。此外,char 至少有 8 位,short 至少有 16 位,long 至少有 32 位。char 可以保存机器字符集的字符。char 类型应该由实现选择为最适合在给定计算机上保存和操作字符的类型;它通常是 8 位字节。类似地,int 类型应该被选择为最适合在给定计算机上保存和操作整数的类型;它通常是 4 字节(32 位)字。假设更多是不明智的。例如,有些机器有 32 位字符。假设 int 的大小与指针的大小相同是极其不明智的;许多机器(“64 位架构”)的指针都大于整数。请注意,不能保证 sizeof(long)<sizeof(long long) 或 sizeof(double)<sizeof(long double)。
只需使用 sizeof即可求得基本类型的一些实现定义信息,更多信息可在 <limits> 中找到。例如:
#include <limits> // §40.2
#include <iostream>
int main()
{
cout << "size of long " << sizeof(1L) << '\n';
cout << "size of long long " << sizeof(1LL) << '\n';
cout << "largest float == " << std::numeric_limits<float>::max() << '\n';
cout << "char is signed == " << std::numeric_limits<char>::is_signed << '\n';
}
<limits> (§40.2) 中的函数是 constexpr (§10.4),因此它们可以在没有运行时开销的情况下使用,并且可用于需要常量表达式的上下文中。
基本类型可以在赋值和表达式中自由混合。只要可能,值就会被转换,以免丢失信息(§10.5)。
如果值 v 可以精确地表示在类型 T 的变量中,则将 v 转换为 T 是保值的。最好避免不保值的转换(§2.2.2、§10.5.2.6)(译注:即数据被截断或丢失精度)。
如果您需要特定大小的整数,例如 16 位整数,则可以 #include 定义各种类型(或类型别名;§6.5)的标准头 <cstdint>。例如:
int16_t x {0xaabb}; // 2 bytes
int64_t xxxx {0xaaaabbbbccccdddd}; // 8 bytes
int_least16_t y; // at least 2 bytes (正如 int)
int_least32_t yy // at least 4 bytes (正如 long)
int_fast32_t z; // 至少4字节
标准头文件 <cstddef> 定义了一个在标准库声明和用户代码中都广泛使用的别名:size_t 是一个实现定义的无符号整数类型,可以保存每个对象的字节大小。因此,它用于我们需要保存对象大小的地方。例如:
void∗ allocate(size_t n); // get n bytes
类似地,<cstddef> 定义了有符号整数类型 ptrdiff_t,用于保存两个指针相减的结果以获得一定数量的元素。
6.2.9 字节对齐(Alignment)
对象需要足够的存储空间来保存其表示,此外,在某些机器架构上,用于保存它的字节必须具有适当的对齐,以便硬件能够有效地访问它(或者在极端情况下也能完全访问它)。例如,4 字节 int 通常必须在字(4 字节)边界上对齐,有时 8 字节 double 必须在字(8 字节)边界上对齐(译注:上述边界是指内存地址的边界)。当然,这一切都非常特定于实现,并且对于大多数程序员来说完全是隐式的。您可以编写数十年的优秀 C++ 代码,而无需明确对齐。对齐最常见的地方是在对象布局中:有时struct包含“洞”以改善对齐(§8.2.1)(译注:即为了实现对数据的高效访问而牺牲一定的存储空间,比如16字节对齐,但是对象只用了12个字节,系统也分给它16字节内存)。
alignof() 运算符用于返回其参数表达式的对齐方式。例如:
auto ac = alignof('c'); // the alignment of a char
auto ai = alignof(1); // the alignment of an int
auto ad = alignof(2.0); // the alignment of a double
int a[20];
auto aa = alignof(a); // the alignment of an int
有时,我们必须在声明中使用对齐,而不允许使用诸如 alignof(x+y) 之类的表达式。相反,我们可以使用类型说明符 alignas:alignas(T) 表示“像 T 一样对齐”。例如,我们可以为某些类型 X 留出未初始化的存储空间,如下所示:
void user(const vector<X>& vx)
{
constexpr int bufmax = 1024;
alignas(X) buffer[bufmax]; // uninitialized
const int max = min(vx.size(),bufmax/siz eof(X));
uninitialized_copy(vx.begin(),vx.begin()+max,buffer);
// ...
}
6.3 声明
在 C++ 程序中使用名称(标识符)之前,必须先声明它。也就是说,必须指定其类型,以告知编译器该名称指的是哪种实体。例如:
char ch;
string s;
auto count = 1;
const double pi {3.1415926535897};
extern int error_number;
const char∗ name = "Njal";
const char∗ season[] = { "spring", "summer", "fall", "winter" };
vector<string> people { name, "Skarphedin", "Gunnar" };
struct Date { int d, m, y; };
int day(Date∗ p) { return p−>d; }
double sqrt(double);
template<class T> T abs(T a) { return a<0 ? −a : a; }
constexpr int fac(int n) { return (n<2)?1:n∗fac(n−1); } // possible compile-time evaluation (§2.2.3)
constexpr double zz { ii∗fac(7) }; // compile-time initialization
using Cmplx = std::complex<double>; // type alias (§3.4.5, §6.5)
struct User; // type name
enum class Beer { Carlsberg, Tuborg, Thor };
namespace NS { int a; }
从这些示例中可以看出,声明的作用不只是将类型与名称关联起来。大多数声明也是定义。定义是提供程序中使用实体所需的所有信息的声明。特别是,如果表示某些内容需要内存,则该内存由其定义留出。另一种术语认为声明是接口的一部分,而定义是实现的一部分。从这种观点来看,我们尝试用可以在单独文件中重复的声明来组成接口(§15.2.2);留出内存的定义不属于接口。
假设这些声明位于全局作用域内(§6.3.4),我们有:
char ch; // 留出一个字节的空间并初始化为 0
auto count = 1; // 留出一个int 的空间并初始化为1
const char∗ name = "Njal"; // 为char指针留出空间
//为字符串文字量 "Njal" 留出空间
// 用这个字符串文字量的空间地址初始化char指针
struct Date { int d, m, y; }; // Date是一个具有3个成员的结构体
int day(Date∗ p) { return p−>d; } // day是指执行具体代码的函数
using Point = std::complex<shor t>;// Point是 std::complex<shor t>的一个名称
关于上述声音,只有三个未定义:
double sqrt(double); // function declaration
extern int error_number; // variable declaration
struct User; // type name declaration
也就是说,如果要使用,它们引用的实体必须在其他地方定义。例如:
double sqrt(double d) { /* ... */ }
int error_number = 1;
struct User { /* ... */ };
在 C++ 程序中,每个名称必须始终只有一个定义(有关 #include 的效果,请参阅 §15.2.3)。但是,可以有多个声明。
实体的所有声明都必须与其类型一致。因此,此代码片段有两个错误:
int count;
int count; // error : 重复定义
extern int error_number;
extern short error_number; // error :类型不一致
这没有错误(对于 extern 的使用,参见§15.2):
extern int error_number;
extern int error_number; // OK: 重复申明
一些定义明确地为其定义的实体指定了“值”。例如:
struct Date { int d, m, y; };
using Point = std::complex<shor t>; // Point 是std::complex<shor t>
int day(Date∗ p) { return p−>d; }
const double pi {3.1415926535897};
对于类型,别名,模板,函数和常量,“值”是永久的。对于非常量数据类型,初始值可能会在以后更改。例如:
void f()
{
int count {1}; // initialize count to 1
const char∗ name {"Bjarne"}; // name是一个指向常量的指针变量(§7.5),
count = 2; // assign 2 to count
name = "Marian";
}
关于定义,仅有两个未指定值:
char ch;
string s;
有关如何以及何时为变量分配默认值的说明,请参阅 §6.3.5 和 §17.3.3。任何指定值的声明都是定义。
6.3.1 声明之结构(声明的格式)
声明的结构由 C++ 语法 (§iso.A) 定义。该语法从早期的 C 语法开始,经过四十多年的发展,非常复杂。但是,如果不进行太多彻底的简化,我们可以将声明视为具有5个部分(按顺序):
可选前缀指定符(specifier)(例如,static 或virtual)
基础类型(base type)(例如,vector<double>或const int)
包含一个名称的可选声明符(declarator)(例如,p[7], n, 或∗(∗)[]))
可选函数后缀指定符(例如,const 或 noexcept )
可选初始化器或函数体(例如,={7,5,3} 或 {return x;})
除了函数和命名空间定义之外,声明以分号结束。考虑 C 风格字符串数组的定义:
const char∗ kings[] = { "Antigonus", "Seleucus", "Ptolemy" };
在此,基础类型是 const char ,声明符是 ∗ kings[] ,而初始化器是 = 后接 {} 列表。
指定符是一个开始关键字,例如 virtual(§3.2.3、§20.3.2)、extern(§15.2)或 constexpr(§2.2.3),用于指定正在声明的内容的某些非类型属性。
声明符由名称和一些可选的声明符运算符组成。最常见的声明符运算符是:
如果它们都是前缀或后缀,它们的使用会很简单。但是,∗,[] 和 () 被设计为反映它们在表达式中的用法(§10.3)。因此,∗ 是前缀,而 [] 和 () 是后缀。后缀声明符运算符比前缀声明符绑定得更紧密。因此,char∗ kings[] 是指向 char 的指针数组,而 char (∗kings)[] 是指向 char 数组的指针。我们必须使用括号来表达诸如“指向数组的指针”和“指向函数的指针”之类的类型;请参阅 §7.2 中的示例。请注意,类型不能在声明中省略。例如:
const c = 7; // error : no type
gt(int a, int b) // error : no return type
{
return (a>b) ? a : b;
}
unsigned ui; // OK: ‘‘unsigned’’means ‘‘unsigned int’’
long li; // OK: ‘‘long’’ means ‘‘long int’’
在这方面,标准 C++ 与早期版本的 C 和 C++ 不同,后者允许前两个示例,即在未指定类型时将 int 视为类型 (§44.3)。此“隐式 int”规则是导致微妙错误和大量混乱的根源。
有些类型的名称由多个关键字组成,例如 long long 和 volatile int。有些类型名称甚至看起来不太像名称,例如 decltype(f(x))(调用 f(x) 的返回类型;§6.3.6.3)。
volatile 说明符在 §41.4 中描述。
alignas() 说明符在 §6.2.9 中描述。
6.3.2 声明多名称(Declaring Multiple Names)
可以在单个声明中声明多个名称。声明多名称仅是用逗号分隔的声明符列表。例如,我们可以像这样声明两个整数:
int x, y; // int x; int y;
运算符仅适用于单个名称,而不适用于同一声明中的任何后续名称。例如:
int∗ p, y; // int* p; int y; NOT int* y;
int x, ∗q; //int x; int* q;
int v[10], ∗pv; // int v[10]; int* pv;
这种具有多个名称和非平凡(nontrivial)声明符(译注:除基本类型之外的部分)的声明会使程序更难阅读,应该避免。
6.3.3 名称(Names)
名称(标识符)由字母和数字序列组成。第一个字符必须是字母。下划线字符“_”视为字母。C++ 对名称中的字符数没有限制。但是,实现的某些部分不受编译器编写者(特别是链接器)的控制,不幸的是,这些部分有时会施加限制。某些运行时环境还需要扩展或限制标识符中接受的字符集。扩展(例如,允许名称中的字符 $)会产生不可移植的程序。C++ 关键字(§6.3.3.1),例如 new 或 int,不能用作用户定义实体的名称。名称示例如下:
hello this_is_a_most_unusually_long_identifier_that_is_better_avoided
DEFINED foO bAr u_name HorseSense
var0 var1 CLASS _class ___
不能用作标识符的字符序列示例包括:
012 afool $sys class 3var
pay.due foo˜bar .name if
以下划线开头的非本地名称是为实现和运行时环境中的特殊设施保留的,因此此类名称不应在应用程序中使用。同样,以双下划线 (__) 或下划线后跟大写字母 (例如 _Foo) 开头的名称也是保留的 (§iso.17.6.4.3)。
当读取程序时,编译器总是寻找能够组成名称的最长字符串。因此,var10 是一个名称,而不是名称 var 后跟数字 10。此外,elseif 是一个名称,而不是关键字 else 后跟关键字 if。
大写字母和小写字母是不同的名称,因此 Count 和 count 是不同的名称,但选择仅在用大写字母区分不同名称通常是不可取的。一般来说,最好避免使用仅在细微方面不同的名称。例如,在某些字体中,大写字母“o”(O)和零(0)可能难以区分,小写字母“L”(l),大写字母“i”(I)和一(1)也是如此。因此,l0,lO,l1,ll 和 I1l 不是标识符名称的良好选择。并非所有字体都有相同的问题,但大多数字体都有一些问题。
大作用域(scope)(译注:指代码块)的名称应该具有相对较长且相当明显的名称,例如 vector,Window_with_border 和 Department_number。但是,如果仅在小作用域内使用的名称具有简短、常规的名称(例如 x、i 和 p),则代码会更清晰。函数(第 12 章),类(第 16 章)和命名空间(§14.3.1)可用于保持作用域较小(译注:保持代码块较小)。将常用的名称保持相对较短,并为不常用的实体保留很长的名称通常很实用。
选择名称来反映实体的含义,而不是其实现。例如,即使电话号码恰好存储在向量中(§4.4),phone_book 也比 number_vector 更好。不要在名称中编码类型信息(例如,将 pcname 表示为 char∗ 名称,将 icount 表示为 int 计数),就像有时在动态或弱类型系统的语言中所做的那样:
在名称中编码类型(译注:在名称中注明名称所代表的数据类型)会降低程序的抽象级别;特别是,它会阻止通用编程(在通用编程中,其依赖于名称能够引用不同类型的实体)。
编译器比您更擅长跟踪类型。
如果您想要更改名称的类型(例如,使用 std::string 来保存名称),则必须更改名称的每次使用(否则类型编码将成为谎言)(译注:类型更改后调用当然也随着更改)。
随着您使用的类型种类的增加,您能想到的任何类型缩写系统都会变得过于复杂和神秘(译注:取个合适的名称,确定是一件困难的事,混乱的名称是很多问题引起的根源,有时候名称可能很长)。
选一个好的名称是一种艺术。
尽量保持一致的命名风格。例如,将用户定义类型的名称大写,并将非类型实体的名称以小写字母开头(例如,Shape 和 current_token)。此外,对宏使用全部大写字母(如果必须使用宏(§12.6);例如,HACK),而对非宏(甚至非宏常量)则不要使用大写字母。使用下划线分隔标识符中的单词;number_of_elements 比 numberOfElements 更易读。然而,一致性很难实现,因为程序通常由来自不同来源的片段组成,并且使用了几种不同的合理风格。在使用缩写和首字母缩略词时要保持一致。请注意,语言和标准库对类型使用小写字母;这可以看作是它们是标准的一部分的暗示。
6.3.3.1 关键字
C++ 关键字有:
此外,关键字 export 为将来使用而保留。
6.3.4 作用域(Scope)
声明将名称引入作用域;也就是说,名称只能在程序文本的特定部分使用。
局部作用域(Local Scope): 在函数(第 12 章)或 lambda(§11.4)中声明的名称称为局部名称。其从用域从声明点延伸到其声明所在的块的末尾。块是由一对大号 {} 分隔的一段代码。函数和 lambda 参数名称被视为其函数或 lambda 最外层块中的局部名称。
类作用域(Class Scope): 如果某个名称是在类中的任何函数,类(第 16 章),枚举类(§8.4.1)或其他命名空间之外定义的,则该名称称为成员名称(或类成员名称)。其作用域从类声明的开头 { 扩展到类声明的结尾。
名字空间作用域(Namespace Scope): 如果名称在任何函数,lambda(§11.4),类(第 16 章)、枚举类(§8.4.1)或其他命名空间之外的命名空间(§14.3.1)中定义,则该名称称为命名空间成员名称。其作用域从声明点延伸到其命名空间的末尾。命名空间名称也可以从其他编译单元(translation units)访问(§15.2)。
全局作用域(Global Scope): 如果名称在任何函数、类(第 16 章)枚举类(§8.4.1)或命名空间(§14.3.1)之外定义,则该名称称为全局名称。全局名称的作用域从声明点延伸到其声明所在的文件末尾。全局名称也可以从其他编译单元访问(§15.2)。从技术上讲,全局命名空间被视为命名空间,因此全局名称是命名空间成员名称的一个示例。
语句作用域(Statement Scope): 如果名称在 for、while、if 或 switch 语句的 () 部分中定义,则该名称位于语句作用域内。其作用域从其声明点延伸到其语句的末尾。语句作用域内的所有名称都是局部名称。
函数作用域(Function Scope): 标签(§9.6)的作用域从其声明点开始直到函数结束。
块中的名称声明可以隐藏封闭块或全局名称中相同名称的声明。也就是说,可以重新定义相同的名称以引用块内的不同实体。退出块后,同名的名称将恢复其先前的含义。例如:
int x; // global x
void f()
{
int x; // local x hides global x
x = 1; // assign to local x
{
int x; // hides first local x
x = 2; // assign to second local x
}
x = 3; // assign to first local x
}
int∗ p = &x; //take address of global x
编写大型程序时,隐藏名称是不可避免的。但是,人类读者很容易注意到名称已被隐藏(也称为阴影)。由于此类错误相对罕见,因此很难发现。因此,应尽量减少名称隐藏。在大型函数中,将 i 和 x 等名称用作全局变量或局部变量会自找麻烦。
可以使用域解析运算符 :: 来引用隐藏的全局名称。例如:
int x;
void f2()
{
int x = 1; // hide global x
::x = 2; // assign to global x
x = 2; // assign to local x
// ...
}
无法使用隐藏的本地名称。
非类成员的名称的作用域从其声明点开始,即在完整声明符之后和初始化符之前。这意味着名称甚至可以用于指定其自己的初始值。例如:
int x = 97;
void f3()
{
int x = x; // 怪哉: 用自己(未初始化的)值初始化自己
}
如果在初始化变量之前使用它,好的编译器会发出警告。
可以使用单个名称引用块中的两个不同对象,而无需使用 :: 运算符。例如:
int x = 11;
void f4() //怪哉: 在单个作用域内使用两个不同的对象
{
int y = x; // 使用全局 x: y = 11
int x = 22;
y = x; // 使用局部 x: y = 22
}
同样,应避免这样的细微区别的用法。
函数参数的名称被视为在函数的最外层块中声明。例如:
void f5(int x)
{
int x; // error
}
这是一个错误,因为 x 同一作用域定义了两次。
for 语句中引入的名称是该语句的局部名称(在语句范围内)。这允许我们在函数中重复使用循环变量的常规名称。例如:
void f(vector<string>& v, list<int>& lst)
{
for (const auto& x : v) cout << x << '\n';
for (auto x : lst) cout << x << '\n';
for (int i = 0, i!=v.siz e(), ++i) cout << v[i] << '\n';
for (auto i : {1, 2, 3, 4, 5, 6, 7}) cout << i << '\n';
}
这不包含任何名称冲突。
不允许将声明作为 if 语句分支上的唯一语句(§9.4.1)。
6.3.5 初始化
如果为对象指定了初始化器,则该初始化器将确定对象的初始值。初始化器可以使用以下四种语法样式之一:
X a1 {v};
X a2 = {v};
X a3 = v;
X a4(v);
其中,只有第一种可以在任何情况下使用,我强烈推荐使用它。它比其他方法更清晰,也更不容易出错。但是,第一种形式(用于 a1)是 C++11 中的新形式,因此其他三种形式是您在旧代码中找到的。使用 = 的两种形式是您在 C 中使用的形式。旧习惯很难改变,所以我有时(不一致地)在用简单值初始化简单变量时使用 = 。例如:
int x1 = 0;
char c1 = 'z';
但是,任何比这更复杂的事情最好使用 {} 来完成。使用 {} 进行初始化,列表初始化,不允许窄化(§iso.8.5.4)。即:
整数不能转换为无法保存其值的另一个整数。例如,char 转换为 int 是允许的,但 int 转换为 char 则不行。
浮点值不能转换为无法保存其值的另一个浮点类型。例如,float 转换为 double 是允许的,但 double 转换为 float 则不行。
浮点值不能转换为整数类型。
整数值不能转换为浮点类型。
例如:
void f(double val, int val2)
{
int x2 = val; // if val==7.9, x2 becomes 7
char c2 = val2; // if val2==1025, c2 becomes 1
int x3 {val}; // error :可能截断
char c3 {val2}; // error : possible narrowing
char c4 {24}; // OK: 24 can be represented exactly as a char
char c5 {264}; // error (assuming 8-bit chars): 264 cannot be represented as a char
int x4 {2.0}; // error : no double to int value conversion
// ...
}
有关内置类型的转换规则,请参阅§10.5。
使用 {} 初始化没有任何优势,并且当使用 auto 获取由初始化器确定的类型时,有一个陷阱。陷阱是,如果初始化器是 {} 列表,我们可能不希望推断其类型(§6.3.6.2)。例如:
auto z1 {99}; // z1 is an initializer_list<int>
auto z2 = 99; // z2 is an int
因此,当使用 auto 类型时, 优先选用 = 进行初始化。
可以定义一个类,以便可以通过值列表初始化对象,或者通过给定几个参数(这些参数不仅仅是要存储的值)来构造对象。经典的例子是整数向量:
vector<int> v1 {99}; // v1是具有一个元素的向量,且这个元素值为99
vector<int> v2(99); // v2是具有99个元素的向量,且每一个元素的默认值是0
因此,除非有充分的理由不这样做,否则请优先选择 {} 初始化而不是其他方法。
空的初始化列表 {} 用于指示需要默认值。例如:
int x4 {}; // x4 becomes 0
double d4 {}; // d4 becomes 0.0
char∗ p {}; //p becomes nullptr
vector<int> v4{}; // v4 becomes the empty vector
string s4 {}; // s4 becomes ""
大多数类型都有默认值。对于整数类型,默认值是零的合适表示。对于指针,默认值为 nullptr(§7.2.2)。对于用户定义类型,默认值(如果有)由类型的构造函数确定(§17.3.3)。
对于用户定义的类型,可以区分直接初始化(允许隐式转换)和复制初始化(不允许隐式转换);参见§16.2.6。
在适当的地方讨论特定类型对象的初始化:
指针: §7.2.2, §7.3.2, §7.4
引用: §7.7.1 (左值), §7.7.2 (右值)
数组:§7.3.1, §7.3.2
常量:§10.4
类:§17.3.1(不使用构造函数)、§17.3.2(使用构造函数)、§17.3.3(默认)、§17.4(成员和基类)、§17.5(复制和移动) 。
6.3.5.1 省略初始化器
对于许多类型(包括所有内置类型),可以省略初始化器。如果你这样做——不幸的是,这很常见——情况会更加复杂。如果你不喜欢这种复杂性,只需一致地初始化即可。未初始化变量的唯一真正好的情况是大型输入缓冲区。例如:
constexpr int max = 1024∗1024;
char buf[max];
some_stream.get(buf,max); //读入最多 max 个字符到 buf 中
我们可以很容易地初始化 buf:
char buf[max] {}; // initialize every char to 0
通过重复初始化,我们可能会遭受可能非常严重的性能损失。尽可能避免使用这种低级缓冲区,并且不要让这些缓冲区处于未初始化状态,除非您知道(例如,通过测量)与使用初始化数组相比,优化效果非常显著。
如果未指定初始化器,则全局的变量 (§6.3.4)、命名空间的变量 (§14.3.1)、局部静态的变量 (§12.1.8) 或静态成员 (§16.2.12)(统称为静态对象)将初始化为适当类型的 {}。例如:
int a; // means ‘‘int a{};’’ so that a becomes 0
double d; // means ‘‘double d{};’’ so that d becomes 0.0
在自由存储中创建的局部变量和对象(有时称为动态对象或堆对象;§11.2)不会默认初始化,除非它们是具有默认构造函数的用户定义类型(§17.3.3)。例如:
void f()
{
int x; // x 没有明确值
char buf[1024]; // buf[i] 没有明确值
int∗ p {newint}; //*p没有明确值
char∗ q {new char[1024]}; // q[i] 没有明确值
string s; // s=="" 因string有默认构造函数
vector<char> v; // v=={} 顺为有vector有默认构造函数
string∗ ps {new string}; // *ps ==""因string有默认构造函数
// ...
}
如果要初始化内置类型的局部变量或使用 new 创建的内置类型的对象,请使用 {}。例如:
void ff()
{
int x {}; // x becomes 0
char buf[1024]{}; // buf[i] becomes 0 for all i
int∗ p {new int{10}}; // *p becomes 10
char∗ q {new char[1024]{}}; // q[i] becomes 0 for all i
// ...
}
如果数组或结构被初始化,则数组或类的成员也会被默认初始化。
6.3.5.2 初始化器列表
到目前为止,我们已经考虑了没有初始化器和一个初始化器值的情况。更复杂的对象可能需要多个值作为初始化器。这主要由以 { 和 } 分隔的初始化器列表处理。例如:
int a[] = { 1, 2 }; // array initializer
struct S { int x, string s };
S s = { 1, "Helios" }; // struct initializer
complex<double> z = { 0, pi }; // use constructor
vector<double> v = { 0.0, 1.1, 2.2, 3.3 }; // use list constructor
有关数组的 C 风格初始化,请参阅 §7.3.1。有关 C 风格结构,请参阅 §8.2。有关具有构造函数的用户定义类型,请参阅 §2.3.2 或 §16.2.5。有关初始化列表构造函数,请参阅 §17.3.4。
在上述情况下,= 是多余的。但是,有些人更喜欢添加它,以强调一组值用于初始化一组成员变量。
在某些情况下,也可以使用函数样式的参数列表(§2.3、§16.2.5)。例如:
complex<double> z(0,pi); // use constructor
vector<double> v(10,3.3); // 使用构造函数 : v 将10个元素初始化为3
在声明中,一对空括号 () 始终表示“函数”(§12.1)。因此,如果您想要明确说明“使用默认初始化”,则需要 {}。例如:
complex<double> z1(1,2); // 函数风格初始化器 (由构造函数初始化)
complex<double> f1(); // 函数声明
complex<double> z2 {1,2}; // 由构造函数初始化为 {1,2}
complex<double> f2 {}; // 构造函数初始化为默认值 {0,0}
请注意,使用 {} 符号的初始化不会窄化(§6.3.5)。
使用auto时,{}列表的类型将推导为 std::initializer_list<T>。例如:
auto x1 {1,2,3,4}; // x1 是一个<int>的初始化器的列表
auto x2 {1.0, 2.25, 3.5 }; // x2 是一个<double>的初始化器的列表
auto x3 {1.0,2}; // error : 不能推导{1.0,2}的类型(§6.3.6.2)
6.3.6 推导一个类型:auto和decltype()
C++语言提供了两种从表达式推断类型的机制:
auto 用于从对象的初始化器中推断出对象的类型;该类型可以是变量类型,const类型或constexpr 类型。
decltype(expr) 用于推断非简单初始化器的类型,例如函数的返回类型或类成员的类型。
这里所做的推导非常简单:auto 和 decltype() 只是报告编译器已知的表达式的类型。
6.3.6.1 auto类型指定符
当变量声明有初始化器时,我们不需要明确指定类型。相反,我们可以让变量具有其初始化器的类型。考虑:
int a1 = 123;
char a2 = 123;
auto a3 = 123; // a3 的类型是 ‘‘int’’
整数字面值 123 的类型是 int,所以 a3 是 int。也就是说,auto 是初始化器类型的占位符。
对于像 123 这样简单的表达式,使用 auto 代替 int 并没有太大的优势。类型越难写,类型越难知道,auto 就越有用。例如:
template<class T> void f1(vector<T>& arg)
{
for (vector<T>::iterator p = arg.begin(); p!=arg.end(); ++p)
∗p = 7;
for (auto p = arg.begin(); p!=arg.end(); ++p)
∗p = 7;
}
使用 auto 的循环编写起来更方便,也更易于阅读。此外,它对代码更改的适应性更强。例如,如果我将 arg 更改为列表,使用 auto的循环仍能正常工作而第一个循环则需要重写。因此,建议在较小作用域内使用 auto。
如果作用域很大,明确提及类型可以帮助定位错误。也就是说,与使用具体类型相比,使用 auto 会延迟类型错误的检测。例如:
void f(double d)
{
constexpr auto max = d+7;
int a[max]; // error : array bound not an integer
// ...
}
如果 auto 导致意外,最好的解决办法通常是使函数更小(译注:如前所述,在较小作用域内使用auto ),这通常是一个好主意(§12.1)。
我们可以用指定符和修饰符(§6.3.1)修饰推导类型,例如 const 和 &(参考;
§7.7)。例如:
void f(vector<int>& v)
{
for (const auto& x : v) { // x is a const int&
// ...
}
}
这里的 auto 由 v 的元素类型决定,即 int。
请注意,表达式的类型永远不会是引用,因为引用在表达式中被隐式取消引用(§7.7)。例如:
void g(int& v)
{
auto x = v; // x is an int (not an int&)
auto& y = v; // y is an int&
}
6.3.6.2 auto和{}列表
当我们明确提到要初始化的对象的类型时,我们需要考虑两种类型:对象的类型和初始化器的类型。例如:
char v1 = 12345; // 12345 is an int
int v2 = 'c'; // 'c' is a char
T v3 = f();
通过使用 {} 初始化器语法进行此类定义,我们可以最大限度地减少不幸转换的可能性:
char v1 {12345}; // error : 窄化发生
int v2 {'c'}; // fine: char->int 隐式转换
T v3 {f()}; // 当且仅当 f() 的类型可以隐匿地转化为 T 时有效
当我们使用 auto 时,只涉及一种类型,即初始化器的类型,我们可以安全地使用 = 语法:
auto v1 = 12345; // v1 is an int
auto v2 = 'c'; // v2 is a char
auto v3 = f(); // v3 是某个恰当的类型
事实上,将 = 语法与 auto 一起使用可能是一个优势,因为 {}列表语法可能会让某些人感到惊讶:
auto v1 {12345}; // v1 is a list of int
auto v2 {'c'}; // v2 is a list of char
auto v3 {f()}; // v3 某个恰当的类型的列表
这是符合逻辑的。考虑:
auto x0 {}; // error :不能推导类型
auto x1 {1}; //具有1个int元素的列表
auto x2 {1,2}; //具有2个int元素的列表
auto x3 {1,2,3}; //具有3个int元素的列表
类型 T 的元素的同质列表的类型被视为类型为 initializer_list<T> (§3.2.1.3, §11.3.3)。特别是,x1 的类型不能推断为 int。如果是 int,x2 和 x3 的类型会是什么?
因此,当我们不想要“列表”时,我建议对指定为 auto 的对象使用 = 而不是 {}。
6.3.6.3 decltype()指定符
当我们有合适的初始化器时,我们可以使用 auto。但有时,我们希望在没有定义初始化变量的情况下推断出类型,则我们可以使用声明类型说明符:decltype(expr) 是 expr 的声明类型。这在泛型编程中最有用。考虑编写一个函数,将两个具有不同元素类型的矩阵相加。加法结果的类型应该是什么?当然是矩阵,但它的元素类型可能是什么?显而易见的答案是,和的元素类型是元素和的类型。所以,我可以声明:
template<class T, class U>
auto operator+(const Matrix<T>& a, const Matrix<U>& b) −> Matrix<decltype(T{}+U{})>;
我使用后缀返回类型语法 (§12.1) 来表达参数的返回类型:Matrix<decltype(T{}+U{})>。也就是说,结果是一个矩阵,其元素类型是从参数矩阵中添加一对元素所得到的:T{}+U{}。
在定义中,我再次需要 decltype() 来表达 Matrix 的元素类型:
template<class T, class U>
auto operator+(const Matrix<T>& a, const Matrix<U>& b) −> Matrix<decltype(T{}+U{})>
{
Matrix<decltype(T{}+U{})> res;
for (int i=0; i!=a.rows(); ++i)
for (int j=0; j!=a.cols(); ++j)
res(i,j) += a(i,j) + b(i,j);
return res;
}
6.4 (内存)对象和值
我们可以分配和使用没有名称的对象(例如,使用 new 创建),并且可以分配给看起来奇怪的表达式(例如,∗p[a+10]=7)。因此,我们需要一个“内存中的某物”的名称。这是对象最简单、最基本的概念。也就是说,对象是存储的连续区域;左值(lvalue)是引用对象的表达式。“左值”一词最初是指“可以位于赋值左侧的东西”。但是,并非每个左值都可以在赋值的左侧使用;左值可以引用常量(§7.7)(译注:类为常量具有内存地址)。未声明为 const 的左值通常称为可修改的左值。这种简单、底级的对象概念不应与类对象和多态类型对象的概念相混淆(§3.2.2、§20.3.2)。(译注:具有程序可访问的内存地址的 C++对象就是左值。)
6.4.1 左值(Lvalues)和右值(Rvalues)
为了补充左值的概念,我们引入了右值的概念。大致说来,右值是指“非左值的值”,例如临时值(例如,函数返回的值)。
如果您需要更加技术化(例如,因为您想阅读 ISO C++ 标准),您需要对左值和右值有更细致的了解。在寻址、复制和移动时,对象有两个重要的属性:
具有唯一身份(Has Identity):程序具有对象的名称、指针或引用,因此可以确定两个对象是否相同、对象的值是否已更改等。
可移动(Movable):对象可被移动(即,我们可以将其值移动到另一个位置,并使对象处于有效但未指定的状态,而不是复制;§17.5)。
事实证明,需要这两个属性的四种可能组合中的三种才能准确描述 C++ 语言规则(我们不需要没有身份且不能移动的对象)。使用“m 表示可移动”和“i 表示具有身份”,我们可以用图形表示这种表达式分类:
因此,经典左值是具有身份且无法移动的东西(因为我们可以在移动后检查它),而经典右值是任何我们可以移动的东西。其他替代方案是 prvalue(“纯右值”),glvalue(“广义左值”)和 xvalue(“x”代表“非凡”或“仅限专家”;关于这个“x”的含义的建议非常有想象力)。例如:
void f(vector<string>& vs)
{
vector<string>& v2 = std::move(vs); // move vs to v2
// ...
}
这里,std::move(vs) 是一个 xvalue:它显然具有身份(我们可以将其称为 vs),但我们已经通过调用 std::move()(§3.3.2、§35.5.1)明确允许将其移动。
对于实际编程来说,通常以右值和左值来思考就足够了。请注意,每个表达式要么是左值,要么是右值,但不能同时是两者。
6.4.2 对象生命周期(Lifetimes of Objects)
对象的生命周期从其构造函数完成时开始,到其析构函数开始执行时结束。没有声明构造函数的类型的对象(例如 int)可被视为具有不执行任何操作的默认构造函数和析构函数。
我们可以根据对象的生命周期对其进行分类:
自动(销毁的)对象(Automatic): 除非程序员另有规定(§12.1.8、§16.2.12),函数中声明的对象在遇到其定义时创建,在其名称超出作用域时销毁。此类对象有时称为自动对象。在典型的实现中,自动对象分配在栈上;函数的每次调用都有自己的栈结构(stack frame)来保存其自动对象。
静态对象(Static): 在全局或命名空间作用域内声明的对象(§6.3.4)和在函数(§12.1.8)或类(§16.2.12)中声明的 static 对象仅创建和初始化一次,并且“存活”到程序终止(§15.4.3)。此类对象称为静态对象。静态对象在程序执行的整个生命周期中具有相同的地址。静态对象可能会在多线程程序中引起严重问题,因为它们在所有线程之间共享,并且通常需要锁定以避免数据争用(§5.3.1、§42.3)。
自由存储对象(Free Store): 使用 new 和 delete 运算符,我们可以创建直接控制其生命周期的对象(§11.2)(译注:即分配在堆上存储的对象,由程序员管理)。
临时对象(Temporary objects): (例如,计算中的中间结果或用于保存 const 参数引用值的对象):它们的生命周期由它们的用途决定。如果它们绑定到引用,则它们的生命周期就是引用的生命周期;否则,它们将“存活”到它们所属的完整表达式的结束。完整表达式是不属于另一个表达式的表达式。通常,临时对象是自动的。
线程局部对象(Thread-local objects): 也就是说,声明为thread_local(§42.2.8)的对象:这样的对象在其线程存在时被创建,在其线程存在时被销毁。
静态对象和自动对象在传统上被称为存储类对象。
数组元素和非静态类成员的生命周期由它们所属的对象决定。
6.5 类型别名
有时,我们需要为某个类型起一个新名字。可能的原因包括:
原始名称太长、太复杂或太丑(在某些程序员眼中)。
编程技术要求不同类型在上下文中具有相同的名称。
仅在一个地方提及特定类型以简化维护。
例如:
using Pchar = char∗; // 字符指针
using PF = int(∗)(double); // 函数指针,返回int,以double为参数
相似的类型可以定义相同的名称作为成员别名:
template<class T>
class vector {
using value_type = T; // every container has a value_type
// ...
};
template<class T>
class list {
using value_type = T; // every container has a value_type
// ...
};
类型别名是其他类型的同义词,而不是不同类型的同义词,无论好坏。也就是说,别名指的是它所指的类型。例如:
Pchar p1 = nullptr; // p1 is a char*
char∗ p3 = p1; // fine
如果希望拥有具有相同语义或相同表示的不同类型,则应该查看枚举(§8.4)和类(第 16 章)。
较旧的语法使用关键字 typedef 并将声明的名称放在变量声明中的位置,这在许多情况下都可以等效使用。例如:
typedef int int32_t; // equivalent to ‘‘using int32_t = int;’’
typedef short int16_t; // equivalent to ‘‘using int16_t = short;’’
typedef void(∗PtoF)(int); // equivalent to ‘‘using PtoF = void(*)(int);’’
当我们想要将代码与底层机器的细节隔离开来时,就会使用别名。
名称 int32_t 表示我们希望它表示一个 32 位整数。以 int32_t 而不是“普通 int”编写代码后,我们可以将代码移植到 sizeof(int)==2 的机器上,方法是重新定义代码中 int32_t 的单个出现以使用更长的整数:
using int32_t = long;
_t 后缀是别名(“typedef”)的常规后缀。 int16_t、int32_t 和其他此类别名可在 <stdint>(§43.7)中找到。请注意,根据类型的表示而不是用途来命名类型不一定是个好主意(§6.3.3)。
using 关键字还可用于引入模板别名(§23.6)。例如:
template<typename T>
using Vector = std::vector<T, My_allocator<T>>;
我们不能将类型指定符(例如 unsigned)应用于别名。例如:
using Char = char;
using Uchar = unsigned Char; // error
using Uchar = unsigned char; // OK
6.6 建议
[1] 有关语言定义问题的最终说明,请参阅 ISO C++ 标准;§6.1。
[2] 避免未指定和未定义的行为;§6.1。
[3] 隔离必须依赖于实现定义行为的代码;§6.1。
[4] 避免对字符的数值做出不必要的假设;§6.2.3.2、§10.5.2.1。
[5] 请记住,以 0 开头的整数是八进制;§6.2.4.1。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup