首页 > 代码库 > C++我们必须要了解的事之具体做法(1)——构造、复制构造、析构、赋值操作符背后的故事

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函数,而不是使用编译器默认合成的。参见前面讲述。

C++我们必须要了解的事之具体做法(1)——构造、复制构造、析构、赋值操作符背后的故事