C++细节:无处不在的const修饰符
justaLoli

const修饰符, 顾名思义是修饰某个东西为“常量”,不允许修改. 在C++中, 有很多地方需要用到const修饰符. 它不仅是一个良好的书写习惯, 在某些情况下, 它甚至是必要的. 这篇文章试图涵盖C++中所有const的出现情况.

目录

  • 1 const与变量定义
    • 1.1 普通变量
    • 1.2 指针
    • 1.3 引用
  • 2 const与函数
    • 2.1 形参
      • 普通
      • 指针
      • 引用
    • 2.2 返回值
    • 2.3 案例: 一些特殊函数
      • 比较运算符重载
      • iostream的输入输出
  • 3 cosnt与类
    • 3.1 属性
    • 3.2 方法
      • 3.2.1 方法的const

1. const 与 变量定义

1.1 const 与 普通变量

这是最初级、最直观的用法: 普通变量的值可以变, 而被const修饰过的普通变量的值不能变. 由此引申出一个特性, 被const修饰的变量必须在声明时立刻初始化.

下文基本上都用int作为普通变量的代表. 它原则上可以直接替换为任何基本变量、结构体、类.

1
2
3
4
5
6
7
8
9
10
/* 1.1.1 无修饰的普通变量 */
int a = 10;
a = 1; // 正确

/* 1.1.2 const修饰变量: 变量的值不能修改 */
/* const修饰的变量必须在声明时立刻初始化 */
// const int b; // 错误
const int b = 20;
a = b; // 正确
b = 1; // 错误, 不能修改b的值

1.2 const 与 指针变量

我想我不用详细描述指针的作用. 指针相当于两层, 指针本身存储地址, 这是一层; 存储的地址对应一个变量, 这是第二层.

由于指针的概念有两层, const的修饰也有两层: 是第一层, 指针存储的地址不能变呢? 还是第二层, 指针指向的变量的值不能变呢?

从这两个维度出发, 可以绘制这样的表格:

指针的地址  通过指针访问的变量的值 能变 不能变
能变 int* const int*
不能变 int* const const int* const

我觉得上表已经很简明的描述了这四种指针的定义以及主要特点. 读者可以对照下面的使用例来判断自己理解是否正确.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* 1.2 const与指针 */
int c = 10;
int d = 10;
const int e = 20;
const int f = 20;

/* 1.2.1 普通 int* 型指针: 指向 可变类型 的 可变指针 */
int* p1 = &c; p1 = &d; *p1 = 1; // 都正确
/* 普通指针无法指向const修饰的变量 */
p1 = &e; // 错误

/* 1.2.2 const int* 型指针: 指向 不可变类型 的 可变指针
即: 指针指向的位置可以修改, 指针指向的位置的值不能修改 */
const int* p2;
p2 = &e; // 正确
/* const int* 可以指向普通变量, 此时直接访问普通变量可以修改其值, 但通过指针访问该变量则不能修改. */
p2 = &c; // 正确
c = 1; // 正确
*p2 = 1; // 错误

/* 1.2.3 int* const 型指针: 指向 可变类型 的 不可变指针
即: 指针指向的位置不能修改, 指针指向的位置的值可以修改 */
/* 类似const修饰的普通变量, 这种指针也必须在声明时立刻初始化 */
int* const p3 = &c;
p3 = &d; // 错误
/* 和 */
int* const p4 = &e; // 错误, 和int* 类似, int* const指向的必须是可变类型, 不能指向const类型.

/* 1.2.4 const int* const型指针: 指向 不可变类型 的 不可变指针
略, 可以自行推理得到它的使用方式. */

1.3 const 与 引用

引用是C++有别于C的一个特性. 由于引用本身也很有话题, 这里不做过多介绍, 只是简单的说明一下引用与const的关系.

可以将引用粗略的理解为指针. 和指针不同在于: 1. 首先, 它不需要*运算符就能直接取得其所引用的变量的值, 因此引用相当于其所引用的变量的“别名” 2. 其次, 它必须在声明时初始化, 之后不能再修改其所引用的是哪个变量.

所以类似指针, 也能画出一个二维表格, 不过, “引用的是哪个变量”这件事, 不论对何种引用而言, 都是不能修改的. 因此这个表格事实上只有一行.

引用的是哪个变量  通过引用访问变量的值 能变 不能变
能变(不存在) 不存在 不存在
不能变 int& const int&
1
2
3
4
5
6
7
8
9
10
11
/* 1.3.1 普通引用无法引用const修饰过的变量 */
const int g = 5;
int& h = g // 错误

/* 1.3.3 形如 const int& 的引用 */
/* 首先, 它类似const int*, 通过引用访问变量是不能修改变量的值的. */
int i = 5;
const int& j = i; // 正确
j = 1; // 错误
/* 其次, 由于引用本身的特性, 它和const int* 不同, 它引用的是哪一个变量, 这件事也不能修改.
也没有任何一个语法能让你修改. */

需要格外注意的是: const int& 有一个非常特殊的用法: 它可以直接引用一个字面量. 这是指针和无const修饰的引用都没有的特性. 在接下来的内容中, 读者将看到这种类型的重要性.

1
2
const int& k = 3; // 正确
int& l = 3; // 错误, 不能将一个字面量赋值给一个普通引用.

这里不得不说明一下: 以下每一行内的几种写法是完全等价的:

1
2
3
4
5
int *p;int* p;int * p;
const int* p; const int *p; const int * p;
int* const p; int *const p; int * const p;
int& p; int &p; int & p;
const int& p; const int &p; const int & p;

简而言之, *, &这样的修饰符, 紧挨着谁都是一样的. 而笔者主要使用第一种写法.

2. const 与 函数

2.1 const 与 函数形参

这部分内容可以直接由上面关于变量的介绍平移而来. 下文推测读者已经基本了解C++函数参数传递的机制.

2.1.1 普通类型形参

1
2
3
4
5
6
7
void fun1(int a){
a = 2; // 正确
}
void fun2(const int a){
/* const修饰的效果: 在函数体内,形参a的值无法被更改 */
a = 3; // 错误.
}

由于参数传递机制,这两个函数都可以接收普通和const修饰的int变量

2.1.2 指针形参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void fun1(int* a){
*a = 2; // 正确
a = nullptr; // 正确
}
void fun2(const int* a){
*a = 3; // 错误
a = nullptr; // 正确
}
void fun3(int* const a){
*a = 2; // 正确
a = nullptr; // 错误
}
void fun4(const int* const a){
*a = 2; // 错误
a = nullptr; // 错误
}

这四个函数都可以传入普通变量的地址. 普通变量的地址被拷贝如函数后, 会被自动加上const修饰.

而只有fun2和fun4可以传入const修饰的变量的地址.

为什么不能传入fun1和fun3?可以这样理解:

编译器: 这个变量本身是const保护的, 传给函数之后, 这个保护就没有了! 在fun1和fun3中, 是允许通过指针a修改变量的值的, 而这个值本身却是const保护的! 这怎么办, 算了给个error吧.

即: 在传递时, 允许加强const条件, 不允许丢掉const条件(这很直观).

其中, fun2是格外有用的. 如果你 不想在参数传递时拷贝原有数据的值 , 又希望函数 以只读的方式访问某个变量 ,fun2是一个好选择.

2.1.3 引用形参

1
2
3
4
5
6
void fun1(int& a){
a = 2; // 正确
}
void fun2(const int& a){
a = 3; // 错误
}

类似的, 两个函数都能传入普通变量. 而fun1不能传入带const修饰的变量.

此外,十分重要的: fun2可以传入字面量! 这很特殊

其中, fun2是格外有用的. 如果你 不想在参数传递时拷贝原有数据的值 , 又希望函数 以只读的方式访问某个变量 , 还希望 函数内部不要出现指针的*->操作, 调用函数不要出现取地址&操作 , 还希望它 能传入字面量 , fun2是一个好选择.

2.2 const 与 函数返回值

这在某些场合是有点莫名其妙的命题. 比如这样

1
2
3
const int fun(){
return 100;
}

对函数返回值传递机制稍有了解便能知道, 这个const是完全无用的.

但在某些情况它会不同, 比如:

1
2
3
4
5
6
7
8
int* fun(){
int* p = new int;
return p;
}
const int* fun2(){
int* p = new int;
return p;
}

就有不同了. fun()返回的指针可以修改所指位置的值, 而fun2()返回的指针不能.

类似的, 对于引用, 有:

1
2
3
4
5
6
7
8
9
int& fun(){
int* p = new int;
*p = 5;
return *p;
}
const int& fun2(){
int p = 5;
return p;
}

它们也是不同的. 不过这两个函数单看其实有些奇怪. 我们之后会在 那一部分更深入的探讨它们的区别和各自运用.

2.3 案例: const 在几种特殊函数中的作用

在2.1.3, 我提到了:

如果你 不想在参数传递时拷贝原有数据的值 , 又希望函数 以只读的方式访问某个变量 , 还希望 函数内部不要出现指针的*->操作, 调用函数不要出现取地址&操作 , 还希望它 能传入字面量 , fun2是一个好选择.

这种情况会在什么时候出现呢? 没错, 那就是——

(函数体外的) 运算符重载

运算符重载有两种, 一种是写在函数体(准确的说是结构体或类定义)外,没有打括号包裹的, 另一种是定义在类内部的.

这里着重举两个例子.

以这样一个结构体为例

1
2
3
struct Int{
int data;
};

2.3.1 类(或结构体)的比较运算符重载

1
2
3
bool operator<(const Int& a,const Int& b){
return a.data < b.data;
}

这是比较规范的运算符重载写法. 注意这里的const Int&, 需要const修饰.

如果不加const,只保留&,那么这个运算符将不能处理右值(可以理解为常量和字面量);

如果不加const,也不保留&,那么调用运算符会进行值的拷贝, 这在Int是一个复杂的数据类型时, 会凭空增加内存消耗, 也很耗时.

2.3.2 输入输出重载

1
2
3
4
5
6
7
8
std::ostream& operator<<(std::ostream& out,const Int& a){
out << a.data;
return out;
}
std::istream& operator>>(std::istream& in,Int& a){
in >> a.data;
return in;
}

这是比较规范的输入输出流重载写法. 注意在输出时需要加const修饰(否则无法输出字面量), 而输入时不能加const修饰(否则无法修改). 这里也能看出引用&符的巨大作用.

3. const 与 类

3.1 const 与 类的属性

基本和变量完全一致.

3.2 const 与 类的方法

比如这样一个类:

1
2
3
4
class string{
public:
char str[100];
}

方法和函数在很多方面是类似的. 它也有参数的const, 和返回值的const. 这里着重介绍另一种: 方法的const.

3.2.1 方法的const

为什么需要方法const? 因为对象可能被const修饰了.

比如, 假设string有一个方法size:

1
2
3
4
5
6
7
class string{
public:
char str[100];
int size(){
return strlen(str);
}
};

对于一个普通string对象, 我们可以执行size().

然而, 对于一个 加了const修饰的string对象 , 还能执行size()吗? 对于一个const修饰的对象, 其中的所有属性都自动增加了const修饰. 然而, 系统不知道这个size()方法会不会修改这些被加了const修饰属性. 因此, 对于const string对象, 系统不会允许执行这个size()方法, 会报错.

1
2
3
4
5
6
string s;
s.size();// 正确
const string s2;
s2.size(); // 错误
/* 报错信息: 'this' argument to member function 'size' has type ' const string'
but function is not marked const */

然而, 事实上这个size()方法并不会修改对象的属性. 我们的确想让const string对象也能执行size()方法. 因此, 我们要给size()对象增加const修饰, 就像这样:

1
2
3
4
5
6
7
class string{
public:
char str[100];
int size()const{ //注意这里的const!
return strlen(str);
}
};

这样, 对于const string对象, 也可以正常执行size()方法了.

这里, const修饰就是告诉编译器: 这个方法内的所有代码都不会修改当前类的属性值.

const修饰的方法有以下几个特性:

  1. const修饰的对象只能执行const修饰的方法
  2. const修饰的方法内部只能执行别的同样被const修饰的方法
  3. 没有const修饰的对象可以执行const修饰的方法
  4. 允许存在一个无const修饰一个有const修饰的两个同名方法. 程序会自动根据对象有没有const选择执行哪一个.

Remark: 关于特性4, 有以下的例子:

1
2
3
4
5
6
7
8
9
10
11
12
class array{
public:
int* data;
array(){data = new int[20];}
~array(){delete [] data;}
int& operator[](const int index){return data[index];}
const int& operator[](const int index)const{return data[index];}
};
array a1;
a1[0] = 1; // 正确
const array a2;
a2[0] = 1; // 错误, 调用第二个方法, 返回const int&不能被修改.

Remark2: const保护并不代表万无一失. 对上面的例子稍作修改:

1
2
3
4
5
6
7
8
9
10
11
12
class array{
public:
int* data;
array(){data = new int[20];}
~array(){delete [] data;}
//我们直接给它加上const呢?
int& operator[](const int index)const{return data[index];}
};
array a1;
a1[0] = 1; // 正确
const array a2;
a2[0] = 1; // 正确

程序会完全正确的运行. 因为, const只保护了data指针, 没有保护data指向的那片空间. 因此, 它是允许你修改诸如 data[0] 的内容的. 但是, 这并不符合我们的预期. 我们给一个array增加const修饰, 当然是想让里面的值不被修改.

因此, 如果你想确保const修饰过的类按你的预期不被修改, 你需要 自觉做到 在const修饰过的方法中不去修改相关的值. 没人能帮你检查.

这个const修饰还有一个常见的应用: 类内部的运算符重载(尤其是==的重载). 请看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class test{
public:
int* p;
test(){p = new int;}
~test(){delete p;}
void fun()const{
*p = 5;
}
bool operator==(const test& b){ // 不够好
return *p == *b.p;
}
bool operator==(const test& b)const{ //正确的写法
return *p == *b.p;
}
};
int main(){
test t,t2;
if(t==t2){ // 如果用第一种写法,这里会报warning
//...
}
}

这里会报一个warning:

ISO C++20 considers use of overloaded operator ‘==’ (with operand types test’ and ‘test’) to be ambiguous despite there being a unique best viable function.

这是为什么呢? 我的理解是, 相等运算==左右两边应当是对等的. 而这个重载的==运算, 由于没有加const修饰, 左右两边变得不对等了. 由于参数加了const, 而方法没有加const, 这意味着进行运算时, 有一方的属性可能被修改, 而另一方的属性标记了不会被修改. 这是不对等的.

这只是我的粗浅理解. 总之, 给这个方法增加const修饰, 这个warning就消失了.

以上是我目前能想到的全部内容.