洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目3] 尽可能使用const

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

const关键字的用途十分广泛。在类的外部,你可以利用它定义全局的或者名字空间域的常量(参见条目2),也可以通过添加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*要返回一个const对象呢?这是因为如果不是这样,客户端将会遇到一些不愉快的状况,比如:

Rational a, b, c;

...

(a * b) = c;                       // 调用operator= 能为a*b的结果赋值!

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

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

如果ab是内建数据类型,那么这样的代码很明显就是非法的。避免与内建数据类型不必要的冲突,这是一个优秀的用户自定义类型的设计标准之一(另请参见条目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

  { return text[position]; }       // operator[] :用于const对象

 

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

  { return text[position]; }       // operator[] :用于非const对象

 

private:

   std::string text;

};

TextBlockoperator[]可以这样使用:

TextBlock tb("Hello");

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

 

const TextBlock ctb("World");

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

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

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

{

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

  ...

}

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

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

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

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

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

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

同时还要注意的是,非constoperator[]的返回值是一个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];               // 调用constoperator[]

                                   // 从而得到一个指向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;          // textLengthlengthIsValid赋值

  }

  return textLength;

}

以上length的实现绝不是按位恒定的。这是因为textLengthlengthIsValid都可以改动。尽管看上去它应该对于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的。通过转型来消去返回值的恒定性是安全的,这是因为任何人调用这一非constoperator[]首先必须拥有一个非const的对象,否则它就不能调用非const函数。所以尽管需要一次转型,在constoperator[]中调用非const版本,可以安全地避免代码重复。下面是实例代码,读完后边的文字解说你会更明了。

class TextBlock {

public:

  ...

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

  {                                // 同上

    ...

    ...

    ...

    return text[position];

  }

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

  {                                // 现在仅调用 constop[]

    return

      const_cast<char&>(           // 消去op[]返回值的const属性

        static_cast<const TextBlock&>(*this)

                                   // *this的类型添加const属性;

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

      );

  }

...

};

就像你所看到的,上面的代码进行了两次转型,而不是一次。我们要让非constoperator[]去调用const版本的,但是如果在非constoperator[]的内部,我们只调用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_castconst_cast还不熟悉,条目27中有相关介绍。)

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

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

就像本条目一开始所说的,const是一个令人赞叹的东西。对于指针和迭代器,以及对于指针、迭代器和引用所涉及的对象,对于函数的参数和返回值,对于局部变量,以及对于成员函数来说,const都是一个强大的伙伴。尽可能去利用它。你一定不会后悔。

时刻牢记

将一些东西声明为const可以帮助编译器及时发现用法上的错误。const可以用于各个领域,包括任意作用域的对象、函数参数和返回值、成员函数。

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

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

posted on 2007-04-11 19:55 ★ROY★ 阅读(1399) 评论(3)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【翻译】Effective C++ (第3项:尽可能使用const)  回复  更多评论   

哈哈 ,我最近在看这本书,蛮好的,但我感觉有的准则不实用,可能是我代码写太少了,没有用到吧!
2007-04-13 16:18 | 攀升

# re: 【翻译】Effective C++ (第3项:尽可能使用const)  回复  更多评论   

请问这个翻译是干嘛用的?博主自己翻译的吗?不是已经有中文版了吗?
2007-04-24 17:49 | 匿名

# re: 【翻译】Effective C++ (第3项:尽可能使用const)  回复  更多评论   

@匿名
向领导汇报:
翻着玩的,自己学习。
是自己翻译的。
是有中文版了,但是我们干的好多事都是别人干过的,不是吗?
2007-04-24 17:55 | ★田德健★

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