上一篇我们讲了指针,这一篇先从 const 讲起。

常量

嗯。const,顾名思义,就是不变。给任何数据类型加上 const,就指明了这个变量不会再变化。任何试图修改变量的操作都会报错,无法通过编译。比如:

1
2
const int a = 10;
a = 11; //Error!

当然,常量也必须在定义时初始化。

常量自己不能变,但这不代表不能使用。它可以被用于初始化其它对象:

1
2
int b = a * 2;
// b = 20

很简单的东西,不是吗?接下来让我们结合一下上一篇的引用和指针。

常量引用

我们可以使用 const 限定修饰一个引用。由于引用本身就不可以更改它绑定的对象,所以这里 const 只是阻止了对绑定对象的修改而已:

1
2
3
4
5
6
7
8
9
const int a = 10;
int b = 10;
const int &c = a;
const int &d = b;
int &e = a; //Error
a = 11;// Error
b = 11;// OK!
c = 11;// Error
d = 11;// Error

看看上面的代码,对 a,c,d 的修改都会产生编译错误。我们一个个分析:

  • a 是常量,但是 e 是个普通引用,非常量不能绑定到常量
  • a 的修改是不可行的,因为它是个常量
  • b 的修改是可行的,因为它是整型变量
  • cd 的修改不可行,因为它们是常量引用,不可修改

也就是说……

**const 限定符应用在引用上时,只是让 C++ 认为引用指向的对象不可以被修改,而实际指向的对象到底是否为常量(是否可以修改)是没有影响的。**

如果已经在对象上施加 const,那么指向它的引用也必须添加,来保持类型一致。可以这么理解:如果引用不加 const,那么 C++ 就认为引用指向的对象可以修改,这显然和对象的不可修改性不符,编译器不允许这样的事情发生。

试试这样想吧:你可以在可以随意使用的瓶子上贴上勿动的标签,但是不能给不能动的瓶子贴上可动的标签

还记得我们曾经把引用比作瓶子上贴的标签,那么这里的 const 限定符就好像在标签上加上一句:不能动!

特殊用法

在继续前进之前,我们来看点奇怪的常量引用。

1
2
3
4
5
int i = 10;
double s = 3.14;
const int &a = 1;
const int &b = i * 2;
const int &c = s * 2;

wow,这里的3、4、5行居然把表达式赋给引用,会报错的。

既然我都说了是奇怪的引用,当然不会编译错误啦。这其实是常量引用的特殊用法:如果一个引用添加了 const 限定,那么编译器允许使用任意表达式(包括字面值、算式、对象),并且能够自动转换。

你一定已经在学习指针前,了解过自动转换了。如果两个变量的类型不匹配,那么编译器会尝试自动转换。

也就是说,上方代码与下方等效:

1
2
3
4
5
6
7
8
9
int i = 10;
double s = 3.14;
int tmp1 = 1;
const int &a = tmp1;
int tmp2 = i * 2;
const int &b = tmp2;
int tmp3 = s * 2; //自动类型转换
const int &c = tmp3;
// b = 20, c = 6

但也别搞混了,这个只在引用带有 const 时生效,普通引用由于是可变的,所以只能绑定一个数据类型匹配的变量。

恭喜你,你已经掌握了引用中的 const,我们一鼓作气,继续看看指针中的 const

const 与指针

添加 const 限定

1
2
3
4
5
6
const int a = 10;
int b = 10;
const int *s = &a;
const int *t = &b;
int *s1 = &a; //Error
int *t1 = &b;

为什么 s1 的定义会报错,但是其它就不报错呢?

我们在常量引用中提到,const 只是告诉编译器,认为指向的对象不可变。举一反三,const 应用于指针,则表示认为指针所指的对象(那个地址对应的对象)不可变。这就解释了 3,4 行的定义。

而第五行的报错也和上文所述相似,你所指的对象不可变,又怎么能够让指针认为所指的对象可变呢?这是不符合常理的。

认为指向对象不可变,也就是解引用后获得的对象不能变化

1
2
*s = 11; //Error
*t = 11; //Error

但是和引用不一样,指针是对象,自己是可变的。上面的 const 限定只是让指针认为自己指向的对象不可变,但指针本身指向哪个对象是可变的。

1
2
s = &b; //OK
t = &a; //OK

上面的代码完全可以正常运行。

常量指针

那怎么让指针自己不可变呢?嗯,这里事情逐渐变得复杂了起来。

1
2
3
4
5
6
const int a = 10;
int b = 10;
int *const s = &a; //Error
int *const t = &b;
const int *const s1 = &a;
const int *const t1 = &b;

Wait Wait Wait 这有点太复杂了,我们还是一行行看。

注意到了吗?我们使用了 *const,它表示定义一个常量指针。顾名思义,指针本身是常量,不能变(不能改变保存的位置,即不能修改它指向的对象是哪一个)。

现在来看代码:

  • 第三行,它定义了一个本身是常量的指针(而认为指向的对象是可变的),但是却绑定到了不可变常量 a,因此报错(上文已经强调过,不能认为不可变的东西可变)
  • 第四行,它定义了一个本身是常量的指针,绑定到了变量 b,没问题。
  • 第五行,它定义了一个本身是常量的指针,且认为指向对象不可变,绑定到了不可变常量,没问题。
  • 第六行,它定义了一个本身是常量的指针,且认为指向对象不可变,绑定到了变量,没问题

为了更加清晰说明什么叫本身不可变,什么叫认为指向对象不可变,再给出以下代码:

1
2
3
4
5
6
7
const int c = 11;
t = &c; //Error
s1 = &c; //Error
t1 = &c; //Error
*t = 12;
*s1 = 12; //Error
*t1 = 12; //Error

仔细想想为什么那些行会报错吧。

  • 本身不可变的指针,不可以重新指向其它位置。因此 2,3,4 行报错
  • 之前提到过,如果指针认为自己指向的对象不可变,那么它解引用后不可变,所以 6,7 行报错

好好思考一下,分清楚什么是本身不可变,什么是认为指向的对象不可变(解引用后不可变)。

Tips:还是再回忆下 const 修饰的真正含义吧。如果认为指向对象不可变,那么这和指向对象实际是否可变没有任何关系。

如果你理解了,真得好好夸夸自己,连这么复杂的东西都搞懂了!

顶层与底层

我们把本身不可变的 const,称作顶层 const;认为指向对象不可变的 const,称作底层 const。

从之前的讲解中,我们不难得到推论:

  • 引用本身不可变,所以只有认为不可变的底层 const 存在。
  • 对于指针,如果放在距离变量名远的地方,那么是底层;距离变量名近的地方,是顶层
  • 底层 const 只和指针、引用有关,而顶层 const 可以修饰大部分对象

很好。接下来我们就要涉及一些更加深入的话题了。

我们曾经在特殊用法那里提过一嘴自动转换。众所周知,在执行赋值操作时,可能会进行自动转换。而变量可以转换为常量,常量也可以转换为变量:

1
2
3
int a = 10;
const int b = a; //b 被顶层 const 修饰,它本身不可变
int c = b;

但对于指针和引用来说,事情就更加复杂了。

当你赋值,涉及指针、引用时,源和目标的顶层 const 可以不同,但顶层(决定本身是否可变)必须满足自动转换(不可变拷贝到可变)。注意上面代码第二行,和下面代码最后一行。

1
2
3
4
5
const int d = 20;
const int *const p1 = &b;
const int *p2 = &d;
p1 = p2;//Error
p2 = p1;//现在,p1,p2 都指向 b。

对于底层 const,这决定了源、目标认为其所指向的对象是否可变。在此过程中,源和目标的底层 const 可以不同,但是底层(指向对象是否可变)必须满足自动转换(可变拷贝到不可变)

1
2
3
4
int e = 1;
int *p3 = &d; //Error
int *p4 = &e;
p2 = p4; //为变量的指针增加不可变修饰

但注意一下,上面只是赋值操作,如果是新创建指针,那么顶层 const 无所谓(正如之前所述):

1
2
const int *const m = p1;
const int *m1 = p1;

我知道你确实有点晕了。

总结一下,修改指针操作时,看等号左侧是否有顶层 const 很重要,有顶层 const 就不能修改;而任何操作时,都有必要去检查下等号右侧的底层 const,如果有,那么左边也必须有,否则左侧随意。

试试这样想吧:const 就是一种修饰。指针是瓶子的标签,你可以让瓶子(对象)本身不可变(顶层 const 修饰),但这样你必须在标签(指针)上写上“别动瓶子”(底层 const 修饰)。如果你看到了“别动”的标签(底层 const 修饰的指针),想根据这个标签给瓶子再贴一个标签,或者把别的瓶子上的标签移过来(创建新指针/修改旧指针),那么另一个标签上也得写“别动”(底层 const 修饰)。

如果你的标签上没有“别动”(没有底层 const),说明瓶子本身一定是可以动的(没有顶层 const),所以新创建的标签写不写“别动”都无所谓(有没有底层 const 并没有关系)。

而如果一个标签是强力胶,撕不下来(指针有顶层 const 修饰),那么它就不能移动。但是你还是可以根据这个标签,移动其它可以移动的标签(将其它无顶层 const 修饰的指针,赋值为它),或者创建一个新的标签,是否为强力胶都可以(创建新的指针时,顶层 const 修饰并不重要)。

用比喻来说,顶层 const 决定了标签有没有强力胶;底层 const 决定我们是否认为瓶子能动。如果有强力胶,一个标签本身就不能移动了,但不影响其它标签。如果我们根据一个标签,不认为瓶子能动,那么也就没办法再贴上能动的标签了。

注意了,我这里一直强调根据某个标签,是因为这是在指针的语境下来说的,我们必须根据指针来进行寻找对象、赋值等操作,而不是直接操作对象。好好想想,上文所述“根据某个标签”,指的就是赋值等号右侧的内容。

你也可以配上下面的表格举例,一起理解(注意代码是无法运行的,这里只是为了看清楚而写出了每个类型,所以没有用赋值的等号):

  • 当操作为:创建指针,并赋值时:
    • int *p <- const int a Error 试图认为不可变的常量可变
    • const int *p <- const int a OK
    • const int *p <- int a OK
    • int *p1 <- int *const p OK
    • int *const p1 <- int *const p OK
    • const int *p1 <- int *const p OK
    • const int *p1 <- int *p OK
    • const int *const p1 <- int *p OK
    • const int *const p1 <- int *const p OK
    • const int *const p1 <- const int *const p OK
    • const int *p1 <- const int *const p OK
    • int *const p1 <- const int *p Error 试图根据指向不可变对象的指针,认为指向可变对象
    • int *const p1 <- const int *const p Error 试图根据指向不可变对象的指针,认为指向可变对象
  • 当操作为:修改左侧已经创建的指针时:
    • int *p <- const int a Error 试图认为不可变的常量可变
    • const int *p <- const int a OK
    • const int *p <- int a OK
    • int *p1 <- int *const p OK
    • int *const p1 <- int *const p Error 试图修改不可变的指针
    • const int *p1 <- int *const p OK
    • const int *p1 <- int *p OK
    • const int *const p1 <- int *p Error 试图修改不可变的指针
    • const int *const p1 <- int *const p Error 试图修改不可变的指针
    • const int *const p1 <- const int *const p Error 试图修改不可变的指针
    • const int *p1 <- const int *const p OK
    • int *const p1 <- const int *p Error 试图根据指向不可变对象的指针,认为指向可变对象 / 试图修改不可变的指针
    • int *const p1 <- const int *const p Error 试图根据指向不可变对象的指针,认为指向可变对象 / 试图修改不可变的指针

OKOKOK,我知道这很难,但是再读一遍,理解一下指针的 const 修饰吧。

哦对了,赋值记得保证除了 const 外的数据类型一致。别忘了这个基础。

如果你连这个都掌握了,你已经对 const 有了相当程度的认识。恭喜!

下一篇,我们将离开指针的苦海,继续讲解 C++。当然,也是奶奶级的拆解哦。