《Effective C++》(二)构造/析构/赋值运算

读过这本书,就获得了迅速提升自己C++功力的契机,地位就是这么高。

fengjingtu

条款5:了解C++默默编写并调用哪儿些函数

什么时候empty class不在是空的呢,答案是当C++处理过他之后。如果你没有自己生命,编译器就会为他生命一个copy构造函数,一个copy assignment操作符和一个析构函数。此外如果你没有声明任何构造函数,编译器也会为你声明一个default构造函数。所有这些都是public且inline。

因此如果你写下:

1
class Empty{};

就好像写下:

1
2
3
4
5
6
class Empty{
Empty(){...};//default
Empty(const Empty&rhs)...};//copy
~Empty(){...}//析构
Empty& operator=(const Empty&rhs){}//copy assignment
};

唯有这些函数被调用,他们才会被编译器创造出来。程序中需要他们是很平常的事情。

1
2
3
4

Empty e1;//default
Empty e2(e1);//copy
e2=e1;//copy assignment

编译器为你写函数,但这些函数做了什么呢?

default构造函数和析构函数主要是给编译器一个地方来放置“藏身幕后的代码”,像是调用base classes和non-static成员变量的构造函数和析构函数。编译器产生出来的析构函数通常是non-virtual的,除非class的base class自身声明有virtual析构函数。

治愈copy构造函数和copy assignment 操作符,编译器创建的版本只是单纯地将来源对象的每一个non-static成员变量宝贝到目标对象,考虑一个NameObject template,它将允许你将一个个名称和类型为T的对象产生关联:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class NameObject{
public:
NameObject(const char *name,const T& value);
NameObject(const std::string& name,const T& value);
...
private:
std::string nameValue;
T objectValue;

}

由于其中声明了一个构造函数,编译器由于不在为它建立default构造函数,这很重要。意味着如果你用心设计一个class,其构造函数要求实参,你就无须担心 编译器会毫无挂虑地为你田建一个无实参构造函数而遮盖掉你的版本。

NameObject既没有声明copy构造函数,也没有声明copy assignment操作符,所以编译器会为它创建那些函数,如果他们被调用的话。现在,看看copy构造函数的用法

1
2
NameObject<int>nol("smallest prime number",2);
NameObject<int>nol2(nol);//调用copy构造函数

编译器生成的copy构造函数必须以nol.nameValue和nol.objectValue为初值设定nol.2nameValue和nol2.objectValue.两者之中,nameValue的类型是string,而标准string有一个copy构造函数,所以nol2.nameValue的初始化方式是调用string的copy构造函数并以nol.nameValue为实参。另一个成员objectValue,是内置类型,所以nol2.objectValue会以拷贝nol.objectValue的每一个bits来完成初始化。

编译器生成的copy assignment操作符,其行为基本上与copy构造函数如出一辙,但一般而言,只有当生出的代码合法且有适当机会证明它有意义,其表现才会如之前所说的那样,万一两个条件有一个不符合,编译器会拒绝为class生出operator=。

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
class NameObject{
public:
NameObject(const char *name,const T& value);
NameObject(const std::string& name,const T& value);
...
private:
std::string& nameValue;//引用
const T objectValue;//const

}
std::string newDog("persephone");
std::string oldDog("Satch");

NameObject<int>p(newDog,2);
NameObject<int>s(oldDog,36);//调用copy构造函数
p=s;

赋值之前,不论p.nameValue,还是s.nameValue都指向string对象(当然不是同一个)。复制动作改如何影响p.nameValue呢。赋值之后p.nameValue应该指向谁。其实,C++的反应是拒绝编译这一赋值动作。如果你想,必须要自己定义copy assignment操作符。内涵const的类,编译器的反应也是一样的。

最后一种情况:如果某个基类将copy assignment操作符声明为private,编译器将拒绝为它的派生类生成一个copy assignment操作符,毕竟编译器为派生类生成的copy assignment操作符想象中可以处理基类的成员,但他们当然没有权限调用private修饰的函数,编译器会无能为力,直接报错的。

请记住

编译器可以暗紫为class创建出default,copy,copy assignment操作符,以及析构函数。

条款6:若不想使用编译器自动生成的函数,就该明确拒绝

如果你不希望class支持某一特定机能,只要不声明对应函数就是了。但在这个策略对copy构造函数和copy assignm操作符不起作用。因为上面我们说过了,他会自己生成的。

这时候,你不声明,编译器自己生成。你声明,你的类就会支持这一机能了。

我们当然要选择声明,并且目标是组织copying。

答案的关键是所有的编译器产生的函数都是public的,我们只要自动声明为private几句可以阻止编译器案子穿件其专属版本。一般而言,这种方法并不可靠,因为成员函数和友元函数还是可以调用的。除非你够聪明不去定义他们。

“将成员函数声明为private而且故意不实现他们”才是最为人们接受的。

1
2
3
4
5
6
7
8
class HomeForSale{
public:
...
private:
...
HomeForSale(const HomeForSale &);
HomeForSale& operator=(const HomeForSale &);
}

如果你这样定义了,而且企图拷贝HomeForSale对象,编译器会报错,如果你在友元和成员函数中想拷贝HomeForSale会出现连接错误。其实我们希望错误早些出现,即便是在友元和成员函数中想拷贝,我们也希望编译器可以报错。这时候应该这样做:

1
2
3
4
5
6
7
8
9
10
11
class Uncopyable{
public:
Uncopyable(){}
~Uncopyable(){}
private:
HomeForSale(const HomeForSale &);
HomeForSale& operator=(const HomeForSale &);
}
class HomeForSaleprivate Uncopyable{
...
}

这个 Uncopyable 类的实现和运用颇为巧妙,包括不一定得以public继承它。以及Uncopyable的析构函数不一定得是virtual等等。

请记住

为驳回编译器自动提供的机能,可将相应的成员函数声明为private并且不予实现,或者继承一个类似Uncopyable 这样的类。

条款7:为多态基类声明virtual析构函数

1
2
3
4
5
6
7
8
9
class TimeKeeper{
public:
TimeKeeper();
~TimeKeeper();
...
}
class AtomicClock:public TimeKeeper{...};
class WaterClock:public TimeKeeper{...};
class WristWatch:public TimeKeeper{...};

这是一个时间类的实现,很多用户不想操心时间如何计算等细节,这时候我们可以设置工厂函数,返回指针执行那个一个计数对象。Factory函数会“返回一个base class指针,指向新生成的派生类对象”

1
TimeKeeper* getTimeKeeper();//TimeKeeper派生类的动态分配对象

为遵循factory函数的规矩,被getTimeKeeper返回的对象必须位于堆中,因此为了避免泄露内存和其他资源,将factory函数每一个对象适当地delete掉很重要:

1
2
TimeKeeper*ptk = getTimeKeeper();
delete pkt;

在这里,有一个根本的弱点,纵使客户吧每一件事都多对了,仍然没有办法知道程序如何行动。

问题出在getTimeKeeper返回的指针,指向一个派生类对象。而那个对象却经由一个base class指针被删除,而目前的base class有一个non-virtual析构函数。

这将是一个灾难。因为C++明白指出,当派生类经由一个基类指针被删除时,而基类带有一个non-virtual析构函数时,其结果没有定义。实际执行时,通常发生的是对象的派生类成分没有被销毁。

如果getTimeKeeper返回一个指向AtomicClock对象的指针,其内的AtomicClock成分很有可能没被销毁。而AtomicClock的析构函数也没有执行起来。而base class的成分被析构了,这个诡异的局部销毁。

消除这个问题的做法很简单,给基类一个virtual析构函数。此后,删除派生类对象就会如你想要的那般,销毁整个对象。

1
2
3
4
5
6
7
8
9
10
11
class TimeKeeper{
public:
TimeKeeper();
virtual ~TimeKeeper();
...
}
class AtomicClock:public TimeKeeper{...};
class WaterClock:public TimeKeeper{...};
class WristWatch:public TimeKeeper{...};
TimeKeeper*ptk = getTimeKeeper();
delete pkt;

任何函数只要带有virtual函数,都几乎确定应该有一个virtual析构函数。

而当class不含有virtual函数,定义一个virtual析构函数是不合理的。事实上,没有virtual函数的类不适合做基类。

有时候令class带有一个纯虚函数可能颇为便利。抽象类总是会被当做基类,又由于基类应该有virtual析构函数,我们可以声明一个纯析构函数:

1
2
3
4
5
class AWOV{
public:
virtual AWOV{} = 0;
}
AWOV::~AWOV(){}

这个类有一个纯虚函数,所以他是抽象类。我们必须为这个纯虚析构函数提供一份定义。析构函数的运作方式是:最深层派生的那个类的析构函数最先被调用,探后其每一个基类的析构函数被调用,编译器会在AWOV的派生类的析构函数中穿件一个~AWOV的调用动作,多以你必须提供一份定义。

给基类一个虚析构函数,这个规则只适用于带有多态性质的基类上。这种设计的目的是为了用来通过基类接口处理派生类对象。

并非所有的基类的设计目的都是为了多态用途。

请记住

多态性质的基类应该声明一个virtual析构函数,如果class带有任何virtual函数,它就应该拥有一个虚析构函数

如果一个类不作为基类,或者不具备多态性,就不该声明virtual析构函数。

条款8:别让异常逃离析构函数

C++并不禁止析构函数吐出异常,但她不鼓励你这样做,这是有理由的。

1
2
3
4
5
6
7
8
9
10
class Widget{
public
...
~Widget(){...}//假设这可能吐出一个异常
}
void doSomething()
{
std::vector<Widget> v;
...
}

当vector v被销毁的时候,他有责任销毁其内涵的所有Widgets。假设v内涵是个Widgets,而在析构第一个元素期间,有个异常被抛出。其他九个widget还是应该被销毁。

如果你的析构函数必须执行一个动作,而该动作可能会在失败时抛出异常,该怎么办?举个例子。假设你使用一个class负责数据库连接:

1
2
3
4
5
6
7

class DBConnection{
public
...
static DBConnection create();
void close();
}

为确保客户不忘记在DBConnection对象身上调用close,一个合理的想法是创建一个用来管理DBConnection资源的类。并在其析构函数中调用close。

1
2
3
4
5
6
7
8
9
10
11
class DBConn{
public:
...
~DBConn()
{
db.close();
}
private:
DBConnection db;

}

只要调用close成功,一切都没好,但如果改调用导致异常,DBConn析构函数会传播该异常,也就是允许他离开这个析构函数。那会造成问题,因为那就是抛出了难以驾驭的麻烦。

两个办法可以避免这个问题,DBConn的析构函数可以:

请记住

析构函数绝对不要吐出异常,如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吐下他们或结束程序。

如果客户需要对一个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数来执行这个操作。

条款9:绝不在构造和析构富哦城中调用virtual函数

不能再构造函数和析构函数期间调用virtual函数,因为这样的调用不会带来你预想的结果。

假如你有一个class继承体系,用来模拟股市交易如买进,卖出的订单等等,这样的交易一定要经过审计,所以每当创建一个交易对象,在审计日志中也需要创建一笔适当记录,下面是一个看起来颇为合理的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Transaction{
public
Transaction();// 所有交易的base class
virtual void logTransaction()const = 0;//做出一份印类型不同而不同的日志记录。
}
Transaction::Transaction()
{
...
logTransaction();
}

class BuyTransaction:public Transaction{
public
virtual void logTransaction()const;
}
class SellTransaction:public Transaction{
public
virtual void logTransaction()const;
}

现在,当以下这行被执行,会翻身什么是:

1
BuyTransaction b;

无疑地,会有一个BuyTransaction 构造函数被调用,但首先Transaction构造函数一定会更早被调用,是的,派生类对象内的基类成分会在派生类自身成分被构造之前先构造妥当。Transaction构造函数的最后一行调用了virtual函数logTransaction,这时候调用的logTransaction是Transaction内的版本。派生类对象的基类成分在构造期间对象的类型就是基类,不是派生类。不只是virtual成员函数会被解析成基类,使用例如dynamic_cast这种,也会把对象看做为基类。

有些时候类会有多个构造函数,为了避免代码重复,通常会使用init:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

class Transaction{
public
Transaction();//调用non-virtual
{
init();
}
virtual void logTransaction()const = 0;//做出一份印类型不同而不同的日志记录。
private
void init()
{
...
logTransaction(); //调用virtual
}
}

这段代码是比较潜藏并且暗中为害的,因为它通常不会引发任何编译器和连接器的抱怨。大多数情况是系统会在运行过程中终止程序。

但你如何确保每次有Transaction继承体系上的对象被创建,就会有适当版本的logTransaction被调用呢?其他方案可以解决这个问题。

一种做法是在class Transaction内将logTransaction函数改为non-virtual,然后要求每一个派生类构造函数传递必要信息给Transaction构造函数,而后那个构造函数便可以可以安全地调用non-virtual logTransaction构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Transaction{
public
explicit Transaction(const std::string& logInfo);
void logTransaction()const = 0;//non-virtual函数
...
}
class BuyTransaction:public Transaction{
public
BuyTransaction(parameters):Transaction(createLogString(parameters))
{...}
private:
static std::string createLogString(parameters);
}
class SellTransaction:public Transaction{
public
SellTransaction(parameters):Transaction(createLogString(parameters))
{...}
private:
static std::string createLogString(parameters);
}

换句话说,由于你无法使用virtual函数从基类向下调用,在构造期间你可以藉由“令派生类将必要的构造信息向上传递到基类的构造函数”替换之而加以弥补。

请注意本例中BuyTransaction内的private static函数createLogString的运用,比起在成员初始化列表内基于基类数据,利用辅助函数创建一个值传给基类的构造函数往往比较方便。令次函数为static,也就不可能意外指向“初期未成熟的BuyTransaction对象内尚未初始化的成员变量”。这很重要,正是因为“那些成员变量处于未定义状态”,所以在基类构造和析构期间调用的virtual函数不可下降至派生类

请记住

在构造和析构期间不要调用virtual函数,因为这类调用从不下降至派生类。

条款10:令operator=返回一个this引用。

关于赋值,有趣的是你可以把他们写成连锁形式:

1
2
int x,y,z;
x=y=z=15;

同样有趣的是,赋值采用又结合律,所以上面的连锁赋值解析为:

1
x=(y=(z=15));

为了实现“连锁赋值”,赋值操作符必须返回一个引用指向操作符的左侧实参。这是你为classes实现赋值操作符时应该遵循的协议:

1
2
3
4
5
6
7
8
9
10
class Widget{
public:
...
Widget& operator=(const Widget& ahs)
{
...
return* this;
}

}

这个协议不仅适用于以上的标准赋值形式,也适用于所有赋值相关的运算,例如:

1
2
3
4
5
6
7
8
9
10
11

class Widget{
public:
...
Widget& operator+=(const Widget& ahs)
{
...
return* this;
}

}

注意,这只是一个协议,并无强制性,如果不遵守它,代码一样可以通过编译,然而这份协议被所有内置类型和标准程序库提供的类型,如string,vector,complex,tr1::shared_ptr或即将提供的类型共同遵守。因此,除非你有一个标新立异的好理由,不然还是随众吧。

请记住

令赋值操作符返回一个this引用。

条款11:在operator=中处理“自我赋值”

“自我赋值”发生在对象被赋值给自己时:

1
2
3
class Widget{...};
Widget w;
w=w;

这看起来有点愚蠢,但是他是合法的,所以不要认定客户绝不会那么做。此外“自我赋值”动作并不总可以被一眼认出:

1
a[i]=a[j];
1
2
3
class Base{...};
class Derived:public Base{...};
void doSomething(const Based &rb,Derived *pd)//rb 和pd有可能其实是同一个对象

如果你会运用对象来管理资源(条款13 和14会提到),而且你可以确定所谓“资源管理对象”在copy发生时有正确的举措,这种情况下你的赋值操作符或许是“自我赋值安全的”。

然而如果你尝试自行管理资源(如果你打算写一个用于资源管理的class就得这么做),可能会掉进“在停止使用资源之前,意外释放了它“的陷进。假设你建立一个class用来保存一个指针指向一块动态分配的位图:

1
2
3
4
5
6
class BitMap{...};
class Widget{
...
private:
BitMap *pb;//指向一个从堆分配而得的对象。
}

下面是operator=实现代码,表面上看起来合理,但自我赋值出现时并不安全(它也不具备异常安全性,稍后讨论)

1
2
3
4
5
6
Widget& Widget::operator=(const Widget& rhs)
{
delete pb;//停止使用当前的bitmap
pb = new Bitmap(*rhs.pb);//使用rhs‘s bitmap的副本
return *this;
}

这里的自我赋值的问题是,operator=函数内的*this(赋值的目的端)和rhs可能是同一个对象。果真如此,delete就不只是销毁当前对象的bitmap,他也销毁了rhs的bitmap。在函数末尾,Widget它原本不该被自我赋值动作改变的——发现自己持有一个指针指向一个已被删除的对象。

欲阻止这种错误,传统做法是藉由operator=最前面的一个“证同测试”达到自我赋值的检验目的:

1
2
3
4
5
6
7
8
9

Widget& Widget::operator=(const Widget& rhs)
{
if(this == &rhs) //认同测试
return *this ;
delete pb;//停止使用当前的bitmap
pb = new Bitmap(*rhs.pb);//使用rhs‘s bitmap的副本
return *this;
}

这样做行得通,刚刚提到的“异常安全性”问题。具体来说,如果new Birmap导致异常,不论是因为分配时内存不足或是因为bitmap的copy构造函数抛出异常,Widget最终会持有一个指针指向一个要删除的Bitmap。这样的指针有害,你无法安全的删除他们,甚至无法读取他们。唯一能对他们做出的安全事情是付出许多调用能量找出错误的起源。

1
2
3
4
5
6
7
8

Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* pOrig = pb;//记住原先的pb
pb = new Bitmap(*rhs.pb);
delete pOrig;//停止使用当前的bitmap
return *this;
}

现在如果new抛出异常,pb会保持原状。即使没有证同测试。这段代码还是能够处理自我赋值,因为我们对原bitmap做了一份附复件。

如果你很关心效率,可以吧“证同测试”在此放到函数起始处。这样做之前,你要问问自己,自我赋值的发生概率有多高。因为这项测试也需要成本。

在operator=函数内手工排列语句的一个替代方案,使用所谓的copy and swap技术,这个技术和异常安全性有密切关系。由以后详细说明。手法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Widget{
...
void swap(Widget& rhs);
...
}
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs);
swap(temp);
return *this;
}
Widget& Widget::operator=(Widget rhs)
{
swap(rhs);
return *this;
}

请记住

确保当对象自我赋值时,operator=有良好行为,其中技术包括比较来源对象和目标对象的地址,精心周到的语句顺序,以及copy and swap.

确定任何函数如果操作一个以上的对象,而其中多个对象时同一个对象时,其行为仍然正确。

条款12:复制对象时勿忘每一个成员。

设计良好的面向对象系统会将对象的背部封存起来,只留两个函数负责对象拷贝。如果你声明自己的copying函数,就是告诉编译器你不喜欢缺省实现中的某些行为。

考虑下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void LogCall(const std::string& funcName);
class Customer{
public:
...
Customer(const Customer&rhs);
Customer& operator=(const Customer&rhs);
...
private:
std::string name;
}
Customer::Customer(const Customer&rhs):name(rhs.name)
{
logCall("Customer copy constructor");
}
Customer& Customer::operator=(const Customer&rhs);
{
logCall("Customer copy assignment operator");
name = rhs.name;
return *this;
}

上面的程序目前为止,是很好的,但是如果我们加一个成员变量,就不是这样了:

1
2
3
4
5
6
7
8
class Date {...}
class Customer{
public:
...
private:
std::string name;
Date lastTransaction;
}

增加了一个成员了,我们必须修改copying函数,而且编译器不会提醒。

一旦发生继承,这种情况会更糟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PriorityCustomerpublic Customer {
public:
...
PriorityCustomer(const PriorityCustomer&rhs);
PriorityCustomer& operator=(const PriorityCustomer&rhs);
...
private:
int priority;
}
PriorityCustomer::PriorityCustomer(const PriorityCustomer&rhs):priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer&rhs);
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
return *this;
}

PriorityCustomer 的copying函数看起来好像复制了PriorityCustomer内的每一个成员,但是他还包括继承来的成员副本,他们并没有被拷贝。PriorityCustomer 并没有指定实参给base class构造函数,也就是说他在他的成员初始化列表中没有提到Customer构造函数。所以Cutomer的成分会被不带实参的default构造函数初始化。

以上事态在PriorityCustomer的copy assignment操作符身上只有轻微不同,它不曾企图改变base class的成员变量,那些成员变量保持不变。

任何时候,为派生类写copying函数的时候,都要小心的也赋值base class的成分。那些成分往往是private的,所以你无法访问,你应该让派生类调用基类的函数。

1
2
3
4
5
6
7
8
9
10
11
12
PriorityCustomer::PriorityCustomer(const PriorityCustomer&rhs)
:Customer(rhs),priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer&rhs);
{
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs);
priority = rhs.priority;
return *this;
}

请记住

copying函数应该确保复制对象内的所有成员变量,及所有baseclass成分

不要尝试以某个copying函数实现另一个copying函数,应该将共同机能放进第三个函数中。