C++ Primer CH2 变量和基本类型

C++ 最基本特性

每一个广泛使用的编程语言都提供一些共通的特性,虽然它们之间的细节有差别。理解这些特性细节是理解语言的第一步。几乎所有语言都提供如下特性:

  • 内置类型,如整数、字符等;
  • 变量;
  • 表达式和语句;
  • 控制结构,如 if 和 while 来控制动作的条件执行或循环执行;
  • 函数,用于定义可调用的计算单元;

大部分语言在这些基础特性上提供两种扩展特性:1. 使得程序员可以定义自己的类型类扩展语言;2. 提供标准库来提供有用的函数和类型而不是内建在语言中,IO 库就是一个例子;

在 C++ 中类型规定了对象可以执行的操作。一个表达式是否是合法的取决表达式中的对象的类型。一些语言提供运行时类型检查,相反,C++ 是静态语言,类型在编译时已经作出检查。因而,编译器了解每个名字的类型。

由于 C++ 允许程序员定义新的数据结构,其表达力得到了极大的提升。程序员因此可以将语言塑造成适合将要解决的问题,而不需要语言设计者提前知晓这些问题的存在。C++ 中最重要的特性就是类,类允许程序员定义自己的类型。这种类型也被称为“类类型”,从而与内置类型区分开来。一些语言只能让程序员定义类型的数据内容,而 C++ 还允许程序员定义类型的操作。 C++ 的一个主要设计目标就是让程序员定义自己的类型,从而与内置类型一样容易使用。C++ 标准库就是这方面的一个典范。

内置类型

类型是语言的基础,类型告诉我们数据的含义和可执行的操作。C++ 提供类型的扩展机制。语言仅定义了若干基础类型(字符、整数、浮点数),并且提供了让我们定义自己的类型的机制。标准库使用这些机制提供了功能复杂的类,如:可变长度字符串 string 类,向量 vector 类等。

本章描述 C++ 的内置类型以及开始描述 C++ 提供的定义复杂类型的机制。

i = i + j;

因为 C++ 的设计目标是为了类类型与内置类型表现一致。所以以上语句当类型不同时其含义也不同。对于算术类型就是执行算术加法,对于 Sales_item 类型就是执行 Sales_item class 所定义的行为。

2.1 内置类型

C++ 的内置类型集合几乎与 C 完全一致,除了多了一个 bool 类型,C 从 C99 开始通过头文件引入了 bool 类型。除此之外,包括字符类型、各种有符号无符号的整数类型以及浮点数类型,以及特殊类型 void。 void 主要用于指示函数不返回任何值,不接收任何参数以及作为 C 中的通用指针。

2.1.1 算术类型

算术类型包含两种形式:整数类型(包含字符和布尔类型)和浮点数类型。内置类型的大小在不同的机器上很可能是不一致的,标准只说明了编译器必须保证的最小尺寸,但是编译器可以提供更大的尺寸,不同的尺寸导致可表示的数字范围不一样。bool 类型仅用于表示真值 true 和 false,我们不关注其尺寸。char 类型保证容纳机器的基本字符集中的字符,通常是 ASCII 字符集,char 长度是一个机器字节。int 类型表示宿主机器的整型自然尺寸(the natural size),通常机器在这个大小上做算术运算是最快的。short 和 long 以及 long long 用于修饰 int 表示各种不同长度的整型。标准保证 short 的长度不长于 int 长度,int 长度不长于 long 类型,long 类型则不长于 long long 类型,现代计算机中 short 通常为 16 位,int 为 32 位,long 在 32 位机器上是 32 位,在 64 位机器上是 64 位。long long 是由新标准引入的,用的比较少。

除此之外,C++ 还支持扩展的字符类型,wchar_t 保证可以容纳机器的最大扩展字符集中的任何字符。char16_t 和 char32_t 则用于 Unicode 字符集,它们的长度如类型名中的数字所示。

浮点数类型常用的有两种:单精度 float 类型和双精度 double 类型还有扩展精度 long double 类型。标准指定了最少有效位比特,大部分编译器会提供更高的精度,但程序员不应该依赖编译器实现细节。目前所有现代计算机都遵循 IEEE 754 浮点数标准。long double 可能是 96 位或 128 位,通常用于容纳特殊目的的浮点数硬件,且精度在不同实现之间不同。

内置类型的机器级表示

任何数据在机器中都是一串 bit,每个比特装载一个 0 或 1 ,对于机器本身来说这些数据是没有固定的意义的,这些指代整数、那些指代浮点数、这里是数据段、那里是代码段,机器中没有定义这些。机器可操作的最小数据块是 byte (字节),但是字节不是机器最自然的处理方式,如果对汇编有所了解会知道为了处理字节,32位 CPU 是先处理成 32 位整型,再截断成字节。因而 int 类型也被成为机器字(word),现在更为广泛使用的是 CPU 以 64 位为机器字。所谓机器字就是处理器的寄存器大小。在任何机器中每个字节都会有自己的地址,从某个地址开始的连续字节可以被解释为不同的类型,关键在于看程序格式如何处理。如:连续的 1 个字节可以处理成 char 类型,4 个字节可以处理成整数类型或者单精度浮点数,内存内容的具体含义取决于程序赋予地址的类型。类型决定了需要使用多少比特以及怎样解释比特的含义。

有符号和无符号类型

在讲解之前先提示有符号和无符号类型混用是 bug 的常见原因。在相互的类型转换混杂算术运算容易导致超出期望的结果,所以建议不要混用这两种类型。在后面会讲解有符号和无符号类型之间的转换规则。

除了 bool 和扩展字符类型(wchar_t, char16_t, char32_t)外,别的整数类型(short, int, long, long long)既可以是 signed 或者 unsigned ,其默认是有符号类型,在类型前面加上 unsigned 就变成无符号类型。unsigned int 可以缩写为 unsigned 。字符类型比较特殊,分为三种明确区分的类型:char, signed char, unsigned char 。char 可能是 signed char 或 unsigned char 中的一种,具体取决于编译器实现。

目前所有现代计算机都用二进制补码来表示有符号整数类型。

决定使用何种内置类型的建议

C++ 和 C 一样将内置类型设计的尽可能贴近硬件,因而算术类型被定义的很宽泛,为的就是满足不同硬件的特性。这种定义规则遵循最小可用原则,尽可能适用于尽可能多的硬件。然后,建议是程序员在编写程序时应该通过限定具体的类型来规避这种复杂性。有以下几点建议:

  • 当知道值不可能为负数时用 unsigned 类型;
  • 在算数运算中使用 int ,short 的运算速度和容量都不及 int,而 long 在 32 位机器下雨 int 大小一致。如果值超出 int 的最小保证范围,使用 long long 。
  • 不要将 char 和 bool 类型用于算术表达式,将它们用于专用的场景。因为 char 可能是有符号或者无符号的,所以真的要使用的话就明确指定 signed char 或者 unsigned char 。这里需要说明一下:标准保证了 ASCII 中的字符数值都是正数,如果只是用 char 提供值而不存储值的话,是可以直接使用 char 的。
  • 将 double 用于浮点运算, float 通常精度不够而且 double 的运算时间可能还优于 float ,long double 除非在特殊场景下几乎不会使用到。

2.1.2 类型转换

相关的算术类型可以进行转换,这些转换是隐式的,意味着不需要强转(cast)就能实现。当我们需要一种类型的对象但是提供了另外一种类型的对象,这时候就会发生自动转型。转换过程将发生什么定义规则如下:

  • 当给一个非布尔值算术类型赋值一个 bool 类型对象时,false 就取值 0, true 将取值 1 。
  • 给 bool 类型对象赋值一个非布尔值时,0 将取值 false,非 0 值取值 true 。
  • 将一个浮点数赋值给整型时,小数点后的值被截断,只保留小数点前的数值;
  • 将整数赋值给一个浮点数时,小数部分为 0 ,如果这个整数超出了浮点数的精度范围,精度将丢失;
  • 将一个超出范围的值赋值给无符号值,结果值将取此值对无符号值的最大范围的模,如:unsigned char 的取值范围是 0~255,如果被赋的值大于等于 256 ,则将对 256 取模再赋值,-1 取模为 255;
  • 当将一个超出范围的值赋值给有符号类型时,结果是未定义的;

《C 语言编程》一书中的附录 A6 给出了类型转换的详细信息,这里简单描述一下。

A6.1 类型提升

字符类型、short 类型或者枚举在表达式中使用,如果 int 可以表示原始值将转为 int 值,否则转为 unsigned int 值,这个过程称为整型提升。

A6.2 整型转换

任何一个整数转为指定的无符号类型是通过在无符号可容纳的范围中找到最小的合适值,简单来说就是对无符号值的最大值加一取模,上面已经描述过,取模的结果一定是非负数。对于二进制补码实现来说,就是在无符号类型范围更小时将原始值的左边的位截断,而在无符号类型范围更大时,如果原始值是无符号类型就对左边位进行 0 填充,如果是原始值是有符号类型就对左边位进行符号填充。

当将任何整数转为有符号类型时,如果目的类型可以表示原始值,值将不变,否则结果由编译器实现决定。

A6.3 整数和浮点数

当将浮点数转为整型时,小数点后的部分将被截断。如果结果值无法被此整型表示,结果是未定义的。特别是将负的浮点数转为无符号整型时,结果未定义。当将整数转为浮点数时,如果值在浮点数的范围内,但没法达到对应的精度时,结果要么是最接近的更大值要么是最接近的更小值。如果值超出了范围,结果是未定义的。

A6.4 浮点类型

将精度更小的浮点数转为精度更大的浮点数,值不变。将精度更大的浮点数转为精度更小的浮点数,结果遵循整数转为浮点数的规则。

A6.5 算术转换

所有算术运算符都有可能引起类型转换,结果是将操作数转为一个相同的类型,同时也是结果的类型,这种行为成为算术转换。遵循规则如下:

  • 如果任何一个操作数是 long double 时,其它的操作数转为 long double;
  • 否则,如果一个操作数是 double 时,其它的操作数转为 double;
  • 否则,如果一个操作数是 float 时,其它操作数转为 float;
  • 否则,先执行整型提升,如果一个操作数是 unsigned long int 时,其它的类型转为 unsigned long int;
  • 否则,如果一个操作数是 long int 而另一个是 unsigned int 时,结果取决于 long int 是否能够表示 unsigned int 的所有值,如果可以则 unsigned int 转为 long int,否则两者都转为 unsigned long int;
  • 否则,如果一个操作数是 long int,其它的操作数转为 long int;
  • 否则,如果一个操作数是 unsigned int,其它操作数转为 unsigned int;
  • 否则,操作数都是 int 类型;

建议:避免未定义或者实现定义(implementation-defined)的行为

未定义行为通常是编译器不需要检查或者无法检查的错误导致的。通常代码是可以编译通过的,但是程序会得到一个错误的结果。更为严重的是,未定义行为是不可预期的,也就是每次得到的结果都不一致,甚至同一个程序的不同编译版本或者同一个编译版本的不同执行时期都会不同。同样,程序应该避免实现定义行为,比如假定 int 的尺寸是固定的某个字节数,这样的程序是不可移植的,当程序移植到其它机器时通常会失败。

算术表达式中有无符号类型

unsigned u1 = 42, u2 = 10;
std::cout << u1 - u2 << std::endl; //32
std::cout << u2 - u1 << std::endl; //4294967264

需要记住的是当有无符号值参与运算时,通常结果就是无符号值,此时即便在我们的直觉中值应该是负数,数值会被解释为一个很大的无符号值。

注意:不要混用有符号和无符号类型

混用有符号和无符号类型可能会参数出乎意料的结果,特别是当有符号值是负数时。需要记住的是有符号值会自动转为无符号值。如:a = -1, b = 1 在 a b 是有符号时的结果与无符号时的结果是不一样的。

2.1.3 字面量

字面量用来描述数字、字符和字符串的值,字面量是常量。每个字面量都有类型,字面量的形式和值决定了其类型。

整型可以写做十进制、八进制或者十六进制。以 0 开头的是八进制,是 0x 或 0X 开头的是十六进制。如:20(十进制) 024(八进制)0x14(十六进制)。通常,十进制是有符号的,而八进制和十六进制可以是无符号或者有符号的。十进制数字顺序从 int, long 或 long long 中选择最小可容纳数值的类型。八进制和十六进制则顺序从 int, unsigned, long, unsigned long, long long 或 unsigned long long 中查找适合的类型。如果数值大于最大的类型的范围则会产生错误。没有 short 类型的字面量。可以在值后加上 L 或 l 明确表示值为 long 类型,或者后缀为 U 或 u 表示无符号类型,ul 或 UL 则明确指示 unsigned long ,后缀 ll 或 LL 表示 long long 类型。以上后缀适用于十进制、八进制和十六进制。如:0XFUL 是 unsigned long 类型的值 15 ,1234L 则是 long 类型的值 1234 。

浮点数字面量既可以包含小数点(如:123.4)也可以包含指数(1e-2),指数可以用 E 或 e 指示,指数值为 -N 表示小数点左移 N 位,正数表示右移。当浮点数没有后缀时表示的 double 类型值。只有当明确加了 f 或 F 后缀时才表示 float 常量。l 或 L 后缀表示 long double 类型。

字符常量值是一个整数。字符写做单引号中的单个字符如:’x’,值是字符在机器字符集中的数字表示值。通常不会直接在代码中写明字符的数字表示值,因为可能每个字符集的数字表示不太一样。字符常量在算术运算就像别的整数一样,尽管它们最常用的场景是和别的字符进行比较。C++ 和 C 中定义了几个可以的字符,这些字符通常是不可打印或者在字符串中有特殊含义。如:\n \r \t \\ \? \' \"。任意字符可以用八进制转义序列 \ooo 或十六进制转义序列 \xhh 表示。特殊的转义 \0 表示值为 0 的字符,也就是空字符。通常写做 \0 而不是数字 0 是为了强调其字符属性。

字符串是双引号中的 0 个或多个字符。如: “I am a string” 和 “” 。字符串字面量就是字符数组,并且编译器会在字符串的末尾隐式加上一个 \0 字符。所以字符串的真正长度比看起来多了一个字符。如:”A” 有两个字符。以上描述的转义同样适用于字符串。两个相邻的字符串(中间只有空白符)会在编译期间拼接成一个字符串,通常如果字符串太长时会这么做。

true 和 false 是 bool 类型的常量。nullptr 是指针的常量,在 C 中一般写做 NULL 宏。

字面量一定是常量,只有常量参与运算的表达式为常量表达式。常量表达式可以在编译期间就求值,并且可以在常量出现的地方使用。

枚举

还有一种常量在 《C++ primer》 这本书中介绍的很晚,就是枚举常量。枚举是一系列整数常量。常量值从 0, 1, 2, … 这样顺下去,除非明确指定每个值。如果只有部分指定了值,没有指定的就从已经指定的开始顺下去。不同枚举中的名字必须是不同的,对于值则没有这样的要求。如:

enum boolean { NO, YES };
enum escapes { BELL = '\a', BACKSPACE = '\b', TAB = '\t', NEWLINE = '\n', RETURN = '\r'};
enum months { JAN = 1, FEB, MAR, APR, MAY, JUN, JUL };

枚举提供将常量和名字绑定的便利机制。并且便于调试器进行打印。

2.2 变量

变量提供程序可操作的具名内存。每个 C++ 变量都有类型,类型决定了变量内存的尺寸和布局以及可保存的值的范围,类型还决定了变量可以进行的操作。在 C++ 中变量和对象是可互换的。

2.2.1 变量定义

变量定义包含类型名和其后的一个或多个变量名,变量名之间用逗号分割,并且以分号结束。定义可以为一个或多个变量提供初始值。

int sum = 0, value, units_sold = 0;
Sales_item item;
std::string book("0-201-78345-X");

上面的定义包含了内置类型和用户自定义类型,并且都有包含初始化值。对象在创建时赋予特定的值成为初始化。初始化值可以是任意复杂的表达式,只要值是对应的类型即可。当在同一条定义语句中定义两个或以上的变量时,前面的变量将马上对后面的变量可见,因而,可以用前面的变量对后面的变量进行初始化。如:

double price = 109.99, discount = price * 0.16;

在 C++ 中初始化是一个非常之复杂的话题,这本书中会一次次的提及。究其原因在于 C++ 中在定义变量时可以对用户类型进行初始化,C 语言中由于没有这种用户定义类型所以初始化直接而简单,Java 中的用户类型对象只存在于堆中,用户定义时仅仅定义了一个对真正的对象的引用,因而都没有 C++ 这么复杂。在 C++ 中许多程序员无法理解 = 号用于初始化变量,大家都倾向于认为这种形式的初始化是赋值,但是在 C++ 中初始化和赋值是完全不同的操作。其实对于 C++ 的内置类型这两种操作区别并不明显,真正区别在于用户定义类型,当初始化用户定义类型对象时调用的是构造函数,而赋值时调用的是赋值操作函数。

初始化不是赋值。初始化发生在变量创建时给定一个值,而赋值是将对象原有的值擦除并替换成新值。

列初始化

C++ 中的初始化变得更复杂的原因还在于 C++ 定义了多种初始化方式。如一下均为初始化 units_sold 的方式:

int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);

新标准允许初始化使用 {} 大括号的形式,这种形式的初始化在之前的版本仅允许在初始化列表时使用,此种方式成为列初始化(list initialization)。大括号形式的初始化子(initializer)现在可以用于初始化对象,或者在一些情况下用于对对象赋予新值。用这种方式来初始化内置类型有一个重要特性,就是编译不允许丢失信息的初始化,如:

long double ld = 3.1415926536;
int a{ld}, b = {ld}; //不允许的

术语:什么是对象?

C++ 程序员倾向于在各个场景使用 object 一词。对象在最广泛的含义上指代内存中的一块包含数据并且有类型的区域。不管对象是内置类型和类类型,是具名(named)还是匿名的(unnamed),是不可改变(immutable)的还是可改变(mutable),都称为对象。

默认初始化

当定义变量时不显式进行初始化,将执行默认初始化(default initialized),默认初始化变量将获得默认值。至于这个默认值是什么则取决于变量类型以及变量在何处定义。

没有显式初始化的内置类型,如果定义在函数外面将初始化为 0,如果定义在函数内部则值是未定义的。使用未定义的值进行任何计算或者赋值给其它变量是错误的,而且是非常常见的错误。

类类型控制本类型对象的初始化过程,不管对象定义在函数内部还是外部。如果类没有定义默认构造函数,就无法构造不显式初始化的对象。大部分类都会定义默认构造函数,类会给对象提供合适的默认值,如:string 类默认初始化为空字符串。有一些类无法提供合适的默认值,所以必须显式提供初始值。

总结来说:函数内的未初始化内置类型变量的值是未定义的,没显式初始化的类对象初始化值由类的默认构造函数控制。

注意:未初始化是一个很严重的 bug

未初始化变量具有未决议的值,使用未初始化的值是一种很难定位的错误,并且这种错误是运行时错误,是编译器不保证检查的错误,尽管大部分编译器会提醒使用了未初始化变量。使用未初始化的变量的后果是未定义的,这是 C++ 中最困难的地方,在 Java 中任何错误的用法都会引发程序抛出异常,但在 C++ 中结果是未定义的。未定义的含义是不知道会发生什么。可能会运行正常、可能会立即 crash 、有时候会错误运行但会等到很久之后才 crash 掉,此时挂掉的位置跟错误真正发生的位置相距甚远,这是真正难以定位的问题,也是 C++ 和 C 难学的重要原因之一,因为当你用错了的时候无法立即得到反馈,你必须用心智去校验是否正确,这是真正的负担。

然后想要正确初始化变量并不是一件容易的事,很多时候当定义变量时,根本不知道什么样的值是合适的,定义任何值似乎都是错误的。

《C++ primer》花了很大的篇幅来将初始化的事,而 K&R 一书中涉及的比较少,我想了一下发现,在 C 中根本无法定义新的类型,所有的初始化机制都是内置的,而且 C 中真正的类型就只有各种数字类型,它们唯一的操作就是算术运算(像数组、指针、结构、union 都在这个框内,并没有增加新的运算),而 C++ 因为可以自定义类型,而且目标是尽可能贴合内置类型,所以 C++ 说类型包含了数据和操作,说初始化是一件复杂的事,因为它在任何时候考虑的范围都包含了类类型。而在 C 中只能定义抽象数据类型(ADT),是一种头脑中的类型,而非真正表现在代码中类型。

2.2.2 变量声明和定义

C++ 沿用了 C 的分离编译方式,分离编译允许我们将程序拆分成多个源文件,而且可以单独编译,最后再将所有的编译出来的 .o 文件链接在一起。当将程序拆分成多个文件后,需要一种能够在多个文件中共享变量的机制。定义在任何函数外部的函数、类、变量对于整个程序来说都是可见的。

C++ 在很多方面类似于 C ,但更为复杂,而且不少不兼容的地方。C++ 文件组织方式和实体(这里函数、类、变量统称为程序中实体)可见性规则与 C 是一样的。C++ 也支持声明和定义的分离。声明的意思是让实体名字对程序来说是可见的,一个文件想要使用另一个文件中名字需要包含一个对这个名字的声明。C++ 为了将所有声明集中到一个地方提供了 .h 头文件以及 #include 给各个源文件包含,被包含的头文件其内容将会被预处理器复制到对应的源文件中去,这就造成了声明天然的有很多份拷贝。

而定义则除了使得名字可见之外,还创建名字,创建的含义对应于变量则是分配内存以及进行可行的初始化,对应于函数就是定义函数体本身。声明和定义一样都必须指定类型名称和变量名。

为了使用别地方定义的名字,必须得使用 extern 关键字,并且不能提供初始值。如果提供了初始值就变成了定义。同一个变量只能定义一次,但可以声明多次,对于函数是一样的。为了多个文件中使用同一的变量,必须只在一个文件中定义它,使用此变量的文件必须声明但是一定不能重复定义它。编译器会帮我们做这方面的检查,所以不用怎么担心。

概念:静态类型

语言分为静态类型和动态类型语言。静态类型的意思是在变量声明时得给出类型,并且在后面的使用中遵循此类型的行为,如果违反了约定的话编译器会帮助我们找到错误并且拒绝生成可执行文件。这个过程叫做类型检查。动态类型则不在编译期间检查类型,而将类型检查放到运行时检查,使用动态类型的语言大多会实现为“鸭子类型”,意思是只要行为像鸭子,我们就可以安全的认为它是鸭子。具体到程序中则是如果对象具有这个成员函数或者成员字段,则可以认为它是符合要求的。那么如果多个类类型具有这些函数和字段,它们就可以合法的传入到对应的位置而不会导致错误,Python 和 Javascript 是这方面的典范。动态语言中的类型是动词类型,而静态语言中的类型是名词类型,也就是说类型只跟类型的名字挂钩,只要名字不一样即便行为一样也被认为是不同的类型。对于小型程序来说动态类型提供了易修改的特性,因而适合快速原型开发或者面对需求易变的场景,而对于大型程序来说静态类型提供了易定位问题的特性,这是一种取舍和权衡,并无哪一种更优。

静态语言强制任何类型在使用之前必须可见,因而强制程序员在使用一个类型时,必须先声明它。

2.2.3 标识符

程序中的名字都是标识符,遵循标识符的规则。大部分的标识符都继承自 C 语言,具体说就是标识符(identifier)必须由字母、数字或下划线组成,其它字符都是非法的。早期的语言实现对于标识符长度有一个限定,超出的部分将会被编译器忽略,而标准规定了这个限定的长度至少得是多少。C++ 对于标识符的长度没有限定。标识符必须以字母或者下划线开头,而且是大小写敏感的。语言中包含了关键字集合,这些关键字是程序结构的骨架,不允许用户定义标识符跟关键字一样。C++ 有许多关键字,查看 C++ 标准检查整个列表。

C++ 还定义了一个操作符名字集合,如:and,bitand,compl,not_eq,and_eq,not,or,xor ,这种关键字很少使用。C++ 中还规定了一些名字规则是专用于库的,因而在应用程序中尽量不要使用它们,如:以两个连续下划线开头的名字,下划线紧接着一个大写字母开头的名字。C++ 建议在函数外定义的名字最好不要以下划线开头。

标识符有一些通用的命名规范:

  • 标识符应该反映其含义;
  • 变量名通常以小写字母打头;
  • 类名以大写字母打头;
  • 包含多个单词的标识符需要将每个单词区分开,C 语言更常用下划线,C++ 还会使用驼峰法;

不论以何种方式命名最好就是遵循一致的规范。

2.2.4 名字的域

作用域(scope)是程序的一部分,名字在其中拥有特定的含义。C++ 中的作用域用大括号 {} 来分割各个作用域。相同的名字可以在不同的作用域中指代不同的实体(函数、类、变量)。名字从定义的位置直到声明它的作用域结束的位置都是可见的。C++ 中有多种级别的作用域:在任何其它作用域之外定义的名字具有 global 作用域,定义在类中的名字具有 class 作用域,定义在名称空间中的名字具有 namespace 作用域,定义在块中的名字具有 block 作用域。其中全局作用域中的名字可以在任何地方访问。

最好的方式是将变量尽量定义在靠近第一次使用的位置,这样可以变量容易找到,从而提高程序的可读性。更为重要的是在靠近使用的地方定义将更容易将变量初始化为有用的值。

作用域是可以嵌套的。一旦名字被定义在外部作用域中,就可以被接下来的内部作用域访问。同时内部作用域中定义的相同名字会遮蔽外部作用域的名字,这样做容易导致 bug 不推荐这样做。全局作用域是没有名字的,::var 这个作用域操作符就是访问全局作用域中的名字。

2.3 复合类型

复合类型指的是用别的类型定义的类型。C++ 中有好几种复合类型,这里重点描述引用(reference)和指针(pointer)。这里给出一个更通用的声明的含义,声明指的是基础类型(base type)加上一系列声明符(declarator)。基础类型是普通类型,是自己可以描述自己的类型,如:内置类型和类类型。声明符是变量名字以及可选的类型描述符,如:指针描述符 *,引用描述符 &,数组描述符 [] 。

2.3.1 引用

C++ 中的引用(reference)与 Java 中的引用虽然都叫引用,但是有很大的区别,Java 中的引用更像 C++ 的指针。区别在于 Java 中所有对象都是用引用来指代,所以对象之间的赋值其实就是单纯的引用交换。而 C++ 中的引用赋值是真的改变了被引用的对象的值。C++ 的引用只有在作为参数传递给函数时才与 Java 一致,此时函数内的参数就是传递过来的实参的别名。

因而可以总结为,C++ 中的引用仅仅在初始化时对对象进行引用,而其后的任何操作都是操作其引用的对象。Java 中的引用更像是 C++ 中的指针,Java 中的引用可以改变从而引用别的对象,C++ 中的引用只要初始化后就只能指向一个对象。

当前讨论的 C++ 引用指的是左值引用(lvalue reference),左值是表示可改变的值,具有明确的内存地址,右值通常没有地址。新标准中定义了右值引用(rvalue reference),右值引用主要用于类的内部,这将在十三章描述。这里描述的都是左值引用。

引用的声明符是 &d 形式的,其中 d 是变量名。通常初始化变量时是将初始值复制到变量内存中,而初始化引用时是将引用绑定(bind)到初始值上。所谓绑定的含义是将名字与给定实体关联,这样使用这个名字就相当于使用此实体。一旦引用绑定到初始对象上就不能再令其绑定到另外一个对象上,因而,任何一个引用都必须初始化。引用不是对象,相反,引用是已存在的对象的另外一个名字。当引用被定义后,任何对引用的操作都最终操作在引用绑定的对象上。当我们给引用赋值时,其实是给引用绑定的对象赋值,这与 Java 是决然不同的。当读取一个引用时,其实是从那个对象读取值,当引用用于初始化时也是使用绑定的对象。如以下代码:

int &refVal3 = refVal; //refVal3 绑定到 refVal 绑定的对象 ival 上,而不是绑定到 refVal 本身。
int i = refVal; //将 refVal 绑定对象 ival 上读取值并用于初始化 i 变量。

因为引用不是对象,程序是不允许定义引用的引用的(reference to reference)。

如前所述,声明是基础类型加一列声明符。因而当在同一个定义语句中定义多个引用时,需要在每个引用前面加上 & 符号。这与指针(pointer)的定义方式是一样的。

int &r3 = i3, &r4 = i2;

除了引用可以定义为指向 const 修饰的类型以及父类引用指向子类外,所有的引用的类型都必须与绑定的对象类型完全一致(match exactly),即便是可以类型转换或整型提升也是不可以的。同时,引用必须是对象,而不能是字面量或者返回右值的表达式,原因在于只有对象有地址。

2.3.2 指针

指针(pointer)是 C 和 C++ 中特有的东西,类似于 Java 语言中的引用,除了表示形式不一样之外。指针跟引用一样都是用来间接访问别的对象的,不同于引用的是指针是对象,会分配内存和地址给指针。指针可以被赋值以及读取,一个指针在其生命周期中可以指向多个不同的对象。与引用不同的是,指针在定义时不是必须初始化的,如果定义为自动变量而没有初始化则指针的内容是任意值。

C++ 的观点是指针通常难以理解,调试与指针相关的错误通常是非常难的,所以 C++ 创造了引用。

指针的声明符是 *d,d 是指针的名字,星号(*)必须在每个指针声明处重复。如:

int *ip1, *ip2;

指针保存另外一个对象的地址,通过取地址操作符(address-of operator &)可以得到一个对象的地址。由于引用不是对象,它们没有地址,因而不能定义引用的指针。除了可以定义 const 修饰的类型指针,以及定义父类指针指向子类外,指针类型必须与被指向的对象类型完全一致。原因在于指针的类型用于推断被指向的对象的类型,以及确定其能够提供的行为,如果错误使用了类型,几乎可以肯定操作会失败。

指针的值

指针有三种有效值,其中只有指向对象的指针是可以解引用的(dereference)。指针的另外两种特殊值:空指针和指向对象的下一个地址(just immediately past the end of an object)是不可解引用的。空指针很好理解,在 C 中已经非常常见了。而指向对象的下一个地址是 C++ 的惯用法,通常用来作为哨兵(sentinel),作为 for 循环的末尾检查点,当讲到 C++ 迭代器时会详述这方面的内容。除此之外的任何值都是无效的,读取无效指针的值或者访问无效指针指向的对象是错误的,这跟使用未初始化的变量一样,是未定义的,而且这种错误编译器是不保证检查的。因此,程序员必须时刻谨记指针是否有效。由于指针的两个特殊值并不指向任何对象,因而访问它们指向的对象是一种未定义(undefined)的行为。

C++ 使用解引用操作符(dereference operator *)来访问指向的对象,解引用将产生指针指向的对象。对此结果进行赋值就是对指向的对象进行赋值,对结果进行的任何操作都是对指向的对象进行操作。只能对指向对象的指针进行解引用。

& 和 * 在不同的语境中具有不同的语义。在声明中分别作为引用和指针的声明,而在表达式中则作为取地址和解引用操作。C++ 往往乐于复用这些符号到不同的地方,因为它们的含义毫无关联,所以忽视它们的相同外形而将其作为完全不同的符号是值得做的。

空指针(null pointer)不指向任何对象,程序应该在使用指针之前校验其是否为空指针。有三种做法来获取空指针,如:

int *p1 = nullptr;
int *p2 = 0;
int *p3 = NULL; //需要包含 <cstdlib> 头文件

在新标准中引入了 nullptr 常量初始化空指针,nullptr 是特殊类型的指针字面量,它可以转为任何其它的指针类型。旧的程序与 C 程序往往使用预处理器变量 NULL,其值在 cstdlib 中被定义为 0 。现代 C++ 程序应该使用 nullptr 而不是 NULL 的原因在于,NULL 由处理器控制,在被编译处理之前已经被全部替换为 0 ,因而编译器将无法获取 NULL 的符号,使用 nullptr 可以弥补这个缺点,nullptr 是可以被编译检查的具有类型的值。同时 NULL 不是 std 名称空间的一部分,因而不需要使用 std:: 来引用。

不能使用 int 类型的变量来初始化指针,即便其值恰好为 0 也是不允许的。

建议:初始化所有指针

未初始化的指针是运行时错误的常见来源,使用任何未初始化的指针都是未定义的。使用未初始化的指针几乎总是导致运行时崩溃,然而,调试程序的崩溃是非常难的。在大部分编译器下,当使用未初始化指针时,指针所占据的内存空间中任何值都会被当做地址。使用这样的一个未初始化的指针相当于访问一个不存在的地址上的不存在的对象。并且还没有一种有效的方法可以区分有效地址和无效地址。所以,初始化指针和初始化任何变量一样重要,甚至更重要。尽可能在定义了对象后再定义指向它的指针,或者将指针初始化为 nullptr 或 0,这样程序可以知道指针没有指向一个有效的对象。

赋值和指针

指针和引用都能间接访问其它对象,但是它们之间有重要的区别。最重要的是引用不是对象。一旦定义了引用,没有任何办法使得引用指向另外一个对象,引用只能指向其初始化时绑定的对象。指针则不一样,指针可以被赋予新的值,使得指针指向一个不同的对象。跟踪一个赋值是改变了指针还是改变了指针指向的对象是困难的,重要的是记住赋值总是改变左操作数。

pi = &ival; // pi 被改变为指向 ival
*pi = 0; // ival 的值被改变,而 pi 的值不变;

其它的指针操作

有效的指针值可以用于条件判断,空指针被判断为 false,任何其它值被判断为 true 。给定两个相同类型的有效指针,如果它们指向同一个地址,那么它们就被认为是相同的,== 将返回 true。两个同时为空指针的指针也被认为是相同的。由于条件判断或者比较操作用到了指针的值,因而,无效指针将导致以上行为未定义。关于指针的加减法和与数组的关系将在后面介绍。

void* 指针

void* 指针是一种特殊类型的指针,这种指针可以包含任何对象的地址。就像任何别的指针一样,void* 指针值是地址,但是我们不知道具体指向的对象是什么类型。void* 指针只能做几种操作:与其它指针比较、传递给函数或者从函数中返回,赋值给其它的 void* 指针。void* 指针不能解引用,因而也不能对操作其指向的对象,因为我们不知道对象的类型,也不知道对象拥有的操作。要对指向的对象进行操作,必须将指针的类型进行强转,这将在后面提到。

void* 指针的作用在于引用一块内存,而不是使用指针来访问指向的对象。

2.3.3 理解复合类型声明

当在一个声明语句中声明多个变量时,每个声明符都说明它对应的变量与基础类型的关系,这与别的声明符是独立的,因而,一个声明语句中可以定义多个不同类型的变量。如:

int i = 1024, *p = &i, &r = i; //i 是 int 型,p 是指针,r 是引用

很多程序员对于基础类型与声明符中的类型修饰的组合很疑惑,通常很容易误解认为类型修饰(type modifier)(* 和 &) 是运用于整个变量定义语句的,这与我们有时将空白符放在了类型修饰符和名字之间有关。如:int* p; 容易让人认为 int* 将成为定义语句中所有变量的类型。然而真实的情况是 int 才是基础类型,* 是用来修饰 p 的,对于同一条语句中的别的名字没有作用。如:int* p1, p2; 其中 p1 是指针,p2 是 int 类型。在本书中倾向于将类型修饰符和名字紧靠在一起。如: int *p1, *p2;

C++ 对于指针的层级没有做限定,使用 ** 表示指针的指针,*** 表示指针的指针的指针。如:

int ival = 1024;
int *pi = &ival;
int **ppi = &pi;

指针的引用

引用不是对象,因此,不能定义引用的指针。而,指针是对象,因而可以定义指针的引用。如:

int i = 42;
int *p;
int *&r = p;
r = &i; // &i 赋值给 r,使得 p 指向 i
*r = 0; // 解引用 r 返回 i 变量,从而将 0 赋值给 i 变量

要理解 *&r ,需要将声明符从右往左读,最靠近名字的修饰符是真正的类型。因而,r 是引用,下一个修饰符 * 表示 r 引用的是一个指针,合起来就是 r is a reference to a pointer to an int,英文的表示更符合顺序,中文则刚好反过来。

2.4 const 限定符

使用 const 可以定义值不可变的变量,这种值是常量,这样程序就不至于不小心而改变了本不该改变的值。通常,常量用于缓冲大小、最大限度等对于程序来说有特殊含义的值,主要是为了取代 C 程序中的 #define 定义的常量。由于不能改变常量的值,常量必须初始化,初始化与变量初始化一样,特别是可以将常量初始化为运行时值,而 #define 只能让你定义字面量的常量。C 中只有几个基本内置类型所以对于常量的使用比较简单,而 C++ 由于引入了类的概念,而且类对象也可以定义为 const ,那么 const 类对象只能支持类的 const 操作,简单来说就是不改变对象的操作。

对于内置类型来说只要不涉及到赋值就不会改变其值,所以常量也能进行所有这些操作。这里非常值得一提的是当使用对象去初始化另外一个对象时,不管是常量还是非常量都是可以的。初始化别的对象并不会改变当前对象。复制对象并不会改变原始对象,一旦复制完成,新的对象就不能够再访问原始对象了。

int i = 42;
const int ci = i;
int j = ci;

默认情况下,const 变量不是全局变量

默认情况下 const 对象就像是 static 变量一样是存在于文件作用域中的。如:

const int bufSize = 512;

编译器会将这种编译时常量在每个使用的地方替换为对应的值,为了这种做编译器必须在单独编译文件时看到 const 对象的初始值,那么就必须将每个文件中都定义此对象。而为了避免重复定义的问题,const 变量默认是非全局的。因而,当我们在不同文件中定义相同名字的 const 对象时,与 static 变量,我们定义的是不同的变量。这个规则对于将 const 对象初始化为非常量值一样有效。

而有时,我们会希望多个文件访问同一个 const 变量,我们必须在定义和声明的地方都加上 extern 关键字。如:

//file_1.cpp 定义并初始化一个可以被其它文件访问的 const 对象
extern const int bufSize = fcn();
//file_1.h 当其它文件包含时将会声明此 const 对象
extern const int bufSize;

2.4.1 const 的引用

我们可以让引用绑定一个 const 类型对象,这样的引用不能改变绑定对象的值。const 对象只能被 const 引用绑定。如:

const int ci = 1024;
const int &r1 = ci;
r1 = 42; //错误! const 引用不能用于改变常量的值
int &r2 = ci; // 非 const 不能绑定 const 对象

const 引用只是对绑定 const 对象的引用的缩写,并不存在引用自己是 const 的引用。两点原因:一、引用不是对象,const 只能修饰对象;二、引用一经初始化就不能在绑定到别的对象,所以严格说所有的引用自己都是 const 的。引用的 const 属性决定的是是否可以通过引用改变其绑定的对象,而与引用本身无关系。

const 引用可以绑定到任何可以转换到引用类型的表达式上,包括非 const 对象、字面量和通用表达式。如:

int i = 42;
const int &r1 = i;  //非 const 对象
const int &r2 = 42; //字面量
const int &r3 = r1 * 2; //通用表达式

非 const 引用必须与绑定的对象类型严格匹配,而 const 是允许转换的。所以 int 类型的 const 引用可以绑定 double 类型的值。如:

double dval = 3.14;
const int &ri = dval;

原因在于 ri 不是真正绑定到 dval 对象上,而是绑定到一个编译器生成的临时对象上。所谓临时对象就是编译器在需要一个内存块来存储表达式求值时所创建的对象。dval 并没有直接绑定到 ri 引用上,而是转换并存储到临时对象上。如果允许将非 const 引用绑定到需要转换的对象上,那么对引用的操作将改变临时对象而不是真正的对象,这肯定不是我们想要的结果。所以,C++ 不允许这种操作。

const 引用可以绑定到非 const 对象上。绑定到 const 对象上的引用只是限制了不能通过引用来改变对象值,而没有限制底层的对象本身是否是 const 的。底层对象可以是非 const ,完全可以通过直接访问和别的引用来改变它的值。如:

int i = 42;
int &r1 = i;
const int &r2 = i; //const 引用绑定到非 const 对象上
r1 = 0; //通过非 const 引用可改变对象值
r2 = 0; //错误!! const 引用不能改变值

2.4.2 指针和 const

指针可以定义为指向 const 对象(point to const)的指针,指向 const 对象的指针不能用于改变指向对象的值。const 对象的地址只能保存在指向 const 对象的指针中。而非 const 对象的地址既可以保存在指向非 const 对象指针中,也可以保存在指向 const 对象指针中。跟引用一样,指向 const 对象的指针与对象本身是否是 const 没有关系,定义指向 const 的指针只是说不能通过指针改变对象的值,如果对象是可变的,那么可以通过别的方式改变对象值。

指向 const 的指针只是“认为”它们指向的对象是 const 的。

const 指针

指针跟引用的区别在于指针是对象,所以可以定义 const 指针(const pointer),所谓 const 指针就是指针本身不可变。const 指针跟别的 const 对象一样必须初始化,并且初始化只有其值不能改变。定义 const 指针得在 * 后面放置 const 限定符,在 * 前或者基础类型前放置 const 都是定义指向 const 对象的指针。如:

int errNumb = 0;
int *const curErr = &errNumb; //指向 int 对象的 const 指针
const double pi = 3.14159;
const double *const pip1 = &pi; //指向 const 对象的 const 指针
double const *const pip2 = &pi; //与上面含义完全一致的指针

要理解这种复杂定义的指针需要按英语方式从右往左读,pip is a const pointer to an object of type const double 。指针自身的 const 与是否可以使用指针改变底层对象无关,是否可以改变底层对象取决于是否指向 const 对象,比如可以用 curErr 改变 errNumb 的值,虽然 curErr 是 const 指针。

2.4.3 顶层 const(Top-Level const)

指针可以分开独立讨论指针本身是否为 const 的和指针指向的对象是否为 const 。我们称指针本身是 const 为顶层 const(top-level const),称指针指向一个 const 对象为底层 const(low-level const)。顶层 const 说明对象本身是 const 的,顶层 const 可以出现在任何对象类型。底层 const 只能出现在复合类型的基础类型中,指针声明可以同时包含顶层 const 和底层 const 。const 引用总是底层 const 。

顶层 const 和 底层 const 区别在于拷贝一个对象时,其顶层 const 会被忽略。如:

int i = 0;
const int ci = 42;
const int *p2 = &ci;
const int *const p3 = p2;
i = ci; //ci 的顶层 const 被忽略
p2 = p3; //p3 的顶层 const 被忽略,但是底层 const 必须匹配
int *p = p3; //错误!! 原因是底层 const 必须匹配
p2 = &i; //可将 int* 转为 const int*
int &r = ci; //错误!! 引用中的 const 总是底层 const,不能忽略
const int &r2 = i; //可将 const int& 绑定到 int 类型

复制对象不会改变被复制的对象,所以对象本身的 const 即顶层 const 可以被忽略。然而底层 const 是不能被忽略的,两个对象间必须有相同的底层 const 限定符。或者将非 const 转为 const,但不能做相反的转换。

2.4.4 constexpr 和常量表达式

常量表达式(constant expression)是值在编译期求值,并且值不可变的表达式。字面是常量表达式,由常量表达式初始化的 const 对象也是常量表达式。非 const 对象或者不是由常量表达式初始化的 const 对象都不是常量表达式。

constexpr 变量

在大系统中往往程序员自己去判断常量表达式是很困难的,而在某些情况下又必须使用常量表达式,通常定义和使用的地方是分离的。在 C++11 中可以通过在定义变量时加上 constexpr 来检查此 const 变量是由常量表达式初始化的。如:

constexpr int mf = 20;
constexpr int limit = mf + 1;
constexpr int sz = size(); //仅当 size 函数是常量函数(constexpr function)时才合法

在第 6 章中将会描述如何将函数定义为 constexpr 函数,这样的函数必须足够简单使得可以在编译期间求值。常量函数可以用于初始化 constexpr 变量。建议将所有的常量表达式初始化的变量都定义为 constexpr ,强制要求编译器进行检查。

字面类型(Literal Types)

因为常量表达式必须在编译期进行求值,所以 constexpr 变量的类型必须符合这一限制,这些类型必须足够简单使得可以在编译期进行求值,所以称为字面类型。这些类型包括算术类型、引用和指针,但是不包括常见的类类型。第 7 章将介绍字面类(Literal Classes)和 19 章介绍的枚举也是字面类型。

对于引用和指针定义为 constexpr 是有限制的,指针可以初始化为 nullptr 和 0 字面量,两者也可以初始化为绑定固定地址的对象。在函数内部定义的变量不是固定地址对象,而定义在函数外的或者定义为 static 的变量是固定地址变量,这种变量从程序一启动就存在,并且持续到程序结束。只有这种变量才能用于初始化 constexpr 引用和指针。即便是定义在函数内部的 const 或 constexpr 变量地址也不是固定的。

当将 constexpr 运用于指针时,需要特别注意 constexpr 是运用于指针本身而不是指针所指对象。constexpr 隐含的意思是顶层 const。如以下声明是完全不同的:

const int *p = nullptr;  //p 指向 const 对象
constexpr int *q = nullptr; //q 本身是 const 的

constexpr 指针可以指向 const 对象。如:

constexpr int i = 42; //必须定义在函数外面,i 是 const int 类型
constexpr const int *p = &i; // p 是 const int *const 类型,指向固定地址对象 i

2.5 类型处理

本节将当类型变得复杂而难以理解时的解决方案:类型别名(Type Aliases)、auto、decltype 关键字。C++ 和 C 的关键字都很容易变得复杂而难以理解,关键就在于使用了很多符号(* & () [])进行嵌套组合,导致要真正理解类型的含义需要费一番功夫,而且过长的类型名很容易拼写错误。过于沉溺于类型是什么,而不是将精力集中在需要解决的问题上是一种舍近求远的措施。动态语言用编译器类型检查交换省略程序员拼写类型名的工作,各有取舍。近年来静态类型语言的发展就是用语法糖减轻程序员拼写类型的工作,Java, C++, Scala 都在这方面做出了努力。

2.5.1 类型别名

类型别名分为两种方式:旧式 C 的 typedef 和新式 C++ 的 using 。类型别名就是给长的易错的难以理解的类型名字定义一个短的名字。使用这个短名字跟使用原来的长名字效果是一样的。短名字的好处在于简化类型定义、易于使用并且强调了类型的作用。

typedef double wages;
typedef wages base, *p;

以上将 wages 定义为 double 的同义词,将 base 定义为 wages 同义词,进而是 double 的同义词。p 则是类型 double* 。typedef 是基础类型的一部分,有 typedef 的声明是在定义类型别名而不是变量,与定义变量一样,声明符中的 * 不会对所有名字起作用。

这里的例子其实很差,并不能反映真实项目代码中的用法,也是我在写笔记时觉得很恶心的地方,相比于 C 语言编程那本书来说给出的示例代码太过于弱智,没有什么实际的用处。为了讲语法而给出一大堆如此不合常理的例子,并且没有一个是完整的例子,也就是说讲到这里还不能让人写出一个完整的小程序,解决一些实际的问题。原因可能是语言过于庞大,但事实是 C++ 编程思想一书很早就开始给出很多可以实际跑起来的例子,那本书相对来说写的更好,除了它没有跟上标准之外。C++ primer 会将一些 C 语言编程不讲的内容进行细化,如:基础类型(base type)和声明符(declarator)的概念,有好有坏,好处是懂的更深入,坏处是记忆量变大了。

不得不说的是 C++ 确实是一门很大的语言,从书的厚度就可以看出,C 是一本不到三百页的小册子,而 C++ 是一本上千页的砖头。

C++11 定义了新的定义别名方式,语法:using name = type 将定义 name 为 type 的别名。使用类型别名跟类型名的效果是一样的,因而类型别名可以出现任何类型名出现的地方。

定义指针的类型别名时需要注意,如果用 const 修饰类型别名将导致指针本身是 const 的,而不是指针所指向对象是 const。如:

typedef char *pstring;
const pstring cstr = 0; //指向 char 类型的 const 指针
const char *astr = 0; //指向 const char 类型的指针

这里 pstring 是指向 char 类型的指针,const 修饰的是 pstring,因而,const pstring 是指向 char 的 const 指针。这是反直觉的。其实,纵观整个 C++ 就这一个特例,记住就行。

2.5.2 auto 类型限定符

C++ 中可以使用 auto 语法糖让编译器去推断变量的类型,使用 auto 的变量必须具有初始值。如:

auto item = val1 + val2;

以上如果 val1 和 val2 是 Sales_item 的话,那么 item 是 Sales_item。如果是 double 的话,item 就是 double 类型。auto 可以定义多个变量,声明中的所有初始值必须拥有一致的类型,特别是当涉及到 auto 推断的引用、指针、const 混杂在一起时会变得尤其复杂。如:

auto i = 0, *p = &i; // i 是 int 类型,p 是指向 int 的指针
auto sz = 0, pi = 3.14; //错误!!! sz 和 pi 的类型不一致

编译器推断的 auto 的类型并不总是与初始值完全匹配的。编译器会将 auto 的类型调整到与通常
的初始化规则一致。使用引用作为初始值,auto 推断的是引用绑定的对象类型。auto 会忽略顶层 const ,但是底层 const 会保留。

int i = 0, &r = i;
auto a = r; // a 是一个 int
const int ci = i, &cr = ci;
auto b = ci; // int
auto c = cr; // int
auto d = &i; // int*
auto e = &ci; // const int*

如果需要顶层 const,需要显式写出。如:

const auto f = ci; //const int

还可以用 auto 声明引用,如:

auto &g = ci; //const int&
auto &h = 42; //错误!! 不能将普通引用绑定到字面量上
const auto &j = 42; //const int&

当声明一个 auto 推断类型的引用时,初始值中的 const 不会被忽略。

auto k = ci, &l = i;  //k 是 int, l 是 int&
auto &m = ci, *p = &ci; //m 是 const int&, p 是 const int*
auto &n = i, *p2 = &ci; //错误!!! n 是 int, p2 是 const int*

2.5.3 decltype 类型说明符

C++11 中引入了 decltype 类型说明,其作用在于由编译器从表达式中推断类型,编译器将对表达式进行分析得出结果的类型但不会真正求值。如:

decltype(f()) sum = x; //sum 的类型是 f() 的返回值类型,但不会真的调用 f()

当将 decltype 运用于变量时,将会保留变量的顶层 const 和引用类型。只有在 decltype 表达式中引用不被当做其绑定的对象的别名。

const int ci = 0, &cj = ci;
decltype(ci) x = 0; //int
decltype(cj) y = x; //const int&
decltype(cj) z; //错误!!! 引用必须初始化

当将 decltype 运用于表达式时,如果表达式返回的是左值则 decltype 返回的类型是引用类型,解引用操作符就是特别典型的例子。如:

int i = 42, *p = &i, &r = i;
decltype(r+0) b; //int
decltype(*p) c; //错误!!! c 是 int&

为了得到变量的引用类型有一种简单的方式就是 decltype((variable)) ,在变量名外加上括号就成为一个返回变量的表达式,并且求值结果是左值。因而,decltype 返回的是引用。decltype(variable) 仅当变量本身是引用时才会返回引用类型。

decltype((i)) d; //错误!!! int& 类型必须得初始化
decltype(i) e; // int

2.6 自定义数据结构

数据结构(data structure)是一种聚合相关数据元素和使用数据的策略的方式。在 C++ 中通过定义类来自定义数据结构。本章将描述没有任何方法的类。如:

struct Sales_data {
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

C++ 中可以用 struct 和 class 关键字定义类。struct 定义的类默认访问权限是 public ,class 定义的类默认访问权限是 private 。除了与别的语言一样的特性(类作用域、类实例字段是每个对象有一份拷贝、类体可以为空、用点号(.)访问成员)外,C++ 的类以分号(;)结尾,原因在于 C++ 允许在类体后定义变量,分号用来结束声明符(通常为空)。

struct Sales_data {} accum, trans, *salesptr;

建议不要将类定义和变量定义放在同一条语句中。

在 C++11 中允许为数据成员类内初始值(in-class initializer),没有初始值的成员将执行默认的初始化(内置类型初始化为 0,类执行类的默认构造函数)。类内初始值的形式必须是列初始化或者等号初始化,类似于函数调用的括号形式的初始化是不允许的。

C++ 规定在同一个文件中只能有一个类定义,这类似于 static 变量或者 const 变量。同时,如果类定义存在于多个文件中,它们必须保持一致。所以,类定义一般放在头文件中。如果头文件更新了,包含头文件的源文件必须重新编译。

术语

  • 基础类型(base type):在声明语句中处于声明符(declarators)的前面的类型说明符,基础类型可以被 const 修饰。基础类型为声明语句提供共同的类型,声明符将以此为基础进行声明;
  • 绑定(bind):将一个名字和给定实体相关联,因而,使用名字就是使用底层的实体。引用就是将名字绑定到对象上;
  • 常量表达式(constant expression):可以在编译期间进行求值的表达式;
  • 数据成员(data member):组成对象的数据元素,对象占据内存的元素。每个对象有自己独立的数据成员拷贝,数据成员可以在类内进行初始化;
  • 声明符(declarator):声明的一部分,包含声明的名字和类型修饰符(* & []);
  • 默认初始化(default initialization):当没有显式给出初始值时如何进行初始化。类对象初始化取决于类本身,定义在类中或者全局作用域中的内置类型值初始化为 0,定义在函数中的内置类型值初始化为未定义值;
  • 类内初始化(in-class initializer):类定义时提供数据成员的初始化值,类内初始化必须是等号符号或者大括号形式初始化;
  • 列初始化(list initialization):用括号形式进行初始化,括号中放油一个或多个初始值;
  • 底层 const(low-level const):在复合类型中与基础类型相结合的 const ,初始化时不会被忽略;
  • 顶层 const(top-level const):修饰对象本身的 const;
  • 临时对象(temporary):由编译器在对表达式求值时定义的未命名对象。临时对象将存活到生成临时对象的表达式结束的地方;
  • 类型说明符(type specifier):类型的名字;
  • 未定义(undefined):语言没有明确说明含义的地方,通常依赖于机器、编译器实现,并且成为难以定位的问题来源;
  • 未初始化(uninitialized):没有给出初始值的变量定义,尝试使用未初始化变量的结果是未定义的;
  • 字(word):机器执行整数运算的自然尺寸,通常也是 int 类型的长度 4 字节;

Leave a Reply

Your email address will not be published. Required fields are marked *