第二章 变量和基本类型
阅读提示
- 本文中的代码分为三类:
- OK 可编译:可直接作为单文件(或放入 main 函数体)编译通过。
- 片段:只展示语法要点,非完整程序;如需编译,请放入函数体或补齐所需声明/头文件。
- NG 错误示例:为阐释语义而故意写出的编译错误,请勿整块复制编译。
引用
引用(reference)为对象起了另外一个名字。定义引用时必须用一个对象初始化;一旦初始化完成,引用就和该对象绑定在一起,之后对引用做的所有操作,实际都落在被绑定的对象上。
片段
int val = 1024;
int &refVal = val; // refVal 是 val 的别名,二者是同一个对象
refVal = 2; // 等同于 val = 2,此后 val == 2
const int &refVal5 = val + 1; // OK:常量引用可以绑定到右值。val + 1 的结果存入一个临时量,refVal5 绑定的是这个临时量(值为 3)
double dval = 3.14;
const int &refVal7 = dval; // OK:dval 先被转换成一个临时 int(值为 3),refVal7 绑定的是这个临时量,不是 dval 本身
dval = 5.14; // 修改 dval 不影响 refVal7,它仍然是 3
NG 错误示例(不可编译,演示语义):
// NG 错误示例(不可编译)
int val = 1024;
double dval = 3.14;
int &refVal2 = 10; // 错:引用的初始值必须是对象(左值)
int &refVal3; // 错:引用必须初始化
int &refVal4 = val + 1; // 错:非常量左值引用不能绑定到右值(val + 1 的结果是个临时量)
int &refVal6 = dval; // 错:非常量左值引用不能绑定到临时量(由 double 转 int 产生)
这里的「左值」「右值」「临时量」是什么意思,见文末附:什么是左值和右值。
指针
-
指针的值应该属于下列4种状态之一:
- 指向一个对象
- 指向紧邻对象所占空间的下一个位置
- 空指针,意味着指针没有指向任何对象
- 无效指针,也就是上述情况之外的其他值
-
注意:对 one-past-the-end 指针(指向“尾后”位置)只能做比较或指针算术(如减法),不可解引用。
-
给指针赋值,就是令它存放一个对象的地址,或者令它为空。
-
通过
*的个数可以区分指针的级别。也就是说,**表示指向指针的指针,***表示指向指针的指针的指针。
引用和指针的区别
两者都能间接访问另一个对象,但本质不同:引用只是既有对象的别名,本身不是对象;指针本身是一个对象。
| 引用 | 指针 | |
|---|---|---|
| 本身是否为对象 | 不是。只是别名,本身没有地址 | 是。指针本身占内存,存放的是所指对象的地址 |
| 定义时是否必须初始化 | 必须 | 不强制(但未初始化的指针是无效指针,应尽量初始化) |
| 之后能否更换目标 | 不能,一旦绑定终生不变 | 可以,随时能改指别的对象 |
| 能否不指向任何对象 | 不能 | 可以(空指针 nullptr) |
| 访问目标对象的方式 | 直接用引用名 | 需要解引用 *p |
最容易混淆的是「赋值」对两者的含义完全不同:
片段
int a = 1, b = 2;
int &r = a; // r 绑定到 a
r = b; // 注意:这不是让 r 改绑到 b,而是把 b 的值赋给 a。执行后 a == 2
r = 10; // 等同于 a = 10
int *p = &a; // p 指向 a
p = &b; // 这才是「换目标」:p 改指 b,a 和 b 的值都不变
*p = 10; // 通过解引用修改所指对象,等同于 b = 10
也就是说:对引用名做的任何操作都落在被绑定的对象上;而对指针名赋值(p = …)改的是指针里存的地址,只有解引用(*p = …)才会碰到所指的对象。文末「指向指针的引用」一节的两段对比代码展示的正是这一差异。
*与&运算符
片段
int i = 42;
int &r = i; // &紧随类型名出现,是声明的一部分,r是一个引用。
int *p; // *紧随类型名出现,是声明的一部分,p是一个指针。
p = &i; // &出现在表达式中,是取地址运算符。
*p = i; // *出现在表达式中,是解引用运算符,用于访问/修改p所指向的对象;这里将i的值写入p所指向的对象。
int &r2 = *p; // &是声明的一部分,r2是一个引用。*是解引用运算符,r2被绑定到p所指向的对象i上。
怎么读懂复杂的指针声明
面对复杂的指针或引用声明时,可以使用"从右向左阅读"的方法来理解变量的类型。
基本规则
核心原则:从变量名开始,按照"离变量名最近的符号优先"的原则来理解类型。
阅读顺序:
- 找到变量名
- 看变量名右边的符号(
[]表示数组,()表示函数) - 看变量名左边的符号(
*表示指针,&表示引用) - 遇到括号就先读完括号内的部分,出了括号后回到第 2 步,继续"先右后左"交替读,直到最左边的基本类型
示例1:指向指针的引用
int *p = nullptr; // 已有一个指向 int 的指针变量
int *&r = p; // r 是“指向 int 指针”的引用,必须绑定到一个现存的指针对象
分析步骤:
- 找到变量名:
r - 变量名右边没有符号
- 变量名左边最近的符号是
&,所以r是一个引用 - 继续向左看到
*,说明r引用的是一个指针 - 最左边是基本类型
int,说明这个指针指向的是int类型
结论:r 是一个指向 int 指针的引用(reference to a pointer to int)
示例2:指向指针的指针
int **p;
分析步骤:
- 找到变量名:
p - 变量名左边第一个
*,所以p是一个指针 - 继续向左第二个
*,说明p指向的也是一个指针 - 基本类型是
int
结论:p 是一个指向 int 指针的指针(pointer to a pointer to int)
示例3:指针数组
int *arr[10];
分析步骤:
- 找到变量名:
arr - 变量名右边是
[10],说明arr是一个数组,有10个元素 - 向左看到
*,说明数组的每个元素是指针 - 基本类型是
int
结论:arr 是一个包含10个 int 指针的数组(array of 10 pointers to int)
示例4:指向数组的指针
int (*p)[10];
分析步骤:
- 找到变量名:
p p被括号包围,优先处理括号内的内容- 括号内,
p左边是*,所以p是一个指针 - 括号右边是
[10],说明p指向的是一个数组,有10个元素 - 基本类型是
int
结论:p 是一个指向包含10个 int 元素的数组的指针(pointer to an array of 10 ints)
注意:括号 () 改变了运算优先级,[] 和 () 的优先级高于 *
示例5:指向「返回指针的函数」的指针
int *(*func)(double);
分析步骤:
- 找到变量名:
func func被括号包围,优先处理括号内的内容- 括号内,
func左边是*,所以func是一个指针 - 括号外右边是
(double),说明func指向的是一个函数,该函数接受一个double参数 - 最左边是
int *,说明这个函数返回一个指向 int 的指针
结论:func 是一个函数指针,该函数接受 double 参数并返回指向 int 的指针(pointer to a function that takes a double and returns a pointer to int)
快速记忆口诀
从变量名出发,右左右左读,括号改优先,最后看类型
- 右:先看右边,处理
[](数组)或()(函数) - 左:再看左边,处理
*(指针)或&(引用) - 括号:括号改变优先级,优先处理括号内的内容
- 类型:最后得到基本数据类型
指向指针的引用
指针本身是一个对象,所以可以被引用。 下面来做一个指针,和指针的引用的例子。
- 指针的引用
片段
int i = 1024;
int newI = 2048;
// p是一个int指针,指向i
int *p = &i;
// r是一个对int指针的引用,它是p的别名
int *&r = p;
// 修改r就是修改p。这里让p指向newI
r = &newI;
// 因为p已经指向newI,所以解引用p得到的是newI的值
std::cout << *p << std::endl; // 输出 2048
- 单纯的指针
片段
int i = 1024;
int newI = 2048;
// p是一个int指针,指向i
int *p = &i;
// r是另一个int指针,它的初始值是p的值(即i的地址)
int *r = p; // 这里r和上面的例子不一样,它不是引用,而是指针。
// 修改r,让r指向newI。这个操作不影响p
r = &newI;
// p仍然指向i
std::cout << *p << std::endl; // 输出 1024
附:什么是左值和右值
本章的错误示例里出现了「左值」「右值」「临时量」这几个词,这里只建立最基本的直觉,第四章会详细说明。
- 左值(lvalue):有名字、有固定内存位置的表达式,可以对它取地址。它代表「对象本身」。
- 右值(rvalue):临时的、用完就销毁的值,不能取地址。它代表「一个值」。
这两个名字来源于早期 C 语言:「能放在赋值号左边被赋值的就是左值」。这个说法在当年是成立的——那时的 C 还没有 const 关键字(C89 才引入),凡是有名字、有地址的变量都能被赋值,「能被赋值」和「有地址」两个标准恰好重合。后来 const 加入语言,出现了「有名字、有地址、却不能被赋值」的变量,两个标准从此分家,按位置判断就失效了。const 变量是左值(标准称之为「不可修改的左值」),却不能放在赋值号左边:
片段
const int c = 5;
// c = 10; // 错:c 是常量,不能被赋值,即不能放在赋值号左边
const int *pc = &c; // 但 c 可以取地址——所以 c 是左值
可见「是左值」和「能被赋值」是两回事。最稳的判断方法是:能用 & 取地址的是左值,不能的是右值。
片段
int val = 1024;
int *p = &val; // OK:val 是左值,可以取地址
// int *q = &(val + 1); // 错:val + 1 的结果是右值,不能取地址
「非常量左值引用不能绑定到临时量」是什么意思
把这句话拆开:
- 非常量左值引用:不带
const的普通引用,如int &r。 - 临时量:编译器造出来、用完就销毁的无名对象,比如
val + 1的计算结果、double转int时产生的中间值。临时量是右值。
引用的意义在于「通过别名修改原对象」。如果允许 int &refVal4 = val + 1;,那么之后 refVal4 = 100 改的是一个马上就要销毁的临时量,val 纹丝不动——程序悄无声息地什么都没干,几乎必然是 bug。int &refVal6 = dval; 更典型:你以为绑定了 dval,实际绑的是 double 转 int 产生的临时量,之后修改 refVal6 根本碰不到 dval。所以 C++ 干脆把这两种写法都定为编译错误,把隐患扼杀在编译期。
而 const 引用只读不写,不存在「改了却没改到」的问题,所以允许绑定临时量——并且 C++ 还会把临时量的寿命延长到和引用一样长,这就是「引用」一节中 refVal5、refVal7 能安全使用的原因。