拷贝构造函数及其参数类型,关于函数返回值的几种情况
分类:web前端

转自

 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

拷贝构造函数的参数类型必须是引用,而且通常情况下还是const的,但是const并不是严格必须的。

 

第9章 类的构造函数、析构函数与赋值函数

条款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解释了为什么它们经常用非成员的友元函数而不用成员函数来实现。另外,不要忘记友元函数在所有实际应用中都是类的接口的一部分。这意味着友元函数影响着类的接口的完整性和最小性。

#include <iostream>

在一个函数的内部,return的时候返回的都是一个拷贝,不管是变量、对象还是指针都是返回拷贝,但是这个拷贝是浅拷贝。

构造函数、析构函数与赋值函数是每个类最基本的函数。它们太普通以致让人容易麻痹大意,其实这些貌似简单的函数就象没有顶盖的下水道那样危险。

条款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的成员函数。

#include <string>

 

       每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。对于任意一个类A,如果不想编写上述函数,C++编译器将自动为A产生四个缺省的函数,如

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

using namespace std;

1.     如果返回一个基本类型的变量,比如:

    A(void);                    // 缺省的无参数构造函数

条款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)不会修改它的参数所指的数据时,才能保证它可以正常工作。

class CClass

int a;

    A(const A &a);              // 缺省的拷贝构造函数

条款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——传值实际上会比传引用更高效。

{

a = 5;

    ~A(void);                   // 缺省的析构函数

条款23: 必须返回一个对象时不要试图返回一个引用

据说爱因斯坦曾提出过这样的建议:尽可能地让事情简单,但不要过于简单。在c++语言中相似的说法应该是:尽可能地使程序高效,但不要过于高效。

一旦程序员抓住了“传值”在效率上的把柄(参见条款22),他们会变得十分极端,恨不得挖出每一个隐藏在程序中的传值操作。岂不知,在他们不懈地追求纯粹的“传引用”的过程中,他们会不可避免地犯另一个严重的错误:传递一个并不存在的对象的引用。这就不是好事了。

看一个表示有理数的类,其中包含一个友元函数,用于两个有理数相乘:

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

  ...

private:
  int n, d;              // 分子和分母

friend
  const rational                      // 参见条款21:为什么
    operator*(const rational& lhs,    // 返回值是const
              const rational& rhs)    
};

inline const rational operator*(const rational& lhs,
                                const rational& rhs)
{
  return rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

很明显,这个版本的operator*是通过传值返回对象结果,如果不去考虑对象构造和析构时的开销,你就是在逃避作为一个程序员的责任。另外一件很明显的事实是,除非确实有必要,否则谁都不愿意承担这样一个临时对象的开销。那么,问题就归结于:确实有必要吗?

答案是,如果能返回一个引用,当然就没有必要。但请记住,引用只是一个名字,一个其它某个已经存在的对象的名字。无论何时看到一个引用的声明,就要立即问自己:它的另一个名字是什么呢?因为它必然还有另外一个什么名字(见条款m1)。拿operator*来说,如果函数要返回一个引用,那它返回的必须是其它某个已经存在的rational对象的引用,这个对象包含了两个对象相乘的结果。

但,期望在调用operator*之前有这样一个对象存在是没道理的。也就是说,如果有下面的代码:

rational a(1, 2);                // a = 1/2
rational b(3, 5);                // b = 3/5
rational c = a * b;              // c 为 3/10

期望已经存在一个值为3/10的有理数是不现实的。如果operator* 一定要返回这样一个数的引用,就必须自己创建这个数的对象。

一个函数只能有两种方法创建一个新对象:在堆栈里或在堆上。在堆栈里创建对象时伴随着一个局部变量的定义,采用这种方法,就要这样写operator*:

// 写此函数的第一个错误方法
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  rational result(lhs.n * rhs.n, lhs.d * rhs.d);
  return result;
}

这个方法应该被否决,因为我们的目标是避免构造函数被调用,但result必须要象其它对象一样被构造。另外,这个函数还有另外一个更严重的问题,它返回的是一个局部对象的引用,关于这个错误,条款31进行了深入的讨论。

那么,在堆上创建一个对象然后返回它的引用呢?基于堆的对象是通过使用new产生的,所以应该这样写operator*:

// 写此函数的第二个错误方法
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  rational *result =
    new rational(lhs.n * rhs.n, lhs.d * rhs.d);
  return *result;
}

首先,你还是得负担构造函数调用的开销,因为new分配的内存是通过调用一个适当的构造函数来初始化的(见条款5和m8)。另外,还有一个问题:谁将负责用delete来删除掉new生成的对象呢?

实际上,这绝对是一个内存泄漏。即使可以说服operator*的调用者去取函数返回值地址,然后用delete去删除它(绝对不可能——条款31展示了这样的代码会是什么样的),但一些复杂的表达式会产生没有名字的临时值,程序员是不可能得到的。例如:

rational w, x, y, z;

w = x * y * z;

两个对operator*的调用都产生了没有名字的临时值,程序员无法看到,因而无法删除。(再次参见条款31)

也许,你会想你比一般的熊——或一般的程序员——要聪明;也许,你注意到在堆栈和堆上创建对象的方法避免不了对构造函数的调用;也许,你想起了我们最初的目标是为了避免这种对构造函数的调用;也许,你有个办法可以只用一个构造函数来搞掂一切;也许,你的眼前出现了这样一段代码:operator*返回一个“在函数内部定义的静态rational对象”的引用:

// 写此函数的第三个错误方法
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  static rational result;      // 将要作为引用返回的
                               // 静态对象

  lhs和rhs 相乘,结果放进result;

  return result;
}

这个方法看起来好象有戏,虽然在实际实现上面的伪代码时你会发现,不调用一个rational构造函数是不可能给出result的正确值的,而避免这样的调用正是我们要谈论的主题。就算你实现了上面的伪代码,但,你再聪明也不能最终挽救这个不幸的设计。

想知道为什么,看看下面这段写得很合理的用户代码:

bool operator==(const rational& lhs,      // rationals的operator==
                const rational& rhs);     //

rational a, b, c, d;

...

if ((a * b) == (c * d)) {

  处理相等的情况;

} else {

  处理不相等的情况;

}

看出来了吗?((a*b) == (c*d)) 会永远为true,不管a,b,c和d是什么值!

用等价的函数形式重写上面的相等判断语句就很容易明白发生这一可恶行为的原因了:

if (operator==(operator*(a, b), operator*(c, d)))

注意当operator==被调用时,总有两个operator*刚被调用,每个调用返回operator*内部的静态rational对象的引用。于是,上面的语句实际上是请求operator==对“operator*内部的静态rational对象的值”和“operator*内部的静态rational对象的值”进行比较,这样的比较不相等才怪呢!

幸运的话,我以上的说明应该足以说服你:想“在象operator*这样的函数里返回一个引用”实际上是在浪费时间。但我没幼稚到会相信幸运总会光临自己。一些人——你们知道这些人是指谁——此刻会在想,“唔,上面那个方法,如果一个静态变量不够用,也许可以用一个静态数组……”

请就此打住!我们难道还没受够吗?

我不能让自己写一段示例代码来太高这个设计,因为即使只抱有上面这种想法都足以令人感到羞愧。首先,你必须选择一个n,指定数组的大小。如果n太小,就会没地方储存函数返回值,这和我们前面否定的那个“采用单个静态变量的设计”相比没有什么改进。如果n太大,就会降低程序的性能,因为函数第一次被调用时数组中每个对象都要被创建。这会带来n个构造函数和n个析构函数的开销,即使这个函数只被调用一次。如果说"optimization"(最优化)是指提高软件的性能的过程, 那么现在这种做法简直可以称为"pessimization"(最差化)。最后,想想怎么把需要的值放到数组的对象中以及需要多大的开销?在对象间传值的最直接的方法是通过赋值,但赋值的开销又有多大呢?一般来说,它相当于调用一个析构函数(摧毁旧值)再加上调用一个构造函数(拷贝新值)。但我们现在的目标正是为了避免构造和析构的开销啊!面对现实吧:这个方法也绝对不能选用。

所以,写一个必须返回一个新对象的函数的正确方法就是让这个函数返回一个新对象。对于rational的operator*来说,这意味着要不就是下面的代码(就是最初看到的那段代码),要不就是本质上和它等价的代码:

inline const rational operator*(const rational& lhs,
                         &nbs

public:

return a;

    A & operate =(const A &a); // 缺省的赋值函数

         CClass() : a(1), b("Hello, world.")

 

 

         {

那么就会a的一个拷贝,即5返回,然后a就被销毁了。尽管a被销毁了,但它的副本5还是成功地返回了,所以这样做没有问题。

这不禁让人疑惑,既然能自动生成函数,为什么还要程序员编写?

         }

 

原因如下:

         // 拷贝构造函数,参数中的const不是严格必须的,但引用符号是必须的

2.     但是对于非动态分配(new/malloc)得到的指针,像1那么做就会有问题,比如在某个函数内部:

(1)如果使用“缺省的无参数构造函数”和“缺省的析构函数”,等于放弃了自主“初始化”和“清除”的机会,C++发明人Stroustrup的好心好意白费了。

         CClass(const CClass& c_class)

int a[] = {1, 2};

(2)“缺省的拷贝构造函数”和“缺省的赋值函数”均采用“位拷贝”而非“值拷贝”的方式来实现,倘若类中含有指针变量,这两个函数注定将出错。

         {

return a;

      

                   a = c_class.a;

那么也会返回指针a的一个拷贝,我们假定a的地址值为0x002345FC,那么这个0x2345FC是能够成功返回的。当return执行完成后,a就要被销毁,也就是0x002345FC所指向的内存被回收了。如果这时候在函数外面,去地址0x002345FC取值,那得到的结果肯定是不对的。这就是为什么不能返回局部指针的原因。返回局部变量的引用的道理和这个类似。

对于那些没有吃够苦头的C++程序员,如果他说编写构造函数、析构函数与赋值函数很容易,可以不用动脑筋,表明他的认识还比较肤浅,水平有待于提高。

                   b = c_class.b;

 

本章以类String的设计与实现为例,深入阐述被很多教科书忽视了的道理。String的结构如下:

         }

3.     对于返回(动态分配得到的)指针的另外一种情况,比如在函数内部:

    class String

         void setValues(int a, string b)

int a = new int(5);

    {

         {

return a;

     public:

                   this->a = a;

这样做是可以的。return a执行完后,a并没有被销毁(必须要用delete才能销毁a),所以这里返回的a是有效的。

        String(const char *str = NULL); // 普通构造函数

                   this->b = b;

 

        String(const String &other);    // 拷贝构造函数

         }

4.     如果不是基本数据类型,比如:

        ~ String(void);                 // 析构函数

         void printValues()

class A

        String & operate =(const String &other);    // 赋值函数

         {

{

     private:

                   cout << "a = " << a << endl;

public:

        char  *m_data;                // 用于保存字符串

                   cout << "b = " << b << endl;

               OtherClass * ...

    };

         }

};

9.1构造函数与析构函数的起源

private:

 

       作为比C更先进的语言,C++提供了更好的机制来增强程序的安全性。C++编译器具有严格的类型安全检查功能,它几乎能找出程序中所有的语法问题,这的确帮了程序员的大忙。但是程序通过了编译检查并不表示错误已经不存在了,在“错误”的大家庭里,“语法错误”的地位只能算是小弟弟。级别高的错误通常隐藏得很深,就象狡猾的罪犯,想逮住他可不容易。

         int a;

如果在某个函数内部有一个A类的局部变量,比如:

       根据经验,不少难以察觉的程序错误是由于变量没有被正确初始化或清除造成的,而初始化和清除工作很容易被人遗忘。Stroustrup在设计C++语言时充分考虑了这个问题并很好地予以解决:把对象的初始化工作放在构造函数中,把清除工作放在析构函数中。当对象被创建时,构造函数被自动执行。当对象消亡时,析构函数被自动执行。这下就不用担心忘了对象的初始化和清除工作。

         string b;

A a;

       构造函数与析构函数的名字不能随便起,必须让编译器认得出才可以被自动执行。Stroustrup的命名方法既简单又合理:让构造函数、析构函数与类同名,由于析构函数的目的与构造函数的相反,就加前缀‘~’以示区别。

};

return a;

除了名字外,构造函数与析构函数的另一个特别之处是没有返回值类型,这与返回值类型为void的函数不同。构造函数与析构函数的使命非常明确,就象出生与死亡,光溜溜地来光溜溜地去。如果它们有返回值类型,那么编译器将不知所措。为了防止节外生枝,干脆规定没有返回值类型。(以上典故参考了文献[Eekel, p55-p56])

int main(void)

这时候也会返回a的一个拷贝,如果A没有写深拷贝构造函数,就会调用缺省的拷贝构造函数(浅拷贝),这样做就会失败的;

9.2构造函数的初始化表

{

如果A中提供了深拷贝构造函数,则这样做就是可以的。

       构造函数有个特殊的初始化方式叫“初始化表达式表”(简称初始化表)。初始化表位于函数参数表之后,却在函数体 {} 之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前。

         CClass c;

 

       构造函数初始化表的使用规则:

         c.setValues(100, "Hello, boys!");

实验代码如下:

u       如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数。

         CClass d(c);              // 此处调用拷贝构造函数

#include <iostream>

例如

         d.printValues();

using namespace std;

    class A

         return 0;

int some_fun1()

    {…

}

{

        A(int x);       // A的构造函数

如果将拷贝构造函数中的引用符号去掉&,编译将无法通过,出错的信息如下:

    int a = 5;

}; 

    return a;                   //OK

    class B : public A

非法的复制构造函数: 第一个参数不应是“CClass”

}

    {…

 

        B(int x, int y);// B的构造函数

没有可用的复制构造函数或复制构造函数声明为“explicit”

int* some_fun2()

    };

{

    B::B(int x, int y)

原因:

    int a = 5;

     : A(x)             // 在初始化表里调用A的构造函数

如果拷贝构造函数中的参数不是一个引用,即形如CClass(const CClass c_class),那么就相当于采用了传值的方式(pass-by-value),而传值的方式会调用该类的拷贝构造函数,从而造成无穷递归地调用拷贝构造函数。因此拷贝构造函数的参数必须是一个引用。

    int *b = &a;

    {

需要澄清的是,传指针其实也是传值,如果上面的拷贝构造函数写成CClass(const CClass* c_class),也是不行的。事实上,只有传引用不是传值外,其他所有的传递方式都是传值。

    return b;                   // not OK

     …

附带说明,在下面几种情况下会调用拷贝构造函数:

}

}  

a.       显式或隐式地用同类型的一个对象来初始化另外一个对象。如上例中,用对象c初始化d;

 

u       类的const常量只能在初始化表里被初始化,因为它不能在函数体内用赋值的方式来初始化(参见5.4节)。

b.       作为实参(argument)传递给一个函数。如CClass(const CClass c_class)中,就会调用CClass的拷贝构造函数;

int* some_fun3()

u       类的数据成员的初始化可以采用初始化表或函数体内赋值两种方式,这两种方式的效率不完全相同。

c.        在函数体内返回一个对象时,也会调用返回值类型的拷贝构造函数;

{

    非内部数据类型的成员对象应当采用第一种方式初始化,以获取更高的效率。例如

d.       初始化序列容器中的元素时。比如 vector<string> svec(5),string的缺省构造函数和拷贝构造函数都会被调用;

    int *c = new int(5);

    class A

e.       用列表的方式初始化数组元素时。string a[] = {string(“hello”), string(“world”)}; 会调用string的拷贝构造函数。

    return c;                   // OK, return c执行完后,并没被销毁(必须要用delete才能销毁)

{…

如果在没有显式声明构造函数的情况下,编译器都会为一个类合成一个缺省的构造函数。如果在一个类中声明了一个构造函数,那么就会阻止编译器为该类合成缺省的构造函数。和构造函数不同的是,即便定义了其他构造函数(但没有定义拷贝构造函数),编译器总是会为我们合成一个拷贝构造函数。

}

    A(void);                // 无参数构造函数

如果想阻止拷贝构造函数发生作用,那么一个类,必须显式声明其拷贝构造函数,并且将其设为private, 并且其实现体是空的。因为仅仅是private的话,友元函数或者友元类还是有机会调用到这个拷贝构造函数。

 

    A(const A &other);      // 拷贝构造函数

通常情况下,如果一个类实现了拷贝构造函数,那么这个类也需要实现缺省构造函数。

class CSomething

    A & operate =( const A &other); // 赋值函数

来自:

{

};

public:

 

    int a;

    class B

    int b;

    {

 

     public:

public:

        B(const A &a); // B的构造函数

    CSomething(int a, int b)

     private: 

    {

        A m_a;         // 成员对象

        this->a = a; 

};

        this->b = b;

 

    }

示例9-2(a)中,类B的构造函数在其初始化表里调用了类A的拷贝构造函数,从而将成员对象m_a初始化。

};

示例9-2 (b)中,类B的构造函数在函数体内用赋值的方式将成员对象m_a初始化。我们看到的只是一条赋值语句,但实际上B的构造函数干了两件事:先暗地里创建m_a对象(调用了A的无参数构造函数),再调用类A的赋值函数,将参数a赋给m_a。

 

 

class CA

B::B(const A &a)
 : m_a(a)          
{
   …
}
B::B(const A &a)
{
m_a = a;
}

{

 示例9-2(a) 成员对象在初始化表中被初始化      示例9-2(b) 成员对象在函数体内被初始化

private:

 

    CSomething* sth;            // 以指针形式存在的成员变量

对于内部数据类型的数据成员而言,两种初始化方式的效率几乎没有区别,但后者的程序版式似乎更清晰些。若类F的声明如下:

                               

class F

public:

{

    CA(CSomething* sth)

 public:

    {

    F(int x, int y);        // 构造函数

        this->sth = new CSomething(sth->a, sth->b);

 private:

    }

    int m_x, m_y;

 

    int m_i, m_j;

    // 如果不实现深拷贝,请注释这个拷贝构造函数

}

    CA(CA& obj)

示例9-2(c)中F的构造函数采用了第一种初始化方式,示例9-2(d)中F的构造函数采用了第二种初始化方式。

    {

 

         sth = new CSomething((obj.sth)->a, (obj.sth)->b);

F::F(int x, int y)
 : m_x(x), m_y(y)          
{
   m_i = 0;
   m_j = 0;
}
F::F(int x, int y)
{
   m_x = x;
   m_y = y;
   m_i = 0;
   m_j = 0;
}

    }

 示例9-2(c) 数据成员在初始化表中被初始化     示例9-2(d) 数据成员在函数体内被初始化

 

9.3构造和析构的次序

    ~CA()

       构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行,该次序是唯一的,否则编译器将无法自动执行析构过程。

    {

一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。这是因为类的声明是唯一的,而类的构造函数可以有多个,因此会有多个不同次序的初始化表。如果成员对象按照初始化表的次序进行构造,这将导致析构函数无法得到唯一的逆序。[Eckel, p260-261]

        cout << "In the destructor of class CA..." << endl;

9.4示例:类String的构造函数与析构函数

        if (NULL != sth) delete sth;

       // String的普通构造函数

    }

       String::String(const char *str)

    void Show()

{

    {

    if(str==NULL)

        cout << "(" << sth->a << ", " << sth->b << ")" << endl;

    {

    }

        m_data = new char[1];

    void setValue(int a, int b)

        *m_data = ‘’;

    {

    }  

        sth->a = a;

    else

        sth->b = b;

    {

    }

        int length = strlen(str);

    void getSthAddress()

        m_data = new char[length+1];

    {

        strcpy(m_data, str);

        cout << sth << endl;

    }

    }

}  

};

 

 

// String的析构函数

CA some_fun4()

       String::~String(void)

{

{

    CSomething c(1, 2);

    delete [] m_data;  

    CA a(&c);

// 由于m_data是内部数据类型,也可以写成 delete m_data;

    return a;                       // 如果CA没有实现深拷贝,则not OK;如果实现深拷贝,则OK

       }

}

9.5不要轻视拷贝构造函数与赋值函数

 

       由于并非所有的对象都会使用拷贝构造函数和赋值函数,程序员可能对这两个函数有些轻视。请先记住以下的警告,在阅读正文时就会多心:

int main(int argc, char* argv[])

u       本章开头讲过,如果不主动编写拷贝构造函数和赋值函数,编译器将以“位拷贝”的方式自动生成缺省的函数。倘若类中含有指针变量,那么这两个缺省的函数就隐含了错误。以类String的两个对象a,b为例,假设a.m_data的内容为“hello”,b.m_data的内容为“world”。

{

现将a赋给b,缺省赋值函数的“位拷贝”意味着执行b.m_data = a.m_data。这将造成三个错误:一是b.m_data原有的内存没被释放,造成内存泄露;二是b.m_data和a.m_data指向同一块内存,a或b任何一方变动都会影响另一方;三是在对象被析构时,m_data被释放了两次。

    int a = some_fun1();

 

    cout << a << endl;              // OK

u       拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。以下程序中,第三个语句和第四个语句很相似,你分得清楚哪个调用了拷贝构造函数,哪个调用了赋值函数吗?

 

String a(“hello”);

    int *b = some_fun2();

String b(“world”);

    cout << *b << endl;             // not OK,即便返回结果正确,也不过是运气好而已

String c = a; // 调用了拷贝构造函数,最好写成 c(a);

 

c = b; // 调用了赋值函数

    int *c = some_fun3();           // OK, return c执行完后,c并没有被销毁(必须要用delete才能销毁)

本例中第三个语句的风格较差,宜改写成String c(a) 以区别于第四个语句。

    cout << *c << endl;

9.6示例:类String的拷贝构造函数与赋值函数

    delete c;

    // 拷贝构造函数

 

    String::String(const String &other)

    CA d = some_fun4();             // 如果CA没有实现深拷贝,则not OK;如果实现深拷贝,则OK

    {  

    d.Show();

// 允许操作other的私有成员m_data

 

    int length = strlen(other.m_data); 

 

    m_data = new char[length+1];

    return 0;

    strcpy(m_data, other.m_data);

}

}

 

// 赋值函数

    String & String::operate =(const String &other)

    {  

        // (1) 检查自赋值

        if(this == &other)

            return *this;

       

        // (2) 释放原有的内存资源

        delete [] m_data;

       

        // (3)分配新的内存资源,并复制内容

    int length = strlen(other.m_data); 

    m_data = new char[length+1];

        strcpy(m_data, other.m_data);

       

        // (4)返回本对象的引用

        return *this;

}  

   

    类String拷贝构造函数与普通构造函数(参见9.4节)的区别是:在函数入口处无需与NULL进行比较,这是因为“引用”不可能是NULL,而“指针”可以为NULL。

    类String的赋值函数比构造函数复杂得多,分四步实现:

(1)第一步,检查自赋值。你可能会认为多此一举,难道有人会愚蠢到写出 a = a 这样的自赋值语句!的确不会。但是间接的自赋值仍有可能出现,例如

   

// 内容自赋值
b = a;
c = b;
a = c; 
// 地址自赋值
b = &a;
a = *b;

 

也许有人会说:“即使出现自赋值,我也可以不理睬,大不了化点时间让对象复制自己而已,反正不会出错!”

他真的说错了。看看第二步的delete,自杀后还能复制自己吗?所以,如果发现自赋值,应该马上终止函数。注意不要将检查自赋值的if语句

if(this == &other)

错写成为

    if( *this == other)

(2)第二步,用delete释放原有的内存资源。如果现在不释放,以后就没机会了,将造成内存泄露。

(3)第三步,分配新的内存资源,并复制字符串。注意函数strlen返回的是有效字符串长度,不包含结束符‘’。函数strcpy则连‘’一起复制。

(4)第四步,返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。注意不要将 return *this 错写成 return this 。那么能否写成return other 呢?效果不是一样吗?

不可以!因为我们不知道参数other的生命期。有可能other是个临时对象,在赋值结束后它马上消失,那么return other返回的将是垃圾。

9.7偷懒的办法处理拷贝构造函数与赋值函数

       如果我们实在不想编写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,怎么办?

       偷懒的办法是:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。

例如:

    class A

    { …

     private:

        A(const A &a);             // 私有的拷贝构造函数

        A & operate =(const A &a); // 私有的赋值函数

    };

 

如果有人试图编写如下程序:

    A b(a);    // 调用了私有的拷贝构造函数

    b = a;      // 调用了私有的赋值函数

编译器将指出错误,因为外界不可以操作A的私有函数。

9.8如何在派生类中实现类的基本函数

       基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时应注意以下事项:

u       派生类的构造函数应在其初始化表里调用基类的构造函数。

u       基类与派生类的析构函数应该为虚(即加virtual关键字)。例如

#include <iostream.h>

class Base

{

 public:

    virtual ~Base() { cout<< "~Base" << endl ; }

};

 

class Derived : public Base

{

 public:

    virtual ~Derived() { cout<< "~Derived" << endl ; }

};

 

void main(void)

{

    Base * pB = new Derived; // upcast

    delete pB;

}

 

输出结果为:

       ~Derived

       ~Base

如果析构函数不为虚,那么输出结果为

       ~Base

 

u       在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值。例如:

class Base

{

 public:

    Base & operate =(const Base &other);    // 类Base的赋值函数

 private:

    int m_i, m_j, m_k;

};

 

class Derived : public Base

{

 public:

    Derived & operate =(const Derived &other); // 类Derived的赋值函数

 private:

    int m_x, m_y, m_z;

};

 

Derived & Derived::operate =(const Derived &other)

{

    //(1)检查自赋值

    if(this == &other)

        return *this;

 

    //(2)对基类的数据成员重新赋值

    Base::operate =(other); // 因为不能直接操作私有数据成员

 

    //(3)对派生类的数据成员赋值

    m_x = other.m_x;

    m_y = other.m_y;

    m_z = other.m_z;

 

    //(4)返回本对象的引用

    return *this;

}

 

9.9一些心得体会

有些C++程序设计书籍称构造函数、析构函数和赋值函数是类的“Big-Three”,它们的确是任何类最重要的函数,不容轻视。

也许你认为本章的内容已经够多了,学会了就能平安无事,我不能作这个保证。如果你希望吃透“Big-Three”,请好好阅读参考文献[Cline] [Meyers] [Murry]。

 

本文由10bet手机官网发布于web前端,转载请注明出处:拷贝构造函数及其参数类型,关于函数返回值的几种情况

上一篇:eclipse常用快捷键10bet手机官网: 下一篇:九九乘法表
猜你喜欢
热门排行
精彩图文