Shuffy

不断的学习,不断的思考,才能不断的进步.Let's do better together!
posts - 102, comments - 43, trackbacks - 0, articles - 19
本文原址:http://www.cppblog.com/tiandejian/archive/2007/04/11/ECPP_03.html

 

第3项: 尽可能使用 const

const令人赞叹之处就是:你可以通过它来指定一个语义上的约束(一个特定的不能够更改的对象)这一约束由编译器来保证。通过一个const,你可以告诉编译器和其他程序员,你的程序中有一个数值需要保持恒定不变。不管何时,当你需要这样一个数时,你都应该这样做,这样你便可以让编译器来协助你确保这一约束不被破坏。

const 关键字的用途十分广泛。在类的外部,你可以定义全局的或者名字空间域的常量,也可以通过添加 static 关键字来定义文件、函数、或者程序块域的对象。在类的内部,你可以使用它来定义静态的或者非静态的数据成员。对于指针,你可以制定一个指针是否是 const 的,其所指的数据是否是 const 的,或者两者都是 const ,或者两者都不是。

char greeting[] = "Hello";

char *p = greeting;                    // const 指针,非 const 数据

const char *p = greeting;              // const 指针, const 数据

char * const p = greeting;             // const 指针,非 const 数据

const char * const p = greeting;       // const 指针, const 数据

这样的语法看上去反复无常,实际上并不是这样。如果 const 关键字出现在星号的左边,那么指针所指向的就是一个常量;如果 const 出现在星号的右边,那么指针本身就是一个常量;如果 const 同时出现在星号的两边,那么两者就都是常量。

当所指向的为常量时,一些程序员喜欢把 const 放在类型之前;其他一些人则喜欢放在类型后边,但要在星号的前边。这两种做法没有什么本质的区别,所以下边给出的两个函数声明的参数表实际上是相同的:

void f1(const Widget *pw); // f1 传入一个指向 Widget 对象常量的指针

void f2(Widget const *pw); // f2 也一样

由于这两种形式在实际代码中都会遇到,所以你都要适应。

STL 迭代器是依照指针模型创建的, 所以说一个 iterator 更加像一个指向 T* 的指针。把一个 iterator 声明为 const 的更像是声明一个 const 的指针(也就是声明一个指向 T* const 的指针): iterator 不允许指向不同类型的内容,但是其所指向的内容可以被修改。如果你希望一个迭代器指向某些不能被修改的内容(也就是指向 const T* 的指针),此时你需要一个 const_iterator

std::vector<int> vec;

...

const std::vector<int>::iterator iter = vec.begin();

                                  // iter 就像一个 T* const

*iter = 10;                       // 正确,可以改变 iter 所指向的内容

++iter;                           // 出错! Iter 是一个 const

 

std::vector<int>::const_iterator cIter = vec.begin();

                                  // cIter 就像一个 const T*

*cIter = 10;                      // 出错! *cIter 是一个 const

++cIter;                          // 正确,可以改变 cIter

const 在函数声明方面还有一些强大的用途。在一个函数声明的内部, const 可以应用在返回值、单个参数,对于成员函数,可以将其本身声明为 const 的。

让函数返回一个常量通常可以减少意外发生的可能,而且不用放弃考虑安全和效率问题。好比有理数乘法函数( operator* )的声明,更多信息请参见第 24 项。

class Rational { ... };

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

很多程序员在初次到这样的代码时都不会正眼看一下。为什么 operator* 要返回一个 c onst 对象呢?这是因为如果不是这样,客户端将会遇到一些不愉快的状况,比如:

Rational a, b, c;

...

(a * b) = c;                  // 调用 operator= 能返回一个 a*b

我不知道为什么一些程序员会企图为两个数 的乘积赋值,但是我确实知道好多程序员的初衷并非如此。他们也许仅仅在录入的时候出了个小差错(他们的本意也许是一个布尔型的表达式):

if (a * b = c) ...            // 本来是想进行一次比较!

显而易见,如果 a b 是内建数据类型,那么这样的代码就是非法的。避免与内建数据类型不必要的冲突,这是一个优秀的用户自定义类型的设计标准之一(另请参见第 18 项),而允许为两数乘积赋值这让人看上去就很不必要。声明 operator* 函数时如果让其返回一个 const 型数据则可以避免这一冲突,这便是要这样做的原因所在。

const 的参数没有什么特别新鲜的——它们与局部 const 对象的行为基本一致,你在必要的时候要尽可能使用它们。除非你需要更改某个参数或者局部对象,其余的所有情况最好都声明为 const 。这仅仅需要你多打六个字母,但是它可以使你从恼人的错误(比如我们刚才见到的“我本想打‘ == ’但是却打了‘ = ’”)中解放出来。

const 成员函数

对成员函数使用 const 的目的是指明这些成员函数可以被 const 对象调用。这一类成员函数是很重要的,首先,它们使得类的接口更加易于理解。很有必要了解哪些函数可以修改而哪些不可以。其次,这些函数可以与 const 对象协同工作。这对于高效编码是十分重要的一方面,这是由于(将在第 20 项中展开解释)提高 C++ 程序性能的一条最基本的途径就是:传递对象的 const 引用。使用这一技 术需要一个前提:这就是首先要有 const 成员函数存在,并且它们用于处理之前生成的 const 对象。

如果若干成员函数之间的区别仅仅为“是否是 const 的”,那么它们也可以被重载。很多人都忽略了这一点,但是这是 C++ 重要特征之一。请观察下面的代码,这是一个文字块的类:

class TextBlock {

public:

 ...

 const char& operator[](std::size_t position) const

                                // operator[] 用于返回相应位置的字符

 { return text[position]; }     // 返回一个 const 对象

 

 char& operator[](std::size_t position)

                                // operator[] 用于返回相应位置的字符

 { return text[position]; }     // 返回一个非 const 对象

private:

   std::string text;

};

TextBlock operator[] 可以这样使用:

TextBlock tb("Hello");

std::cout << tb[0];        // 调用非 const TextBlock::operator[]

 

const TextBlock ctb("World");

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

顺便说一下,在真实的程序中, const 对象在大多数情况下都以“通过指针传递”或“引用一个 const ”的形式出现。 上面的 ctb 的例子纯粹是人为的,而下面的例子在真实状况中常会出现:

void print(const TextBlock& ctb)       // 在这个函数中 ctb const

{

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

 ...

}

通过对 operator[] 的重载以及为每个版本提供不同类型的返回值,你便可以以不同的方式处理 const 的或者非 const TextBlock

std::cout << tb[0];           // 正确:读入一个非 const TextBlock

tb [0] = 'x';                  // 正确:改写一个非 const TextBlock

std::cout << ctb[0];          // 正确:读入一个 const TextBlock

ctb [0] = 'x';                 // 错误 ! 不能改写 const TextBlock

请注意,这一错误只与所调用的 operator[] 返回值的类型有关,如果仅仅调用 operator[] 本身则不会出现任何问题。错误出现在:企图为一个 const char& 赋值,而 const char& 则是 operator[] const 版本的返回值类型。

同时还要注意的是,非 const operator[] 的返回值类型是一个 char 的引用,而不是 char 本身。如果 operator[] 真的简单的返回一个 char ,那么下面的语句将不能正确编译:

tb[0] = 'x';

这是因为,企图修改一个返回内建数据类型的函数的返回值根本都是非法的。即使假设这样做合法,而 C++ 通过传值返回对象的,所修改的仅仅是由 tb.text[0] 复制出的一份副本,而不是 tb.text[0] 本身,你不会得到预期的效果。

让我们暂停一小会儿,来考虑一下这里边的哲学问题。把一个成员函数声明为 const 的有什么涵义呢?这里有两个流行的说法:按位恒定(也可叫做物理恒定)和逻辑恒定

按位 恒定阵营坚信:当且仅当一个成员函数对于所有对象的数据成员( static 数据成员除外)都不做出改动时,才需要将这一成员函数声明为 const 的,换句话说,将成员函数声明为 const 的条件是:成员函数不对对象内部做任何的改动。按位恒定的好处之一就是,它使得错误检查便得更轻松:编译器仅需要查找对数据成员的赋值。实际上,按位恒定就是 C++ 对于恒定的定义,如果一个 const 的成员函数调用了某个对象,那么即使该对象拥有非静态数据成员,其所有数据成员也都是不可修改的。

不幸的是,大多数不完全是 const 的成员函数也可以通过按位恒定的检验。在特定的情况下,如果一个成员函数频繁的修改一个指针所指的位置,那么我们说它就不是一个 const 的成员函数。但是只要这个指针存在于一个对象中,这个函数就是按位恒定的,这时候编译器不会报错。这样会导致编成的行为不符合常规习惯。比如说,我们手头有一个类似于 TextBlock 的类,其中保存着 char* 类型的数据而不是 string ,因为这段代码有可能要与一些 C 语言的 API 交互,但是 C 语言中没有 string 对象一说。

class CTextBlock {

public:

 ...

 char& operator[](std::size_t position) const

 // operator[] 不恰当的 (但是符合按位恒定规则)定义方法

 { return pText[position]; }

private:

 char *pText;

};

尽管 operator[] 返回一个对对象内部数据的引用,这个类仍(不恰当地)将其声明为 const 成员函数(第 28 项将深入讨论这个问题)。先忽略这个问题,请注意 operator[] 的实现中并没有以任何形式修改 pText 。于是编译器便会欣然接受这样的做法,毕竟,所有的编译器所检查的是“代码是否符合按位恒定规则”。但是请观察,在编译器的纵容下,还会有什么样的事情发生:

const CTextBlock cctb("Hello");// 声明对象常量

 

char *pc = &cctb[0];            // 调用 const operator[]

                                // 从而得到一个指向 cctb 中数据的指针

 

*pc = 'J';                      // cctb 现在的值为 "Jello"

当你创建了一个包含具体值的对象常量后,你仅仅通过对其调用 const 的成员函数,就可以改变它的值!这显然是有问题的。

 

辑恒定应运而生。坚持这一宗旨的人们争论道,一个 const 的成员函数可能对其调用的对象内部做出改动,但是仅仅以客户端无法察觉的方式进行。比如说,你的 CTextBlock 类可能需要保存文字块的长度,以便在需要的时候调用:

class CTextBlock {

public:

 ...

 std::size_t length() const;

 

private:

 char *pText;

 std::size_t textLength;      // 最后一次计算出的文字块长度

 bool lengthIsValid;          // 当前长度是否可用

};

 

std::size_t CTextBlock::length() const

{

 if (!lengthIsValid) {

textLength = std::strlen(pText);    // 错误!不能在 const 成员函数中

lengthIsValid = true;          // textLength lengthIsValid 赋值

 }

 return textLength;

}

以上 length 的实现绝不是按位恒定的。这是因为 textLength lengthIsValid 都可以改动。尽管看上去它应该对于 CTextBlock 对象常量可用,但是编译器不答应。编译器始终坚持遵守按位恒定。那么该怎么办呢?

解决方法很简单:利用 C++ const 相关的灵活性,使用可变的( mutable )数据成员。 mutable 可以使非静态数据成员不受按位恒定规则的约束:

class CTextBlock {

public:

 ...

 std::size_t length() const;

 

private:

 char *pText;

 mutable std::size_t textLength;// 这些数据成员在任何情况下均可修改

 mutable bool lengthIsValid;     // const 成员函数中也可以

};

 

std::size_t CTextBlock::length() const

{

 if (!lengthIsValid) {

    textLength = std::strlen(pText);    // 现在可以修改了

    lengthIsValid = true;               // 同上

 }

 return textLength;

}

避免 const 与非 const 成员函数之间的重复

mutable 对于“我不了解按位恒定”的情况不失为一个良好的解决方案,但是它对于所有的 const 难题并不能做到一劳永逸。举例说, TextBlock (以及 CTextBlock )中的 operator[] 不仅仅返回一个对恰当字符的引用,同时还要进行边界检查、记录访问信息,甚至还要进行数据完整性检测。如果将所有这些统统放在 const 或非 const 函数(我们现在会得到过于冗长的隐式内联函数,不过不要惊慌,在第 30 项中这个问题会得到解决)中,看看我们会得到什么样的庞然大物:

class TextBlock {

public:

 ...

 const char& operator[](std::size_t position) const

 {

    ...                                 // 边界检查

    ...                                 // 记录数据访问信息

    ...                                 // 确认数据完整性

    return text[position];

 }

 char& operator[](std::size_t position)

 {

    ...                                 // 边界检查

    ...                                 // 记录数据访问信息

    ...                                 // 确认数据完整性

    return text[position];

 }

 

private:

   std::string text;

};

噢!天哪,这让人头疼:重复代码,以及随之而来的编译时间增长、维护成本增加、代码膨胀、等等……当然,像边界检查这一类代码是可以移走的,它们可以单独放在一个成员函数(当然是私有的)中,然后让这两个版本的 operator[] 来调用它,但是你的代码仍然有重复的函数调用,以及重复的 return 语句。

对于 operator[] 你真正需要的是:一次实现,两次使用。也就是说,你需要一个版本的 operator[] 来调用另一个。这样便可以通过转型来消去函数的恒定性。

通常情况下转型是一个坏主意,后边我将专门用一项来告诉你为什么不要使用转型(第 21 项),但是代码重复也不会让人感到有多轻松。在这种情况下, const 版的 operator[] 与非 const 版的 operator[] 所做的事情完全相同,不同的仅仅是它的返回值是 const 的。通过转型来消去返回值的恒定性是安全的,这是因为任何人调用这一非 const operator[] 首先必须拥有一个非 const 的对象,否则它就不能调用非 const 函数。所以尽管需要一次转型,在 const operator[] 中调用非 const 版本,可以安全地避免代码重复。下面是实例代码,读完后边的文字解说你会更明了。

class TextBlock {

public:

 ...

 const char& operator[](std::size_t position) const     // 同上

 {

    ...

    ...

    ...

    return text[position];

 }

 char& operator[](std::size_t position) // 现在仅调用 const op[]

 {

    return

      const_cast<char&>(       // 通过对 op[] 的返回值进行转型,消去 const

        static_cast<const TextBlock&>(*this)// *this 的类型添加 const

          [position];                      // 调用 const 版本的 op[]

      );

 }

...

};

就像你所看到的,上面的代码进行了两次转型,而不是一次。我们要让非 const operator[] 去调用 const 版本的,但是如果在非 const operator[] 的内部,我们只调用 operator[] 而不标明 const ,那么函数将对自己进行递归调用。那将是成千上万次的毫无意义的操作。为了避免无穷递归的出现,我们必须要指明我们要调用的是 const 版本的 operator[] ,但是手头并没有直接的办法。我们可以用 *this TextBlock& 转型到 const TextBlock& 来取代。是的,我们使用了一次转型添加了一个 const !这样我们就进行了两次转型:一次为 *this 添加了 const (于是对于 operator[] 的调用将会正确地选择 const 版本),第二次转型消去了 const operator[] 返回值中的 const

添加 const 的那次转型是为了保证转换工作的安全性(从一个非 const 对象转换为一个 const 的),这项工作的关键字是 static_cast 。消去 const 的工作只可以通过 const_cast 来完成,所以在这里我们实际上并没有其他的选择。(从技术上讲,我们有。 C 言风格的转型在这里也能工作,但是,就像我在第 27 项中所讲的,这一类转型在很多情况下都不是好的选择。如果你对于 static_cast const_cast 还不熟悉,第 27 项中有详细的介绍。)

在众多的示例中,我们最终选择了一个运算符来进行演示,因此上面的语法显得有些古怪。这些代码可能不会赢得任何选美比赛,但是通过以 const 版本的形式实现非 const 版本的 operator[] ,可以避免代码重复,这正是我们所期望的。为达到这一目标而写下看似笨拙的代码,这样做是否值得全看你的选择,但是,以 const 版本的形式来实现非 const 的成员函数——了解这一技术肯定是值得的。

更值得你了解的是按反方向完成上面的工作——通过让 const 版本的函数调用非 const 版本来避免代码重复——一定不要这样做。请记住,一个 const 成员函数保证其对象永远不会更改其逻辑状态,但是一个非 const 的成员函数并没有这一类的保证。如果你在一个 const 函数中调用了一个非 const 函数,曾保证不会被改动的对象就有被修改的风险。这就是为什么说让一个 const 函数调用一个非 const 函数是错误的:对象有可能被修改。实际上,为了使代码能够得到编译,你还需要使用一个 const_cast 来消去 *this const 属性,显然这是不必要的麻烦。上一段中相反的调用次序才是安全的:非 const 成员函数可以对一个对象做任何想做的事情,因此调用一个 const 成员函数不会带来任何风险。这就是为什么 static_cast 在没有与 const 相关的危险的情况下可以正常工作的原因。

就像本项最开始所说的, const 是一个令人赞叹的东西。对于指针和迭代器,以及指针、迭代器和引用所涉及的对象,函数的参数和返回值,局部变量,成员函数来说, const 都是一个强大的伙伴。只要可能就可以使用它。你会对你所做的事情感到高兴的。

需要记住的

将一些东西声明为 const 的可以帮助编译器及时发现用法上的错误。 const 针对对象作用于所有的作用域,针对函数参数和返回值、成员函数作用于整体。

编译器严格遵守按位恒定规则,但是你应该在需要时应用逻辑恒定。

const 和非 const 成员函数的实现在本质上相同时,可以通过使用一个非 const 版本来调用 const 版本来避免代码重复。


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理