C/C++编程:拷贝构造函数的构建操作

有三种情况,会以一个对象的内容作为另一个类对象的初值

  • 最明显的一种情况是对一个对象做明确的初始化操作,比如:
class X{ ... };
X x;
X xx = x; // 明确的以一个对象的内容作为另一个类对象的初值
  • 另一种情况是当对象被当作参数交给某个函数时,比如:
extern void foo(X x);void bar(){X xx;foo(xx); // 以xx作为foo()第一个参数的初值(不明显的初始化操作)
}
  • 当函数返回一个类对象是,比如:
X foo_bar(){X xx;return xx;
}

假设类设计者明确定义了一个拷贝构造函数(有一个参数的类型是类类型[class type]),比如:

// 用户定义的拷贝构造函数的实例
// 可以是多参数形式,其第二参数以及后继参数有默认值
X::X(const X& x);
Y::Y(const Y& y, int = 0);

那么在大部分情况下,当一个类对象以另一个同类实体作为初值时,就会调用上面的构造函数。这可能会导致一个临时类对象的产生或者重新代码的蜕变

Default Memberwise Initialization

如果类没有提供一个显式拷贝构造函数会是怎样?

  • 当类对象以相同类的另一个对象作为初值时,其内部是以所谓的默认成员初始化(Default Memberwise Initialization)手法完成的
  • 也就是把每一个内建的或者派生的数据成员的值,从某个对象拷贝一份到另一个对象身上。
  • 不过它不会拷贝其中的成员类对象(member class object),而是以递归的方式施行memberwise initalization。比如:
class String{
public://没有显示拷贝构造函数
private:char *str;int len;
};

一个String对象的默认成员初始化发生在这种情况下:

String noun("book");
String verb = noun;

其完成方式就好像个别设定每一个成员一样:

// 语义相等
verb.str = noun.str;
verb.len = noun.len;

如果一个String对象被声明为另一个类的成员,像这样:

class Word{
public://没有显式拷贝构造
private:int _occurs;String _word; // String对象是word类的一个成员
};

那么Word对象的默认成员初始化会拷贝其内建的成员_occurs,然后再于String成员对象_word上递归实施memberwise initalization

这个操作实际上怎么完成呢?

  • 从概念上讲,对于一个类X,这个操作是被一个拷贝构造函数实现的
  • 一个良好的编译器可以为大部分类对象产生逐位拷贝(bitwise copies),因为它们有bitwise copy semantics

也就是说,”如果一个类没有定义拷贝构造函数,编译器就自动产生一个“这句话不对。默认构造函数和拷贝构造函数都是在必要的时候才由编译器产生出来的

这个必要指的是当类不展现bitwise copy semantics时。

一个类对象可以从两种方式复制得到:

  • 被初始化,通过拷贝构造函数完成
  • 被指定,通过拷贝赋值运算符完成

如果类没有声明一个拷贝构造函数,就会有隐式声明或者隐式定义一个。

  • C++标准把拷贝构造函数分为trivial和nontrivial两种。
  • 只有nontrival的实体才会被合成于程序中
  • 决定一个拷贝构造函数是否为trivial的标准在于类是否展示出所谓的bitwise copy semantics`

bitwise copy semantics(位逐次拷贝)

Word noun("book");
Word verb = noun;

verb是根据noun来初始化的。

  • 如果类Word显式定义了一个拷贝构造函数,verb的初始化操作就会调用它
  • 如果类Word没有显式定义一个拷贝构造函数,那么是否有一个编译器合成的实体被调用呢?这就需要看该类是否展现bitwise copy semantics。 看个例子:
// 以下声明展现了bitwise copy semantics
class Word{
public:Word(const char *);~Word(){delete [] str};
private:int   cnt;char  *str;
};

这种情况下不需要合成出一个默认拷贝函数,因为上面声明展现了default copy semantics。 而如果Word声明如下:

// 以下声明没有展现了bitwise copy semantics
class Word{
public:Word(const string &);~Word();
private:int   cnt;String str;
};

其中String声明了一个显式拷贝函数

class String{
public:String(const char *);String(const String &);~String();
};

这时,编译器必须合成一个拷贝构造函数以调用成员类对象String的拷贝构造函数

// 一个被合成出来的拷贝构造函数
inline Word::Word(const Word &wd){str.String::String(wd.str);cnt = wd.cnt;
}

注意:这里的拷贝构造函数中,比如整数、指针、数组等nonclass members也会被赋值

没有bitwise copy semantics

当类不再保持bitwise copy semantics时,而且没有声明默认拷贝函数时,这个类会被视为nontrival。如果没有声明拷贝函数,编译器为了正确处理以一个类对象作为另一个类对象的初值,必须合成一个拷贝对象。

什么时候一个类不展示出bitwise copy semantics呢?有四种情况:

  • 含有一个成员对象而后者的声明有一个拷贝构造函数时(不管这个拷贝构造函数是被显式声明还是被编译器合成的)
  • 继承自一个基类而后者存在有一个拷贝构造函数时(不管这个拷贝构造函数是被显式声明还是被编译器合成的)
  • 声明了一个或者多个虚函数
  • 派生自一个继承串链,其中有一个或者多个虚基类

前两种情况中,编译器必须将成员对象或者基类拷贝构造函数调用操作插入到被合成的拷贝构造函数中

后两种请看下面讨论:

重新设定虚函数表的指针

当有一个类声明了一个或者多个虚函数时,编译期间就会做如下扩张工作:

  • 增加一个虚函数表vtbl,内含每一个有作用的虚函数的地址
  • 将一个指向虚函数表的指针vptr,安插在每一个类对象

显然,如果编译器对于每一个新产生的类对象的vptr不能成功正确的设定好初值,将导致可怕的后果。因此,当编译器导入一个vptr到类之后,该类就不再展现bitwise semantics了。现在,编译器需要合成出一个拷贝构造函数,以求将vptr适当的初始化。举个例子:

class ZooAnimal{public:ZooAnimal();virtual ~ZooAnimal();virtual void animate();virtual void draw();private://...
};class Bear : public ZooAnimal{public:Bear();void animate(); // 虽然没有明写virtual,但是是一个virtualvoid draw();    //  虽然没有明写virtual,但是是一个virtualvirtual void dance();private:// ...
};

ZooAnimal类对象以另一个ZooAnimal类对象作为初值,或者Bear类对象以另一个Bear类对象作为初值,都可以直接靠"bitwise copy semantics"完成,举个例子:

Bear yogi;
Bear winnie = yopi;

yogi会被默认构造函数初始化,在这个默认构造函数中,yogi的vptr被设定指向Bear类的虚函数表(靠编译器安插的码完成)。因此,把yogi的vptr值拷贝给winnie的vptr是安全的。
在这里插入图片描述
当一个基类对象以其派生类对象内容做初始化操作时,其vptr复制操作也必须保证安全。比如:

ZooAnimal franny = yogi; //这会发生切割行为 

franny的vptr不可以被设定为指向Bear类的虚函数表,但是如果yogi的vptr被直接"bitwise copy",就会导致此结果。后果是下面程序片段就会被“炸毁”

void draw(const ZooAnimal& zoey) { zoey.draw(); }
void foo(){// franny的vptr指向ZooAnimal的虚函数表而不是Bear的虚函数表ZooAnimal franny = yogi;draw(yogi); // 调用Bear::draw;draw(franny); // 调用ZooAnimal::draw;
}

通过franny调用虚函数draw,调用的是ZooAnimal实体而不是Bear实体(虽然franny是以Bear类yogi作为初值),因为franny是一个ZooAnimal对象。实际上,yogi的Bear部分已经在franny初始化时被切割掉了。如果franny被声明为一个引用(或者指针,其值为yogi的地址),那么经由franny所调用的draw()才会是Bear的函数实体
在这里插入图片描述
也就是说,合成出来的ZooAnimal拷贝构造函数会明确设定对象的vptr指向ZooAnimal类的虚函数表,而不是直接从右手边的类对象中将其vptr现值拷贝出来。

处理虚基类子对象

虚基类的存在需要特别处理。一个类对象如果以另一个对象作为初值,而后者有虚基类子对象(virtual base class subobject),那么也会使bitwise copy semantics失效

每一个编译器对于虚拟继承的支持承诺,都表示必须让派生类对象中的虚基类子对象位置在执行期就准备妥当。维护位置的完整性是编译器的责任。bitwise copy semantics肯能会破坏这个位置,所以编译器必须在(编译器)合成出来拷贝构造函数做出仲裁。举个例子:

class Raccon : public virtual ZooAnimal{public:Raccon(){}Raccon(int val){}private:
};

编译器所产生的代码(用以调用ZooAnimal的默认构造函数、将Raccon的vptr初始化,并定位出Raccon的ZooAnimal subobject)被安插在两个Raccon构造函数之内,成为其先头部队

那么所有成员初始化呢?

  • 首先,一个虚基类的存在会使得bitwise copy semantics失效
  • 其次,问题并不发生于一个类对象以另一个同类对象作为初值(这时可以bitwise copy semantics)之时,而是发生与一个类对象以其派生类对象作为初值(bitwise copy semantics失效)时。比如:
class RedRanda : public Raccon{public:RedRanda(){};RedRanda(int val){};private:
}

如果同类对象作为初值,那么bitwise copy就绰绰有余了:

// 简单的bitwise copy就足够了
Raccon rocky;
Raccon little_critter = rocky;

如果以子对象作为父对象的初值,编译器必须判断"后继当程序员视图存取其ZooAnimal子对象时是否能够正确的执行":

// 简单的bitwise copy不够,编译器必须能够明确little_critter的虚基类pointer/offset初始化
RedRanda little_red;
Raccon little_critter = little_red;

在这种情况下,为了完成正确的little_critter初值设定,编译器必须合成一个拷贝构造函数,安插一些码以设定虚基类pointer/offset的初值,对每一个成员指向必要的初始化操作,以及执行它们的内存相关操作
在这里插入图片描述
再看一种情况:

// 简单的bitwise copy可能够用,也可能不够用
Raccon *ptr;
Raccon lillte_critter = *ptr;

上面编译器无法知道是否bitwise copy semantics还保持着,因为它无法知道Raccon指针是否指向一个真正的Raccon对象,还是指向一个派生类对象。

这里有一个有趣的问题:当一个初始化操作存在并保持着bitwise copy semantics的状态时,如果编译器能够保证对象有正确而相等的初始化操作,是否它应该压抑拷贝构造函数的调用,以使其所产生的程序代码优化?

  • 如果是合成的拷贝构造函数,程序副作用为〇,会优化
  • 如果这个拷贝构造是由类设计者提供的呢?

Published by

风君子

独自遨游何稽首 揭天掀地慰生平

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注