类型
基本内置类型
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 位有效数字 |
C++中最基本的字符类型是char
, 它用于存储基本字符集中的任意字符的对应数值, 也就是字符在机器字符集中的数值, 通常是一个字节的大小.
提示
在C++中, 基本字符集是指一种最低限度的字符集合, 包含编程语言中必需的字符, 这些字符如字母和数字, 空格, 标点符号, 操作符, 换行符, 回车符, 制表符等等.
C++还提供了多种扩展字符类型, 以支持更大范围的字符集(国际化), 这些类型包括wchar_t
, char16_t
和char32_t
. wchar_t
是C++早期设计的宽字符类型, 不针对Unicode, 用于表示大于一个字节的字符, 它的大小在不同的平台上不一致. char16_t
和char32_t
由C++11引入, 是专门为Unicode设计的, 大小固定, 能够确保跨平台统一, 一致.
除了字符类型外, C++中的其他整数类型可能表示不同大小的整数. 标准规定int
至少和short
一样大, long
至少和int
一样大, long long
至少和long
一样大.
浮点型可以表示单精度, 双精度和扩展精度值, C++标准制定了一个浮点数有效位数的最小值, 然而大多数编译器都实现了更高的精度. 通常, float
以1个字(32bit)来表示, double
以2个字(64bit)来表示, long double
以3个或4个字(96或128bit)来表示. 一般来说, 类型float
和double
分别有7和16个有效位, 类型long double
常被用于有特殊浮点需求的硬件, 具体实现不同, 精度也不同.
提示
可寻址的最小内存块称为"字节", 存储的基本单元称为"字", 它通常是由几个字节组成的. 大多数机器的字节由8bit构成, 字则由32或者64bit构成, 也就是4或者8字节.
带符号类型和无符号类型
除去布尔型和扩展的字符型之外, 其他整型可以划分为带符号的(signed)和无符号的(unsigned)两种, 带符号类型可以表示正数, 负数或0, 无符号类型则仅能表示大于等于0的值.
类型int
, short
, long
和long long
都是带符号的, 通过在这些类型前面添加unsigned
就可以得到无符号类型, 例如unsigned long
, 类型unsigned int
可以缩写为unsigned
.
与其他整型不同, 字符型被分为了三种: char
, signed char
和unsigned char
. 特别需要注意的是, 类型char
和类型signed char
并不一样. 因为char
在某些编译器上可以视为无符号的, 有些视为有符号的(C++没有规定char
是否带符号), 对于unsigned char
, 在8位的情况下, 它可以表示从0到255之间的值; 对于signed char
, 理论上应该表示-127到127的范围, 但是在大多数计算机上通常是-128到127.
警告
如何选择类型?
- 当明确知道数值不可能为负的时候, 应该选用无符号类型
- 使用
int
执行整数运算, 如果数值超过了int
的范围, 用long long
- 不要在算术表达式里面用
char
和bool
, 只有在存放字符或者布尔值的时候才使用它们. 因为类型char
在某些机器上是有符号的, 在另一些机器上是无符号的, 如果用char
进行算术运算很容易出错, 需要制定类型为signed char
/unsigned char
- 执行浮点数运算选用
double
, 这是因为float
通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几. 对于某些机器来说, 双精度甚至比单精度快
类型转换
对象的类型定义了对象能包含的数据和能参与的运算, 其中一种运算是被大多数类型支持的, 就是将对象从一种给定类型转换到另一种类型.
当在程序中我们使用了一种类型而其实对象应该采取另一种类型的时候, 程序会进行自动类型转换. 如给某种类型的对象强行赋予了另一种类型的值的时候, 类型所能表示的值的范围决定了转换过程:
- 当我们将一个非布尔类型的算术赋值给布尔类型的时候, 初始值为0的结果为
false
, 否则结果为true
- 当我们将一个布尔值复制给非布尔类型的时候, 初始值为
false
, 初始值为true
则结果为1 - 当我们把一个浮点数赋值给整数类型的时候, 进行了近似处理, 结果值将仅保留浮点数中小数点之前的部分
- 当我们把一个整数值赋值给浮点类型的时候, 小数部分记为0, 如果整数所占的空间超过了浮点类型的容量, 精度可能会有损失
- 当我们赋给一个无符号类型一个超过它表示范围的值时, 结果是初始值对无符号类型表示数值总数取模后的余数. 例如, 8bit大小的
unsigned char
可以表示0到255区间范围内的值, 所以如果赋了一个区间以外的值, 实际结果是该值对256取模后所得的余数, 因此, 把-1复制给8bit大小的unsigned char
所得的结果是255 - 当我们赋给带符号类型一个超出它表示范围的值时, 结果是未定义. 此时, 程序可能继续工作, 可能奔溃, 也可能生成垃圾数据
警告
"无法预知"的行为源于编译器无须(有时候是不能)检测的错误, 即使代码编译通过了, 如果程序执行了一条未定义的表达式, 仍有可能产生错误. 不幸的是, 在某些情况/某些编译器下, 含有无法预知行为的程序也能正常执行, 但是我们无法确保同样一个程序在别的编译器下能够正常工作, 甚至是已经编译通过的代码再次执行也可能会出错. 此外, 也不能认为程序对一组输入有效, 对另一组输入就一定有效. 程序也应该尽量避免依赖于实现环境的行为. 如果我们把int
的尺寸看成是一个确定不变的值, 那么这样的程序就被称为是"不可移植的". 当程序移植到别的机器上后, 依赖于当前实现环境的程序就可能发生错误.
当在程序的某处使用了算术类型而需求是另一种类型的时候, 编译器同样会自动执行上述转换. 例如, 如果我们使用了一个非布尔值作为条件, 那么它会被自动转换为布尔值. 这一做法和把非布尔值赋值给布尔变量的操作完全一样.
含有无符号类型的表达式
尽管我们在正常写代码的时候, 不会故意给无符号对象赋一个负值, 但是很容易写出怎么做的代码. 例如, 当一个算术表达式中既有无符号数又有int
值的时候, 那个int
值就会转换成无符号数.
例子
unsigned u = 20;
int i = -42;
std::cout << i + i << std::endl;
std::cout << u + i << std::endl;
在第一个输出表达式里, 两个负整数相加得到了-84. 在第二个输出表达式里, 相加前首先把整数-42转换为无符号数, 把负数转换为无符号数的过程之前讲过了, 类似于直接给无符号数赋一个负值, 结果等于这个负数加上无符号数的模.
当从无符号数中减去一个值的时候, 不管这个值是不是无符号数, 我们必须确保结果不可能是一个负值.
例子
unsigned u1 = 42, u2 = 10;
std::cout << u1 - u2 << std::endl;
std::cout << u2 - u1 << std::endl;
第一个正确, 输出32; 第二个错误, 输出的是取模后的值.
无符号数不会小于0这个事实会影响到循环的写法.
例子
for (unsigned int i = 10; i >= 0; --i)
std::cout << i << std::endl;
这可能会导致i
永远大于0. 因为等于0的下一次迭代中, 它会减一, 然后会被自动转换成一个合法的无符号数, 假设int类型占32字节, 则会被转换为4294967295.
一种解决的方法是, 用while
语句代替for
语句, 因为前者能够先查再减.
unsigned u = 11;
while (u > 0) {
--u;
std::cout << u << std::endl;
}
警告
切勿混用带符号类型和无符号类型. 这会导致有符号数被自动转换为带符号数, 结果错误.
字面量常量
一个形如42的值被称为字面量(literal). 每个字面量都对应一种数据类型, 字面量的形式和值决定了它的数据类型.
整型和浮点型字面量
我们可以将整型字面量写作十进制数, 八进制数和十六进制数的形式. 以0开头的整数代表八进制数, 以0x或0X开头的代表十六进制数. 例如, 我们可以用任意一种来表示数值20: 20(十进制), 024(八进制), 0x14(十六进制).
整型字面量的具体数据类型由它的值和符号决定. 默认情况下, 十进制字面量是带符号数, 八进制和十六进制字面量既可以是带符号也可以是不带符号的. 十进制字面量是能够容纳那个值的情况下, int
, long
和long long
中最小的那个. 八进制和十六进制字面量的类型是能够容纳其数值的int
, unsigned int
, long
, unsigned long
, long long
和unsigned long long
中尺寸最小的那个. 如果一个字面量连与之关联的最大的数据类型都放不下, 会产生错误. 类型short
没有对应的字面量.
尽管整型字面值可以存储在带符号数据类型中, 但是严格来说, 十进制的整型字面量本身不会是负数. 举个例子, 当我们写-42的时候, 42是一个正的十进制整数字面量, 而前面的-是一个操作符, 它的作用是对这个字面量取负值, 所以严格来说, -42不是一个负数字面量, 而是操作符-作用于整数42的结果.
浮点型字面量表现为一个小数或者以科学计数法表示的指数, 其中指数部分用E或者e标识: 3.149, 3.14159E0, 0., 0e0, .001. 默认下, 浮点型字面量是一个double
.
字符和字符串字面量
由单个引号括起来的一个字符称为char
型字面量, 双括号括起来的零个或者多个字符则构成字符串型字面量. 字符串字面量的类型实际上是由常量字符构成的数组. 编译器会在每个字符串的结尾处添加一个空字符('\0'), 因此, 字符串字面量的实际长度要比它的内容多1. 例如, 字面量'A'表示的就是单独的字符A, 而字符串"A"则代表了一个字符的数组, 该数组包含两个字符: 一个是字母A, 另一个是空字符.
如果两个字符串字面量位置紧邻且仅由空格, 缩进和换行符分隔, 则它们实际上是一个整体. 当书写的字符串字面量比较长, 写在一行里面不太合适的时候, 就可以采取分开书写的方式.
例子
std::cout << "a really, really long string literal "
"that spans two lines" << std::endl;
转义序列
有两类字符程序员不能直接使用: 一类是不可打印的字符, 如退格或者其他控制字符, 因为它们没有可视的图符, 另一类是在C++中由特殊含义的字符(单引号, 双引号, 问号, 反斜线). 在这些情况下需要用到转义序列(escape sequence), 转义序列均以反斜线作为开始, C++规定的转义序列包括:
- 换行符:
\n
- 纵向制表符:
\v
- 反斜线:
\\
- 回车符:
\r
- 横向制表符:
\t
- 退格符:
\b
- 问号:
\?
- 进纸符:
\f
- 报警(响铃)符:
\a
- 双引号:
\"
- 单引号:
\'
在程序中, 上述的转移字符会被当做一个字符使用. 我们也可以使用泛化的转义序列, 其形式是\x
后面紧跟1个或者多个十六进制数字, 或者\
后面紧跟1个, 2个或者3个八进制数字, 其中数字部分表示的是字符对应的数值. 假设使用的是Latin-1字符集, 一些示例: \7
(响铃), \115
(字符M), \x4d
(字符M), \0
空格符. 注意, 如果反斜杠\
后面跟着的八进制数字超过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 or U | unsigned |
l or L | long |
ll or LL | long long |
对于一个整型字面量来说, 我们能够分别指定它是否带有符号以及占用多少空间. 如果后缀中有U
, 则该字面量属于无符号类型, 也就是说, 以U
为后缀的十进制数, 八进制数或者十六进制数都将从unsigned int
, unsigned long
和unsigned long long
中选择能匹配的空间最小的一个作为其数据类型. 如果后缀中有L
, 则字面量的类型至少是long
. 如果后缀中有ll
, 则字面量的类型将会是long long
和unsigned long long
中的一种. 可以将U
和L
或者LL
混在一起使用. 例如, 以UL
为后缀的字面量的数据类型将根据数值情况或者取unsigned long
, 或者取unsigned long long
.
浮点型字面量
后缀 | 类型 |
---|---|
f or F | float |
l or L | long double |
布尔字面量和指针字面量
true
和false
是布尔类型的字面量. nullptr
是指针字面量. 后面会有更多的详细介绍.
变量
变量提供一个具名的, 可供程序操作的存储空间. C++中的每个变量都有其数据类型, 数据类型决定着变量所占内存空间的大小和布局方式, 该空间能存储的值的范围, 以及变量能够参与的运算. 对于C++程序员来说, "变量"和"对象"一般可以互换使用.
变量定义
变量定义的基本形式是: 首先是类型说明符, 随后紧跟由一个或者多个变量名组成的列表, 其中变量名以逗号分割, 最后以分号结束. 列表中的每个变量名的类型都由类型说明符指定, 定义的时候还可以为一个或者多个变量赋初始值.
例子
int sum = 0, value,
units_sold = 0;
Sales_item item; // item的类型是Sales_item
std::string book("0-201-x"); // string是一种库类型, 表示一个可变长的字符序列
提示
何为对象?
C++程序员在很多场合都会使用对象这个词, 通常情况下, 对象是指一块能够存储数据并具有某种类型的内存空间. 一些人仅在与类有关的场景下才使用"对象"这个词, 另一些人则把已命名的对象和未命名的对象区分开来, 它们把已命名的对象叫做变量, 还有一些人把对象和值区分开来, 其中对象指能够被程序修改的数据, 而值指只读的数据. 本项目遵循大多人的习惯用法, 即认为对象是具有某种数据类型的内存空间. 我们在使用对象这个词的时候, 并不严格区分是类还是内置类型, 也不区分是否命名或是否只读.
初始值
当对象在创建的时候获得了一个特定的值, 我们说这个对象被初始化了. 当一次定义两个或者多个变量的时候, 对象的名字会随变量变得立即可用. 因此在同一条定义语句中, 可以用先定义的变量去初始化后定义的其他变量.
例子
double price = 109.99, discount = price * 0.16;
double salePrice = applyDiscount(price, discount);
列表初始化
在实际的编程中, 对于不同的变量, 初始化的手段多种多样, 这些不同的初始化方法都有各自的适用范围, C++11中为了统一初始化的方式, 引入了列表初始化的概念, 它使用花括号来初始化变量和对象, 可以用在几乎所有的数据类型上, 包括基本数据类型, 类对象, 数组, 结构体等.
例子
int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);
其中, 第二种和第三种是列表初始化.
使用列表初始化还能防范数据丢失的风险. 对于内置类型的变量, 如果我们使用列表初始化且初始值存在丢失信息的风险, 则编译器将报错.
例子
long double ld = 3.14159;
int a{ld}, b = {ld}; // 错误, 因为存在丢失信息的风险
int c(ld), d = ld; // 正确, 且确实丢失了部分值
使用long double
的值初始化int
变量的时候可能丢失数据, 所以编译器拒绝了第二行中a
和b
的初始化请求. 其中, 至少ld
的小数部分会丢失掉, 而且int
可能存不下ld
的整数部分.
默认初始化
如果定义变量的时候没有指定初始值, 那么变量会被默认初始化. 默认值由变量类型决定, 同时定义变量的位置也会对此有影响.
全局便来你给或者在函数体之外的变量如果是内置类型且未被初始化, 则会被默认初始化为0. 局部变量如果是内置类型且未被初始化, 则其值是自定义的. 未定义的值意味着使用它们会导致不可预测的行为, 导致错误.
每个类可以自定义其对象的初始化方式. 对于类对象的默认初始化行为, 取决于类的设计. 有些类定义了默认构造函数, 因此可以生成合理的默认值, 例如, std::string
类型在默认初始化的时候会生成一个空字符串; 也有一些不允许对象没有显式初始化就被定义, 这种情况下, 必须为对象提供初始值, 否则编译器会报错.
警告
未初始化的变量含有一个不确定的值, 使用未初始化变量的值是一种错误的编程行为并且很难调试, 尽管大多数编辑器都能对一部分使用未初始化变量的行为提出警告. 但是严格来说, 编译器并未要求检查此类错误.
使用未初始化的变量将带来无法预计的后果. 有时候我们足够幸运, 一访问此类对象就奔溃报错. 此时只需要找到奔溃的位置就很容易发现我们对变量没有初始化的问题. 另外一些时候, 程序会一直执行完并产生错误的结果. 更糟糕的情况是, 程序结果时对时错, 无法把我. 而且, 往无关的位置添加代码还会导致我们误以为程序对了, 其实结果仍旧有错.
所以, 建议初始化每一个内置类型的变量. 虽然并非必须这么做, 但是如果我们不能确保初始化后程序的安全, 那么这么做不失为一种简单可靠的方法.
变量声明和定义的关系
为了允许把程序拆分成多个逻辑部分来编写, C++支持分离式编译机制, 该机制允许将程序分割成若干个文件, 每个文件都可以独立编译.
如果将程序分为多个文件, 则需要有在文件之间共享代码的方法. 例如, 一个文件的代码可能需要使用另一个文件中定义的变量. 一个实际的例子是std::cout
和std::cin
, 它们用于定义标准库, 却能被我们写的程序使用.
为了支持分离式编译, C++将声明和定义区分开来. 声明使得名字为程序所知, 一个文件如果想使用别处定义的名字则必须包含对那个名字的声明, 而定义负责创建与名字相关联的实体.
变量声明规定了变量的类型和名字, 在这一点上定义与之相同. 但是除此之外, 定义还申请存储空间, 也可能会为变量赋一个初始值.
就例如头文件和库文件的关系. 头文件相当于告诉编译器有哪些章节(声明)和它们的概述(类型, 参数等). 而库文件则提供了每个章节的实际内容(定义和实现), 保证在链接阶段, 符号可以被正确替换为库文件中的实际内容.
如果想声明一个变量而不是定义它, 可以在变量前面加上关键字extern
, 而且不要显式初始化变量.
例子
extern int i; // 声明i而非定义i
int j; // 声明并定义j
任何包含了显式初始化的声明即成定义. 我们能给由extern
关键字标记的变量赋一个初始值, 但是这么做抵消了extern
的作用. extern
语句如果包含初始值就不再是声明, 而变成定义了.
例子
extern double pi = 3.1416; // 定义
而在函数题内部, 如果试图初始化一个由extern
关键字标记的变量, 将引发错误.
提示
变量能且只能被定义一次, 但是可以被多次声明. 如果要在多个文件中使用同一个变量, 就必须将声明和定义分离. 此时, 变量的定义必须出现且只能出现在一个文件中, 而其他用到该变量的文件必须对其进行声明, 却绝不能重复定义.
标识符
C++的标识符由字母, 数字和下划线组成, 其中, 必须以字母或者下划线开头. 标识符的长度没有限制, 但是对大小写敏感.
C++的保留字不能被用作标识符. 同时, C++也为标准库保留了一些名字, 用户自定义的标识符中不能连续出现两个下划线, 也不能以下划线紧连大写字母开头. 此外, 定义在函数体以外的标识符不能以下划线开头.
变量命名规范
变量命名有很多约定俗成的规范:
- 标识符要能体现实际含义
- 变量名一般用小写字母, 如
index
, 不要用Index
和INDEX
- 用户自定义的类名一般以大写字母开头, 如
Sales_item
- 如果标识符由多个单词组成, 则单词之间应用很明显的区分, 如
student_loan
或者studentLoan
, 不要使用studentloan
C++关键字请见: https://learn.microsoft.com/zh-cn/cpp/cpp/keywords-cpp?view=msvc-170
名字的作用域
不论式在程序的什么位置, 使用到的每个名字都会指向一个特定的实体: 变量, 函数, 类型等. 然而, 同一个名字如果出现在程序的不同位置, 也可能指向的式不同实体.
作用域式程序的一部分, 在其中名字有其特定的含义. C++中大多数作用域都以花括号分隔. 同一个名字在不同的作用域中可能指向不同的实体. 名字的有效区域始于名字的声明语句, 以声明语句所在的作用域末端为结束.
例子
#include <iostream>
int main() {
int sum = 0;
for (int val = 1; val <= 10; ++val) {
sum += val;
}
std::cout << "Sum of 1 to 10 inclusive is " << sum << std::endl;
return 0;
}
这段程序定义了3个名字: main
, sum
和val
, 同时使用了命名空间的名字std
, 该空间提供了2个名字cout
和cin
供程序使用.
名字main
定义于所有的花括号之外, 它和其他大多数定义在函数体外的名字一样具有全局作用域. 一旦声明后, 全局作用域内的名字在整个程序的范围内都可以使用. 名字sum
定义于main
函数所限定的作用域之内, 从声明sum
开始直到main
函数结束为止都可以访问它, 但是出了main
函数所在的块就无法访问了, 因此说变量sum
拥有块作用域. 名字val
定义于for
语句内, 在for
语句之内可以访问val
, 但是在main
函数的其他部分就不能再访问它了.
提示
一般来说, 在对象第一次被使用的地方附近定义它式一种很好的选择, 因为这样做有助于更容易找到变量的定义. 更重要的式, 当变量的定义和它第一次被使用的地方很近的时候, 我们也会赋给它一个比较合理的初始值.
嵌套作用域
作用域能够彼此包含, 被包含(或者说被嵌套)的作用域被称为内层作用域, 包含着别的作用域的作用域称为外层作用域.
作用域中一旦声明了某个名字, 它所嵌套着的所有作用域中都能访问该名字. 同时, 允许在内层作用域中重新定义外城作用域的已有名字.
例子
#include <iostream>
int reused = 42;
int main()
{
int unique = 0;
std::cout << reused << " " << unique << std::endl; // 输出42 0
int reused = 0;
std::cout << reused << " " << unique << std::endl; // 输出0 0
std::cout << ::reused << " " << unique << std::endl; // 输出42 0
return 0;
}
第一个输出出现在局部变量reused
定义之前, 因此这条语句使用全局作用域中定义的名字reused
, 输出42 0
. 第二个输出发生在局部变量reused
定义之后, 此时局部变量reused
正在作用域内, 因此第二条输出语句使用的是局部变量reused
而非全局变量, 输出0 0
. 第三个输出使用作用域操作符来覆盖默认的作用域规则, 因为全局作用域本身没有名字, 所以当作用域操作符的左侧为空的时候, 向全局作用域发出请求获取作用域操作符右侧名字对应的变量. 结果是, 第三条输出语句使用全局变量reused
, 输出42 0
.
复合类型
复合类型是指基于其他类型定义的类型. C++有几种复合类型, 本节中会将两种: 引用和指针.
与我们已经掌握的变量声明相比, 定义复合类型的变量要复杂很多. 一条简单的声明语句由一个数据类型和一个紧随其后的变量名列表组成. 更加通用的描述是, 一条声明语句由一个基本数据类型和一个紧随其后的声明符列表组成, 每个声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型.
到目前为止, 我们接触的声明语句都只是基本数据类型的变量. 后面还会遇到包含复合类型的声明, 它们可以基于基本数据类型扩展出更加复杂的类型, 然后用来定义变量.
引用
引用为对象起了另外一个名字, 引用类型引用另外一种类型, 通过将声明符写成&d
的形式来定义引用类型, 其中d
是声明的变量名.
例子
int ival = 1024;
int &refVal = ival; // refVal指向ival
int &refVal2; // 报错, 必须初始化
一般在初始化对象的时候, 初始值会被拷贝到新建的对象的内存空间中. 然而定义引用的时候, 程序不会拷贝初始值, 而是直接绑定到这个对象本身. 换句话说, 引用只是另一个名称, 用来访问这个对象, 此时如果修改这个对象的值, 则引用和对象的原始名称都会反映出这个改变, 因为它们指向同一个内存地址.
引用即别名
定义了一个引用后, 对其进行的所有操作都是在与之绑定的对象上进行的.
例子
int ival = 1024;
int &reVal = ival;
refVal = 2; // 把2赋值给refVal指向的对象, 在这里是赋值给ival
int ii = refVal; // 与ii=ival的执行效果是一样的
可以看出, 为引用赋值, 其实是赋值给了引用绑定的对象. 获取引用的值, 实际上是获取了与引用绑定的对象的值. 同理, 以引用为初始值, 实际上是以与绑定的对象作为初始值.
例子
int &refVal3 = refVal; // refVal3绑定到了那个和refVal绑定的对象上, 这里就是绑定到ival上
int i = refVal; // i被初始化为ival的值
因为引用本身不是一个对象, 所以不能定义引用的引用.
引用的定义
允许在一条语句中定义多个引用, 其中每个引用标识符都必须以符号&
开头.
例子
int i = 1024, i2 = 2048;
int &r = i, r2 = i2; // r是一个引用, 与i绑定在一起, r2是int
int i3 = 1024, &ri = 13; //
int &r3 = i3, &r4 = i2;
几乎所有引用的类型都要和与之绑定的对象严格匹配. 而且, 引用只能绑定在对象上, 而不能与字面量或者某个表达式的计算结果绑定在一起.
例子
int &refVal4 = 10; // 错误: 引用类型的初始值必须是一个对象, 而不能是字面量
double dval = 3.14;
int &refVal5 = dval; // 错误: 此处引用类型的初始值必须是一个int型变量
指针
指针是指向另外一种类型的复合类型. 与引用类似, 指针也实现了对其他对象的间接访问. 然而指针与引用相比又有许多的不同点, 其一, 指针本身就是一个对象, 允许对指针的赋值和拷贝, 而且在其生命周期内它可以先后指向几个不同的对象. 其二, 指针无须在定义的时候赋值. 和其他的内置类型一样, 在块作用域内定义的指针如果没有被初始化, 也将拥有一个不确定的值.
定义指针类型的方法将被声明符号写成*d
的形式, 其中d
是变量名. 如果在一条语句中定义了几个指针变量, 每个变量前面都必须要有符号*
.
例子
int *ip1, *ip2; // ip1和ip2都是指向int类型对象的指针
double dp, *dp2; // dp2是指向double型对象的指针, dp是double型对象
获取对象的地址
指针存放某个对象的地址, 要想获取该地址, 需要使用取地址符(操作符&
):
例子
int ival = 42;
int *p = &ival;
第二条语句把p
定义为一个指向int
的指针, 随后初始化p
令其指向名为ival
的int
对象, 因为引用不是对象, 没有实际地址, 所以不能定义指向引用的指针.
指针值
指针的值(即地址)应该属于下列4种状态之一:
- 指向一个对象
- 指向紧邻对象所占空间的下一个位置
- 空指针, 意味着指针没有指向任何对象
- 无效指针, 也就是上述情况之外的其他值
试图拷贝或者以其他方式访问无效指针的值都将引发错误. 编译器步负责检查此类错误, 这一点和试图使用未经初始化的变量是一样的. 访问无效指针的后果无法预计, 因此程序员必须清楚任意给定的指针是否有效.
尽管第2种和第3种形式的指针是有效的, 但是其使用同样受到限制. 显然这些指针没有指向任何具体对象, 所以试图访问此类指针(假定的)对象的行为不被允许. 如果这样做了, 后果无法预计.
利用指针访问对象
如果指针指向了一个对象, 则允许使用解引用符(操作符*
)来访问该对象.
例子
int ival = 42;
int *p = &ival; // p存放着变量ival的地址, 或者说p是指向变量ival的指针
count << *p; // 由符号*得到指针p所指的对象, 输出42
对指针解引用会得到所指的对象, 因此如果给解引用的结果赋值, 实际上也就是给指针所指的对象赋值.
例子
*p = 0; // 由符号*可以得到指针p所指向的对象, 即可经由p为变量ival赋值
cout << *p; // 输出0
警告
解引用操作仅适用于那些确实指向了某个对象的有效指针.
空指针
空指针不指向任何对象, 在试图使用一个指针之前代码可以首先检查它是否为空, 以下列出几个生成空指针的方法.
例子
int *p1 = nullptr;
int *p2 = 0; // 直接将p2初始化为字面量0
int *p3 = NULL; // 等价于int *p3 = 0, 首先需要#include cstdlib
得到空指针最直接的方法就是使用字面量nullptr
来初始化指针, 这是C++11新标准刚引入的方法. nullptr
是一种特殊类型的字面量, 它可以被转换为任意其他的指针类型. 第二种方法就是使用字面量0
来初始化指针. 第三种方法用到一个名为NULL
的预处理变量, 这个变量在头文件cstdlib
中定义, 它的值就是0
.
赋值和指针
指针和引用都能提供对其他对象的间接访问, 然而在具体实现细节上二者也很大不同, 其中最重要的一点就是引用本身并非一个对象. 一旦定义了引用, 就无法令其再绑定到其他的对象, 之后每次使用这个引用都是访问它最初绑定的那个对象.
指针和它存放的地址之间就没有这种限制了. 和其他任何变量(只要不是引用)一样, 给指针赋值即使令它存放一个新的地址, 从而指向一个新的对象.
例子
int i = 42;
int *pi = 0; // pi被初始化, 但是没有指向任何对象
int *pi2 = &i; // pi2被初始化, 存放i的地址
int *pi3; // 如果pi3定义于块内, 则pi3的值是无法确定的
pi3 = pi2; // pi3和pi2指向同一个对象i
pi2 = 0; // 现在pi2不指向任何对象了
其他指针操作
只要指针拥有一个合法值, 就能将它用在条件表达式中. 和采用算术值作为条件遵循的规则类似, 如果指针的值是0
, 条件取false
.
例子
int ival = 1024;
int *pi = 0; // pi合法, 是一个空指针
int *pi2 = &ival; // pi2是一个合法的指针, 存放着ival的地址
if (pi) // pi的值是0, 因此条件的值是false
// ...
if (pi2) // pi2指向ival, 因此它的值不是0, 条件的值是true
// ...
对于两个类型相同的合法指针, 可以使用相等操作符==
或者不相等操作符!=
来比较它们, 比较的结果是布尔类型. 如果两个指针存放的地址值相同, 则它们相等; 反之它们不相等. 如果两个指针存放的地址值相同(两个指针相同)有三种可能: 它们都为空, 都指向同一个对象, 或者都指向了同一个对象的下一个地址. 需要注意的是, 一个指针指向某对象, 同时另一个指针指向另外对象的下一个地址, 此时也有可能出现两个指针值相同的情况, 即指针相等.
因为上述操作要用到指针的值, 所以不论是作为条件还是参与比较运算, 都必须使用合法的指针, 使用非法的指针作为条件或进行比较都会引发不可预料的后果.
void*指针
void*
是一种特殊的指针类型, 可用于存放任意对象的地址. 一个void*
指针存放着一个地址, 这一点和其他指针类似. 不同的是, 我们对该地址中到底是一个什么类型的对象并不了解.
例子
double obj = 3.14; *pd = &obj;
void *pv = &obj; // void*能够存放任意类型对象的地址, obj可以是任意类型的对象
pv = pd; // pv可以存放任意类型的指针
利用void*
指针能做的事情很有限: 拿它和别的指针比较, 作为函数的输入或者输出, 或者赋值给另一个void*
指针, 不能直接操作void*
指针所指向的对象, 因为我们并不知道这个对象到底是什么类型, 也就无法确定能在这个对象上做哪些操作.
概括, 以void*
的视角来看内存空间就仅仅只是内存空间, 没办法访问内存空间中所存的对象.
理解复合类型的声明
如前面所述, 变量的定义包括一个基本数据类型和一组声明符. 在同一条定义语句中, 虽然基本数据类型只有一个, 但是声明符的形式却可以不同. 也就是说, 一条定义语句可能定义不同类型的变量.
例子
// i是一个int型的数, p是一个int型指针, r是一个int型引用
int i = 1024; *p = &i, &r = i;
定义多个变量
经常会有一种观点误认为, 在定义语句中, 类型修饰符(*
或&
)作用域本次定义的全部变量, 造成这种错误看法的原因有很多, 其中之一是我们可以把空格写在类型修饰符和变量名中间, 如int* p;
我们说这种写法可能产生误导是因为int*
放在一起好像是这条语句中所有变量共同的类型一样. 其实恰恰相反, 基本数据类型是int
而非是int*
, *
仅仅只是修饰了p
而已, 对该声明语句中的其他变量, 它并不产生任何作用.
例子
int* p1, p2; // p1是指向int的指针, p2是int
所以, 涉及到指针或者引用的声明, 一般有两种写法, 第一种是把修饰符和变量标识符写在一起.
例子
int *p1, *p2;
这种形式强调了变量具有的复合类型. 第二种把修饰符和类型名写在一起, 并且每条语句只定义一个变量.
例子
int* p1;
int* p2;
这种形式着重强调本次声明定义了一种复合类型.
指向指针的指针
一般来说, 声明符中修饰符的个数并没有限制. 当有多个修饰符连在一起的时候, 按照其逻辑关系详加解释即可. 以指针为例, 指针是内存中的对象, 像其他对象一样也有自己的地址, 因此允许把指针的地址再存放到另一个指针当中.
通过*
的个数可以区分指针的级别. 也就是说, **
表示指向指针的指针, ***
表示指向指针的指针的指针, 以此类推.
例子
int ival = 1024;
int *pi = &ival; // pi指向一个int型的数
int **ppi = π // ppi指向一个int类型的指针
此处pi
是指向int
的指针, 而ppi
是指向int
型指针的指针.
解引用int
型指针会得到一个int
型的数, 同样, 解引用指向指针的指针会得到一个指针. 此时为了访问到原始的那个对象, 需要对指针的指针做两次解引用.
例子
cout << "The value of ival\n"
<< "direct value: " << ival << "\n"
<< "indirect value: " << *pi << "\n"
<< "doubly indirect value: " << **ppi
<< endl;
该程序通过三种不同的方式输出了变量ival
的值, 第一种直接输出, 第二种通过int
型指针pi
输出, 第三种通过两次解引用ppi
.
指向指针的引用
引用本身不是一个对象, 因此不能定义指向引用的指针. 但是指针是对象, 所以存在对指针的应用.
例子
int 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
对变量的类型加以限定. 由于const
对象一旦创建之后它的值就不能再改变, 所以const
对象必须初始化.
例子
const int i = get_size(); // 正确: 运行时初始化
const int j = 42; // 正确: 编译时候初始化
const int k; // 错误: k是一个未经初始化的常量
初始化和const
与非const
类型所能参与的操作相比, const
类型的对象能够完整其中大部分, 但也不是所有的操作都适合. 主要的限制就是只能在const
类型的对象上执行不改变其内容的操作. 例如, const int
和普通的int
一样都能参与算术运算, 也都能转换成一个布尔值, 等等.
默认状态下, const对象仅在文件内有效
当以编译时初始化的方式定义一个const
对象时, 就如对bufSize
的定义一样: const int bufSize = 512;
. 编译器在编译的过程中把用到该变量的地方都替换成对应的值. 为了执行这个替换, 编译器必须知道变量的初始值. 如果程序包含多个文件, 则每个用了const
对象的文件都必须得能访问到它的初始值才行. 要做到这一点, 就必须在每个用到变量的文件中都有对它的定义. 为了支持这一用法, 同时避免对同一变量的重复定义, 默认情况下, const
对象被设定为仅在文件内有效, 当多个文件中出现了同名的const
变量的时候, 其实等同于在不同文件中分别定义了独立的变量.
例子
情况1:
const
变量未使用extern
声明constant.h:
cppconst int constVariable = 100;
file1.cpp:
cpp#include "constants.h" void func1() { // 使用constVariable }
file2.cpp:
cpp#include "constants.h" void func2() { // 使用constVariable }
在上述情况下, file1.cpp和file2.cpp中的
constVariable
是不同的实体, 它们各自拥有各自的副本. 一般头文件中不能初始化变量, 但是const
在这里是一个例外.情况2: 非
const
变量未使用extern
声明globals.h:
cppint globalVariable = 42;
file1.cpp:
cpp#include "globals.h" void func1() { // 使用globalVariable }
file2.cpp:
cpp#include "globals.h" void func2() { // 使用globalVariable }
这会导致链接器错误, 因为
globalVariable
在多个翻译单元中被多次定义.
如果希望const
只定义一次, 就能在多个文件中访问, 在这一点上, 非const
变量和const
变量是一致的. 都是在头文件中加入extern
, 然后在某个文件中初始化. 不同的是, 若没有加extern
, 非const
变量在头文件中初始化可能会导致重复定义错误. const
变量在头文件中初始化是安全的, 因为在每一个引用该变量的文件中都会生成一个独立的实体, 例子如上.
const的引用
可以把引用绑定到const
对象上, 就像绑定到其他对象上一样, 我们称之为对常量的引用. 与普通引用不同的是, 对常量的引用不能被用作修改它绑定的对象.
例子
const int ci = 1024;
const int &r1 = ci; // 正确: 引用及其引用的对象都是常量
r1 = 42; // 错误: r1是对常量的引用, 不能修改常量的值
int &r2 = ci; // 错误: 试图让一个非常量引用指向一个常量对象
由于ci
是常量, 所以不能通过引用去间接改变ci
的值. 因此, 对于r2
的初始化是错误的. 如果该初始化是合法的话, 可以通过r2
来改变它引用对象的值, 显然是不正确的.
警告
const int &r1
中的const
表示的是对常量对象的引用, 而不是引用本身不可变. 引用的对象是不可变的, 通过r1
这个引用, 无法修改ci
的值. 而在C++中, 所有的引用(无论是否有const
)一旦绑定到某个对象, 就无法重新绑定到另一个对象, 这是引用的固有属性, 与const
修饰无关.
初始化和对const的引用
前面说过, 引用的类型必须和其所引用的对象一致. 但是有两个例外. 第一种例外的情况就是在初始化常量引用的时候允许任意表达式作为初始值, 只要该表达式的结果能够转换为引用的类型即可. 尤其, 允许为一个常量引用绑定非常量的对象, 字面量甚至是个一般表达式.
例子
int i = 42;
const int &r1 = i; // 允许将const int&绑定到一个普通的int对象上
const int &r2 = 42; // 正确: r1是一个常量引用
const int &r3 = r1 * 2; // 正确: r3是一个常量引用
int &r4 = r1 * 2; // 错误: r4是一个普通的非常量引用
当一个常量引用被绑定到另一种类型上的时候到底发生了什么呢.
例子
double dval = 3.14;
const int &ri = dval;
此处ri
引用了一个int
型的数. 对ri
的操作应该是整数运算, 但是dval
确实一个双精度浮点数而非整数. 因此为了确保让ri
绑定一个整数, 编译器把上述代码变成了如下形式:
const int temp = dval; // 由双精度浮点数生成一个临时的整型变量
const int &ri = temp; // 让ri绑定这个临时量
在这种情况下, ri
绑定了一个临时量对象. 所谓临时量对象就是当编译器需要一个空间来暂存表达式的求值结果的时候临时创建的一个未命名对象, 简称为临时量.
double dval = 3.14;
const int &ri = dval;
cout << ri << endl; // 输出3
dval = 4.24;
cout << ri << endl; // 输出还是3, 说明它绑定的是那个临时量而不是dval
const
引用可以引用一个并非const
的对象
必须认识到, 常量引用仅对引用可以参与的操作做出了限定, 对于引用的对象本身是不是一个常量并没有做限定. 因此对象也可能是一个非常量.
例子
int i = 42;
int &r1 = i; // 引用ri绑定对象i
const int &r2 = i; // r2也绑定到对象i, 但是不允许通过r2修改i的值
r1 = 0; // 正确: i的值被修改为0
r2 = 0; // 错误: r2是一个常量引用
指针和const
与引用一样, 也可以令指针指向常量或者非常量. 类似于常量引用, 指向常量的指针不能用于改变其所指对象的值. 要想存放常量对象的地址, 只能使用指向常量的指针.
例子
const double pi = 3.14; // pi是一个常量, 它的值不能改变
double *ptr = π // 错误: ptr是一个普通指针
const double *cptr = π // 正确: cptr可以指向一个双精度常量
*cptr = 42; // 错误: 不能给*cptr赋值
与引用类似, 前面已经提到, 指针的类型必须与其所指的对象的类型一致, 但是有两个例外. 第一种例外的情况是允许令一个指向常量的指针指向一个非常量对象.
例子
double dval = 3.14; // dval是一个双精度浮点数, 它的值可以改变
const double *cptr = &dval; // 正确: 但是不能通过cptr改变dval的值