const正确性

Const 正确性

什么是“const正确性”?

一件好事。它意味着使用关键字const来防止const对象被修改。

例如,如果你想创建一个接受std::string的函数f(),并且你想向调用者承诺不改变传递给f()的调用者的std::string,你可以让f()以这种方式接收其std::string参数……

  • void f1(const std::string& s); // 通过引用传递给const
  • void f2(const std::string* sptr); // 通过指针传递给const
  • void f3(std::string s); // 通过值传递

通过引用传递给const通过指针传递给const的情况下,在f()函数内部任何试图更改调用者std::string的行为都将在编译时被编译器标记为错误。这个检查完全在编译时完成:const不会产生任何运行时空间或速度开销。在通过值传递的情况下(f3()),被调用的函数会获得调用者std::string的一个副本。这意味着f3()可以更改其局部副本,但当f3()返回时,该副本就会被销毁。特别是,f3()不能更改调用者的std::string对象。

作为反例,假设你想创建一个接受std::string的函数g(),但你想让调用者知道g()可能会改变调用者的std::string对象。在这种情况下,你可以让g()以这种方式接收其std::string参数……

  • void g1(std::string& s); // 通过引用传递给非const
  • void g2(std::string* sptr); // 通过指针传递给非const

这些函数中缺少const告诉编译器,它们允许(但不是必须)更改调用者的std::string对象。因此,它们可以将自己的std::string传递给任何f()函数,但只有f3()(那个“按值”接收参数的函数)可以将其std::string传递给g1()g2()。如果f1()f2()需要调用任何一个g()函数,则必须将std::string对象的局部副本传递给g()函数;f1()f2()的参数不能直接传递给任何一个g()函数。例如,

void g1(std::string& s);

void f1(const std::string& s)
{
  g1(s);          // Compile-time Error since s is const

  std::string localCopy = s;
  g1(localCopy);  // Okay since localCopy is not const
}

当然,在上述情况下,g1()所做的任何更改都是针对f1()内部的localCopy对象。特别是,传递给f1()const参数不会发生任何更改。

const正确性”与普通类型安全有何关系?

声明参数的const属性只是类型安全的另一种形式。

如果你发现普通类型安全有助于你正确地构建系统(它确实如此;尤其是在大型系统中),你也会发现const正确性同样有帮助。

const正确性的好处是,它可以防止你无意中修改你未曾预期会被修改的东西。最终你需要在代码中添加一些额外的按键(const关键字),其好处是你正在告诉编译器其他程序员一些额外的重要语义信息——这些信息编译器用于防止错误,而其他程序员则用作文档。

从概念上讲,你可以想象,例如,const std::string与普通的std::string是不同的类,因为const变体在概念上缺少非const变体中可用的各种修改操作。例如,你可以概念性地想象,一个const std::string根本没有赋值运算符+=或任何其他修改操作。

我应该“更早”还是“更晚”实现const正确性?

在最最,最,开始的时候。

后期修补const正确性会导致滚雪球效应:你“这边”添加的每一个const都需要“那边”再添加四个。

尽早且经常添加const

const X* p”是什么意思?

它表示p指向一个X类的对象,但p不能用于改变那个X对象(当然,p也可以是NULL)。

从右到左阅读:“p是一个指向常量的X的指针。”

例如,如果类X有一个const成员函数,例如inspect() const,那么说p->inspect()是允许的。但是如果类X有一个const成员函数叫做mutate(),那么你说p->mutate()就是错误的。

重要的是,这个错误在编译时就被编译器捕获——不需要进行运行时测试。这意味着const不会减慢你的程序,也不需要你编写额外的测试用例在运行时检查事物——编译器在编译时就完成了这项工作。

const X* p”、“X* const p”和“const X* const p”有什么区别?

从右到左阅读指针声明。

  • const X* p的意思是“p指向一个constX”:X对象不能通过p改变。
  • X* const p的意思是“p是一个const指针,指向一个非constX”:你不能改变指针p本身,但可以通过p改变X对象。
  • const X* const p的意思是“p是一个const指针,指向一个constX”:你不能改变指针p本身,也不能通过p改变X对象。

哦,对了,我有没有提到要从右到左阅读你的指针声明?

const X& x”是什么意思?

它表示xX对象的一个别名,但你不能通过x改变那个X对象。

从右到左阅读:“x是对一个constX的引用。”

例如,如果类X有一个const成员函数,例如inspect() const,那么说x.inspect()是允许的。但是如果类X有一个const成员函数叫做mutate(),那么你说x.mutate()就是错误的。

这与指向const的指针完全对称,包括编译器在编译时完成所有检查,这意味着const不会减慢你的程序,也不需要你编写额外的测试用例在运行时检查事物。

X const& x”和“X const* p”是什么意思?

X const& x 等价于 const X& x,而 X const* x 等价于 const X* x

有些人偏好const在右边的风格,称之为“一致的const”或,用西蒙·布兰德创造的术语来说,“东const”。事实上,“东const”风格可以比替代风格更一致: “东const”风格总是const放在它所限定的事物的右边,而另一种风格有时const放在左边,有时放在右边(对于const指针声明和const成员函数)。

使用“东const”风格,一个const局部变量的定义是const在右边:int const a = 42;。同样地,一个const静态变量被定义为static double const x = 3.14;。基本上,每个const都最终出现在它所限定的事物的右边,包括那些必须放在右边的constconst指针声明和const成员函数

当与类型别名一起使用时,“东const”风格也更不容易混淆:为什么这里的foobar有不同的类型?

using X_ptr = X*;

const X_ptr foo;
const X* bar;

使用“东const”风格会使这更清晰

using X_ptr = X*;

X_ptr const foo;
X* const foobar;
X const* bar;

这里更清楚的是,foofoobar是相同的类型,而bar是不同的类型。

“东const”风格也与指针声明更一致。对比传统风格

const X** foo;
const X* const* bar;
const X* const* const baz;

与“东const”风格

X const** foo;
X const* const* bar;
X const* const* const baz;

尽管有这些好处,但“const在右”的风格尚未普及,因此遗留代码倾向于采用传统风格。

X& const x”有意义吗?

不,这是无稽之谈。

要找出上面声明的含义,从右到左阅读:“x是对一个Xconst引用”。但这很冗余——引用总是const的,因为你永远不能重新设置引用以使其引用不同的对象。永远不能。无论有没有const

换句话说,“X& const x”在功能上等同于“X& x”。既然在&之后添加const没有任何益处,你就不应该添加它:它会让人困惑——const会让一些人认为Xconst的,就像你说了“const X& x”一样。

什么是“const成员函数”?

一个检查(而不是修改)其对象的成员函数。

一个const成员函数通过在成员函数的参数列表后紧跟一个const后缀来表示。带有const后缀的成员函数被称为“const成员函数”或“检查器”。没有const后缀的成员函数被称为“非const成员函数”或“修改器”。

class Fred {
public:
  void inspect() const;   // This member promises NOT to change *this
  void mutate();          // This member function might change *this
};

void userCode(Fred& changeable, const Fred& unchangeable)
{
  changeable.inspect();   // Okay: doesn't change a changeable object
  changeable.mutate();    // Okay: changes a changeable object

  unchangeable.inspect(); // Okay: doesn't change an unchangeable object
  unchangeable.mutate();  // ERROR: attempt to change unchangeable object
}

尝试调用unchangeable.mutate()是一个在编译时捕获的错误。const没有运行时空间或速度惩罚,你不需要编写测试用例在运行时检查它。

inspect()成员函数末尾的const应该用来表示该方法不会改变对象的抽象(客户端可见)状态。这与说该方法不会改变对象struct的“原始位”略有不同。C++编译器不允许采用“按位”解释,除非它们能够解决别名问题,而这通常是无法解决的(即,可能存在一个非const别名,可以修改对象的状态)。从这个别名问题中得出的另一个(重要)见解是:用指向const的指针指向一个对象并不能保证该对象不会改变;它只是承诺该对象不会通过那个指针改变。

返回引用与const成员函数之间有什么关系?

如果你想从一个检查器方法中通过引用返回你的this对象的一个成员,你应该使用对const的引用(const X& inspect() const)或按值(X inspect() const)返回它。

class Person {
public:
  const std::string& name_good() const;  // Right: the caller can't change the Person's name
  std::string& name_evil() const;        // Wrong: the caller can change the Person's name
  int age() const;                       // Also right: the caller can't change the Person's age
  // ...
};

void myCode(const Person& p)  // myCode() promises not to change the Person object...
{
  p.name_evil() = "Igor";     // But myCode() changed it anyway!!
}

好消息是,如果你做错了,编译器通常会发现。特别是,如果你不小心通过非const引用返回你的this对象的一个成员,就像上面Person::name_evil()中那样,编译器通常会检测到它,并在编译(本例中为Person::name_evil()的)内部时给你一个编译时错误。

坏消息是,编译器不总是会发现你:在某些情况下,编译器根本不会给你一个编译时错误信息。

翻译:你需要思考。如果这让你害怕,那就换个工作;“思考”不是什么骂人的词。

记住本节中普及的“const哲学”:一个const成员函数不得改变(或允许调用者改变)this对象的逻辑状态(又称抽象状态,又称语义状态)。思考一个对象意味着什么,而不是它内部是如何实现的。一个人的年龄和姓名在逻辑上是人的组成部分,但人的邻居和雇主不是。一个返回this对象的逻辑/抽象/语义状态一部分的检查器方法决不能返回指向该部分的非const指针(或引用),无论该部分在内部是作为直接数据成员物理嵌入在this对象中还是以其他方式实现。

const重载”是怎么回事?

const重载帮助你实现const正确性。

const重载是指你有一个检查器方法和一个修改器方法,它们具有相同的名称、相同的参数数量和类型。这两个不同的方法只在于检查器是const的,而修改器是非const的。

const重载最常见的用法是与下标运算符一起使用。你通常应该尝试使用标准容器模板之一,例如std::vector,但如果你需要创建自己的带有下标运算符的类,这里有一个经验法则:下标运算符通常成对出现。

class Fred { /*...*/ };

class MyFredList {
public:
  const Fred& operator[] (unsigned index) const;  // Subscript operators often come in pairs
  Fred&       operator[] (unsigned index);        // Subscript operators often come in pairs
  // ...
};

const下标运算符返回一个const引用,因此编译器将防止调用者无意中修改/更改Fred。非const下标运算符返回一个非const引用,这是你告诉调用者(和编译器)你的调用者可以修改Fred对象的方式。

MyFredList类的用户调用下标运算符时,编译器会根据他们的 MyFredList的const属性来选择调用哪个重载。如果调用者有一个MyFredList aMyFredList& a,那么a[3]将调用非const下标运算符,调用者最终会得到一个对Fred的非const引用

例如,假设class Fred有一个检查器方法inspect() const和一个修改器方法mutate()

void f(MyFredList& a)  // The MyFredList is non-const
{
  // Okay to call methods that inspect (look but not mutate/change) the Fred at a[3]:
  Fred x = a[3];       // Doesn't change to the Fred at a[3]: merely makes a copy of that Fred
  a[3].inspect();      // Doesn't change to the Fred at a[3]: inspect() const is an inspector-method

  // Okay to call methods that DO change the Fred at a[3]:
  Fred y;
  a[3] = y;            // Changes the Fred at a[3]
  a[3].mutate();       // Changes the Fred at a[3]: mutate() is a mutator-method
}

但是,如果调用者有一个const MyFredList aconst MyFredList& a,那么a[3]将调用const下标运算符,调用者最终会得到一个对Fredconst引用。这允许调用者检查a[3]处的Fred,但可以防止调用者无意中修改/更改a[3]处的Fred

void f(const MyFredList& a)  // The MyFredList is const
{
  // Okay to call methods that DON'T change the Fred at a[3]:
  Fred x = a[3];
  a[3].inspect();

  // Compile-time error (fortunately!) if you try to mutate/change the Fred at a[3]:
  Fred y;
  a[3] = y;       // Fortunately(!) the compiler catches this error at compile-time
  a[3].mutate();  // Fortunately(!) the compiler catches this error at compile-time
}

下标运算符和函数调用运算符的const重载在这里这里这里这里这里都有说明。

当然,你也可以将const重载用于下标运算符以外的事物。

如果我区分逻辑状态物理状态,它如何帮助我设计更好的类?

因为它鼓励你从外向内而不是从内向外设计你的类,这反过来使你的类和对象更容易理解和使用,更直观,更不容易出错,并且更快。(好吧,这有点过于简化了。要理解所有的“如果”、“和”、“但是”,你只需要阅读这个答案的其余部分!)

让我们从内到外来理解这一点——你将(应该)从外向内设计你的类,但如果你是这个概念的新手,从内到外理解会更容易。

在内部,你的对象拥有物理(或具体或位级)状态。这是程序员容易看到和理解的状态;如果类只是一个C风格的struct,那么就会存在这种状态。

在外部,你的对象拥有类的用户,这些用户仅限于使用public成员函数和friend。这些外部用户也认为对象具有状态,例如,如果对象是具有width()height()area()方法的Rectangle类,你的用户会说这三个都是对象逻辑(或抽象或意义)状态的一部分。对于外部用户来说,Rectangle对象实际上有一个面积,即使该面积是即时计算的(例如,如果area()方法返回对象宽度和高度的乘积)。实际上,这是重要的一点,你的用户不知道也不关心你如何实现这些方法;你的用户仍然从他们的角度来看,你的对象逻辑上具有宽度、高度和面积的意义状态。

area()的例子展示了逻辑状态可以包含在物理状态中没有直接实现元素的案例。反之亦然:类有时会故意向用户隐藏其对象的物理(具体、位级)状态的一部分——它们故意不提供任何public成员函数或friend,以允许用户读取、写入甚至了解这种隐藏状态。这意味着对象的物理状态中存在一些位,它们在对象的逻辑状态中没有对应的元素。

作为后一种情况的一个例子,一个集合对象可能会缓存其上次查找结果,以期提高下次查找的性能。这个缓存当然是对象物理状态的一部分,但它是一个内部实现细节,可能不会暴露给用户——它可能不会成为对象逻辑状态的一部分。如果你从外向内思考,判断什么是什么是很容易的:如果集合对象的用户无法检查缓存本身的状态,那么缓存是透明的,并且不属于对象的逻辑状态。

我的public成员函数的const性应该基于方法对对象的逻辑状态还是物理状态的操作?

逻辑状态。

接下来的部分没有简单的方法。它很痛苦。最好的建议是坐下。为了你的安全,请确保附近没有尖锐的工具。

让我们回到集合对象示例。记住:有一个查找方法缓存了上次查找,希望能加快未来的查找。

让我们陈述一下可能显而易见的事情:假设查找方法对集合对象的任何逻辑状态都没有进行任何更改。

那么……痛苦的时刻来了。你准备好了吗?

来了:如果查找方法没有对集合对象的任何逻辑状态进行任何更改,但它确实更改了集合对象的物理状态(它对真实的缓存进行了非常真实的更改),那么查找方法应该是const吗?

答案是响亮的“是”。(每条规则都有例外,所以“是”旁边应该有一个星号,但绝大多数情况下,答案是“是”。)

这一切都关乎“逻辑 const”而非“物理 const”。这意味着,是否用const修饰方法的决定,主要取决于该方法是否保持逻辑状态不变,无论(你坐着吗?)(你可能想坐下)无论该方法是否恰好对对象的真实物理状态进行了非常真实的更改。

如果这还没有深入人心,或者你还没有感到痛苦,让我们将其分解为两种情况

  • 如果一个方法改变了对象逻辑状态的任何部分,那么它在逻辑上就是一个修改器;它不应该是const即使(实际确实如此!)该方法没有改变对象具体状态的任何物理位。
  • 反之,如果一个方法从不改变对象逻辑状态的任何部分,那么它在逻辑上就是检查器,应该加上const,即使(实际确实如此!)该方法改变了对象具体状态的物理位。

如果你感到困惑,请再读一遍。

如果你不困惑但感到愤怒,那很好:你可能还不喜欢它,但至少你理解了。深吸一口气,跟我重复:“一个方法的const性应该从对象的外部来看才有意义。”

如果你仍然生气,重复三遍这句话:“一个方法的const属性必须对对象的用户有意义,而这些用户只能看到对象的逻辑状态。”

如果你仍然生气,很抱歉,事实就是如此。忍着吧。是的,会有例外;每条规则都有例外。但作为一条规则,总的来说,这种逻辑const的概念对你和你的软件都有好处。

还有一件事。这可能会变得很荒谬,但让我们精确地讨论一个方法是否改变了对象的逻辑状态。如果你在类外部——你是一个普通用户,你所能执行的每一次实验(你调用的每一个方法或方法序列)都将产生相同的结果(相同的返回值,相同的异常或没有异常),无论你是否首先调用了那个查找方法。如果查找函数改变了任何未来方法的任何未来行为(不仅仅是使其更快,而是改变了结果,改变了返回值,改变了异常),那么查找方法改变了对象的逻辑状态——它是一个修改器。但如果查找方法除了可能使某些事情更快之外没有改变任何其他东西,那么它是一个检查器。

如果我想让一个const成员函数对数据成员进行“不可见”的修改,我该怎么做?

使用mutable(或者,作为最后的手段,使用const_cast)。

有少数检查器需要对对象的物理状态进行外部用户无法观察到的更改——对物理状态而非逻辑状态的更改。

例如,前面讨论的集合对象缓存了它的上次查找,希望能提高下次查找的性能。由于在这个例子中,缓存无法通过集合对象的任何公共接口(除了时间)直接观察到,它的存在和状态不属于对象的逻辑状态,所以对它的更改对外部用户是不可见的。查找方法是一个检查器,因为它从不改变对象的逻辑状态,无论它是否(至少在当前的实现中)改变了对象的物理状态

当方法改变物理状态但未改变逻辑状态时,该方法通常应标记为const,因为它确实是一个检查器方法。这会产生一个问题:当编译器看到你的const方法改变this对象的物理状态时,它会抱怨——它会给你的代码一个错误消息。

C++ 编译器语言使用mutable关键字来帮助你接受这种逻辑const概念。在这种情况下,你会在缓存上标记mutable关键字,这样编译器就知道它可以在const方法内部或通过任何其他const指针或引用进行更改。用我们的术语来说,mutable关键字标记了对象物理状态中不属于逻辑状态的部分。

mutable关键字紧跟在数据成员声明之前,也就是说,与你可以放置const的位置相同。另一种不推荐的方法是去除this指针的const属性,通常通过const_cast关键字进行

Set* self = const_cast<Set*>(this);
  // See the NOTE below before doing this!

在这行之后,self将与this拥有相同的位,也就是说,self == this,但是self是一个Set*而不是const Set*(严格来说,this是一个const Set* const,但最右边的const与本次讨论无关)。这意味着你可以使用self来修改this指向的对象。

注意: const_cast可能会发生一种极其罕见的错误。它只发生在三种非常罕见的情况同时发生时:一个本应是mutable的数据成员(如上所述),一个不支持mutable关键字和/或程序员不使用它的编译器,以及一个最初定义为const的对象(与被指向const的指针指向的普通非const对象相对)。尽管这种组合非常罕见,你可能永远不会遇到,但如果真的发生了,代码可能无法工作(标准规定行为未定义)。

如果你想使用const_cast,请改用mutable。换句话说,如果你需要修改对象的成员,并且该对象被指向const的指针所指向,最安全、最简单的方法是为该成员的声明添加mutable。如果你确定实际对象不是const(例如,如果你确定对象被声明为Set s;),那么你可以使用const_cast,但如果对象本身可能是const(例如,如果它可能被声明为const Set s;),则使用mutable而不是const_cast

不要写信说编译器Y在机器Z上的版本X允许你修改const对象的非mutable成员。我不在乎——根据语言规范,这是非法的,你的代码很可能会在不同的编译器甚至相同编译器的不同版本(升级版)上失败。就说“不”。改用mutable。编写保证能工作的代码,而不是似乎不会出错的代码。

const_cast是否意味着失去优化机会?

理论上是,实践中不是。

即使语言禁止const_cast,避免在调用const成员函数时刷新寄存器缓存的唯一方法是解决别名问题(即,证明没有非const指针指向该对象)。这只有在极少数情况下才能发生(当对象在const成员函数调用的作用域内构造时,并且在对象构造和const成员函数调用之间所有非const成员函数调用都是静态绑定的,并且这些调用中的每一个也都inline了,并且构造函数本身也inline了,并且构造函数调用的任何成员函数都inline)。

为什么编译器允许我在用const int*指向一个int后修改它?

因为“const int* p”的意思是“p承诺不改变*p”,而不是*p承诺不改变”。

const int*指向一个int并不会使这个int变成const。这个int不能通过const int*来改变,但是如果其他人有一个int*(注意:没有const)指向(“别名”)同一个int,那么这个int*就可以用来改变这个int。例如

void f(const int* p1, int* p2)
{
  int i = *p1;         // Get the (original) value of *p1
  *p2 = 7;             // If p1 == p2, this will also change *p1
  int j = *p1;         // Get the (possibly new) value of *p1
  if (i != j) {
    std::cout << "*p1 changed, but it didn't change via pointer p1!\n";
    assert(p1 == p2);  // This is the only way *p1 could be different
  }
}

int main()
{
  int x = 5;
  f(&x, &x);           // This is perfectly legal (and even moral!)
  // ...
}

请注意,main()f(const int*,int*)可能位于不同的编译单元中,它们在每周的不同日期编译。在这种情况下,编译器不可能在编译时检测到别名。因此,我们无法制定一个禁止此类行为的语言规则。事实上,我们甚至不希望制定这样的规则,因为通常允许有多个指针指向同一个事物被认为是一个特性。其中一个指针承诺不改变底层“事物”的事实,只是指针所做的承诺;它不是“事物”所做的承诺。

const Fred* p”是否意味着*p不能改变?

不!(这与关于int指针别名的FAQ有关。)

const Fred* p”表示Fred不能通过指针p改变,但可能存在其他不通过const(例如,一个别名的非const指针,如Fred*)访问对象的方式。例如,如果你有两个指针“const Fred* p”和“Fred* q”指向同一个Fred对象(别名),那么指针q可以用来改变Fred对象,但指针p不能。

class Fred {
public:
  void inspect() const;   // A const member function
  void mutate();          // A non-const member function
};

int main()
{
  Fred f;
  const Fred* p = &f;
  Fred*       q = &f;

  p->inspect();    // Okay: No change to *p
  p->mutate();     // Error: Can't change *p via p

  q->inspect();    // Okay: q is allowed to inspect the object
  q->mutate();     // Okay: q is allowed to mutate the object

  f.inspect();     // Okay: f is allowed to inspect the object
  f.mutate();      // Okay: f is allowed to mutate the object

  // ...
}

为什么我将Foo**转换为const Foo**时会出错?

因为将Foo**转换为const Foo**是无效且危险的。

C++允许(安全地)将Foo*转换为Foo const*,但如果你尝试隐式地将Foo**转换为const Foo**,则会报错。

为什么这个错误是件好事,原因如下。但首先,最常见的解决方案是:只需将const Foo**更改为const Foo* const*

class Foo { /* ... */ };

void f(const Foo** p);
void g(const Foo* const* p);

int main()
{
  Foo** p = /*...*/;
  // ...
  f(p);  // ERROR: it's illegal and immoral to convert Foo** to const Foo**
  g(p);  // Okay: it's legal and moral to convert Foo** to const Foo* const*
  // ...
}

Foo**转换为const Foo**之所以危险,是因为它会让你在没有强制转换的情况下,悄无声息地意外修改一个const Foo对象

class Foo {
public:
  void modify();  // make some modification to the this object
};

int main()
{
  const Foo x;
  Foo* p;
  const Foo** q = &p;  // q now points to p; this is (fortunately!) an error
  *q = &x;             // p now points to x
  p->modify();         // Ouch: modifies a const Foo!!
  // ...
}

如果q = &p这行代码是合法的,那么q将指向p。下一行,*q = &x,将p本身(因为*q就是p)改变为指向x。这将是一件坏事,因为我们丢失了const限定符:p是一个Foo*,但x是一个const Foop->modify()这行利用了p修改其所指对象的能力,这才是真正的问题,因为我们最终修改了一个const Foo

打个比方,如果你把一个罪犯伪装成守法公民,他就可以利用这种伪装所带来的信任。这很糟糕。

幸好C++阻止你这样做:q = &p这一行被C++编译器标记为编译时错误。提醒:请不要通过指针强制转换来绕过那个编译时错误信息。就说“不”!

(注:这与禁止将Derived**转换为Base**有概念上的相似之处。)