重读深度探索C++对象模型:构造
默认构造函数的生成:
在没有显示定义的情况下,编译器只会在四种情况下生成一个默认构造函数。
如果类成员X中没有特殊情况,则编译期只会生成一个不做任何工作的默认构造函数,这个默认构造函数是trivial的。
而只在四种情况下才会生成一个non-trivial的默认构造函数。
1.class的某个成员带有默认构造函数:
比如
class Foo { public: Foo(); }; class Bar { public: Foo foo; char* str; };
在这种情况下,由于Bar中成员foo带有默认构造函数,因此编译期还是会生成一个nontrival的默认构造函数,在该默认构造函数中显示调用foo.Foo::Foo();
Bar::Bar() { foo.Foo::Foo(); }
而如果对Bar的构造函数定义了
Bar::Bar(){str=0} 但未对foo进行构造
则真正编译期最后形成的默认构造函数为
Bar::Bar() { foo.Foo::Foo(); str=0; }
也就是说,在用户定义的构造函数内的代码之前,编译器会先调用必要的默认构造函数比如foo的默认构造函数,然后才执行用户定义的默认构造函数。
如果有多个成员变量由默认构造函数,则编译器将按这些成员变量声明的顺序来调用这些成员的构造函数,之后在执行用户定义的构造函数。
如果在初始化成员列表中调用构造函数,比如:
我们在Snow_White的构造函数中,首先用初始化列表的方式调用sneezy的构造函数,然后里面对mumble进行了赋值。
class Dopey { public: Dopey(); }; class Sneezy { public: Sneezy(int); }; class Bashful { public: Bashful(); }; class Snow_White { public: Dopey dopey; Sneezy sneezy; Bashful bashful; private: int mumble; }; Snow_White::Snow_White():sneezy(1024)//程序员定义的构造函数 { mumble=2048; }
而实际上最后编译期形成的构造函数为:
Snow_White::Snow_White:sneezy(1024)//还是按照声明顺序调用默认构造函数,然后是用户定义的构造函数里的指令mumble=2048;另外注意初始化成员列表调用的位置 { dopey.Dopey::Dpoey(); sneezy.Sneezy::Sneezy(1024); bashfule.Bashful::Bashful(); mumble=2048; }
2.基类带有默认构造函数:
如果一个class没有任何的构造函数,但它派生自一个带有默认构造函数的基类,则这个派生类的默认构造函数将被视为nontrivial而被合成出来。其中的内容为调用基类的默认构造函数。
如果程序员提供了多个构造函数,但未提供默认构造函数,则编译期不会生成一个新的默认构造函数,因为类中已经有其他构造函数存在了(但都不是默认构造函数)。但编译期会扩充现有的由程序员提供的构造函数,在其中添加nontrivial的操作,如基类的默认构造函数,如带有默认构造函数的成员的构造。
另外,生成的默认构造函数的执行顺序为:
首先调用基类的构造函数,然后调用带有默认构造函数的成员变量的默认构造函数,最后才执行用户定义的构造操作。
3.带有虚函数的类:
当某个class声明了,或继承了一个虚函数时,编译器也会生成一个nontrivial的默认构造函数。
当某个类声明了或继承了一个虚函数时,编译期时,编译器会做两件事:
1).生成一个虚函数表vtbl,内存虚函数们的地址
2).为该类生成一个指针成员bptr,该指针指向vtbl虚表的地址。
4.带有一个虚基类的类:
对于:
class X { public: int i; }; class A:virtual public X { }; class B:virtual public X { }; class C:public A,public B { };
在foo函数中,由于pa的指向是不确定的,它既可以指向A,也可以指向C,因此pa->i的偏移无法在编译期就确定下来(因为对应指向A和指向C的情况下,i的偏移不同:因为虚基类一般是变化部分,会随着类的增长而增长),因此要通过其他办法,将偏移的确定拖到执行期才进行。
因此需要对类的每一个虚基类都安排一个指针来指向这个虚基类,这样在编译期,编译器将这个存取操作翻译成通过指针来找到对应的虚基类部分,而i相对于虚基类的起始的偏移是固定的,这样无论怎么派生都能找到对应的i,因此这样就将i的存取拖延到了执行期。
由于虚基类的存在,必须要构造函数中指明这些指针vbcX的情况,因此如果不存在一个默认构造函数,则编译器会自动生成一个默认构造函数并在其中指明这些指向虚基类指针的指向。
void foo(const A* pa) { pa->i=1024; }
总结:
只有四种情况,编译器才会为未声明构造函数的类合成一个默认构造函数(如果类中存在了任意一种构造函数,则都不会生成默认构造函数)
在合成的默认构造函数中,只有基类成员和含有构造函数的成员会被初始化,其他的成员,如指针之类的都不会初始化。
因此像:
1).任何class如果没有定义默认构造函数,则会被合成一个出来
2).编译器生成的默认构造函数会明确设定每一个数据成员的值
这两种认知都是错误的!!!
拷贝构造函数
拷贝构造函数被调用的时机:
1.明确的调用拷贝构造函数:
X xx=tmp实际上是调用了拷贝构造函数来构造xx.
class X { }; X tmp; X xx=tmp;
2.作为参数时,对临时变量调用拷贝构造函数:
foo中的参数,这个临时变量实际上调用了拷贝构造函数,利用xx拷贝构造了自己
void foo(X x); { }; X xx; foo(xx);
3.作为返回值且返回值非引用返回时:
实际上,返回的值是一个临时变量而非xx,这个临时变量利用xx拷贝构造了自己。
X foo_bar() { X xx; return xx; }
生成拷贝构造函数:
在下述四种情况下,编译器会自动为一个不含有拷贝构造函数的类生成一个拷贝构造函数:
1.当类内含一个成员变量,且该成员的类声明中有一个拷贝构造函数时(无论这个拷贝构造函数是程序员所写的还是也是由编译器生成的)
2.当class继承自一个基类,且该基类中含有一个拷贝构造函数时(无论这个拷贝构造函数是程序员所写的还是也是由编译器生成的)
3.当class声明了一个或多个虚函数时
4.当class派生自一个继承链,且其中有一个或多个虚基类时
前两种情况,编译器必须将成员或基类的拷贝构造函数安插到合成的拷贝构造函数中
如:
class Word { Sting str; int cnt; }; inline Word::Word(const Word&wd)//编译器合成的拷贝构造函数 { str.String::(wd.str); cnt=wd.cnt;//也被复制了 }
另外,在这个被合成的拷贝构造函数中,如整数,指针,数组等的成员也都会被复制.
当class声明了一个或多个虚函数时:
对于:
class ZooAnimal { ZooAnimal(); virtual ~ZooAnimal(); virtual void animate(); virtual void draw(); }; class Bear:public ZooAnimal { public: Bear(); void animate(); void draw(); virtual void dance(); };
对于下面这种情况:
yogi与winnie都是Bear类,因此如果我们使用位逐次拷贝的方式不会有任何问题,因为winnie的vptr就应该指向yogi的vptr所指向的vtbl
Bear yogi; Bear winnie=yogi;
但以下这种情况的出现,使得对含有vptr的类,必须生成一个拷贝构造函数:
ZooAnimal franny=yogi;
用一个派生类变量去给一个基类变量赋值。这种情况下,由于基类小于派生类,因此对派生类进行了切割。此时由于ZooAnimal是一个变量,而不是多态的用法是一个指针或引用,其vptr指向的应该是没有被派生类重写的vtbl,但此时用一个派生类去给他赋值,如果还是用上面位逐次拷贝的方式,将引发问题,因为此时franny是一个ZooAnimal型变量且不是多态用法,它的虚函数不应该是被重写的虚函数。
也就是说,合成的ZooAnimal的拷贝构造函数会明确设定vptr指向ZooAnimal的vtbl而不是Bear型的yogi的vtbl.
当class派生自一个继承链,且其中有一个或多个虚基类时:
对于:
class ZooAnimal { ZooAnimal(); virtual ~ZooAnimal(); virtual void animate(); virtual void draw(); }; class Raccoon:public virtual ZooAnimal { }; class RedPanda:public Raccoon { };
对于下述这种情况,只使用位逐次拷贝是毫无问题的:
因为little_critter与rocky都同属于Raccoon型,虚基类ZooAnimal在他们的内部的偏移是一模一样的
Raccoon rocky; Raccoon little_critter=rocky;
但对于下述这种情况,位逐次拷贝一定会发生问题:
因为又派生了一层,因此如果vbcX还跟little_critter的指向一样,则是错误的,因为在新的内存布局中完全不知道这个指向到底是什么
RedPanda little_red; Raccoon little_critter=little_red;
而编译器是无法判断的,比如下述情况:
由于不可能在编译期能直到ptr的指向,它既可能是指向Raccoon型变量,也可能指向RedPanda型变量,因此必须在有虚基类的情况下生成一个拷贝构造函数,在其中调整vbcX的位置
Raccoon *ptr; Raccoon little_critter=*ptr;
成员初始化列表:
以下四种情况,一定要使用成员初始化列表:
1.初始化一个引用成员
2.初始化一个const成员
3.调用一个基类的构造函数,且这个构造函数需要传参
4.调用一个成员类的构造函数,且这个成员的构造函数要传参时
对于3:
如果想要使用传参的方式构造基类,则不能显示的在构造函数中调用,因为构造函数的explicit user code是落后于基类的构造之后的,也就是说如果在构造函数中显示调用,但实际上在进行explicit user code之前已经对该类进行了初始化了,这样实际上调用了两次构造函数,一次为默认构造函数,一次为含参的构造函数,这样的语义显示是有问题的。
对于4:
在进入explicit user code之前,该成员的默认构造函数实际上已经调用了,也就是说该成员已经构造完毕,而此时是不能显示调用带参的构造函数的,即使调用了,也相当于调用了两次,一次默认一次含参,这显示也是有问题的。