您的位置:首页 > 编程语言 > C语言/C++

C++独孤九剑第五式——人生几何(对象复制控制)

2016-06-16 17:06 330 查看
对酒当歌,人生几何? 譬如朝露,去日苦多。

人的一生可能惊涛骇浪,更可能波澜不惊,这次我们就来探讨一下“对象”(当然各位同学自己的对象不在本次讨论范围之内O(∩_∩)O,课后自己讨论吧)一生的“起起落落”,即对象的复制控制。

复制控制包括三个部分:复制构造函数的调用、赋值操作符的调用、析构函数的调用。下面就这三个操作来逐一进行介绍,大家共同学习(*^-^*)

一、复制构造函数

复制构造函数:首先它也是构造函数,所以函数名与类名相同,没有返回值;其次,它只有一个形参,且该形参是对本类类型对象的引用(常用const修饰)。

为什么复制构造函数的参数需要用引用呢?我们先看复制构造函数的调用场景:

1. 用一个同类对象显示或隐式初始化另一个对象时。

2. 复制一个对象,把它作为实参传递给函数时。

3. 从函数返回一个对象时。

4. 初始化顺序容器中的元素时。

5. 根据元素初始化式列表初始化数组元素时。

假设复制构造函数的参数是该类类型的对象,而不是引用。那么当我们在给一个函数传递实参的时候(其它情况也是一样的道理)会隐式调用复制构造函数,而复制构造函数本身又需要该类对象的实参,又会去调用该类的复制构造函数!从而将形成函数调用的“死循环”。

初始化和赋值有时候对于新手来说可能不是一件容易区分的事情,我就稍作说明吧。初始化就是类对象刚刚形成的同时对其进行值的设置,而赋值则是对象形成后再对其进行值的设置(在这里,我们把同一个语句中的操作当成是同时进行的操作,虽然从微观的角度来看并非如此)。

假设有如下的代码调用:

{

A a1 = a2;//a2也是类A的对象,对象a1在形成的同时进行值设置,所以调用复制构造函数,因为这是初始化操作。

}

{

A a1;

a1 =a2;//此时调用赋值操作符函数,因为此时不再是初始化了。

}

和默认构造函数一样,C++ Standard上说,如果类中没有声明拷贝构造函数,就会有一个隐式地声明或者定义出现。但是拷贝构造函数又会被分为trivial和nontrivial,实际上只有nontrivial的时候编译器才会合成相应的函数(很多时候都是这样的,习惯就好了)。而且,C++ Standard要求编译器尽量延迟nontrivalmembers的实际合成操作,直到真正遇到其使用场合为止。

那么什么时候编译器会真正合成复制构造函数呢?在以下四种情况出现的时候:

1.当类中含有一个成员对象,而该对象的类中有一个复制构造函数的时候(无论是显式声明或者隐式合成)。此时,需要在合成出来的复制构造函数中调用该成员对象的复制构造函数。

2.类中的基类有复制构造函数时(显式或隐式)。此时,需要在合成出来的复制构造函数中调用该基类的复制构造函数。

3.类中存在虚函数时(继承的或自身声明的)。此时,需要在合成出来的复制构造函数中设置vptr(虚函数表指针)的指向。

4.类的继承链中存在虚基类的时候(无论是直接的还是间接的)。此时,需要在合成出来的复制构造函数中维护虚基类部分的位置信息。

上面的情况与默认构造函数的情况可以类比一下,要证明也可以模仿默认构造函数调用时的证明方式,我就偷偷懒了(。・_・。)

需要注意的是,默认情况下的复制操作都是浅复制(有指针时,只是复制了指针的值,而并没有复制指针指向的对象),要实现深复制(既复制指针的值,同时也复制指针指向的对象)时,还是需要我们亲自来操作的。

二、赋值操作符

要让赋值操作符(=)有效地执行我们指定的操作,有时需要显示重载等号操作符。当然,当合成的赋值操作符可以达到要求时,就没有必要再手动定义了,因为这可能还会使执行效率降低。那么什么时候需要显示重载该操作符呢?逐个成员赋值不能满足我们需求的时候,典型的情况就是类中有指针成员的时候。

我们说,当类中没有定义自己的赋值操作符,则编译器会合成一个。这是理论上的(很多事情都是理论上的,大家都懂的),实际上编译器不一定会合成相应的操作符函数。

那么什么时候编译器会合成呢?主要是以下四种情况(编译器永远只在自己需要的时候才合成相应的函数):

1.类中有一个成员对象,而该对象的类中有一个赋值操作符函数。这时,我们在为该成员对象赋值时需要调用该操作符。

2.类的基类中有赋值操作符。这时,我们在为基类对象(作为派生类对象的一部分)赋值时会调用该操作符。

3.类中声明了虚函数。此时不能直接复制右端vptr(虚函数指针)的值,因为它可能是一个派生类对象。

4.该类继承了虚基类。此时对象虚基类部分的位置可能会发生改变(如果从派生类对象赋值的话)。

可以简单地证明一下:

当只有如下简单的类定义时

class A

{

int a;

};

进行如下操作:

{

A a1;

A a2;

a2 = a1;

}

其反汇编代码如下图:



由此可见,其中并没有调用赋值操作符函数。

如果为上面的类添加一个虚函数(其它情况就不一一证明了),则同样的代码调用,其反汇编情况如下:



有上可见,其中调用了合成的默认构造函数和赋值操作符。

上面的四种情况可以对比复制构造函数合成的情况,其实赋值操作符的工作和复制构造函数也差不多,只是调用时机有所区别。实际上,我们应该将这两个操作看作一个单元。如果需要其中一个,我们几乎也肯定需要另一个。

赋值操作符必须定义为成员函数而不能是友元函数。这是语法规则上的限制,当然即使没有这个限制也不应该定义为为友元,毕竟调用的时候可能会让人不适应。例如:operator=(a1,a2)相对应a1 = a2这种方式,是不是显得有点别扭。

自己定义时有一种情况需要特别注意:自身赋值,尤其是有资源需要释放的时候。

正确的定义方式如下:

Class& Class::operator=(const Class &rhs)

{

if(this == &rhs)

return *this;

//释放对象持有的资源

//分配指定的新资源

……

return *this;

}

如果没有判断自身赋值的情况直接释放原有资源肯定是要出事的,我想这就不用多说了。

三、析构函数

析构函数作为构造函数的补充,在构造函数中打开和分配所需的资源,在析构函数中关闭和释放已有的资源。

析构函数没有返回值,没有形参,就像是在默认构造函数的前面加了一个‘~’符号。如下:

class A{

……

~A(){cout<<”在析构函数中”<<endl;}//简单的析构函数

};

当对象被撤销时会自动调用析构函数,如果是动态分配的对象,只有在指向该对象的指针被删除时才撤销。注意:当对象的引用或指针(不论是栈指针还是接收new操作首地址的指针)超出作用域时,不会运行析构函数。只有删除指向动态分配对象的指针或实际对象(而不是对象的引用或指针)超出作用域时,才会运行析构函数。

示例代码如下:

1.栈指针的情况:

{

A a;

{

A*pa = &a;

}//此时pa超出作用域,但并不运行A的析构函数

}//此时实例对象a超出作用域,将运行相应的析构函数

2.接收new操作符首地址的情况:

{

A*pa = new A();

//delete pa;在此处显示调用delete语句可以析构pa指向的对象

}//此时pa指针超出作用域,但并不调用析构函数;而且错过释放内存的机会,将造成内存泄漏。

3.引用的情况:

{

Aa;

{

A&pa = a;

}//此时a的引用pa超出作用域,但不会调用析构函数

}//此时对象a自身超出作用域,将会调用析构函数

当撤销一个容器(不论是标准库容器还是内置数组)时,也会运行容器中的类类型元素的析构函数。

示例代码如下:

{

AarrayA[3] = {A(1),A(2),A(3)};

vector<A> vA(arrayA,arrayA + 3);

A *pa = new A[3];

delete [] pa;//调用pa中3个元素的析构函数

}//调用arrayA和vA中元素的析构函数

如果我们的类没有显示定义的析构函数,编译器会在需要的时候为我们合成一个。合成的析构函数按对象创建时的逆序撤销每个非static成员,即按成员在类中的声明次序的逆序撤销成员。对于类类型的成员,合成析构函数会调用该成员的析构函数来撤销对象。注意:合成析构函数并不删除指针成员所指向的对象。

上面说编译器将会在需要的时候合成析构函数,那么什么时候是所谓“需要的时候”?就是以下的两种情况:

1.class内的成员对象拥有析构函数(不论是合成的还是显示定义的)。

2.class的基类含有析构函数(不论是合成的还是显示定义的)。

这个时候为什么需要合成析构函数呢?因为编译器需要在合成的析构函数中调用上面对应的析构函数达到析构相应对象的目的。

例如有如下的类定义:

classVirtualBase

{

int vb;

};

classA:public virtual VirtualBase

{

public:

int a;

virtual void vfun(){}

};

当我们有如下代码时:

{

A a;

}

其反汇编的代码如下:



尽管有虚函数、虚继承这种复杂的机制,但是此时编译器依然没有为我们合成析构函数。因为在析构函数看来,虚函数和虚继承所带来的只是两个指针而已(可参考第一式),而撤销指针不需要额外的操作(调用一个析构函数此时反而是效率上的负担)。由上也可以看到编译器此时为我们合成了构造函数(可参考第四式)。

若有如下的类定义:

class B

{

string name;

};

类似的代码:

{

B b;

}

此时反汇编情况如下:



我们可以看到后面调用了相应的析构函数。因为string类中定义了析构函数,编译器需要合成一个析构函数来调用string类成员对象的析构函数。

下面是析构函数的操作顺序:

1. 执行析构函数的函数体

2. 如果类中具有成员对象,而后者拥有析构函数,那么它们会以其声明顺序的相反顺序被调用。

3. 如果对象内含有虚函数指针,现在被重新设定,指向适当基类的虚函数表。

4. 如果有任何直接的非虚继承的基类,而后者有析构函数,它们会以其声明顺序的相反顺序被调用。

5. 如果有任何的虚基类有析构函数,而目前的这个类是最尾端的类(最后的非虚基类),那么它们的析构函数会以其原来的构造顺序相反的顺序被调用。

关于上面的顺序,只要定义一个合适的继承链,显示定义析构函数输出相应的信息就可以得出结论了。

最后还有一个实践得出的经验法则。三法则:如果类需要析构函数,则它也需要复制构造函数和赋值操作符。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: