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

  • 相关阅读:
    HtmlParser 2.0 中文乱码问题
    关于phpmyadmin中添加外键的做法
    jquery easyui Tab 引入页面的问题
    Python用户交互input()和print()
    Python运算符
    计算机硬件基础知识(五)操作系统发展史
    Python学习0304作业
    Python的垃圾回收机制
    Python的两种运行程序的方式
    Python发展史和编程语言的分类
  • 原文地址:https://www.cnblogs.com/hancm/p/3973801.html
Copyright © 2020-2023  润新知