关闭

第四章 类和函数:设计与声明

783人阅读 评论(0) 收藏 举报

第四章 类和函数:设计与声明

 

在程序中声明一个新类将导致产生一种新的类型:类的设计就是类型设计。可能你对类型设计没有太多经验,因为大多数语言没有为你提供实践的机会。在c++中,这却是很基本的特性,不是因为你想去做才可以这么做,而是因为每次你声明一个类的时候实际上就在做,无论你想不想做。

设计一个好的类很具有挑战性,因为设计好的类型很具有挑战性。好的类型具有自然的语法,直观的语义和高效的实现。在c++中,一个糟糕的类的定义是无法实现这些目标的。即使一个类的成员函数的性能也是由这些成员函数的声明和定义决定的。

那么,怎么着手设计高效的类呢?首先,必须清楚你面临的问题。实际上,设计每个类时都会遇到下面的问题,它的答案将影响到你的设计。

·对象将如何被创建和摧毁?它将极大地影响构造函数和析构函数的设计,以及自定义的operator new, operator new[], operator delete, 和operator delete[]。(条款m8描述了这些术语的区别)

·对象初始化和对象赋值有什么不同?答案决定了构造函数和赋值运算符的行为以及它们之间的区别。

·通过值来传递新类型的对象意味着什么?记住,拷贝函数负责对此做出回答。

·新类型的合法值有什么限制?这些限制决定了成员函数(特别是构造函数和赋值运算符)内部的错误检查的种类。它可能还影响到函数抛出的例外的种类以及函数的例外规范(参见条款m14),如果你使用它们的话。

·新类型符合继承关系吗?如果是从已有的类继承而来,那么新类的设计就要受限于这些类,特别是受限于被继承的类是虚拟的还是非虚拟的。如果新类允许被别的类继承,这将影响到函数是否要声明为虚拟的。

·允许哪种类型转换?如果允许类型a的对象隐式转换为类型b的对象,就要在类a中写一个类型转换函数,或者,在类b中写一个可以用单个参数来调用的非explicit构造函数。如果只允许显式转换,就要写函数来执行转换功能,但不用把它们写成类型转换运算符和或单参数的非explicit构造函数。(条款m5讨论了用户自定义转换函数的优点和缺点)

·什么运算符和函数对新类型有意义?答案决定了将要在类接口中声明什么函数。

·哪些运算符和函数要被明确地禁止?它们需要被声明为private。

·谁有权访问新类型的成员?这个问题有助于决定哪些成员是公有的,哪些是保护的,哪些私有的。它还有助于确定哪些类和/或函数必须是友元,以及将一个类嵌套到另一个类中是否有意义。

·新类型的通用性如何?也许你实际上不是在定义一个新的类型,而是在定义一整套的类型。如果是这样,就不要定义一个新类,而要定义一个新的类模板。

这些都是很难回答的问题,所以c++中定义一个高效的类远不是那么简单。但如果做好了,c++中用户自定义的类所产生的类型就会和固定类型几乎没什么区别,如果能达到这样的效果,其价值也就体现出来了。

上面每一个问题如果要详细讨论都可以单独组成一本书。所以后面条款中所介绍的准则决不会面面俱到。但是,它们强调了在设计中一些很重要的注意事项,提醒一些常犯的错误,对设计者常碰到的一些问题提供了解决方案。很多建议对非成员函数和成员函数都适用,所以本章节我也考虑了全局函数和名字空间中的函数的设计和声明。

条款18: 争取使类的接口完整并且最小

 

类的用户接口是指使用这个类的程序员所能访问得到的接口。典型的接口里只有函数存在,因为在用户接口里放上数据成员会有很多缺点(见条款20)。

哪些函数该放在类的接口里呢?有时这个问题会使你发疯,因为有两个截然不同的目标要你去完成。一方面,设计出来的类要易于理解,易于使用,易于实现。这意味着函数的数量要尽可能地少,每一个函数都完成各自不同的任务。另一方面,类的功能要强大,要方便使用,这意味着要不时增加函数以提供对各种通用功能的支持。你会怎样决定哪些函数该放进类里,哪些不放呢?

试试这个建议:类接口的目标是完整且最小。

一个完整的接口是指那种允许用户做他们想做的任何合理的事情的接口。也就是说,对用户想完成的任何合理的任务,都有一个合理的方法去实现,即使这个方法对用户来说没有所想象的那样方便。相反,一个最小的接口,是指那种函数尽可能少、每两个函数都没有重叠功能的接口。如果能提供一个完整、最小的接口,用户就可以做任何他们想做的事,但类的接口不必再那样复杂。

追求接口的完整看起来很自然,但为什么要使接口最小呢?为什么不让用户做任何他们想做的事,增加更多的函数,使大家都高兴呢?

撇开处世原则方面的因素不谈——牵就你的用户真的正确吗?——充斥着大量函数的类的接口从技术上来说有很多缺点。第一,接口中函数越多,以后的潜在用户就越难理解。他们越难理解,就越不愿意去学该怎么用。一个有10个函数的类好象对大多数人来说都易于使用,但一个有100个函数的类对许多程序员来说都难以驾驭。在扩展类的功能使之尽可能地吸引用户的时候,注意不要去打击用户学习使用它们的积极性。

大的接口还会带来混淆。假设在一个人工智能程序里建立一个支持识别功能的类。其中一个成员函数叫think(想),后来有些人想把函数名叫做ponder(深思),另外还一些人喜欢叫ruminate(沉思)。为了满足所有人的需要,你提供了三个函数,虽然他们做同样的事。那么想想,以后某个使用这个类的用户会怎么想呢?这个用户会面对三个不同的函数,每个函数好象都是做相同的事。真的吗?难道这三个函数有什么微妙的不同,效率上,通用性上,或可靠性上?如果没有不同,为什么会有三个函数?这样的话,这个用户不但不感激你提供的灵活性,还会纳闷你究竟在想(或者深思,或者沉思)些什么?

大的类接口的第二个缺点是难以维护(见条款m32)。含有大量函数的类比含有少量函数的类更难维护和升级,更难以避免重复代码(以及重复的bug),而且难以保持接口的一致性。同时,也难以建立文档。

最后,长的类定义会导致长的头文件。因为程序在每次编译时都要读头文件(见条款34),类的定义太长会导致项目开发过程中浪费大量的编译时间。

概括起来就是说,无端地在接口里增加函数不是没有代价的,所以在增加一个新函数时要仔细考虑:它所带来的方便性(只有在接口完整的前提下才应该考虑增加一个新函数以提供方便性)是否超过它所带来的额外代价,如复杂性,可读性,可维护性和编译时间等。

但太过吝啬也没必要。在最小的接口上增加一些函数有时是合理的。如果一个通用的功能用成员函数实现起来会更高效,这将是把它增加到接口中的好理由。(但,有时不会,参见条款m16)如果增加一个成员函数使得类易于使用,或者可以防止用户错误,也都是把它加入到接口中的有力依据。

看一个具体的例子:一个类模板,实现了用户自定义下标上下限的数组功能,另外提供上下限检查选项。模板的开头部分如下所示:

template<class t>
class array {
public:
  enum boundscheckingstatus {no_check_bounds = 0,
                             check_bounds = 1};

  array(int lowbound, int highbound,
       boundscheckingstatus check = no_check_bounds);

  array(const array& rhs);

  ~array();

  array& operator=(const array& rhs);

private:
  int lbound, hbound;         // 下限, 上限

  vector<t> data;             // 数组内容; 关于vector,
                              // 请参见条款49

  boundscheckingstatus checkingbounds;
};

目前为止声明的成员函数是基本上不用想(或深思,沉思)就该声明的。一个允许用户确定每个数组上下限的构造函数,一个拷贝构造函数,一个赋值运算符和一个析构函数。析构函数被声明为非虚拟的,意味着这个类将不作为基类使用(见条款14)。

对于赋值运算符的声明,第一眼看上去会觉得目的不那么明确。毕竟,c++中固定类型的数组是不允许赋值的,所以好象也应该不允许array对象赋值(参见条款27)。但另一方面,数组似的vector模板(存在于标准库——参见条款49)允许vector对象间赋值。在本例中,决定遵循vector的规定,正如下面将会看到的,这个决定将影响到类的接口的其他部分。

老的c程序员看到这个接口会被吓退:怎么竟然不支持固定大小的数组声明?很容易增加一个构造函数来实现啊:

array(int size,
      boundscheckingstatus check = no_check_bounds);

但这就不能成为最小接口了,因为带上下限参数的那个构造函数可以完成同样的事。尽管如此,出于某些目的去迎合那些老程序员们的需要也可能是明智的,特别是出于和基本语言(c语言)一致的考虑。

还需要哪些函数?对于一个完整的接口来说当然还需要对数组的索引:

// 返回可以读/写的元素
t& operator[](int index);

// 返回只读元素
const t& operator[](int index) const;

通过两次声明同一个函数,一次带const一次没有const,就提供了对const和非const array对象的支持。返回值不同很重要,条款21对此进行了说明。

现在,array模板支持构造函数,析构函数,传值,赋值,索引,你可能想到这已经是一个完整的接口了。但再看清楚一些。假如一个用户想遍历一个整数数组,打印其中的每一个元素,如下所示:

array<int> a(10, 20);      // 下标上下限为:10到20

...

for (int i = a的下标下限; i <= a的下标上限; ++i)
  cout << "a[" << i << "] = " << a[i] << '/n';

用户怎么得到a的下标上下限呢?答案取决于array对象的赋值操作做了些什么,即在array::operator=里做了什么。特别是,如果赋值操作可以改变array对象的上下限,就必须提供一个返回当前上下限值的成员函数,因为用户无法总能在程序的某个地方推出上下限值是多少。比如上面的例子,a是在被定义后、用于循环前的时间段里被赋值的,用户在循环语句中就无法知道a当前的上下限值。

如果array对象的上下限值在赋值时不能改变,那它在a被定义时就固定下来了,用户就可能有办法(虽然很麻烦)对其进行跟踪。这种情况下,提供一个函数返回当前上下限值是很方便,但接口就不能做到最小。

继续前面的赋值操作可以改变对象上下限的假设,上下限函数可以这样声明:

int lowbound() const;
int highbound() const;

因为这两个函数不对它们所在的对象进行任何修改操作,而且为遵循“能用const就尽量用const”的原则(见条款21),它们被声明为const成员函数。有了这两个函数,循环语句可以象下面这样写:

for (int i = a.lowbound(); i <= a.highbound(); ++i)
  cout << "a[" << i << "] = " << a[i] << '/n';

当然,要使这样一个操作类型t的对象数组的循环语句工作,还要为类型t的对象定义一个operator<<函数。(说得不太准确。应该是,必须有一个类型t的operator<<,或,t可以隐式转换(见条款m5)成的其它类型的operator<<)

一些人会争论,array类应该提供一个函数以返回array对象里元素的数量。元素的数量可以简单地得到:highbound()-lowbound()+1,所以这个函数不是那么真的必要。但考虑到很多人经常忘了"+1",增加这个函数也不是坏主意。

还有一些其他函数可以加到类里,包括那些输入输出方面的操作,还有各种关系运算符(例如,<, >, ==, 等)。但这些函数都不是最小接口的一部分,因为它们都可以通过包含operator[]调用的循环来实现。

说到象operator<<, operator>>这样的函数以及关系运算符,条款19解释了为什么它们经常用非成员的友元函数而不用成员函数来实现。另外,不要忘记友元函数在所有实际应用中都是类的接口的一部分。这意味着友元函数影响着类的接口的完整性和最小性。

条款19: 分清成员函数,非成员函数和友元函数

 

成员函数和非成员函数最大的区别在于成员函数可以是虚拟的而非成员函数不行。所以,如果有个函数必须进行动态绑定(见条款38),就要采用虚拟函数,而虚拟函数必定是某个类的成员函数。关于这一点就这么简单。如果函数不必是虚拟的,情况就稍微复杂一点。

看下面表示有理数的一个类:

class rational {
public:
  rational(int numerator = 0, int denominator = 1);
  int numerator() const;
  int denominator() const;

private:
  ...
};

这是一个没有一点用处的类。(用条款18的术语来说,接口的确最小,但远不够完整。)所以,要对它增加加,减,乘等算术操作支持,但是,该用成员函数还是非成员函数,或者,非成员的友元函数来实现呢?

当拿不定主意的时候,用面向对象的方法来考虑!有理数的乘法是和rational类相联系的,所以,写一个成员函数把这个操作包到类中。

class rational {
public:

  ...

  const rational operator*(const rational& rhs) const;
};

(如果你不明白为什么这个函数以这种方式声明——返回一个const值而取一个const的引用作为它的参数——参考条款21-23。)

现在可以很容易地对有理数进行乘法操作:

rational oneeighth(1, 8);
rational onehalf(1, 2);

rational result = onehalf * oneeighth;   // 运行良好

result = result * oneeighth;             // 运行良好

但不要满足,还要支持混合类型操作,比如,rational要能和int相乘。但当写下下面的代码时,只有一半工作:

result = onehalf * 2;      // 运行良好

result = 2 * onehalf;      // 出错!

这是一个不好的苗头。记得吗?乘法要满足交换律。

如果用下面的等价函数形式重写上面的两个例子,问题的原因就很明显了:

result = onehalf.operator*(2);      // 运行良好

result = 2.operator*(onehalf);      // 出错!

对象onehalf是一个包含operator*函数的类的实例,所以编译器调用了那个函数。而整数2没有相应的类,所以没有operator*成员函数。编译器还会去搜索一个可以象下面这样调用的非成员的operator*函数(即,在某个可见的名字空间里的operator*函数或全局的operator*函数):

result = operator*(2, onehalf);      // 错误!

但没有这样一个参数为int和rational的非成员operator*函数,所以搜索失败。

再看看那个成功的调用。它的第二参数是整数2,然而rational::operator*期望的参数却是rational对象。怎么回事?为什么2在一个地方可以工作而另一个地方不行?

秘密在于隐式类型转换。编译器知道传的值是int而函数需要的是rational,但它也同时知道调用rational的构造函数将int转换成一个合适的rational,所以才有上面成功的调用(见条款m19)。换句话说,编译器处理这个调用时的情形类似下面这样:

const rational temp(2);      // 从2产生一个临时
                             // rational对象

result = onehalf * temp;     // 同onehalf.operator*(temp);

当然,只有所涉及的构造函数没有声明为explicit的情况下才会这样,因为explicit构造函数不能用于隐式转换,这正是explicit的含义。如果rational象下面这样定义:

class rational {
public:
  explicit rational(int numerator = 0,     // 此构造函数为
                    int denominator = 1);  // explicit
  ...

  const rational operator*(const rational& rhs) const;

  ...

};

那么,下面的语句都不能通过编译:

result = onehalf * 2;             // 错误!
result = 2 * onehalf;             // 错误!

这不会为混合运算提供支持,但至少两条语句的行为一致了。

然而,我们刚才研究的这个类是要设计成可以允许固定类型到rational的隐式转换的——这就是为什么rational的构造函数没有声明为explicit的原因。这样,编译器将执行必要的隐式转换使上面result的第一个赋值语句通过编译。实际上,如果需要的话,编译器会对每个函数的每个参数执行这种隐式类型转换。但它只对函数参数表中列出的参数进行转换,决不会对成员函数所在的对象(即,成员函数中的*this指针所对应的对象)进行转换。这就是为什么这个语句可以工作:

result = onehalf.operator*(2);      // converts int -> rational

而这个语句不行:

result = 2.operator*(onehalf);      // 不会转换
                                    // int -> rational

第一种情形操作的是列在函数声明中的一个参数,而第二种情形不是。

尽管如此,你可能还是想支持混合型的算术操作,而实现的方法现在应该清楚了:使operator*成为一个非成员函数,从而允许编译器对所有的参数执行隐式类型转换:

class rational {

  ...                               // contains no operator*

};

// 在全局或某一名字空间声明,
// 参见条款m20了解为什么要这么做
const rational operator*(const rational& lhs,
                         const rational& rhs)
{
  return rational(lhs.numerator() * rhs.numerator(),
                  lhs.denominator() * rhs.denominator());
}

rational onefourth(1, 4);
rational result;

result = onefourth * 2;           // 工作良好
result = 2 * onefourth;           // 万岁, 它也工作了!

这当然是一个完美的结局,但还有一个担心:operator*应该成为rational类的友元吗?

这种情况下,答案是不必要。因为operator*可以完全通过类的公有(public)接口来实现。上面的代码就是这么做的。只要能避免使用友元函数就要避免,因为,和现实生活中差不多,友元(朋友)带来的麻烦往往比它(他/她)对你的帮助多。

然而,很多情况下,不是成员的函数从概念上说也可能是类接口的一部分,它们需要访问类的非公有成员的情况也不少。

让我们回头再来看看本书那个主要的例子,string类。如果想重载operator>>和operator<<来读写string对象,你会很快发现它们不能是成员函数。如果是成员函数的话,调用它们时就必须把string对象放在它们的左边:

// 一个不正确地将operator>>和
// operator<<作为成员函数的类
class string {
public:
  string(const char *value);

  ...

  istream& operator>>(istream& input);
  ostream& operator<<(ostream& output);

private:
  char *data;
};

string s;

s >> cin;                   // 合法, 但
                            // 有违常规

s << cout;                  // 同上

这会把别人弄糊涂。所以这些函数不能是成员函数。注意这种情况和前面的不同。这里的目标是自然的调用语法,前面关心的是隐式类型转换。

所以,如果来设计这些函数,就象这样:

istream& operator>>(istream& input, string& string)
{
  delete [] string.data;

  read from input into some memory, and make string.data
  point to it

  return input;
}

ostream& operator<<(ostream& output,
                    const string& string)
{
  return output << string.data;
}

注意上面两个函数都要访问string类的data成员,而这个成员是私有(private)的。但我们已经知道,这个函数一定要是非成员函数。这样,就别无选择了:需要访问非公有成员的非成员函数只能是类的友元函数。

本条款得出的结论如下。假设f是想正确声明的函数,c是和它相关的类:

·虚函数必须是成员函数。如果f必须是虚函数,就让它成为c的成员函数。

·operator>>和operator<<决不能是成员函数。如果f是operator>>或operator<<,让f成为非成员函数。如果f还需要访问c的非公有成员,让f成为c的友元函数。

·只有非成员函数对最左边的参数进行类型转换。如果f需要对最左边的参数进行类型转换,让f成为非成员函数。如果f还需要访问c的非公有成员,让f成为c的友元函数。

·其它情况下都声明为成员函数。如果以上情况都不是,让f成为c的成员函数。

条款20: 避免public接口出现数据成员

 

首先,从“一致性”的角度来看这个问题。如果public接口里都是函数,用户每次访问类的成员时就用不着抓脑袋去想:是该用括号还是不该用括号呢?——用括号就是了!因为每个成员都是函数。一生中,这可以避免你多少次抓脑袋啊!

你不买“一致性”的帐?那你总得承认采用函数可以更精确地控制数据成员的访问权这一事实吧?如果使数据成员为public,每个人都可以对它读写;如果用函数来获取或设定它的值,就可以实现禁止访问、只读访问和读写访问等多种控制。甚至,如果你愿意,还可以实现只写访问:

class accesslevels {
public:
  int getreadonly() const{ return readonly; }

  void setreadwrite(int value) { readwrite = value; }
  int getreadwrite() const { return readwrite; }

  void setwriteonly(int value) { writeonly = value; }

private:
  int noaccess;             // 禁止访问这个int

  int readonly;             // 可以只读这个int

  int readwrite;            // 可以读/写这个int

  int writeonly;            // 可以只写这个int
};

还没说服你?那只得搬出这门重型大炮:功能分离(functional abstraction)。如果用函数来实现对数据成员的访问,以后就有可能用一段计算来取代这个数据成员,而使用这个类的用户却一无所知。

例如,假设写一个用自动化仪器检测汽车行驶速度的应用程序。每辆车行驶过来时,计算出的速度值添加到一个集中了当前所有的汽车速度数据的集合里:

class speeddatacollection {
public:
  void addvalue(int speed);       // 添加新速度值

  double averagesofar() const;    // 返回平均速度
};

现在考虑怎么实现成员函数averagesofar(另见条款m18)。一种方法是用类的一个数据成员来保存当前收集到的所有速度数据的运行平均值。只要averagesofar被调用,就返回这个数据成员的值。另一个不同的方法则是在averagesofar每次被调用时才通过检查集合中的所有的数据值计算出结果。(关于这两个方法的更全面的讨论参见条款m17和m18。)

第一种方法——保持一个运行值——使得每个speeddatacollection对象更大,因为必须为保存运行值的数据成员分配空间。但averagesofar实现起来很高效:它可以是一个仅用返回数据成员值的内联函数(见条款33)。相反,每次调用时都要计算平均值的方案则使得averagesofar运行更慢,但每个speeddatacollection对象会更小。

谁能说哪个方法更好?在内存很紧张的机器里,或在不是频繁需要平均值的应用程序里,每次计算平均值是个好方案。在频繁需要平均值的应用程序里,速度是最根本的,内存不是主要问题,保持一个运行值的方法更可取。重要之处在于,用成员函数来访问平均值,就可以使用任何一种方法,它具有极大价值的灵活性,这是那个在public接口里包含平均值数据成员的方案所不具有的。

所以,结论是,在public接口里放上数据成员无异于自找麻烦,所以要把数据成员安全地隐藏在与功能分离的高墙后。如果现在就开始这么做,那我们就可以无需任何代价地换来一致性和精确的访问控制。

 

条款21: 尽可能使用const

使用const的好处在于它允许指定一种语意上的约束——某种对象不能被修改——编译器具体来实施这种约束。通过const,你可以通知编译器和其他程序员某个值要保持不变。只要是这种情况,你就要明确地使用const ,因为这样做就可以借助编译器的帮助确保这种约束不被破坏。

const关键字实在是神通广大。在类的外面,它可以用于全局或名字空间常量(见条款1和47),以及静态对象(某一文件或程序块范围内的局部对象)。在类的内部,它可以用于静态和非静态成员(见条款12)。

对指针来说,可以指定指针本身为const,也可以指定指针所指的数据为const,或二者同时指定为const,还有,两者都不指定为const:

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

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

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

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

语法并非看起来那么变化多端。一般来说,你可以在头脑里画一条垂直线穿过指针声明中的星号(*)位置,如果const出现在线的左边,指针指向的数据为常量;如果const出现在线的右边,指针本身为常量;如果const在线的两边都出现,二者都是常量。

在指针所指为常量的情况下,有些程序员喜欢把const放在类型名之前,有些程序员则喜欢把const放在类型名之后、星号之前。所以,下面的函数取的是同种参数类型:

class widget { ... };

void f1(const widget *pw);      // f1取的是指向
                                // widget常量对象的指针

void f2(widget const *pw);      // 同f2

因为两种表示形式在实际代码中都存在,所以要使自己对这两种形式都习惯。

const的一些强大的功能基于它在函数声明中的应用。在一个函数声明中,const可以指的是函数的返回值,或某个参数;对于成员函数,还可以指的是整个函数。

让函数返回一个常量值经常可以在不降低安全性和效率的情况下减少用户出错的几率。实际上正如条款29所说明的,对返回值使用const有可能提高一个函数的安全性和效率,否则还会出问题。

例如,看这个在条款19中介绍的有理数的operator*函数的声明:

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

很多程序员第一眼看到它会纳闷:为什么operator*的返回结果是一个const对象?因为如果不是这样,用户就可以做下面这样的坏事:

rational a, b, c;

...

(a * b) = c;      // 对a*b的结果赋值

我不知道为什么有些程序员会想到对两个数的运算结果直接赋值,但我却知道:如果a,b和c是固定类型,这样做显然是不合法的。一个好的用户自定义类型的特征是,它会避免那种没道理的与固定类型不兼容的行为。对我来说,对两个数的运算结果赋值是非常没道理的。声明operator*的返回值为const可以防止这种情况,所以这样做才是正确的。

关于const参数没什么特别之处要强调——它们的运作和局部const对象一样。(但,见条款m19,const参数会导致一个临时对象的产生)然而,如果成员函数为const,那就是另一回事了。

const成员函数的目的当然是为了指明哪个成员函数可以在const对象上被调用。但很多人忽视了这样一个事实:仅在const方面有不同的成员函数可以重载。这是c++的一个重要特性。再次看这个string类:

class string {
public:

  ...

  // 用于非const对象的operator[]
  char& operator[](int position)
  { return data[position]; }

  // 用于const对象的operator[]
  const char& operator[](int position) const
  { return data[position]; }

private:
  char *data;
};

string s1 = "hello";
cout << s1[0];                  // 调用非const
                                // string::operator[]
const string s2 = "world";
cout << s2[0];                  // 调用const
                                // string::operator[]

通过重载operator[]并给不同版本不同的返回值,就可以对const和非const string进行不同的处理:

string s = "hello";              // 非const string对象

cout << s[0];                    // 正确——读一个
                                 // 非const string

s[0] = 'x';                      // 正确——写一个
                                 // 非const string

const string cs = "world";       // const string 对象

cout << cs[0];                   // 正确——读一个
                                 // const string

cs[0] = 'x';                     // 错误!——写一个
                                 // const string

另外注意,这里的错误只和调用operator[]的返回值有关;operator[]调用本身没问题。 错误产生的原因在于企图对一个const char&赋值,因为被赋值的对象是const版本的operator[]函数的返回值。

还要注意,非const operator[]的返回类型必须是一个char的引用——char本身则不行。如果operator[]真的返回了一个简单的char,如下所示的语句就不会通过编译:

s[0] = 'x';

因为,修改一个“返回值为固定类型”的函数的返回值绝对是不合法的。即使合法,由于c++“通过值(而不是引用)来返回对象”(见条款22)的内部机制的原因,s.data[0]的一个拷贝会被修改,而不是s.data[0]自己,这就不是你所想要的结果了。

让我们停下来看一个基本原理。一个成员函数为const的确切含义是什么?有两种主要的看法:数据意义上的const(bitwise constness)和概念意义上的const(conceptual constness)。

bitwise constness的坚持者认为,当且仅当成员函数不修改对象的任何数据成员(静态数据成员除外)时,即不修改对象中任何一个比特(bit)时,这个成员函数才是const的。bitwise constness最大的好处是可以很容易地检测到违反bitwise constness规定的事件:编译器只用去寻找有无对数据成员的赋值就可以了。实际上,bitwise constness正是c++对const问题的定义,const成员函数不被允许修改它所在对象的任何一个数据成员。

不幸的是,很多不遵守bitwise constness定义的成员函数也可以通过bitwise测试。特别是,一个“修改了指针所指向的数据”的成员函数,其行为显然违反了bitwise constness定义,但如果对象中仅包含这个指针,这个函数也是bitwise const的,编译时会通过。这就和我们的直觉有差异:

class string {
public:
  // 构造函数,使data指向一个
  // value所指向的数据的拷贝
  string(const char *value);

  ...

  operator char *() const { return data;}

private:
  char *data;
};

const string s = "hello";      // 声明常量对象

char *nasty = s;               // 调用 operator char*() const

*nasty = 'm';                  // 修改s.data[0]

cout << s;                     // 输出"mello"

显然,在用一个值创建一个常量对象并调用对象的const成员函数时一定有什么错误,对象的值竟然可以修改!(关于这个例子更详细的讨论参见条款29)

这就导致conceptual constness观点的引入。此观点的坚持者认为,一个const成员函数可以修改它所在对象的一些数据(bits) ,但只有在用户不会发觉的情况下。例如,假设string类想保存对象每次被请求时数据的长度:

class string {
public:
  // 构造函数,使data指向一个
  // value所指向的数据的拷贝
  string(const char *value): lengthisvalid(false) { ... }

  ...

  size_t length() const;

private:
  char *data;

  size_t datalength;           // 最后计算出的
                               // string的长度

  bool lengthisvalid;          // 长度当前
                               // 是否合法
};

size_t string::length() const
{
  if (!lengthisvalid) {
    datalength = strlen(data); // 错误!
    lengthisvalid = true;      // 错误!
  }

  return datalength;
}

这个length的实现显然不符合“bitwise const”的定义——datalength 和lengthisvalid都可以修改——但对const string对象来说,似乎它一定要是合法的才行。但编译器也不同意, 它们坚持“bitwise constness”,怎么办?

解决方案很简单:利用c++标准组织针对这类情况专门提供的有关const问题的另一个可选方案。此方案使用了关键字mutable,当对非静态数据成员运用mutable时,这些成员的“bitwise constness”限制就被解除:

class string {
public:

  ...    // same as above

private:
  char *data;

  mutable size_t datalength;      // 这些数据成员现在
                                  // 为mutable;他们可以在
  mutable bool lengthisvalid;     // 任何地方被修改,即使
                                  // 在const成员函数里
};

size_t string::length() const
{
  if (!lengthisvalid) {
    datalength = strlen(data);    // 现在合法
    lengthisvalid = true;         // 同样合法
  }

  return datalength;
}

mutable在处理“bitwise-constness限制”问题时是一个很好的方案,但它被加入到c++标准中的时间不长,所以有的编译器可能还不支持它。如果是这样,就不得不倒退到c++黑暗的旧时代去,在那儿,生活很简陋,const有时可能会被抛弃。

类c的一个成员函数中,this指针就好象经过如下的声明:

c * const this;              // 非const成员函数中

const c * const this;        // const成员函数中

这种情况下(即编译器不支持mutable的情况下),如果想使那个有问题的string::length版本对const和非const对象都合法,就只有把this的类型从const c * const改成c * const。不能直接这么做,但可以通过初始化一个局部变量指针,使之指向this所指的同一个对象来间接实现。然后,就可以通过这个局部指针来访问你想修改的成员:

size_t string::length() const
{
  // 定义一个不指向const对象的
  // 局部版本的this指针
  string * const localthis =
    const_cast<string * const>(this);

  if (!lengthisvalid) {
    localthis->datalength = strlen(data);
    localthis->lengthisvalid = true;
  }

  return datalength;
}

做的不是很漂亮。但为了完成想要的功能也就只有这么做。

当然,如果不能保证这个方法一定可行,就不要这么做:比如,一些老的“消除const”的方法就不行。特别是,如果this所指的对象真的是const,即,在定义时被声明为const,那么,“消除const”就会导致不可确定的后果。所以,如果想在成员函数中通过转换消除const,就最好先确信你要转换的对象最初没有被定义为const。

还有一种情况下,通过类型转换消除const会既有用又安全。这就是:将一个const对象传递到一个取非const参数的函数中,同时你又知道参数不会在函数内部被修改的情况时。第二个条件很重要,因为对一个只会被读的对象(不会被写)消除const永远是安全的,即使那个对象最初曾被定义为const。

例如,已经知道有些库不正确地声明了象下面这样的strlen函数:

size_t strlen(char *s);

strlen当然不会去修改s所指的数据——至少我一辈子没看见过。但因为有了这个声明,对一个const char *类型的指针调用这个函数时就会不合法。为解决这个问题,可以在给strlen传参数时安全地把这个指针的const强制转换掉:

const char *klingongreeting = "nuqneh"; // "nuqneh"即"hello"
                                        //
size_t length =
  strlen(const_cast<char*>(klingongreeting));

但不要滥用这个方法。只有在被调用的函数(比如本例中的strlen)不会修改它的参数所指的数据时,才能保证它可以正常工作。

条款22: 尽量用“传引用”而不用“传值”

c语言中,什么都是通过传值来实现的,c++继承了这一传统并将它作为默认方式。除非明确指定,函数的形参总是通过“实参的拷贝”来初始化的,函数的调用者得到的也是函数返回值的拷贝。

正如我在本书的导言中所指出的,“通过值来传递一个对象”的具体含义是由这个对象的类的拷贝构造函数定义的。这使得传值成为一种非常昂贵的操作。例如,看下面这个(只是假想的)类的结构:

class person {
public:
  person();                         // 为简化,省略参数
                                    //
  ~person();

  ...

private:
  string name, address;
};

class student: public person {
public:
  student();                        // 为简化,省略参数
                                    //
  ~student();

  ...

private:
  string schoolname, schooladdress;
};

现在定义一个简单的函数returnstudent,它取一个student参数(通过值)然后立即返回它(也通过值)。定义完后,调用这个函数:

student returnstudent(student s) { return s; }

student plato;                      // plato(柏拉图)在
                                    // socrates(苏格拉底)门下学习

returnstudent(plato);               // 调用returnstudent

这个看起来无关痛痒的函数调用过程,其内部究竟发生了些什么呢?

简单地说就是:首先,调用了student的拷贝构造函数用以将s初始化为plato;然后再次调用student的拷贝构造函数用以将函数返回值对象初始化为s;接着,s的析构函数被调用;最后,returnstudent返回值对象的析构函数被调用。所以,这个什么也没做的函数的成本是两个student的拷贝构造函数加上两个student析构函数。

但没完,还有!student对象中有两个string对象,所以每次构造一个student对象时必须也要构造两个string对象。student对象还是从person对象继承而来的,所以每次构造一个student对象时也必须构造一个person对象。一个person对象内部有另外两个string对象,所以每个person的构造也必然伴随另两个string的构造。所以,通过值来传递一个student对象最终导致调用了一个student拷贝构造函数,一个person拷贝构造函数,四个string拷贝构造函数。当student对象被摧毁时,每个构造函数对应一个析构函数的调用。所以,通过值来传递一个student对象的最终开销是六个构造函数和六个析构函数。因为returnstudent函数使用了两次传值(一次对参数,一次对返回值),这个函数总共调用了十二个构造函数和十二个析构函数!

在c++编译器的设计者眼里,这是最糟糕的情况。编译器可以用来消除一些对拷贝构造函数的调用(c++标准——见条款50——描述了具体在哪些条件下编译器可以执行这类的优化工作,条款m20给出了例子)。一些编译器也这样做了。但在不是所有编译器都普遍这么做的情况下,一定要对通过值来传递对象所造成的开销有所警惕。

为避免这种潜在的昂贵的开销,就不要通过值来传递对象,而要通过引用:

const student& returnstudent(const student& s)
{ return s; }

这会非常高效:没有构造函数或析构函数被调用,因为没有新的对象被创建。

通过引用来传递参数还有另外一个优点:它避免了所谓的“切割问题(slicing problem)”。当一个派生类的对象作为基类对象被传递时,它(派生类对象)的作为派生类所具有的行为特性会被“切割”掉,从而变成了一个简单的基类对象。这往往不是你所想要的。例如,假设设计这么一套实现图形窗口系统的类:

class window {
public:
  string name() const;             // 返回窗口名
  virtual void display() const;    // 绘制窗口内容
};

class windowwithscrollbars: public window {
public:
  virtual void display() const;
};

每个window对象都有一个名字,可以通过name函数得到;每个窗口都可以被显示,着可以通过调用display函数实现。display声明为virtual意味着一个简单的window基类对象被显示的方式往往和价格昂贵的windowwithscrollbars对象被显示的方式不同(见条款36,37,m33)。

现在假设写一个函数来打印窗口的名字然后显示这个窗口。下面是一个用错误的方法写出来的函数:

// 一个受“切割问题”困扰的函数
void printnameanddisplay(window w)
{
  cout << w.name();
  w.display();
}

想象当用一个windowwithscrollbars对象来调用这个函数时将发生什么:

windowwithscrollbars wwsb;

printnameanddisplay(wwsb);

参数w将会作为一个windows对象而被创建(它是通过值来传递的,记得吗?),所有wwsb所具有的作为windowwithscrollbars对象的行为特性都被“切割”掉了。printnameanddisplay内部,w的行为就象是一个类window的对象(因为它本身就是一个window的对象),而不管当初传到函数的对象类型是什么。尤其是,printnameanddisplay内部对display的调用总是window::display,而不是windowwithscrollbars::display。

解决切割问题的方法是通过引用来传递w:

// 一个不受“切割问题”困扰的函数
void printnameanddisplay(const window& w)
{
  cout << w.name();
  w.display();
}

现在w的行为就和传到函数的真实类型一致了。为了强调w虽然通过引用传递但在函数内部不能修改,就要采纳条款21的建议将它声明为const。

传递引用是个很好的做法,但它会导致自身的复杂性,最大的一个问题就是别名问题,这在条款17进行了讨论。另外,更重要的是,有时不能用引用来传递对象,参见条款23。最后要说的是,引用几乎都是通过指针来实现的,所以通过引用传递对象实际上是传递指针。因此,如果是一个很小的对象——例如int——传值实际上会比传引用更高效。

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:780370次
    • 积分:8971
    • 等级:
    • 排名:第2192名
    • 原创:135篇
    • 转载:160篇
    • 译文:1篇
    • 评论:308条
    最新评论