谈谈 C++ 中的 const
C++ 用关键字 const 标识一个类型不可变. 这其实很容易理解. 不过, 对于 C++ 而言, 简单的概念也有很多可以讨论的. 我们来看一个问题.
问题
我们知道 const 可以用于修饰成员函数, 标识这个函数不能修改这个类的数据. 假设一个类有一个指针类型的成员 T *p, 我们希望通过 get() 方法获取 p 所指向的对象的引用. 如果 get() 被 const 修饰, 它应该返回什么类型, 是 T& 还是 const T& 呢?
1 | |
可能很多同学很自然地认为应该返回 const T&, 因为 get() 不应该改变数据. 的确, 很多类就是这样处理的. 例如标准库的顺序容器都有 front 方法, 返回容器中第一个元素的引用. 如 vector<int>::front()
1 | |
可以看到非 const 版本返回的是 int&, 而 const 版本返回的是 const int&.
我们看另一个例子. 标准库的迭代器, 例如 vector<int>::iterator, 会重载解引用运算符 operator*(). 那么它的返回类型是什么呢?
1 | |
它返回了 int& 而不是 const int&, 即使这个 operator*() 是 const 版本的.
引用类型, 顶层 const 和底层 const
首先我们知道, C++ 的类型分为值类型和引用类型. 对于引用类型而言, 例如指针, 它有两层 const: 顶层 (top-level) const 和底层 (low-level) const. 顶层 const 表示这个变量本身不可变.
1 | |
而底层 const 表示这个变量引用的值不可变.
1 | |
对变量赋值或初始化时, 顶层 const 可以隐式加上或去除, 底层 const 可以隐式加上, 却不能去除.
1 | |
如果一个类的成员函数被 const 修饰, 则这个函数的 this 指针是底层 const 的, 也就是 const T *this. 那么通过 this 指针访问到的所有成员, 也就是这个函数能访问到的所有成员, 都是顶层 const 的.
以本文开头的例子, get() 被 const 修饰, get() 中访问到的 p 的类型应该是 T *const p. 编译器并不阻止我们在 const 成员函数里修改指针成员指向的值, 那为什么有些类要禁止修改, 而有些类允许修改呢?
引用类型还是值类型
如果一个类有一个指针类型的成员 T *p, 那么我们在拷贝这个类的对象时, 是复制这个指针本身还是复制指针指向的值呢?
1 | |
C++ 允许开发者控制对象拷贝时的行为. 我们可以仅拷贝指针, 让拷贝前后指向同一个对象; 也可以拷贝指针指向的值, 向用户隐藏这个类存在引用成员这一事实.
当我们拷贝指针指向的值时, 这个类看起来就是个值类型. 例如 std::vector, 它的内存是动态分配的, vector 对象本身只记录指向分配内存的指针. 但是我们在拷贝 vector 时, 会复制其包含的所有对象. 因此对于用户来说它就是个值类型.
既然是值类型, 就只有一层 const, 也就是顶层 const. 因此当一个 vector 是 const 的时候, vector::front() 也应该返回 const 的引用. 类需要负责将顶层的 const 传递到底层.
当我们仅拷贝指针本身时, 这个类看起来就是个引用类型. 例如 vector::iterator, 它包含一个指向 vector 中元素的指针. 当拷贝迭代器时, 仅会拷贝指针本身, 拷贝前后的迭代器指向同一个元素. 因此对于用户来说它就是个引用类型.
既然是引用类型, 就应该区分底层 const 和底层 const. 因此即使迭代器本身是 const 的, operator*() 也不会返回 const 的引用, 因为顶层 const 不会传递到底层. 怎样设置迭代器的底层 const? vector 提供了两个类, vector::iterator 和 vector::const_iterator. 后者无论迭代器本身是否是 const, operator*() 始终返回 const 的引用, 因为它是底层 const 的.
C++ 很强大
回到本文开头的问题. 标准答案是, 返回 const T& 还是 T& 取决于我们如何定义这个类. 如果 class C 的拷贝控制函数拷贝 (或移动) 了 p 指向的值, 则应当返回 const T&; 如果只是拷贝指针本身, 则应当返回 T&.
更一般地总结一下, 对于包含引用类型成员 (如指针, 智能指针) 的类来说, 如果要将其视为值类型, 则
- 拷贝控制函数需要拷贝引用类型成员所引用的数据
- 对于访问所引用数据的方法, 应当提供 const 和非 const 两个版本
1 | |
反之, 如果将其视为引用类型, 则
- 拷贝控制函数拷贝引用类型成员本身
- 应当通过一些方式 (如模版) 设置底层 const
- 对于访问所引用数据的方法, 只需要 const 版本. 如果是底层非 const, 则允许修改所引用数据.
1 | |
当然, 如果这个类是诸如某某管理器之类的单例类或不可拷贝的类, 就不需要考虑这么多了, 根据需求处理即可.
与其他语言 (Java, Go, Python) 不同, C++ 的类既可以是值类型, 又可以是引用类型, 这取决于开发者怎样设计. C++ 希望开发者可以像用内置类型一样使用自定义类型, 因此它提供了运算符重载, 拷贝控制等一系列的机制, 这让 C++ 的类很强大, 同时也比较复杂. 这就要求我们能够理解这些概念, 而不是只是记住 const 有哪几种用法.