const
真理大讨论之“语法和语义const
”
在接下来我准备花三篇文章的篇幅讨论我们的老朋友const
。我曾经在一篇文章里提到了有关指针和引用的顶/底层const
,但是在这三篇文章中我想跳出const
的语法,讨论一下在使用const
时需要特别注意的其他事项,包括const
正确性、mutable
以及在使用const
时的线程安全。这大概构成了这三篇文章的主题,我愿统一称他们为”const
的真理大讨论”,希望你们会喜欢。
第一篇文章是关于const
正确性的,有时写出const
正确的代码可不仅仅通过编译器的考验就够了。在C++中,const
一般包含两个部分:语法部分和语义部分,我们分别来看一下。
语法const
const
语法部分指的是编译器需要检查的部分,即我们从语法层面有没有违反了const
规则。如果我们声明了一个变量是简单的const
类型,比如一个const
类型的int
,那么编译器就不会让我们更改他。
int const shouldNotAlter = 42;
shouldNotAlter = 38; //Error
如果编译器是GCC,那么错误信息就会告诉我们正在试图给一个”read-only variable”赋值,相对应的Clang会弹出一个”variable with const-qualified type”。当然,如果我们试图改变一个const
的class或struct的成员变量,我们会得到同样的报错信息。
struct Data{
int i;
double d;
};
Data const data{1, 2.0};
data.i = 2; //Error
那么对于函数而言是怎样呢?
如果在一个类里有一个成员函数,编译器此时会默认我们假定这个方法有可能会改变这个对象的成员,所以对于一个const
对象而言,我们无法调用那些非const
方法(我在上一篇文章中从this
指针和顶/底层const
的角度详细分析了原因)。我们必须手动声明这些方法是const
类型的,才能够被const
成员所调用。
class MyClass {
public:
void couldModify();
void dontModify() const;
};
MyClass const myClassP{};
myClass.dontModify(); //OK
myClass.couldModify(); //Error
报错信息可能会不太一样,但总体意思还是this
指针具有的是const SomeClass
类型,但是函数并没有被标记为const
。
编译器能做的还不止如此。当我们标记成员一个函数为const
类型时,它会主动检查我们是否真的没有改变任何一个成员变量。在一个const
函数中更改成员变量,或者调用更改成员变量的其他函数,也会导致错误:
class MyClass {
int i;
public:
void dontModify() const {
i = 47; //Error
}
void dontModifyEither() const {
changeData(); //Error
}
private:
void changeData() {
i = 3;
}
};
当然了,这只是针对非static
成员变量的,因为static
成员变量本身也不在类某个对象的范围内,所以const
成员函数可以在不改变类对象的情况下,改变static
成员变量,这个行为本身也是合法的。
语义const
语法const
存在着语言层面上的缺陷。比如我们有一个(顶层)const
指针,这个指针本身可能不会改变,也就是它一旦指向哪里,它就一直指向那个地方;但是被指向的内存里的内容,是可以被改变的:
int i = 0;
int j = 1;
int *const pInt = &i;
pInt = &j; //Error
*pInt = 1; //OK!
这种情形对普通指针和智能指针都是适用的。先别急着说我蠢,你可能会说现在大家都会写成const int*
或者const int *const
的形式来避免这种问题啊,其实我的重点不在这里,我们来看下边的例子:如果我们在一个类中有一个指针成员,在一个const
成员函数中,我们可以使用这个指针成员来改变它指向的内容吗?答案是可以的!
class Sandwich {
unique_ptr<Bread> bread;
public:
void freeze() const {
bread->modify(); //OK!
}
};
可见,我们在语法层面对成员函数的施加const
限制并不能真正阻止这个函数对指针内容的更改!那现在,成员指针所指向的真实的对象,我们愿称之为“语义const
”。什么意思呢?就是语法上它并没有被标记为const
,但是却本应具有const
属性。
处理这种语义上是const
但是语法上并没有完备定义成const
的对象,我们要多加小心。这个问题在我们使用getter
函数获得一些成员时就应更加注意了:
Bread const& Sandwich::getBread() const {
return *(this->bread);
}
上例中的第一个const
是非常关键的,否则我们就会允许Sandwich
类的用户去修改一个const
类型的“某一部分”了。比如:
//如果上例写成这样
Bread& Sandwich::getBread() const {
return *(this->bread);
}
Sandwich const mySandwich{/* ... */}; //对一个const Sandwich进行适当初始化
Bread particularBread = mySandwich.getBread(); //在这里bread的const属性已丧失
particularBread.modify(); //本应是const的mySandwich的一部分在变量外被改变了
在许多标准容器中我们也会看到类似的设计,比如:
vector<string> const sVec{"123","456"};
sVec[0] = "234"; //Error
在上例中,sVec[0
返回的是 String const&
类型,尽管vector
内部使用的是一个指针指向了真正的数据。
还不够”const
”
上边这些例子都比较饵咸钩直,不足以骗过机智的你,我们看一个稍微复杂一点的例子。考虑一个二叉树,每个节点都有一个父节点和两个子节点。现在我们为每个节点都设立了一个getter
函数,那这些函数应该返回一个const
还是非const
节点呢?这些getter
函数本身是否应该被标记为const
呢?
class BinaryTreeNode {
BinaryTreeNode* parent;
BinaryTreeNode* left;
BinaryTreeNode* right;
public:
BinaryTreeNode* getParent() const;
BinaryTreeNode* getLeft() const;
BinaryTreeNode* getRight() const;
};
BinaryTreeNode const* node = getBinaryTree();
BinaryTreeNode* leftChild = node->getLeft();
BinaryTreeNode* thief = leftChild->getParent();
在上边thief
(小偷)是一个非const
的指针,但却从const
指针node
中“偷走”了被指向的内存!但是我们一路用的都是const
方法呀!所以在上边这些成员函数中的const
其实都是骗人精,轻松就被我们突破了防线。因此我们在开发接口的时候,一定要多加小心,尤其是在处理const
相关的事务上。
有点太“const
”了
恰恰相反的情形,有时候语义const
并不能明确的反应我们的需求——在const
语法上形成的语义const
又太多余了。我们来看下边的例子:
这是我在做点云处理时经常发生的例子。比如在一个3D程序中,我们从点云获得网格模型这个操作称为mesh化。在mesh化后,可以计算出这个网格模型的体积。然而,这个计算是非常消耗资源的。所以只有当我们需要用到模型的体积的时候,我们才会计算它,而不是主动去计算每次mesh后的体积;同时呢,我们每次计算出来体积后,还是想把它保存下来的。我们来看一下实现:
class Mesh {
vector<Vertex> vertices; //模型顶点的集合
double volume; //计算出来的体积
bool volumeCalculated; //有没有计算过体积的标志
public:
Mesh(): volume{0}, volumeCalculated{false} {}
void change() {volumeCalulated = false;}
double calcVolume() const {
/*
...
*/
}
double getVolume() const {
if(volumeCalculated) {
return volume;
}
volume = calcVolume(); //Error!
volumeCalculated = true; //Error!
return volume;
}
};
为了保证例子的简洁,上边这个例子中的函数参数我做了一些简化。上边这个例子不会编译通过,因为在const
函数中volume
和volumeCalculated
被改变了。为了解决这个问题,一些错误的做法是直接去掉getVolume()
函数的const
标志,把它变成非const
的——结果就是对于一些const Mesh
类型的变量就再也不能调用getVolume
方法了,这也直接导致我们在该使用const
创建变量的时候放弃使用const
。
在这种非常常见的dilemma中,正确的做法是将volume
和volumeCalculated
声明为mutable
。这个关键字告诉编译器,有它标记声明的变量可能会在const
成员函数中被改变。我们在这个系列的第二篇文章中会详细讲解mutable
。细心的你会发现上边这个例子就线程而言也是不安全的,我也会单独在第三篇文章中详细介绍这个问题。
结语
并不是说在各个地方都使用const
就能保证const
的安全性。它是类型设计中非常重要的一环,在有些时候可能还要非常慎重的考虑。