跳到主要内容

第二章 变量和基本类型

阅读提示

  • 本文中的代码分为三类:
    • 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上。

怎么读懂复杂的指针声明

面对复杂的指针或引用声明时,可以使用"从右向左阅读"的方法来理解变量的类型。

基本规则

核心原则:从变量名开始,按照"离变量名最近的符号优先"的原则来理解类型。

阅读顺序

  1. 找到变量名
  2. 看变量名右边的符号([] 表示数组,() 表示函数)
  3. 看变量名左边的符号(* 表示指针,& 表示引用)
  4. 遇到括号就先读完括号内的部分,出了括号后回到第 2 步,继续"先右后左"交替读,直到最左边的基本类型

示例1:指向指针的引用

int *p = nullptr; // 已有一个指向 int 的指针变量
int *&r = p; // r 是“指向 int 指针”的引用,必须绑定到一个现存的指针对象

分析步骤

  1. 找到变量名:r
  2. 变量名右边没有符号
  3. 变量名左边最近的符号是 &,所以 r 是一个引用
  4. 继续向左看到 *,说明 r 引用的是一个指针
  5. 最左边是基本类型 int,说明这个指针指向的是 int 类型

结论r 是一个指向 int 指针的引用(reference to a pointer to int)

示例2:指向指针的指针

int **p;

分析步骤

  1. 找到变量名:p
  2. 变量名左边第一个 *,所以 p 是一个指针
  3. 继续向左第二个 *,说明 p 指向的也是一个指针
  4. 基本类型是 int

结论p 是一个指向 int 指针的指针(pointer to a pointer to int)

示例3:指针数组

int *arr[10];

分析步骤

  1. 找到变量名:arr
  2. 变量名右边是 [10],说明 arr 是一个数组,有10个元素
  3. 向左看到 *,说明数组的每个元素是指针
  4. 基本类型是 int

结论arr 是一个包含10个 int 指针的数组(array of 10 pointers to int)

示例4:指向数组的指针

int (*p)[10];

分析步骤

  1. 找到变量名:p
  2. p 被括号包围,优先处理括号内的内容
  3. 括号内,p 左边是 *,所以 p 是一个指针
  4. 括号右边是 [10],说明 p 指向的是一个数组,有10个元素
  5. 基本类型是 int

结论p 是一个指向包含10个 int 元素的数组的指针(pointer to an array of 10 ints)

注意:括号 () 改变了运算优先级,[]() 的优先级高于 *

示例5:指向「返回指针的函数」的指针

int *(*func)(double);

分析步骤

  1. 找到变量名:func
  2. func 被括号包围,优先处理括号内的内容
  3. 括号内,func 左边是 *,所以 func 是一个指针
  4. 括号外右边是 (double),说明 func 指向的是一个函数,该函数接受一个 double 参数
  5. 最左边是 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 的计算结果、doubleint 时产生的中间值。临时量是右值。

引用的意义在于「通过别名修改原对象」。如果允许 int &refVal4 = val + 1;,那么之后 refVal4 = 100 改的是一个马上就要销毁的临时量,val 纹丝不动——程序悄无声息地什么都没干,几乎必然是 bug。int &refVal6 = dval; 更典型:你以为绑定了 dval,实际绑的是 doubleint 产生的临时量,之后修改 refVal6 根本碰不到 dval。所以 C++ 干脆把这两种写法都定为编译错误,把隐患扼杀在编译期。

const 引用只读不写,不存在「改了却没改到」的问题,所以允许绑定临时量——并且 C++ 还会把临时量的寿命延长到和引用一样长,这就是「引用」一节中 refVal5refVal7 能安全使用的原因。