一些语言,如 Smalltalk 和 Python 等,在程序运行时检查数据类型;与之相反,C++ 是一种静态数据类型语言,它的类型检查发生在编译时。因此,编译器必须知道程序中每一个变量对应的数据类型。
C++ 标准规定的各种算数类型数据尺寸的最小值:
类型 含义 最小尺寸 bool 布尔类型 未定义 char 字符 8位 wchar_t 宽字符 16位 char16_t Unicode字符 16位 char32_t Unicode字符 32位 short 短整型 16位 int 整型 16位 long 长整型 32位 long long 长整型 64位 float 单精度浮点数 6位有效数字 double 双精度浮点数 10位有效数字 long double 扩展精度浮点数 10位有效数字 一个
char
的大小和一个机器字节一样。C++ 语言规定一个
int
至少和一个short
一样大,一个long
至少和一个int
一样大,一个long long
至少和一个long
一样大。其中,数据类型long long
是在 C++11 中新定义的。计算机以比特序列存储数据,每个比特非 0 即 1。大多数计算机以 2 的整数次幂个比特作为块来处理内存,可寻址的最小内存块称为“字节(byte)”,存储的基本单元称为“字(word)”,它通常由几个字节组成。在 C++语言中,一个字节要至少能容纳机器基本字符集中的字符。大多数机器的字节由 8 比特构成,字则由 32 或 64 比特构成,也就是 4 或 8 字节。大多数计算机将内存中的每个字节与一个数字(被称为“地址(address)”)关联起来。
浮点型可表示单精度、双精度和扩展精度值。C++ 标准指定了一个浮点数有效位数的最小值,然而大多数编译器都实现了更高的精度。通常,
float
以 1 个字(32 比特)来表示,doub1e
以 2 个字(64 比特)来表示,long double
以 3 或 4 个字(96 或 128 比特)来表示。一般来说,类型float
和double
分别有 7 和 16 个有效位;类型long doub1e
则常常被用于有特殊浮点需求的硬件,它的具体实现不同,精度也各不相同。与其他整型不同,字符型被分为了三种:
char
、signed char
和unsigned char
。特别需要注意的是:类型char
和类型signed char
并不一样。尽管字符型有三种,但是字符的表现形式却只有两种:带符号的和无符号的。类型char
实际上会表现为上述两种形式中的一种,具体是哪种由编译器决定。无符号类型中所有比特都用来存储值,例如,8 比特的unsigned char
可以表示 0 至 255 区间内的值。C++标准并没有规定带符号类型应如何表示,但是约定了在表示范围内正值和负值的量应该平衡。因此,8 比特的signed char
理论上应该可以表示-127 至 127 区间内的值,大多数现代计算机将实际的表示范围定为-128 至 127。在算术表达式中不要使用
char
或bool
,只有在存放字符或布尔值时才使用它们。因为类型char
在一些机器上是有符号的,而在另一些机器上又是无符号的,所以如果使用char
进行运算特别容易出问题。如果你需要使用一个不大的整数,那么明确指定它的类型是signed char
或者unsigned char
。执行浮点数运算选用
double
,因为float
通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几。事实上,对于某些机器来说,双精度运算甚至比单精度还快。long double
提供的精度在一般情况下是没有必要的,况且它带来的运行时消耗也不容忽视。观察下述代码,
c
值为多少:1
unsigned char c = -9;
首先,要清楚数据在计算机中以“补码”进行存储这一客观事实;其次,还要看机器的大小端模式:对于小端模式,为 247;对于大端模式,为 255。
类型所能表示的值的范围决定了转换的过程:
- 当把一个浮点数赋给整数类型时,进行了近似处理。结果值将仅保留浮点数中小数点之前的部分;
- 当把一个整数值赋给浮点类型时,小数部分记为 0。如果该整数所占的空间超过了浮点类型的容量,精度可能有损失;
- 当赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。例如,8 比特大小的
unsigned char
可以表示 0 至 255 区间内的值,如果我们赋了一个区间以外的值,则实际的结果是该值对 256 取模后所得的余数。因此,把-1 赋给 8 比特大小的unsigned char
所得的结果是 255; - 当赋给带符号类型一个超出它表示范围的值时,结果是未定义的(undefined)。此时,程序可能继续工作、可能崩溃,也可能生成垃圾数据。
当一个算术表达式中既有无符号数又有
int
值时,那个int
值就会转换成无符号数。把int
转换成无符号数的过程和把int
直接赋给无符号变量等效。观察下述代码片段:1
2
3
4unsigned u = 10;
int i = -42;
std::cout << i + i << std::endl; // 输出-84
std::cout << u + i << std::endl; // 如果int占32位,输出4294967264在第一个输出表达式里,两个(负)整数相加并得到了期望的结果。在第二个输出表达式里,相加前首先把整数-42 转换成无符号数。把负数转换成无符号数类似于直接给无符号数赋一个负值,结果等于这个负数加上无符号数的模。
当从无符号数中减去一个值时,不管这个值是不是无符号数,我们都必须确保结果不能是一个负值:1
2
3unsigned u1 = 42, u2 = 10;
std::cout << u1 - u2 << std::endl; // 输出32
std::cout << u2 - u1 << std::endl; // 结果是取模后的值无符号数不会小于 0 这一事实同样关系到循环的写法,下述代码片段存在死循环的 bug:
1
2for (unsigned int u = 10; u >= 0; --u)
std::cout << u << std::endl;当 u 等于 0 时迭代输出 0,然后继续执行
for
语句里的表达式。表达式- -u 从 u 当中减去 1,得到的结果-1 并不满足无符号数的要求,此时像所有表示范围之外的其他数字一样,-1 被自动地转换成一个合法的无符号数。假设int
类型占 32 位,则当 u 等于 0 时,- -u 的结果将会是 4294967295,即$-1 + 2^{32}$。切勿混用带符号类型和无符号类型。如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动地转换成无符号数。例如,在一个形如 a*b 的式子中,如果 a=-1,b=1,而且 a 和 b 都是
int
,则表达式的值显然为-1。然而,如果 a 是int
,而 b 是unsigned
,则结果须视在当前机器上int
所占位数而定。当int
占 32 位时,结果是 4294967295。可以将整数型字面值写作十进制数、八进制数或十六进制数的形式。以 0 开头的整数代表八进制数,以 0x 或 0X 开头的代表十六进制数。例如,能用下面的任意一种形式来表示数值 20:
1
20 /*十进制*/ 024 /*八进制*/ 0x14 /*十六进制*/
默认情况下,十进制字面值是带符号数,八进制和十六进制字面值既可能是带符号的也可能是无符号的。十进制字面值的类型是
int
、long
和long long
中尺寸最小的那个(例如,三者当中最小是int
),当然前提是这种类型要能容纳下当前的值。八进制和十六进制字面值的类型是能容纳其数值的int
、unsigned int
、long
、unsigned long
、long long
和unsigned long long
中的尺寸最小者。如果一个字面值连与之关联的最大的数据类型都放不下,将产生错误。类型short
没有对应的字面值。默认的,浮点型字面值是
double
类型。字符串字面值的类型实际上是由常量字符构成的数组(array),编译器在每个字符串的结尾处添加一个空字符(’\0’),因此,字符串字面值的实际长度要比它的内容多 1。例如,字面值’A’表示的就是单独的字符 A,而字符串”A”则代表了一个字符的数组,该数组包含两个字符:一个是字母 A、另一个是空字符。
泛化的转义序列,其形式是\x 后紧跟 1 个或多个十六进制数字,或者\后紧跟 1 个、2 个或 3 个八进制数字,其中数字部分表示的是字符对应的数值。如果反斜线\后面跟着的八进制数字超过 3 个,只有前 3 个数字与\构成转义序列。例如,”\1234”表示 2 个字符,即八进制数 123 对应的字符以及字符 4。相反,\x 要用到后面跟着的所有数字,例如,”\x1234”表示一个 16 位的字符,该字符由这 4 个十六进制数所对应的比特唯一确定。因为大多数机器的
char
型数据占 8 位,所以上面这个例子可能会报错。通过添加如下表所列的前缀或后缀,可以改变整型、浮点型和字符型字面值的默认类型:
字符和字符串字面值 前缀 含义 类型 u Unicode 16字符 char16_t U Unicode 32字符 char32_t L 宽字符 wchar_t u8 UTF-8(仅用于字符串字面常量) char 整型字面值 浮点型字面值 后缀 最小匹配类型 后缀 类型 u 或 U unsigned f 或F float l 或 L long l或L long double ll或LL long long 示例如下:
1
2
3
4
5L'a' // 宽字符型字面值,类型是wchar_t
u8"hi!" // utf-8字符串字面值(utf-8用8位编码一个Unicode字符)
42ULL // 无符号整型字面值,类型是unsigned 1ong 1ong
1E-3F // 单精度浮点型字面值,类型是float
3.14159L // 扩展精度浮点型字面值,类型是1ong double在 C++ 语言中,初始化和赋值是两个完全不同的操作。初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来替代。
要想定义一个名为 units sold 的
int
变量并初始化为 0,以下的 4 条语句都可以做到这一点:1
2
3
4int units sold = 0;
int units sold = {0};
int units sold{0};
int units_sold(0);作为 C++11 新标准的一部分,用花括号来初始化变量得到了全面应用,这种初始化的形式被称为列表初始化。现在,无论是初始化对象还是某些时候为对象赋新值,都可以使用这样一组由花括号括起来的初始值了。当用于内置类型的变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器将报错:
1
2
3long double1d = 3.1415926536;
int a{1d}, b = {1d}; // 错误:转换未执行,因为存在丢失信息的危险
int c(1d), d = 1d; // 正确:转换执行,且确实丢失了部分值如果是内置类型的变量未被显式初始化,它的值由定义的位置决定。定义于任何函数体之外的变量(全局变量)被初始化为 0。定义在函数体内部的内置类型变量(局部变量)将不被初始化(uninitialized)。一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式访问此类值将引发错误。定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。类的对象如果没有显式地初始化,则其值由类确定。
绝大多数类都支持无须显式初始化而定义对象,这样的类提供了一个合适的默认值。
string
类规定如果没有指定初值则生成一个空串。一些类要求每个对象都显式初始化,此时如果创建了一个该类的对象而未对其做明确的初始化操作,将引发错误。下列变量的初值分别是什么?
1
2
3
4
5
6
7std::string global_str; // 空串
int global_int; // 0
int main()
{
int local_int; // 未知
std::string local_str; // 空串
}为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译(separate compilation)机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。为了支持分离式编译,C++ 语言将声明和定义区分开来。声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义(definition)负责创建与名字关联的实体。变量声明规定了变量的类型和名字,在这一点上定义与之相同。但是除此之外,定义还申请存储空间,也可能会为变量赋一个初始值。如果想声明一个变量而非定义它,就在变量名前添加关键字
extern
,而且不要显式地初始化变量:1
2extern int i; // 变量声明
int j; // 变量定义任何包含了显式初始化的声明即成为定义。我们能给由
extern
关键字标记的变量赋一个初始值,但是这么做也就抵消了extern
的作用。extern
语句如果包含初始值就不再是声明,而变成定义了:1
extern double pi = 3.1416; // 变量定义
在函数体内部,如果试图初始化一个由
extern
关键字标记的变量,将引发错误。变量能且只能被定义一次,但是可以被多次声明。变量的定义必须出现在且只能出现在一个文件中,而其他用到该变量的文件必须对其进行声明,却绝对不能重复定义。指出下面的语句是声明还是定义:
1
2
3extern int ix = 1024; // 定义
int iy; // 定义
extern int iz; // 声明C++ 是一种静态类型(statically typed)语言,其含义是在编译阶段检查类型。其中,检查类型的过程称为类型检查(type checking)。
用户自定义的标识中不能连续出现两个下画线,也不能以下画线紧连大写字母开头。此外,定义在函数体外的标识符不能以下画线开头。
作用域(scope)是程序的一部分,在其中名字有其特定的含义。C++ 语言中大多数作用域都以花括号分隔。同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束。
main
拥有全局作用域(global scope)。一旦声明之后,全局作用域内的名字在整个程序的范围内都可使用。作用域能彼此包含,被包含(或者说被嵌套)的作用域称为内层作用域(inner scope),包含着别的作用域的作用域称为外层作用域(outer scope)。作用域中一旦声明了某个名字,它所嵌套着的所有作用域中都能访问该名字。同时,允许在内层作用域中重新定义外层作用域已有的名字。观察下述代码片段:1
2
3
4
5
6
7
8
9
int reused = 42; // 全局变量reused
int main()
{
std::cout << reused << std::endl; // 使用全局变量reused,输出42
int reused = 0;
std::cout << reused << std::endl; // 使用局部变量reused,输出0
std::cout << ::reused << std::endl; // 显示访问全局变量reused,输出42
}全局作用域本身并没有名字,所以当作用域操作符的左侧为空时,向全局作用域发出请求获取作用域操作符右侧名字对应的变量。如果函数有可能用到某全局变量,则不宜再定义一个同名的局部变量。
引用(reference)为对象起了另外一个名字,引用类型引用(refers to)另外一种类型。通过将声明符写成
&d
的形式来定义引用类型,其中 d 是声明的变量名。一般在初始化变量时,初始值会被拷贝到新建的对象中。然而定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。定义了一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的。为引用赋值,实际上是把值赋给了与引用绑定的对象。获取引用的值,实际上是获取了与引用绑定的对象的值。同理,以引用作为初始值,实际上是以与引用绑定的对象作为初始值。因为引用本身不是一个对象,所以不能定义引用的引用。
除去两种例外情况(后面将会介绍),引用的类型都要和与之绑定的对象严格匹配。而且,引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起。
下面的语句哪些合法:
1
2
3
4
5
6
7int i = 0, &r1 = i;
double d = 0, &r2 = d;
r2 = 3.14159; // a
r2 = r1; // b
i = r2; // c
r1 = d; // d四条语句均合法,但编译器会提示语句 c 和语句 d 存在数据丢失风险。
指针与引用相比有很多不同点:
- 指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象;
- 指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。
引用不是对象,没有实际地址,所以不能定义指向引用的指针。除去两种例外情况(后面将会介绍),所有指针的类型都要和它所指向的对象严格匹配。在声明语句中指针的类型实际上被用于指定它所指向对象的类型,所以二者必须匹配。如果指针指向了一个其他类型的对象,对该对象的操作将发生错误。
对指针解引用会得出所指的对象,因此如果给解引用的结果赋值,实际上也就是给指针所指的对象赋值。解引用操作仅适用于那些确实指向了某个对象的有效指针。
空指针(null pointer)不指向任何对象,在试图使用一个指针之前代码可以首先检查它是否为空。以下列出几个生成空指针的方法:
1
2
3int *p1 = nullptr;
int *p2 = 0;
int *p3 = NULL; // 需要首先#include cstdlib得到空指针最直接的办法就是用字面值
nullptr
来初始化指针,这也是 C++11 新标准新引入的一种方法。nullptr
是一种特殊类型的字面值,它可以被转换成任意其他的指针类型。过去的程序还会用到一个名为NULL
的预处理变量(preprocessor variable)来给指针赋值,这个变量在头文件 cstdlib 中定义,它的值就是 0。预处理变量不属于命名空间std
,它由预处理器负责管理,因此我们可以直接使用预处理变量而无须在前面加上std::
。当用到一个预处理变量时,预处理器会自动地将它替换为实际值,因此用NULL
初始化指针和用 0 初始化指针是一样的。在新标准下,现在的 C++程序最好使用nullptr
,同时尽量避免使用NULL
。建议初始化所有的指针,并且在可能的情况下,尽量等定义了对象之后再定义指向它的指针。如果实在不清楚指针应该指向何处,就把它初始化为
nullptr
或者 0。一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。
void*
是一种特殊的指针类型,可用于存放任意对象的地址。引用本身不是一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对指针的引用:
1
2
3
4
5int i = 42;
int *p; // p是一个int型指针
int *&r = p; // r是一个对指针p的引用
r = &i; // r引用了一个指针,因此给r赋值&i就是令p指向i
*r = 0; // 解引用r得到i,也就是p指向的对象,将i的值改为0要理解 r 的类型到底是什么,最简单的办法是从右向左阅读r 的定义。离变量名最近的符号(此例中是
&r
的符号 &)对变量的类型有最直接的影响,因此 r 是一个引用。声明符的其余部分用以确定 r 引用的类型是什么,此例中的符号*说明 r 引用的是一个指针。最后,声明的基本数据类型部分指出 r 引用的是一个int
指针。const
对象一旦创建后其值就不能再改变,所以const
对象必须初始化。 一如既往,初始值可以是任意复杂的表达式:1
2
3const int i = get_size(); // 正确:运行时初始化
const int j = 42; // 正确:编译时初始化
const int k; // 错误:k是一个未经初始化的常量只能在
const
类型的对象上执行不改变其内容的操作。例如,const int
和普通的int
一样都能参与算术运算,也都能转换成一个布尔值,等等。在不改变const
对象的操作中还有一种是初始化,如果利用一个对象去初始化另外一个对象,则它们是不是const
都无关紧要:1
2
3int i = 42;
const int ci = i;
int j = ci;尽管 ci 是整型常量,但无论如何 ci 中的值还是一个整型数。ci 的常量特征仅仅在执行改变 ci 的操作时才会发挥作用。当用 ci 去初始化时,根本无须在意 ci 是不是一个常量。拷贝一个对象的值并不会改变它,一旦拷贝完成,新的对象就和原来的对象没什么关系了。
默认情况下,
const
对象被设定为仅在文件内有效。当多个文件中出现了同名的const
变量时,其实等同于在不同文件中分别定义了独立的变量。
某些时候有这样一种const
变量,它的初始值不是一个常量表达式,但又确实有必要在文件间共享。这种情况下,我们不希望编译器为每个文件分别生成独立的变量。相反,我们想让这类const
对象像其他(非常量)对象一样工作,也就是说,只在一个文件中定义const
,而在其他多个文件中声明并使用它。解决的办法是,对于const
变量不管是声明还是定义都添加extern
关键字,这样只需定义一次就可以了:1
2
3
4// file1.cc定义并初始化了一个常量,该常量能被其他文件访问
extern const int bufSize = fcn();
// file 1.h头文件
extern const int bufSize; // 与file 1.cc中定义的bufSize是同一个如果想在多个文件之间共享
const
对象,必须在变量的定义之前添加extern
关键字。可以把引用绑定到
const
对象上,就像绑定到其他对象上一样,我们称之为对常量的引用(reference to const)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象。常引用常用于函数传参,有两个优点:节省资源开销和确保传入数据安全(不可更改)。常量引用可以绑定非常量对象,但不允许通过常量引用去修改所引用的非常量对象的值。例如,对于形参为常量引用的函数,我们传入的往往是非常量;普通引用(非常量引用)不能绑定常量对象。
前文曾提到,引用的类型必须与其所引用对象的类型一致,但是有两个例外。第一种例外情况就是在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。 尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是个一般表达式:
1
2
3
4
5int i = 42;
const int &r1 = i; // 允许将const int&绑定到一个普通int对象上
const int &r2 = 42; // 正确:r2是一个常量引用
const int &r3 = r1 * 2; // 正确:r3是一个常量引用
int &r4 = r1 * 2; // 错误:r4是一个普通的非常量引用要想理解这种例外情况的原因,最简单的办法是弄清楚当一个常量引用被绑定到另外一种类型上时到底发生了什么:
1
2double dval = 3.14;
const int& ri = dval;此处 ri 引用了一个
int
型的数。对 ri 的操作应该是整数运算,但 dval 却是一个双精度浮点数而非整数。因此为了确保让 ri 绑定一个整数,编译器把上述代码变成了如下形式:1
2const int temp = dval; // 由双精度浮点数生成一个临时的整型常量
const int& ri = temp; // 让ri绑定这个临时量在这种情况下,ri 绑定了一个临时量(temporary)对象。所谓临时量对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。常常把临时量对象简称为临时量。
对
const
的引用可能引用一个并非const
的对象。必须认识到,常量引用仅对引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未作限定。因为对象也可能是个非常量,所以允许通过其他途径改变它的值:1
2
3
4
5int i = 42;
int &rl = i; // 引用ri绑定对象i
const int& r2 = i; // r2也绑定对象i,但是不允许通过r2修改i的值
r1 = 0; // r1并非常量引用,i的值修改为0
r2 = 0; // 错误:r2是一个常量引用r2 绑定(非常量)整数 i 是合法的行为。然而,不允许通过 r2 修改 i 的值。尽管如此,i 的值仍然允许通过其他途径修改,既可以直接给 i 赋值,也可以通过像 r1 一样绑定到 i 的其他引用来修改。
指向常量的指针(pointer to const)不能用于改变其所指对象的值。要想存放常量对象的地址,只能使用指向常量的指针:
1
2
3
4const double pi = 3.14; // pi是个常量,它的值不能改变
double *ptr = π // 错误:ptr是一个普通指针
const double *cptr = π // 正确:cptr可以指向一个双精度常量
*cptr = 42; // 错误:不能给*cptr赋值前文曾提到,指针的类型必须与其所指对象的类型一致,但是有两个例外。第二种例外情况是允许令一个指向常量的指针指向一个非常量对象:
1
2double dval = 3.14; // dval是一个双精度浮点数,它的值可以改变
cptr = &dval; // 正确:但是不能通过cptr改变dval的值和常量引用一样,指向常量的指针也没有规定其所指的对象必须是一个常量。所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他途径改变。所谓指向常量的指针或引用,不过是指针或引用“自以为是”罢了,它们觉得自己指向了常量,所以自觉地不去改变所指对象的值。
要区分开常量指针、指向常量的指针和常量引用这些概念。
常量指针(const pointer)必须初始化(因为常量指针本身是一个常量),而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。把*放在
const
关键字之前用以说明指针是一个常量,这样的书写形式隐含着一层意味,即不变的是指针本身的值而非指向的那个值:1
2
3
4int errNumb = 0;
int *const curErr = &errNumb; // curErr将一直指向errNumb
const double pi = 3.14159;
const double *const pip = π // pip是一个指向常量对象的常量指针指针本身是一个常量并不意味着不能通过指针修改其所指对象的值,能否这样做完全依赖于所指对象的类型。
下面的哪些初始化是合法的?
1
2
3
4
5
6
7int i = -1, &r = 0; // 错误:非常量引用的初始值必须为左值
int *const p2 = &i2; // 正确
const int i = -1, &r = 0; // 正确
const int *const p3 = &i2; // 正确
const int *p1 = &i2; // 正确
const int &const r2; // 错误:r2是一个引用,定义时必须初始化,且第二个const没必要
const int i2 = i, &r = i; // 正确下面的定义哪些是合法的?
1
2
3
4
5int i, *const cp; // 错误:常量和引用都必须初始化
int *p1, *const p2; // 错误:常量和引用都必须初始化
const int ic, &r = ic; // 错误:常量和引用都必须初始化
const int *const p3; // 错误:常量和引用都必须初始化
const int *p; // 正确用名词顶层
const
(top-level const)表示指针本身是个常量,而用名词底层const
(low-level const)表示指针所指的对象是一个常量。更一般的,顶层const
可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层const
则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层const
也可以是底层const
,这一点和其他类型相比区别明显:1
2
3
4
5
6int i = 0;
int *const p1 = &i; // 不能改变p1的值,这是一个顶层const
const int ci = 42; // 不能改变ci的值,这是一个顶层const
const int *p2 = &ci; // 允许改变p2的值,这是一个底层const
const int *const p3 = p2; // 靠右的const是顶层const,靠左的是底层const
const int &r = ci; // 用于声明引用的const都是底层const当执行对象的拷贝操作时,拷入和搭出的对象必须县有相同的底层
const
资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行。接上例中的代码,观察下述代码片段:1
2
3
4
5int *p = p3; // 错误:p3包含底层const的定义,而p没有
p2 = p3; // 正确:p2和p3都是底层const
p2 = &i; // 正确:int*能转换成const int*
int &r = ci; // 错误:普通的int&不能绑定到int常量上
const int &r2 = i; // 正确:const int&可以绑定到一个普通int上常量表达式(const expression)是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的
const
对象也是常量表达式。一个对象(或表达式)是不是常量表达式由它的数据类型和初始值共同决定,例如:1
2
3
4const int max_files = 20; // max_files是常量表达式
const int limit = max files + 1; // limit是常量表达式
int staff_size = 27; // staff_size不是常量表达式
const int sz = get_size(); // sz不是常量表达式尽管 staff_size 的初始值是个字面值常量,但由于它的数据类型只是一个普通
int
而非const int
,所以它不属于常量表达式。另一方面,尽管 sz 本身是一个常量,但它的具体值直到运行时才能获取到,所以也不是常量表达式。C++11 新标准规定,允许将变量声明为
constexpr
类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr
的变量一定是一个常量,而且必须用常量表达式初始化:1
constexpr int sz = size(); // 中有当size是一个constexpr函数时才是一条正确的声明语句
尽管不能使用普通函数作为
constexpr
变量的初始值,但 C++标准允许定义一种特殊的constexpr
函数。这种函数应该足够简单以使得编译时就可以计算其结果,这样就能用constexpr
函数去初始化constexpr
变量了。一般来说,如果你认定变量是一个常量表达式,那就把它声明成constexpr
类型。常量表达式的值需要在编译时就得到计算,因此对声明
constexpr
时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把它们称为“字面值类型”(literal type)。算术类型、引用和指针都属于字面值类型。自定义类、IO 库、string
类型则不属于字面值类型,也就不能被定义成constexpr
。尽管指针和引用都能定义成constexpr
,但它们的初始值却受到严格限制。一个constexpr
指针的初始值必须是 nullptr 或者 0,或者是存储于某个固定地址中的对象。函数体内定义的变量一般来说并非存放在固定地址中,因此constexpr
指针不能指向这样的变量。相反的,定义于所有函数体之外的对象其地址固定不变,能用来初始化constexpr
指针。在
constexpr
声明中如果定义了一个指针,限定符constexpr
仅对指针有效,与指针所指的对象无关:1
2const int *p = nullptr; // p是一个指向整型常量的指针
constexpr int *q = nullptr; // q是一个指向整数的常量指针p 和 q 的类型相差甚远,p 是一个指向常量的指针,而 q 是一个常量指针,其中的关键在于
constexpr
把它所定义的对象置为了顶层const
。与其他常量指针类似,constexpr
指针既可以指向常量也可以指向一个非常量:1
2
3
4
5
6constexpr int *np = nullptr; // np是一个指向整数的常量指针,其值为空
int j = 0;
constexpr int i = 42; // i的类型是整型常量
// i和j都必须定义在函数体之外
constexpr const int *p = &i; // p是常量指针,指向整型常量i constexpr
int *pl = &j; // p1是常量指针,指向整数j观察下述代码片段:
1
2int null = 0, *p = null; // 错误:int类型的值不能用于初始化int *类型的值
int null = 0, *p = 0; // 正确类型别名(type alias)是一个名字。有两种方法可用于定义类型别名。传统的方法是使用关键字
typedef
:1
2typedef double wages; // wages是double的同义词
typedef wages base, *p; // base是double的同义词,p是double*的同义词新标准规定了一种新的方法,使用别名声明(alias declaration)来定义类型的别名:
1
using SI = Sales item; // SI是Sales item的同义词
如果某个类型别名指代的是复合类型或常量,那么把它用到声明语句里就会产生意想不到的后果。例如下面的声明语句用到了类型 pstring,它实际上是类型
char*
的别名:1
2
3typedef char *pstring;
const pstring cstr = 0; // cstr是指向char的常量指针
const pstring *ps; // ps是一个指针,它的对象是指向char的常量指针上述两条声明语句的基本数据类型都是
const pstring
,和过去一样,const
是对给定类型的修饰。pstring 实际上是指向char
的指针,因此,const pstring
就是指向char
的常量指针,而非指向常量字符的指针。 遇到一条使用了类型别名的声明语句时,人们往往会错误地尝试把类型别名替换成它本来的样子,以理解该语句的含义:1
const char *cstr = 0; // 是对const pstring cstr的错误理解
auto
类型说明符,用它就能让编译器替我们去分析表达式所属的类型。auto
让编译器通过初始值来推算变量的类型。显然,auto
定义的变量必须有初始值:1
2// 由va11和va12相加的结果可以推断出item的类型
auto item = val1 + val2; // item初始化为val1和va12相加的结果此处编译器将根据 va11 和 va12 相加的结果来推断 item 的类型。
使用
auto
也能在一条语句中声明多个变量。因为一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型都必须一样:1
2auto i = 0, *p = &i; // 正确:i是整数、p是整型指针
auto sz = 0, pi = 3.14; // 错误:sz和pi的类型不一致编译器推断出来的
auto
类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。首先,正如我们所熟知的,使用引用其实是使用引用的对象,特别是当引用被用作初始值时,真正参与初始化的其实是引用对象的值。此时编译器以引用对象的类型作为auto
的类型:1
2int i = 0, &r = i;
auto a = r; // a是一个整数(r是i的别名,而i是一个整数)其次,
auto
一般会忽略掉顶层const
,同时底层const
则会保留下来,比如当初始值是一个指向常量的指针时:1
2
3
4
5const int ci = i, &cr = ci;
auto b = ci; // b是一个整数(ci的顶层const特性被忽略掉了)
auto c = cr; // c是一个整数(cr是ci的别名,ci本身是一个顶层const)
auto d = &i; // d是一个整型指针(整数的地址就是指向整数的指针)
auto e = &ci; // e是一个指向整数常量的指针(对常量对象取地址是一种底层const)如果希望推断出的
auto
类型是一个顶层const
,需要明确指出:1
const auto f = ci; // ci的推演类型是int,f是const int
还可以将引用的类型设为
auto
,此时原来的初始化规则仍然适用:1
2
3auto &g = ci; // g是一个整型常量引用,绑定到ci
auto &h = 42; // 错误:不能为非常量引用绑定字面值
const auto &j = 42; // 正确:可以为常量引用绑定字面值设置一个类型为
auto
的引用时,初始值中的顶层常量属性仍然保留。和往常一样,如果我们给初始值绑定一个引用,则此时的常量就不是顶层常量了。要在一条语句中定义多个变量,切记,符号&
和*
只从属于某个声明符,而非基本数据类型的一部分,因此初始值必须是同一种类型:1
2
3
4auto k = ci, &l = i; // k是整数,l是整型引用
auto &m = ci, *p = &ci; // m是对整型常量的引用,p是指向整型常量的指针
// 错误:i的类型是int而&ci的类型是const int
auto &n = i, *p2 = &ci;常量引用可以绑定到非常量,普通引用只能绑定到非常量;指向常量的指针可以指向非常量,普通指针只能指向非常量。所谓的“常量引用”和“指向常量的指针”限制的只是引用和指针的行为。
判断下列定义推断出的类型:
1
2
3
4
5const int i = 42;
auto j = i;
const auto &k = i;
auto *p = &i;
const auto j2 = i, &k2 = i;j 是个整型变量,k 是个对整型常量的引用,p 是个指向整型常量的指针,j2 是个整型常量,k2 是个对整型常量的引用。
C++11 新标准引入了第二种类型说明符
decltype
,它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值。decltype
处理顶层const
和引用的方式与auto
有些许不同。如果decltype
使用的表达式是一个变量,则decltype
返回该变量的类型(包括顶层const
和引用在内):1
2
3
4const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x的类型是const int
decltype(cj) y = x; // y的类型是const int&,y绑定到变量x
decltype(cj) z; // 错误:z是一个引用,必须初始化因为 cj 是一个引用,
decltype(cj)
的结果就是引用类型,因此作为引用的 z 必须被初始化。需要指出的是,引用从来都作为其所指对象的同义词出现,只有用在decltype
处是一个例外。如果
decltype
使用的表达式不是一个变量,则decltype
返回表达式结果对应的类型。有些表达式将向decltype
返回一个引用类型。一般来说当这种情况发生时,意味着该表达式的结果对象能作为一条赋值语句的左值:1
2
3
4// decltype的结果可以是引用类型
int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // 正确:加法的结果是int,因此b是一个(未初始化的)int
decltype(*p) c; // 错误:c是int&,必须初始化因为 r 是一个引用,因此
decltype(r)
的结果是引用类型。如果想让结果类型是 r 所指的类型,可以把 r 作为表达式的一部分,如 r+0,显然这个表达式的结果将是一个具体值而非一个引用。另一方面,如果表达式的内容是解引用操作,则decltype
将得到引用类型。正如我们所熟悉的那样,解引用指针可以得到指针所指的对象,而且还能给这个对象赋值。因此,decltype(*p)
的结果类型就是int&
,而非int
。decltype
和auto
的另一处重要区别是,decltype
的结果类型与表达式形式密切相关。有一种情况需要特别注意:对于decltype
所用的表达式来说,如果变量名加上了一对括号,则得到的类型与不加括号时会有不同。如果decltype
使用的是一个不加括号的变量,则得到的结果就是该变量的类型;如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype
就会得到引用类型:1
2
3// decltype的表达式如果是加上了括号的变量,结果将是引用
decltype((i)) d; // 错误:d是int&,必须初始化
decltype(i) e; // 正确:e是一个(未初始化的)int切记:
decltype((variable))
(注意是双层括号)的结果永远是引用,而decltype(variable)
结果只有当 variable 本身就是一个引用时才是引用。请指出每一个变量的类型以及程序结束时它们各自的值:
1
2
3
4
5int a = 3, b = 4;
decltype(a) c = a;
decltype((b)) d = a;
++c;
++d;a:整型变量,4;b:整型变量,4;c:整型变量,4;d:对整型变量的引用,4。
赋值是会产生引用的一类典型表达式,引用的类型就是左值的类型。也就是说,如果 i 是
int
,则表达式 i=x 的类型是int&
。根据这一特点,指出下面代码中每一个变量的类型和值:1
2
3int a = 3, b = 4;
decltype(a) c = a;
decltype(a = b) d = a;a:整型变量,3;b:整型变量,4;c:整型变量,3;d:对整型变量的引用,3。
类体右侧的表示结束的花括号后必须写一个分号,这是因为类体后面可以紧跟变量名以示对该类型对象的定义,所以分号必不可少。
类的数据成员定义了类的对象的具体内容,每个对象有自己的一份数据成员拷贝。修改一个对象的数据成员,不会影响其他的对象。
C++11 新标准规定,可以为数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化。
类内初始值的限制:或者放在花括号里,或者放在等号右边,记住不能使用圆括号。
头文件一旦改变,相关的源文件必须重新编译以获取更新过的声明。
确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocesor),预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。
#include
,当预处理器看到#include
标记时就会用指定的头文件的内容代替#include
。C++ 程序还会用到的一项预处理功能是头文件保护符(header guard),头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。#define
指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义:#ifdef
当且仅当变量已定义时为真,#ifndef
当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif
指令为止。预处理变量无视 C++语言中关于作用域的规则。常量指针(const pointer)是一种指针,它的值永不改变。
常量引用(const reference)是一种习惯叫法,含义是指向常量的引用。
常量表达式(const expression)能在编译时计算并获取结果的表达式。
constexpr是一种函数,用于代表一条常量表达式。
数据成员(data member)组成对象的数据元素,类的每个对象都有类的数据成员的一份拷贝。数据成员可以在类内部声明的同时初始化。
声明符(declarator)是声明的一部分,包括被定义的名字和类型修饰符,其中类型修饰符可以有也可以没有。
默认初始化(default initialization)当对象未被显式地赋予初始值时执行的初始化行为。由类本身负责执行的类对象的初始化行为。全局作用域的内置类型对象初始化为 0;局部作用域的对象未被初始化即拥有未定义的值。
标识符(identifier)组成名字的字符序列,标识符对大小写敏感。
列表初始化(list initialization)利用花括号把一个或多个初始值放在一起的初始化形式。
底层 const(low-level const)一个不属于顶层的
const
,类型如果由底层常量定义,则不能被忽略。空指针(null pointer)值为 0 的指针,空指针合法但是不指向任何对象。
nullptr
是表示空指针的字面值常量。指针(pointer)是一个对象,存放着某个对象的地址,或者某个对象存储区域之后的下一地址,或者 0。
指向常量的指针(pointer to const)是一个指针,存放着某个常量对象或非常量对象的地址。指向常量的指针不能用来改变它所指对象的值。
对常量的引用(reference to const)是一一个引用,不能用来改变它所绑定对象的值。对常量的引用可以绑定常量对象,或者非常量对象,或者表达式的结果。
顶层 const(top-level const)是一个
const
,规定某对象的值不能改变。void* 可以指向任意非常量的指针类型,不能执行解引用操作。
C++ Primer - 第 2 章 变量和基本类型
猜你喜欢
Thank you for your donate!
- 本文链接: https://blog.shipengx.com/archives/e005f4cb.html
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!