C++箴言:只要可能就用const

  • 发布于:2023-09-04
  • 132 人围观

  关于 const 的一件美妙的事情是它允许你指定一种语义上的约束:一个特定的对象不应该被修改。而编译器将执行这一约束。它允许你通知编译器和其他程序员,某个值应该保持不变。如果确实如此,你就应该明确地表示出来,因为这样一来,你就可以谋取编译器的帮助,确定这个值不会被改变。

  关键字 const 非常多才多艺。在类的外部,你可以将它用于全局常量或命名空间常量,就像那些在文件、函数或模块范围内被声明为 static 的对象。在类的内部,你可以将它用于 static 和 non-static 数据成员上。对于指针,你可以指定这个指针本身是 const,或者它所指向的数据是 const,或者两者都是,或者都不是。

char greeting[] = "Hello";

char *p = greeting; // non-const pointer,
// non-const data

const char *p = greeting; // non-const pointer,
// const data

char * const p = greeting; // const pointer,
// non-const data

const char * const p = greeting; // const pointer,
// const data

  这样的语法本身其实并不像表面上那样反复无常。如果 const 出现在 * 左边,则指针指向的内容为常量;如果 const 出现在 * 右边,则指针自身为常量;如果 const 出现在 * 两边,则两者都为常量。

  当指针指向的内容为常量时,一些人将 const 放在类型之前,另一些人将 const 放在类型之后 * 之前。两者在意义上并没有区别,所以,如下两个函数具有相同的参数类型:

void f1(const Widget *pw); // f1 takes a pointer to a
// constant Widget object

void f2(Widget const *pw); // so does f2

  因为它们都存在于实际的代码中,你应该习惯于这两种形式。

  STL iterators 以指针为原型,所以一个 iterator 在行为上非常类似于一个 T* 指针。声明一个 iterator 为 const 就类似于声明一个指针为 const(也就是说声明一个 T* const 指针):不能将 iterator 指向另外一件不同的东西,但是它所指向的东西本身可以变化。如果你要一个 iterator 指向一个不能变化的东西(也就是 const T* 的 STL 对等物),你应该用 const_iterator:

std::vector<int> vec;
...
const std::vector<int>::iterator iter = // iter acts like a T* const

vec.begin();

*iter = 10; // OK, changes what iter points to

++iter; // error! iter is const

std::vector<int>::const_iterator cIter = //cIter acts like a const T*

vec.begin();

*cIter = 10; // error! *cIter is const

++cIter; // fine, changes cIter

  对 const 最强有力的用法来自于它在函数声明中的应用。在一个函数声明中,const 既可以用在函数返回值上,也可以用在个别的参数上,对于成员函数,还可以用于整个函数。

  一个函数返回一个常量,常常可以在不放弃安全和效率的前提下尽可能减少客户的错误造成的影响。例如,考虑在 Item 24 中考察的 rational 成员 operator* 的声明:

class Rational { ... };

const Rational operator*(const Rational& lhs, const Rational& rhs);

  很多第一次看到这些的人会不以为然。为什么 operator* 的结果应该是一个 const 对象?因为如果它不是,客户就可以犯下如此暴行:

Rational a, b, c;
...
(a * b) = c; // invoke operator= on the
// result of a*b!

  我不知道为什么一些程序员要为两个数的乘积赋值,但是我知道很多程序员这样做也并非不称职。所有这些可能来自一个简单的输入错误(要求这个类型能够隐式转型到 bool):

if (a * b = c) ... // oops, meant to do a comparison!
  如果 a 和 b 是内建类型,这样的代码显而易见是非法的。一个好的用户自定义类型的特点就是要避免与内建类型毫无理由的不和谐,而且对我来说允许给两个数的乘积赋值看上去正是毫无理由的。将 operator* 的返回值声明为 const 就可以避免这一点,这就是我们要这样做的理由。

  关于 const 参数没什么特别新鲜之处——它们的行为就像局部的 const 对象,而且无论何时,只要你能,你就应该这样使用。除非你需要改变一个参数或本地对象的能力,否则,确认将它声明为 const。它只需要你键入六个字符,就能将你从我们刚刚看到的这个恼人的错误中拯救出来:“我想键入‘==’,但我意外地键入了‘=’”。

  const 成员函数

  成员函数被声明为 const 的目的是确信这个函数可能会被 const 对象调用。因为两个原因,这样的成员函数非常重要。首先,它使一个类的接口更容易被理解。知道哪个函数可以改变对象而哪个不可以是很重要的。第二,它们可以和 const 对象一起工作。书写高效代码有一个很重要的方面,就像 Item 20 所解释的,提升一个 C++ 程序的性能的基本方法就是就是传递一个对象的引用给一个 const 参数。这个技术只有在 const 候选对象有 const 成员函数可操作时才是可用的。

  很多人没有注意到这样的事实,即成员函数只有常量性不同时是可以被重载的,这是 C++ 的一个重要特性。考虑一个代表文字块的类:

class TextBlock {

  public:
   ...
   const char& operator[](std::size_t position) const // operator[] for
   { return text[position]; } // const objects
   char& operator[](std::size_t position) // operator[] for
   { return text[position]; } // non-const objects
  private:
   std::string text;
};

  TextBlock 的 operator[]s 可能会这样使用:

TextBlock tb("Hello");

std::cout << tb[0]; // calls non-const
// TextBlock::operator[]

const TextBlock ctb("World");

std::cout << ctb[0]; // calls const TextBlock::operator[]

  顺便提一下,const 对象在实际程序中最经常使用的是作为这样一个操作的结果:将指针或者引用传递给 const 参数。上面的 ctb 的例子是人工假造的。下面这个例子更真实一些:

void print(const TextBlock& ctb) // in this function, ctb is const
{
  std::cout << ctb[0]; // calls const TextBlock::operator[]
  ...
}

  通过将 operator[] 重载,而且给不同的版本不同的返回类型,你能对 const 和 non-const 的 TextBlocks 做不同的操作:

std::cout << tb[0]; // fine - reading a
// non-const TextBlock

tb[0] = ’x’; // fine - writing a
// non-const TextBlock

std::cout << ctb[0]; // fine - reading a
// const TextBlock

ctb[0] = ’x’; // error! - writing a
// const TextBlock

  请注意这个错误只是发生在调用 operator[] 的返回类型上,而调用 operator[] 本身总是正确的。错误出现在企图为 const char& 赋值的时候,而这正是 const 版本的 operator[] 的返回类型。

  再请注意 non-const 版本的 operator[] 的返回类型是一个字符的引用而不是字符本身。如果 operator[] 只是简单地返回一个字符,下面的语句将无法编译:

tb[0] = ’x’;
  因为改变一个返回内建类型的函数的返回值总是非法的。如果它合法,那么 C++ 以值(by value)返回对象这一事实(参见 Item 20)就意味着 tb.text[0] 的副本被改变,而不是 tb.text[0] 自己,这不会是你想要的行为。

  让我们为哲学留一点时间。看看一个成员函数是 const 意味着什么?有两个主要的概念:二进制位常量性(bitwise constness)(也称为物理常量性(physical constness))和逻辑常量性(logical constness)。

  二进制位 const 派别坚持认为,一个成员函数,当且仅当它不能改变对象的任何数据成员(static 成员除外),也就是说不能改变对象内的任何二进制位,则这个成员函数就是 const。二进制位常量性的一个好处是比较容易监测违例:编译器只需要寻找对数据成员的赋值。实际上,二进制位常量性就是 C++ 对常量性的定义,一个 const 成员函数不被允许改变调用它的对象的任何 non-static 数据成员。

  不幸的事,很多成员函数并不能完全通过二进制位常量性的检验。特别是,一个经常改变一个指针指向的内容的成员函数。除非这个指针在这个对象中,否则这个函数就是二进制位 const 的,编译器也不会提出异议。例如,假设我们有一个类似 TextBlock 的类,因为它需要与一个不知 string 为何物的 C API 打交道,所以它需要将它的数据存储为 char* 而不是 string。

class CTextBlock {
  public:
   ...
   char& operator[](std::size_t position) const // inappropriate (but bitwise

   { return pText[position]; } // const) declaration of
   // operator[]
  private:
   char *pText;
};

  尽管 operator[] 返回对象内部数据的引用,这个类还是(不适当地)将它声明为 const 成员函数(Item 28 将谈论一个深入的主题)。先将它放到一边,看看 operator[] 的实现,它并没有使用任何手段改变 pText。结果,编译器愉快地生成了 operator[] 的代码,因为对所有编译器而言,它都是二进制位 const 的,但是我们看看会发生什么:

const CTextBlock cctb("Hello"); // declare constant object

char *pc = &cctb[0]; // call the const operator[] to get a
// pointer to cctb’s data

*pc = ’J’; // cctb now has the value "Jello"

  这里确实出了问题,你用一个确定的值创建一个常量对象,然后你只是用它调用了 const 成员函数,但是你改变了它的值! 这就引出了逻辑常量性的概念。这一理论的信徒认为:一个 const 成员函数被调用的时候可能会改变对象中的一些二进制位,但是只能用客户无法感觉到的方法。例如,你的 CTextBlock 类在需要的时候可以储存文字块的长度:

class CTextBlock {
  public:
   ..
   std::size_t length() const;

  private:
   char *pText;
   std::size_t textLength; // last calculated length of textblock
   bool lengthIsValid; // whether length is currently valid
};

std::size_t CTextBlock::length() const
{
  if (!lengthIsValid) {
   textLength = std::strlen(pText); // error! can’t assign to textLength
   lengthIsValid = true; // and lengthIsValid in a const
  } // member function
  return textLength;
}

  length 的实现当然不是二进制位 const 的—— textLength 和 lengthIsValid 都可能会被改变——但是它还是被看作对 const CTextBlock 对象有效。但编译器不同意,它还是坚持二进制位常量性,怎么办呢?

  解决方法很简单:利用以关键字 mutable 为表现形式的 C++ 的 const-related 的灵活空间。mutable 将 non-static 数据成员从二进制位常量性的约束中解放出来:

class CTextBlock {
  public:
   ...
   std::size_t length() const;

  private:
   char *pText;
   mutable std::size_t textLength; // these data members may
   mutable bool lengthIsValid; // always be modified, even in
}; // const member functions

std::size_t CTextBlock::length() const
{
  if (!lengthIsValid) {
   textLength = std::strlen(pText); // now fine
   lengthIsValid = true; // also fine
  }
  return textLength;
}

  避免 const 和 non-const 成员函数的重复

  mutable 对于解决二进制位常量性不太合我的心意的问题是一个不错的解决方案,但它不能解决全部的 const-related 难题。例如,假设 TextBlock(包括 CTextBlock)中的 operator[] 不仅要返回一个适当的字符的引用,它还要进行边界检查,记录访问信息,甚至数据完整性确认,将这些功能加入到 const 和 non-const 的 operator[] 函数中,使它们变成如下这样的庞然大物:

class TextBlock {
  public:
   ..
   const char& operator[](std::size_t position) const
   {
    ... // do bounds checking
    ... // log access data
    ... // verify data integrity
    return text[position];
   }
   char& operator[](std::size_t position)
   {
    ... // do bounds checking
    ... // log access data
    ... // verify data integrity
    return text[position];
   }
  private:
   std::string text;
};

  哎呀!你是说重复代码?还有随之而来的额外的编译时间,维护成本以及代码膨胀等令人头痛的事情吗?当然,也可以将边界检查等全部代码转移到一个单独的成员函数(当然是私有的)中,并让两个版本的 operator[] 来调用它,但是,你还是要重复写出调用那个函数和返回语句的代码。

  怎样才能只实现一次 operator[] 功能,又可以使用两次呢?你可以用一个版本的 operator[] 去调用另一个版本。并通过强制转型去掉常量性。

  作为一个通用规则,强制转型是一个非常坏的主意,我将投入整个一个 Item 来告诉你不要使用它,但是重复代码也不是什么好事。在当前情况下,const 版本的 operator[] 所做的事也正是 non-const 版本所做的,仅有的不同是它有一个 const 返回类型。在这种情况下,通过转型去掉返回类型的常量性是安全的,因为,无论谁调用 non-const operator[],首要条件是有一个 non-const 对象。否则,他不可能调用一个 non-const 函数。所以,即使需要一个强制转型,让 non-const operator[] 调用 const 版本以避免重复代码的方法也是安全的。代码如下,随后的解释可能会让你对它的理解更加清晰:

class TextBlock {
  public:
   ...
   const char& operator[](std::size_t position) const // same as before
   {
    ...
    ...
    ...
    return text[position];
   }
   char& operator[](std::size_t position) // now just calls const op[]
   {
    return
    const_cast<char&>( // cast away const on
    // op[]’s return type;
    static_cast<const TextBlock&>(*this) // add const to *this’s type;
    [position] // call const version of op[]
   );
  }
  ...
};

  正如你看到的,代码中有两处强制转型,而不止一处。我们让 non-const operator[] 调用 const 版本,但是,如果在 non-const operator[] 的内部,我们仅仅调用了 operator[],那我们将递归调用我们自己一百万次甚至更多。为了避免无限递归,我们必须明确指出我们要调用 const operator[],但是没有直接的办法能做到这一点,于是我们将 *this 从它本来的类型 TextBlock& 强制转型到 const TextBlock&。是的,我们使用强制转型为它加上了 const!所以我们有两次强制转型:第一次是为 *this 加上 const(目的是当我们调用 operator[] 时调用的是 const 版本),第二次是从 const operator[] 的返回值之中去掉 const。

  增加 const 的强制转型是一次安全的转换(从一个 non-const 对象到一个 const 对象),所以我们用 static_cast 来做。去掉 const 的强制转型可以用 const_cast 来完成,在这里我们没有别的选择。

  在完成其它事情的基础上,我们在此例中调用了一个操作符,所以,语法看上去有些奇怪。导致其不会赢得选美比赛,但是它通过在 const 版本的 operator[] 之上实现其 non-const 版本而避免重复代码的方法达到了预期的效果。使用丑陋的语法达到目标是否值得最好由你自己决定,但是这种在一个 const 成员函数的基础上实现它的 non-const 版本的技术却非常值得掌握。

  更加值得知道的是做这件事的反向方法——通过用 const 版本调用 non-const 版本来避免代码重复——是你不能做的。记住,一个 const 成员函数承诺不会改变它的对象的逻辑状态,但是一个 non-const 成员函数不会做这样的承诺。如果你从一个 const 成员函数调用一个 non-const 成员函数,你将面临你承诺不会变化的对象被改变的风险。这就是为什么使用一个 const 成员函数调用一个 non-const 成员函数是错误的,对象可能会被改变。实际上,那样的代码如果想通过编译,你必须用一个 const_cast 来去掉 *this 的 const,这样做是一个显而易见的麻烦。而反向的调用——就像我在上面的例子中用的——是安全的:一个 non-const 成员函数对一个对象能够为所欲为,所以调用一个 const 成员函数也没有任何风险。这就是 static_cast 可以在这里工作的原因:这里没有 const-related 危险。

  就像在本文开始我所说的,const 是一件美妙的东西。在指针和迭代器上,在涉及对象的指针,迭代器和引用上,在函数参数和返回值上,在局部变量上,在成员函数上,const 是一个强有力的盟友。只要可能就用它,你会为你所做的感到高兴。

  Things to Remember

  ·将某些东西声明为 const 有助于编译器发现使用错误。const 能被用于对象的任何范围,用于函数参数和返回类型,用于整个成员函数。

  ·编译器坚持二进制位常量性,但是你应该用概念上的常量性(conceptual constness)来编程。(此处原文有误,conceptual constness 为作者在本书第二版中对 logical constness 的称呼,正文中的称呼改了,此处却没有改。其实此处还是作者新加的部分,却使用了旧的术语,怪!——译者)

  ·当 const 和 non-const 成员函数具有本质上相同的实现的时候,使用 non-const 版本调用 const 版本可以避免重复代码。

万企互联
标签: