C++

漫谈C++——const真理大讨论之 语法和语义const

C++学习笔记

Posted by Felix Zhang on November 1, 2020

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函数中volumevolumeCalculated被改变了。为了解决这个问题,一些错误的做法是直接去掉getVolume()函数的const标志,把它变成非const的——结果就是对于一些const Mesh类型的变量就再也不能调用getVolume方法了,这也直接导致我们在该使用const创建变量的时候放弃使用const

在这种非常常见的dilemma中,正确的做法是将volumevolumeCalculated声明为mutable。这个关键字告诉编译器,有它标记声明的变量可能会在const成员函数中被改变。我们在这个系列的第二篇文章中会详细讲解mutable。细心的你会发现上边这个例子就线程而言也是不安全的,我也会单独在第三篇文章中详细介绍这个问题。

结语

并不是说在各个地方都使用const就能保证const的安全性。它是类型设计中非常重要的一环,在有些时候可能还要非常慎重的考虑。