构造、析构、拷贝语意学


一、构造

1.1、"无继承"情况下的对象构造

考虑下面代码:
在这里插入图片描述
L1、L5、L6表现出三种不同的对象产生方式:global内存配置、local内存配置和heap内存配置。一个对象的声明,是该对象的一个执行期属性。local对象的声明从L5的定义开始,到L10结束。global对象的声明和整个程序的生命相同。heap对象的生命从它被new运算符配置出来开始,到它被delete运算符销毁为止。

下面是Point的声明,可以写成C程序。C++ Standard说这是一种所谓的Plain old data(点击查看plain old data)。
在这里插入图片描述
如果我们以C++ 来编译这段代码,会发生什么事情?观念上,编译器会为Point声明一个trivial default constructor、一个trivial destructor、一个trivial copy constructor,以及一个trivial copy assignment operator。但实际上,编译器会分析这个声明,并为它贴上plain old data标签。

当编译器遇到这样的定义:
在这里插入图片描述
观念上Point的trivial constructor和destructor都会被产生并被调用,constructor在程序起始处被调用而destructor在程序的exit()处被调用(exit()是由系统产生的,放在main()结束之前)。然而事实上那些trivial members要不就是没被定义,就是没被调用,程序的行为一如它在C中的表现一样。

foobar()函数中的L5,有一个local对象,同样也是既没有被构造也没有被析构。

heap对象在L6的初始化操作:
在这里插入图片描述
会被转换为对new运算符的调用:
在这里插入图片描述
需要注意的是,并没有default constructor施行于new运算符所传回的对象身上。

L9执行一个delete操作:
在这里插入图片描述
会被转换为对delete运算符的调用:
在这里插入图片描述
观念上,这样的操作会触发Point的trivial destructor。但一如我们所见,destructor要不是没有被产生就是没有被调用。

1.1.1、抽象数据类型

下面是Point的第二次声明,在public接口之下多了private数据,提供完整的封装性,但没有任何虚函数:
在这里插入图片描述
这个经过封装的Point类,其大小并没有改变,还是三个连续的float。我们并没有为Point定义一个copy constructor或copy operator,因为默认的位语意(default bitwise semantics)已经足够。我们也不需要提供一个destructor,因为程序默认的内存管理方法也已经足够。

观念上,我们的Point类有一个相关的default copy constructor、copy operator和destructor。然而它们都是trivial,而且编译器实际上根本没有产生它们。

1.1.2、为继承做准备

下面是Point的第三次声明,将为继承性质以及某些操作的动态决议(dynamic resolution)做准备。
在这里插入图片描述
我们并没有定义一个copy constructor、copy operator、destructor。我们的所有成员都以数值来存储,因此在程序层面的默认语意下,行为良好。

虚函数的导入促使每一个Point对象拥有一个虚函数表指针(vptr),这个指针给我们提供virtual接口的弹性,其成本是:每一个对象需要额外的一个word空间。

除了每一个类对象多负担一个vptr之外,虚函数的导入也引发编译器对于我们的Point类产生膨胀作用:

  • 我们所定义的constructor被附加了一些代码,以便将vptr初始化。这些代码必须被附加在任何基类构造函数调用之后,但必须在任何由程序员提供的代码之前。例如,下面是可能的附加结果:
    在这里插入图片描述
  • 合成一个copy constructor和一个copy assignment operator,而且其操作不再是trivial。如果一个Point对象被初始化或以一个派生类对象赋值,那么以位为基础(bitwise)的操作可能对vptr带来非法设定。
    在这里插入图片描述

1.2、继承体系下的对象构造

当我们定义一个对象:T object;,实际上会发生什么事情?如果T有一个constructor,它会被调用。这很明显,比较不明显的是,constructor的调用真正伴随了什么?

constructor可能内含大量的隐藏代码,因为编译器会扩充每一个constructor,扩充程度视类T的继承体系而定。一般而言编译器所做的扩充操作大约如下:

  • 1、记录在成员初始化列表中的数据成员初始化操作会被放进constructor的函数体,并以成员的声明顺序为顺序
  • 2、如果有一个成员并没有出现在成员初始化列表之中,但它有一个default constructor,那么该default constructor必须被调用
  • 3、在那之前,如果类对象有虚函数表指针,它必须被设定初值,指向适当的虚函数表
  • 4、在那之前,所有上一层的基类构造函数必须被调用,以基类的声明顺序为顺序(与成员初始化列表中的顺序没关联):
    • 如果基类被列于成员初始化列表中,那么任何显式指定的参数都应该传递过去
    • 如果基类没有被列于成员初始化列表中,而它有default constructor,那么就调用
    • 如果基类是多重继承下的第二个或后继的基类,那么this指针必须有所调整
  • 5、在那之前,所有虚函数基类的构造函数必须被调用,从左到右,从最深到最浅:
    • 如果类被列于成员初始化列表中,那么如果有任何显式指定的参数,都应该传递过去。若没有列于成员初始化列表中,而类有一个default constructor,亦应该调用
    • 此外,类中的每一个虚基类子对象的偏移位置必须在执行期可被存取
    • 如果类对象是最底层(most-derived)的类,其构造函数可能被调用;某些用以支持这一行为的机制必须被放进来

接下来,我们从"C++ 语言对类所保证的语意"这个角度,探讨constructor扩充的必要性。再次以Point为例,并为它增加一个copy constructor、一个copy operator、一个virtual destructor,如下所示:
在这里插入图片描述
在我们开始介绍并一步步走过以Point为根源的继承体系之前,先很快地看看Line类的声明和扩充结果,它由_begin和_end两个点构成:
在这里插入图片描述
每一个explicit constructor都会被扩充以调用其两个成员类对象的constructor。如果我们定义constructor如下:
在这里插入图片描述
它会被编译器扩充并转换为:
在这里插入图片描述
由于Point声明了一个copy constructor、一个copy operator,以及一个destructor,所以类Line的implicit copy constructor、copy operator和destructor都将有具体效用(nontrivial)。

当写下这样的代码:Line a;时,implicit Line destructor会被合成出来。其中,它的成员类对象的destructor会被调用(以其构造的相反顺序):
在这里插入图片描述
类似的道理,当我们写下:Line b = a;时,implicit Line copy constructor会被合成出来,成为一个inline public member。

当我们写下:a = b;时,implicit copy assignment operator会被合成出来,成为一个inline public member。

1.2.1、虚继承

考虑下面这个继承体系:
在这里插入图片描述
传统的constructor扩充并没有用,因为虚基类的共享性之故。试想以下三种类的派生情况:
在这里插入图片描述
Vertex的constructor必须也调用Point的constructor。然而,当Point3d和Vertex同为Vertex3d的subobject时,它们对Point constructor的调用操作一定不可以发生;取而代之的是,作为一个最底层的类,Vertex3d有责任将Point初始化。而更往下的继承,则由PVertex来负责完成被共享的Point subobject的构造。

在这里存在某种状态,在此状态中,虚基类的构造函数被调用有着明确的定义:只有当一个完整的类对象被定义出来时,它才会被调用;如果对象只是某个完整的对象的subobject,它就不会被调用。

1.2.2、vptr初始化语意学

在继承体系中,虚函数的调用是由虚函数候选列表决定的。而虚函数候选列表又由vptr决定,所以为了控制一个类中有所作用的函数,编译系统只要简单地控制住vptr的初始化和设定操作即可。当然,设定vptr是编译器的责任,任何程序员都不必操心此事。

vptr初始化操作应该如何处理呢?答案是在基类构造函数调用操作之后,但是在程序员提供的代码或是成员初始化列表中所列的成员初始化操作之前。

令每一个基类构造函数设定其对象的vptr,使它指向相关的virtual table之后,构造中的对象就可以严格而正确地变成构造过程中所幻化出来的每一个类的对象。也就是说,一个PVertex对象会先形成一个Point对象、一个Point3d对象、一个Vertex对象、一个Vertex3d对象,然后才成为一个PVertex对象。在每一个基类构造函数中,对象可以与构造函数的类的完整对象做比较。构造函数的执行算法通常如下:

  • 1、在派生类构造函数中,所有的虚基类及上一层的基类的构造函数会被调用
  • 2、上述完成之后,对象的vptr被初始化,指向相关的virtual table
  • 3、如果有成员初始化列表,将在构造函数体内扩展开来。这必须在vptr被设定之后,以免有一个虚成员函数被调用。
  • 4、最后,执行程序员所提供的代码

但是有两种情况下,vptr必须被设定:

  • 1、当一个完整的对象被构造时。如果我们声明一个Point对象,则Point constructor必须先设定其vptr
  • 2、当一个subobject constructor调用了一个虚函数时

二、拷贝

以Point类为例:
在这里插入图片描述
没有什么理由需要禁止拷贝一个Point对象。问题是:默认行为是否足够?如果我们要支持的只是一个简单的拷贝操作,那么默认行为不但足够而且有效率,我们没有理由再自己提供一个copy assignment operator。

只有在默认行为所导致的语意不安全或不正确时,我们才需要设计一个copy assignment operator。如果我们不对Point供应一个copy assignment operator,而只是依赖默认的memberwise copy,编译器会产生出一个实例吗?这个答案和copy constructor(有关copy constructor的详细情况,请参看构造函数语意学)的情况一样:实际上不会!由于此类已经有了bitwise copy语意,所以implicit copy assignment operator被视为毫无用处,也根本不会被合成出来。

一个类对于默认的copy assignment operator,在以下情况,不会表现出bitwise copy语意:

  • 1、当类内含一个成员对象,而该成员对象所属的类有一个copy assignment operator时
  • 2、当一个类的基类有一个copy assignment operator时
  • 3、当一个类声明了任何虚函数时
  • 4、当类继承自一个虚基类时

C++ Standard上说copy assignment operator并不表示bitwise copy semantics是nontrivial。实际上,只有nontrivial instances才会被合成出来。

于是,对于我们的Point类,这样的赋值操作:
在这里插入图片描述
由bitwise copy完成,把Point b拷贝到Point a,其间并没有copy assignment operator被调用。注意,我们还是可能提供一个copy constructor,为的是把NRV打开。copy constructor的出现不应该让我们以为也一定要提供一个copy assignment operator。

现在,我们增加一个copy assignment operator,用以说明在继承之下的行为:
在这里插入图片描述
现在派生一个Point3d类:
在这里插入图片描述
如果我们没有为Point3d定义一个copy assignment operator,编译器就必须合成一个。合成的东西可能看起来像这样:
在这里插入图片描述

三、析构

如果类没有定义destructor,那么只有在类内含的成员对象(或者类的基类)拥有destructor的情况下,编译器才会自动合成出一个来。例如,我们的Point,默认情况下并没有被编译器合成出一个destructor——虽然它拥有一个虚函数:
在这里插入图片描述
类似的道理,如果我们把两个Point对象组合成一个Line类:
在这里插入图片描述
Line类也不会拥有一个被合成出来的destructor,因为Point并没有destructor。

为了决定类是否需要一个程序层面的destructor(或是constructor),请想想一个类对象的生命在哪里结束(或开始)?需要什么操作才能保证对象的完整?这是constructor和destructor什么时候起作用的关键。例如:
在这里插入图片描述
我们看到,pt和p在作为foo()函数的参数之前,都必须先初始化为某些坐标值。这时候需要一个constructor,否则使用者必须显式地提供坐标值。一般而言,类的使用者没有办法检验一个local变量或heap变量以知道它们是否被初始化。把constructor想象为程序员的一个额外负担是错误的,因为它们的工作有其必要性。如果没有它们,抽象化的使用就会有错误的倾向。

当我们显式地delete p,会如何?有任何程序上必须处理的吗?是否需要在delete之前这么做:
在这里插入图片描述
不,当然不需要。没有任何理由说在delete一个对象之前先得将其内容清楚干净。你也不需要归还任何资源。在结束pt和p的生命之前,没有任何类使用者层面的程序操作是绝对必要的。因此,也就不需要一个destructor。

一个由程序员定义的destructor被扩展的方式类似constructor被扩展的方式,但顺序相反:

  • 1、destructor的函数体首先被执行
  • 2、如果类拥有成员类对象,而该对象所属的类拥有destructor,那么它们会以其声明顺序的相反顺序被调用
  • 3、如果对象内含一个vptr,现在被重新设定,指向适当的基类virtual table
  • 4、如果有任何直接的(上一层)非虚基类拥有destructor,它们会以其声明顺序的相反顺序被调用
  • 5、如果有任何虚基类拥有destructor,而目前讨论的这个类是最底(most-derived)的类,那么它们会以其原来的构造顺序的相反顺序被调用

四、总结

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值