C++系列:声明符

这篇文章介绍 C++ 声明符(declarator)。

概述

通常来说,一个声明语句由一个基本数据类型(base type)和紧随其后的一个声明符(declarator)列表组成。每一个声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型。在基本的定义中,声明符其实就是变量名,变量的类型就是声明的基本类型,在下列中,int 表示数据类型,而 sum 是声明符。

1
int sum;

其实还可能有更复杂的声明符,它基于基本数据类型到更复杂的类型,并把它指定给变量,如指针和引用。

引用

引用(reference) 为对象起了另外一个名字,引用类型引用(refer to)另外一种类型。通过将声明符写成 &d 的形式来定义引用类型,其中 d 是声明的变量名。

1
2
3
int ival = 1024;
int &refVal = ival; // refVal 指向 ival(是 ival的另外一个名字)
int &refVal2; // 报错:引用必须被初始化

一般在初始化变量时,初始值会被拷贝到新建的对象中。然而定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。

引用即别名
引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。

定义了一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行。

1
2
refVal = 2;       // 把 2 赋给 refVal 所指向的对象,此处即是赋给了 ival
int ii = refVal; // 与 ii = ival 执行结果一样

为引用赋值,实际上把值赋给了与引用绑定的对象。获取引用的值,实际上获取了与引用绑定的对象的值。同理,以引用作为初始值,实际上是以引用绑定的对象作为初始值:

1
2
3
4
5
// 正确:refVal3 绑定了那个与 refVal 绑定的对象上,这里是绑定到 ival 上
int &refVal3 = refVal
// 利用与 refVal 绑定的对象的值初始化变量 i
int i = refVal; // 正确: i 被初始化 ival 的值

因为引用本身不是一个对象,所以不能定义引用的引用。

引用只能绑定在对象上,而不能与字面值或某一个表达式的计算结果绑定在一起,另外,在正常情况下,要求引用的类型与绑定的对象的类型严格匹配,如下所示:

1
2
3
int &refVal4 = 10;     // 错误:引用类型的初始值必须是是一个对象
double dval = 3.14;
int &refVal5 = dval; // 错误:此处引用类型的初始值必须是 int 型对象

指针

指针(pointer) 是“指向(point to)” 另外一种类型的复合类型。与引用类似,指针也实现了对其它对象的间接访问。然而指针与引用相比又有很多不同点。其一,指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。其二,指针无须在定义是赋初值。和其它内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。

定义指针类型的方法将声明符写成 *d 的形式,其中 d 是变量名。如果在一条语句中定义了几个指针变量,每一个变量前都必须有符号 *:

1
2
int *ip1, *ip2;     // ip1 和 ip2 都是指向 int 型对象的指针
double dp, *dp2; // dp2 是指向 double 型对象的指针,dp 是 double 型对象

获取对象的地址
指针存放某个对象的地址,要想获取该地址,需要使用取地址符(操作符&):

1
2
int ival = 42;
int *p = &ival; // p 存放变量 ival 的地址,或者说 p 是指向变量 ival 的指针

第二条语句把 p 定义为一个指向 int 的指针,随后初始化 p 令其指向名为 ival 的 int 对象。因为引用不是对象,没有实际地址,所以不能定义指向引用的指针。

正常情况下,指针的类型都要和它所指向的对象严格匹配:

1
2
3
4
5
6
double dval;
double *pd = &dval; // 正确:初始值是 double 型对象的地址
double *pd2 = pd; // 正确:初始值是指向 double 对象的指针

int *pi = pd; // 错误:指针 pi 的类型和 pd 的类型不匹配
pi = &dval; // 错误:试图把 double 型对象的地址赋给 int 型指针

因为在声明语句中指针的类型实际上被用于指定它指向对象的类型,所以二者必须匹配。如果指针指向了一个其它类型的对象,对该对象的操作将发生错误。

指针值
指针的值(即地址)就属下列4种情况之一:

  1. 指向一个对象;
  2. 指向紧邻对象所占空间的下一个位置;
  3. 空指针,意味着指针没有指向任何对象;
  4. 无效指针,也就是上述情况之外的其他值。

试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器并不负责检查此为错误,这一点和试图使用未经初始化的变量是一样的。访问无效指针的后果无法预计,因此程序员必须清楚任意给定的指针是否有效。

利用指针访问对象
如果指针指向了一个对象,则允许使用解引用符(操作符*)来访问该对象:

1
2
3
4
5
6
int ival = 42;
int *p = &ival; // p 存放着变量 ival 的地址,或者说 p 是指向变量 ival的指针
cout << *p; // 由符号 * 得到指针 p 指向的对象,输出 42.

*p = 0; // 由符号 * 得到指针指针 p 所指的对象,即可经由 p 为变量 ival 赋值
cout << *p // 输出 0

如上述程序所示,为 *p 赋值实际上是为 p 所指的对象赋值。

关键概念:
像 & 和 * 这样的符号,既能用作表达式里的运算符,也能作为声明的一部分出现,符号的上下文决定了符号的意义:

1
2
3
4
5
6
int i = 42;
int &r = i; // & 紧随类型名出现,因此是声明的一部分,r 是一个引用
int *p; // * 紧随类型名出现,因此是声明的一部分,p 是一个指针
p = &i; // & 出现在表达式中,是一个取地址符
*p = i; // * 出现在表达式中,是一个解引用符
int &r2 = *p; // & 是声明的一部分,* 是一个解引用符

在声明语句中,& 和 * 用于组成复合类型;在表达式中,它们的角色又转变为运算符。

空指针
空指针 (null pointor) 不指向任何对象,在试图使用一个指针之前代码首先检查它是否为空。以下列出几个生成空指针的方法:

1
2
3
4
int *p1 = nullptr;   // 等价于 int *pi = 0
int *p2 = 0; // 直接将 p2 初始化为字面量0
// 需要引入 #include cstdlib
int *p3 = NULL; // 等价于 int *p3 = 0

得到空指针最直接的办法就是用字面值 nullptr 来初始化指针,这也是 C++11 新标准刚刚引入的一种方法。 nullptr 是一种特殊类型的字面值,它可以转换成使用其它的指针类型。另一种办法如对 p2 的定义一样,也可以通过将指针初始化为字面值 0 来生成空指针。

指针判空
只要指针拥有一个合法值,就能将它用在条件表达式。和采用算术值作为条件遵循的规则类型,如果指针的值是 0, 条件取 false, 任何非 0 指针对应的条件值都是 true.

void*指针
void* 是一种特殊的指针类型,可用于存放任意对象的地址。一个 void* 指针存放着一个地址,这一点和其他指针类似。不同的是,我们对该地址中到底是一个什么类型的对象并不了解:

1
2
3
4
double obj = 3.14, *pd = &obj;
// 正确:void* 能存放任意类型对象的地址
void *pv = &obj; // obj 可以是任意类型的对象
pv = pd; // pv 可以存放任意类型的指针

利用 void* 指针能做的事比较有限:拿它和别的指针比较、能为函数的输入或输出,或者赋给另外一个 void* 指针。不能直接操作 void* 指针所指的对象,因为我们并不知道这个对象到底是什么类型,也就无法确定在这个对象上做那些操作。
概括说来,以 void* 的视角来看内存空间也就仅仅是内存空间,没办法访问内存空间所存的对象。

理解复合类型的声明

如前所述,变量的定义包括一个基本数据类型(base type) 和一组声明符。在同一条定义语句中,虽然基本类型只有一个,但是声明符的形式却可以不同。也就是说,一条定义语句可能定义出不同类型的变量:

1
2
// i 是一个 int 型的数, p 是一个 int 型的指针,r 是一个 int 型引用
int i = 1024, *p = &i, &r = i;

定义多个变量
经常有一种误解,在定义语句中,类型修饰符(* 或 &) 作用于本次定义的全部变量,如下所示:

1
int* p1, p2; 

在上面的代码中,误以为 p1, p2 都是 int 类型的指针。其实只有 p1 是 int 类型的指针,而 p2 是 int 类型的变量。 * 仅仅是修饰了 p1 而已,对该声明语句中的其他变量,它并不产生任何作用。
涉及指针或引用的声明,一般有两种写法。第一种把修饰符和变量标识符写在一起:

1
int *p1, *p2;   // p1 和 p2 都是指向 int 的指针

这种形式着重强调变量具有的复合类型。第二种把修饰符和类型名称写在一起,并且每条语句只定义一个变量:

1
2
int* p1;    // p1 指向 int 的指针
int* p2; // p2 指向 int 的指针

这种形式着重强调本次声明定义了一种复合类型。

建议在程序中只使用一种形式,不要混用。

指向指针的指针
指针是内存中的对象,像其他对象一样也有自己的地址,因此允许把指针的地址再存放到另外一个指针当中。
通过 * 的个数可以区分指针的级别。也就是说,** 表示指向指针的指针,*** 表示指向指针的指针的指针,以此类推。

指向指针的引用
引用本身一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对指针的引用。

1
2
3
4
5
6
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 指针。