C++我们必须要了解的事之具体做法(1)——构造、复制构造、析构、赋值操作符背后的故事
1. C++默认调用哪些函数
当类中的数据成员类型是trival数据类型(就是原c语言的struct类型)时,编译器默认不会创建ctor、 copy ctor、assign operator、dctor。
只有在这些函数被调用时,编译器才会创建他们。
这时候我们要自己创建构造函数,初始化内置数据类型。一般我们不需要复制控制函数,当需要时编译器合成的就很好。一般编译器合成的复制控制函数只是简单的复制成员,若能满足要求就不需要自己写。
当类中含有引用、const成员时,必须在初始化列表中初始化成员。且它们的copy cotr、assign operator都是不允许的。
三元素法则:一般有构造函数的类不需要析构函数。但是当类需要析构函数(往往是要删除构造函数初始化的资源如堆上的指针)时,一般同时也需要copy ctor、assign operaotr。
但是以下几种编译器一定会合成ctor:
类中含有类的(如vector等),编译器要调用其默认构造函数初始化成员。
类中含有虚函数的,编译器要初始化vptr。
类是虚继承的,要初始化虚基类在本类中的偏移量。
若只有这些类型,编译器合成的ctor就很好用。但是要注意,若有内置数据类型,我们需要自己创建ctor并初始化内置数据成员。
详细信息参见另外一篇博客:
2. 若不想使用编译器合成的copy ctor、copy assign需要明确的拒绝
在必要的时候编译器会为我们合成这两个函数,但是对于有些类我们并不需要它们(例如iostream中的类,或者是某种第一无二的资源等)。
这时我们需要明确拒绝:方法是将这两者声明为私有,不要定义它们。
class home {
public:
…
private:
home(const home&); //声明而不定义它们
home &operator=(const home&);
};
当类企图拷贝home时编译器发出错误(没有访问权限)。对于member函数和friend函数链接器发出错误(有访问权限,但是有声明没有定义时)。
另外一种方法是定义一个base class:编译时发生错误,没有访问权限。
class uncopyable {
protected: //允许derived类构造和析构
uncopyable() { }
~uncopyable() { } //不需要为virtual,这不是多态基类。而且不含数据成员,可以实现空基类优化。
private: //阻止coping
uncopyable(const uncopyable&);
uncopyable &operator=(const uncopyable);
};
class home : private uncopyable { //private继承,不一定需要public继承
… //不在声明copy构造函数、copy assign操作符
};
3. 为多态基类声明virtual析构函数
原则:
当我们编写的类会被作为基类,且会多态的使用这个基类(基类的指针或引用会处理继承类对象)时,这时我们需要将基类析构函数声明为virtual。
原因在于若基类的指针指向派生类(在堆上)时,在我们delete指针时(首先调用基类析构函数,发现不是virtual就不会再调用派生类析构函数了),会发生未定义的行为,
大多数情况下是只析构了基类对象,派生类没有被销毁,产生了局部销毁。
若类带有一个虚函数(允许派生类实现定制化),应该有虚析构函数。
对于不作为基类的类,我们就不应该声明虚析构函数。
但是有些类可以作为基类,但是不想具有多态性,我们就不应该声明虚析构函数。如上节的uncopyable, string, STL容器,它们的数据成员往往都是protect,我们可以继承,但是不具有多态性。
当然最后是不要派生它们。
4. 析构函数绝不应该抛出异常
C++不禁止析构函数抛出异常,但是不应该这么做。这样一定会带来过早终止或发生不明确行为。
在其他函数抛出异常时,stack unwind(栈展开)发生(目的是要catch异常,函数调用的现场信息等),会调用对象的析构函数,若析构函数再抛出异常,程序会过早终止或发生不明确行为。
若是正常的调用析构函数,析构函数抛出一个异常,处于异常调用点之后的代码不会不执行,其中可能会有回收资源,就发生了资源泄露。
然后再发生栈展开,又抛出一个异常程序会过早终止或发生不明确行为。
几种常见处理方式:
在类中有指针时:
class test {
public:
test(int val) : p(new int(val)) { }
~test() { delete p; }
private:
int *p;
};
当类中含有指针时,往往需要析构函数,两个copy函数。
此时new可能抛出异常bad_alloc。
当类析构函数需要处理一些必要的操作时,例如close_usb, close_db(关闭数据库),但是析构函数可能会抛出异常。
以数据库连接为例:
//负责数据库连接
class dbconnection {
public:
static dbconnection create(); //联机
void close(); //关闭联机,失败抛出异常
};
//管理dbconnection
class dbconn {
public:
~dbconn()
{
db.close();
}
private:
dbconnection db;
};
我们可以这样使用:
dbconn dbc(dbconnection::create());
自动调用~dbconn();close();但这只是理想状态,若close()抛出异常,析构函数抛出异常就会出问题。
可能做法1:抛出异常就结束程序,调用abort()完成。
dbconn::~dbconn()
{
try {
db.close();
} catch(…) {
//记录一些必要信息,表明close()失败。
std::abort;
}
}
可能做法2:吞下异常
dbconn::~dbconn()
{
try {
db.close();
} catch(…) {
//记录一些必要信息,表明close()失败。
}
}
一般认为,吞下异常是个坏主意,因为压制了“某些动作失败”的重要信息。
但是有时候比直接终止好。
这两个可能的做法都不太好,一种较好的做法是从新设计dbconn,给客户一个处理该异常。
class dbconn {
public:
void close() //客户使用的新函数
{
db.close();
closed = true;
}
~dbconn()
{
if (!closed) {
try {
db.close();
} catch(…) {
//记录一些必要信息,表明close()失败。
}
}
private:
dbconnection db;
bool closed;
};
这样就给客户一个机会处理错误的机会,若客户没有调用这个close,析构函数在调用。
在这里db.close()会抛出异常,我们绝不应该在析构函数中抛出,而是像这里的close(),在一个普通函数中执行该操作,给客户处理这个异常的机会。
5. 绝不在构造和析构函数中调用virtual函数
在构造函数中调用虚函数:
虚函数涉及到基类与派生类。在基类的构造函数期间虚函数绝不会下降到派生类,即此时的虚函数不是虚函数。根本原因在派生类对象的基类构造期间,对象的类型是基类而不是派生类。
不只虚函数会被编译器解析为基类,运行期类型信息(dynamic_cast, typeid)也会被视为基类类型。
这么做的理由:
基类的构造函数执行早于派生类构造函数,基类构造函数执行时派生类成员尚未初始化。此时若要使用这些尚未初始化的成员变量,会造成不明确的行为。C++不允许你这么做。
在析构函数中调用虚函数:一旦派生类的析构函数开始执行,对象内的派生类成员变量就处于为定义值,C++时它们仿佛不存在。进入基类析构函数对象就成为基类对象,
而C++任何部分包括虚函数、dynamic_cast等也就这么看待它。
构造函数或析构函数可能会把需要执行的相同代码放在一个函数中,例如:init(),destroy();这些调用的函数可能调用虚函数,这个比较隐蔽,不容易察觉。
怎样知道是否调用了虚函数呢?
方法是:确定你的构造函数和析构函数都没有调用虚函数,而且它们调用的所有函数都符合这个要求。
解决这个问题的一个方法是:派生类构造函数传递必要的信息给基类构造函数,基类构造函数可以安全调用非虚函数。
class base {
public:
explicit base(const std::string &loginfo)
{
log(loginfo);
}
void log(const std::string &loginfo) const; //此时这时非虚函数
};
class derived : public base {
public:
derived(para) : base(create_loginfo(para)) { } //将log信息传递给基类构造函数
private:
static std::string create_loginfo(para); //静态成员函数,不会调用成员函数,可以用过传递一个形参使用成员函数。
};
6. opreator=
由于内置的赋值操作符返回的是左操作数的引用。所以正确形式是:
testclass &operator=(const testclass &rhs)
{
…
return *this; //返回左操作数
}
要处理的问题是怎样处理“自我赋值”:
class bitmap {
…
};
class wrapper {
…
private:
bitmap *pb; //指向从heap上分配的对象
};
wrapper &operator(const wrapper &rhs)
{
delete pb;
pb = new bitmap(*rhs.pb);
return *this;
}
在没有处理自我赋值时:pb所值的资源已经被回收,他所执行的值处于未定义状态(随机值),*rhs.pb是个已经删除的对象new不可能得到正确的指针。
处理自我赋值方法1:证同测试(identify test);
wrapper &operator(const wrapper &rhs)
{
if (&rsh == this) { //自我赋值时什么都不做
reuturn *this;
}
delete pb;
pb = new bitmap(*rhs.pb);
return *this;
}
方法2:方法1的问题是不具异常安全性:若new抛出异常pb会指向已经被删除的bitmap。好的做法是使它具有“异常安全性”,附带防止自我赋值。
//通过合理安排语句顺序
wrapper &operator(const wrapper &rhs)
{
bitmap *old = pb;
pb = new bitmap(*rhs.pb); //若抛出异常会处于原状态
delete old;
return *this;
}
可以把证同测试放在前面,但这么做会使代码变大,并降低执行速度。我们需要自己“自我赋值”发生频率。
方法3:copy and swap技术,这也是异常安全的一种方式。
wrapper &operator(const wrapper &rhs)
{
wrapper tmp(rhs);
swap(tmp);
return *this;
}
下面的做法与这个等同:使用实参副本,清晰性不够,但有时会产生更高效的代码
wrapper &operator(wrapper rhs)
{
swap(tmp);
return *this;
}
在函数会操作一个以上对象时,我们要保证对个对象是同一个对象时,其行为仍然正确。
7. coping 函数必须复制每个部分
若派生类构造函数没有调用基类的构造函数,则会调用基类的默认构造函数,若没有default构造函数则无法编译成功。
copy构造函数也有同样的问题,若复制构造函数没有调用基类的构造函数,则同样调用基类的默认构造函数,造成基类的数据成员仍然是基类的部分,
而派生类的数据成员则被const testclass &rhs中派生类数据初始化,造成数据的不一致。
copy assign 操作符与copy ctor有些不同,它不会修改基类数据成员,这些成员保持不变。
所以我们要做的是除了复制对象中的所有成员变量,和调用基类的适当的构造函数base(rhs)、调用基类的operator=(rhs)完成对基类的所有数据的初始化。
注意事项:
我们不能令copy assignment操作符调用copy 构造函数,因为copy构造函数是用来构造对象的,相当于我们在构造一个已经存在的对象。
同样,令copy构造函数调用copy assignment操作符也是不允许的,因为copy assignment操作符是作用于已经初始化的对象,而此时对象尚没有构造好。
正确做法:
将它们相近的代码放在一个private成员函数中,常常命名为init。
最最重要的是:我们要知道什么时候我们需要自己写coping函数,而不是使用编译器默认合成的。参见前面讲述。