构造/析构/赋值运算
条款05:了解C++默认编写并调用哪些函数
什么时候空类不再是一个空类了呢?当C++处理之后就不再是了,编译器会自动为它声明一个编译器版本的默认构造函数、合成拷贝构造函数、合成拷贝赋值运算符、析构函数。只有当这些函数被调用时,才会被编译器创建出来。
默认构造函数和析构函数,将调用基类和成员变量的默认构造函数及析构函数。需要注意的是,编译器合成的析构函数不是虚函数,只有在基类析构函数是虚函数的情况下才会合成虚析构函数。
合成拷贝构造函数和合成拷贝赋值运算符,编译器创建的版本只是单纯的将来源对象的每一个非静态成员变量拷贝到目标对象。然而在某些情况下,编译器是拒绝合成拷贝函数的,比如:成员变量中有引用或const类型,基类的拷贝函数是private。
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
地产中介卖房子,一个中介软件系统必然会有一个类来描述房屋,但是每个房屋都是独一无二的不可能完全相同,因此该类不应该允许拷贝操作。但是如果我们不定义拷贝构造函数和拷贝赋值运算符,编译器也会自动生成它们,并不能达到我们的目的。
为此,我们有两个解决方法:
- C++11新标准中,为默认构造函数、合成拷贝构造函数、合成拷贝赋值运算符提供了”=delete”关键字,将这些函数声明为删除的。
- 我们还可以将拷贝控制操作定义为private的,不允许类外对象访问它们。同时,为了避免类内成员函数和友元函数调用它们导致错误,我们只声明而不定义它们。
条款07:为多态基类声明virtual析构函数
当我们申请一个基类指针,将其指向派生类对象时,在销毁该对象时,若基类析构函数不是虚函数,那么将会执行基类的析构函数,仅销毁派生类对象的基类部分,造成一个局部销毁的对象。因此,我们需要将基类的析构函数声明为virtual。
如果一个类中含有虚函数,那么它就很可能是基类,因此含有虚函数的类都应将其析构函数声明为virtual。但如果一个类中不含有虚函数,那么它就没有做基类的意图,此时我们不应该为它声明virtual析构函数。
class Point
{
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x,y;
};
在上述代码中,我们定义了一个表示二维空间点坐标的Point类,如果int占32个bit,那么Point对象可塞入一个64bit的缓存器中,并且可以当做一个64bit量传给其他语言如C语言编写的函数。
但如果将析构函数声明为virtual函数,就不是这个样子的了。
如果一个类中含有虚函数,为了便于运行时绑定,这个类会有一张虚表(virtual table),表的内容是由虚函数的函数指针构成的数组,并且会有一个虚表指针(virtual table pointer)指向这个虚表,而这个虚表指针会被纳入类的存储空间。
因此,在32bit系统中,Point对象是96bit的,在64bit系统中,是128bit的,可以看出体积增加了两倍(非常浪费)。并且由于其他语言中没有虚表指针的存在,因此不能将Point对象传递给其他语言。
需要注意,STL中的容器如string、vector、list、set等都不含有虚函数,不应被作为基类。
并且,当我们为抽象类声明了一个纯虚析构函数时,一定要给出它的定义,因为派生类析构函数会默认调用它,若它没有被定义,连接时会出错。
条款08:别让异常逃离析构函数
C++并不禁止析构函数吐出异常,但我们不鼓励这样做。
class Widget
{
public:
...
~Widget(){...} //假设这个可能会吐出异常
};
void doSomthing()
{
vector<Widget> v;
... //v在这里被销毁
}
在上述代码中,doSomthing结束时v会被销毁,v内的每个Widget成员都会被销毁。假设v内有10个成员,销毁第一个成员时,析构函数吐出异常,此时应当继续销毁剩下成员。在销毁第二个成员时,析构函数又吐出了异常,当两个异常同时存在时,C++规定程序要么提前结束要么发生不明确的行为。因此,只要允许析构函数吐出异常,程序就有可能提前结束或出现不明确的行为。
假如我们有一个基类A,一个派生类B,在调用B的析构函数时,销毁B的派生类部分时失败,并且异常没有在析构函数内处理而是吐出异常,那么派生类对象的基类部分将无法被销毁,造成内存泄漏。
但如果析构函数必须执行一个动作,执行失败时需要抛出异常,应该怎么办?
class DBConnection
{
public:
...
static DBConnection create();
void close(); //关闭连接,失败抛出异常
};
//管理资源的类
class DBConn{
public:
...
~DBConn(){ db.close();}
private:
DBConnection db;
};
我们建立了一个数据库连接类DBConnection,为确保客户不忘记在DBConnection对象身上调用close(),我们创建一个用来管理DBConnection资源的类,并在它的析构函数中调用close()。
如果调用close()成功,那一切都很美好。但如果调用失败导致异常,DBConn析构函数会传播该异常,造成麻烦。我们可以捕获异常或者将异常转移到非析构函数中。
//只要抛出异常就结束程序
DBConn::~DBConn()
{
try{ db.close();}
catch(...) {
//记下对close的调用失败;
std::abort(); //结束程序
}
//只要抛出异常就吞下,继续执行
DBConn::~DBConn()
{
try{ db.close();}
catch(...) {
//记下对close的调用失败;
}
以上两种方式的问题在于无法对导致close抛出的异常做出反应,因此我们应为DBConn类提供一个普通函数(而不是析构函数)执行close操作。
class DBConn{
public:
...
void close() //用户调用,若db.close发生异常,可由用户操作
{
db.close();
closed = true;
}
~DBConn()
{
if(!closed){
try{
db.close(); //如果连接没有被关闭,关闭连接
}
catch(...){
}
}
}
private:
DBConnection db;
bool closed;
};
条款总结:
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉异常,吞下它们或结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class内应该提供一个普通函数执行该操作。
条款09:绝不在构造和析构过程中调用virtual函数
我们之前已经知道,构造函数和析构函数中调用的virtual函数,是本类类型的virtual函数,也就是说,虚函数在构造和虚构过程中失效了。
当我们构造一个派生类对象时,从基类部分开始构造,如果基类的构造函数调用了虚函数,那么该虚函数将绑定基类版本,哪怕原对象是派生类对象。之前所了解到的解释是,在构造基类部分时,派生类部分没有初始化,因此它不可能将虚函数下降到派生类的版本。
其实还有更深一层的原因:在派生类对象的基类部分构造期间,对象的类型是基类类型而不是派生类类型。不止虚函数会被解析至基类,若使用运行期类型信息,也会把对象视为基类。对象在派生类构造函数执行开始前不会成为一个派生类对象。很容易理解,因为在构造基类部分时派生类部分没有被初始化,因此编译器对它们视而不见。
同样地,对于析构函数,从派生类部分开始销毁,若一个类的派生类部分被销毁,那么编译器也会对它们视而不见。到执行基类的析构函数时,编译器便会把这个对象看做基类对象。
而在某些时候,在一个继承体系中,需要我们每创建一个对象就有相对应版本的记录,显然在构造函数中调用虚函数创建记录不能解决我们的问题(因为不会调用相应版本的虚函数)。这个时候,我们应该以非虚函数代替虚函数,在基类的构造函数中调用该函数记录信息,而派生类将记录信息传给基类的构造函数。
条款10:令operator=返回一个*this的引用
关于赋值,有趣的是我们可以把它写成连锁形式,并且它遵循右结合律。为了更好地实现连锁形式,我们令与赋值有关的运算符都返回*this,可以避免许多额外的内存开销。
int x,y,z;
x = y = z = 10;
x = ( y = (z = 10));
class Widget{
public:
...
Widget& operator=(const Widget& rhs)
{
...
return *this;
}
Widget& operator+=(const Widget& rhs)
{
...
return *this;
}
};
条款11:在operator=中处理自我赋值
w = w;
a[i] = a[j]; //i和j是相等的
*px = *py; //px和py指向同一内存
如上述代码所示,自我赋值就是对象自己给自己赋值。我们前面已经了解到,在定义类的拷贝赋值运算符时,必须要考虑到对象的自我赋值。
一个比较好的做法就是,先将要赋值的对象拷贝下来,释放当前对象资源,最后进行赋值。
HasPtr & HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放旧内存
ps = newp;
i = rhs.i;
return *this;
}
另一个比较好的做法是,使用swap进行赋值。
void HasPtr::swap(HasPtr &rhs);
HasPtr & HasPtr::operator=(const HasPtr &rhs)
{
HasPtr temp(rhs); //rhs是引用,因此需要创建一个临时对象交换才能达到赋值效果
swap(temp);
return *this;
}
条款12:复制对象时勿忘每一个成分
当我们定义自己的拷贝构造函数和拷贝赋值运算符时,一定要记得复制对象的每一个成分。包括:(1)复制派生类成员 (2)调用基类的拷贝操作
对于基类的成员,我们只能通过调用基类的接口进行操作。若在派生类拷贝构造函数中,不调用基类的拷贝构造函数,那么编译器会默认初始化基类部分。而对于拷贝赋值运算符,则保留原来的基类部分。
class Customer{
public:
...
Customer(const Customer& rhs);
Customer& operator=(const Customer &rhs);
private:
std::string name;
};
class PriCustomer{
public:
...
PriCustomer(const PriCustomer& rhs):Customer(rhs),pri(rhs.pri)
{
//调用基类的拷贝构造函数,拷贝派生类成员
}
PriCustomer& operator=(const PriCustomer& rhs)
{
//调用基类的拷贝赋值运算符,拷贝派生类成员
Customer::operator=(rhs);
pri = rhs.pri;
return *this;
}
private:
int pri;
};