初看到这个题目,你可能会有些疑惑:C++类对象的创建还有什么好说的,不就是调用构造函数么?实际上情况并不是想象中的那么简单,大量的细节被隐藏或者被忽略了,而这些细节又是解决一些其他问题的关键,所以我们很有必要深入到这块"神秘"的区域,去探索鲜为人知的秘密。
分配空间(Allocation)
创建C++类对象的第一步就是为其分配内存空间。对于全局对象,静态对象以及分配在栈区域内的对象,对它们的内存分配是在编译阶段就完成了,而对于分配在堆区域内的对象,它们的分配是在运行是动态进行的。内存空间的分配过程涉及到两个关键的问题:需要分配空间的大小,即类对象的大小。这么问题对于编译器来说并不是什么问题,因为类对象的大小就是由它决定的,对于要分配多少内存,它最清楚不过了。
是否有足够的内存空间来满足分配。对于不同的情况我们需要具体问题具体分析:全局对象和静态对象。编译器会为他们划分一个独立的段(全局段)为他们分配足够的空间,一般不会涉及到内存空间不够的问题。分配在栈区域的对象。栈区域的大小由编译器的设置决定,不管具体的设置怎样,总归它是有一个具体的值,所以栈空间是有限的,在栈区域内同时分配超过空间大小的对象会导致栈区域溢出,由于栈区域的分配是在编译阶段完成的,所以在栈区域溢出的时候会抛出编译阶段的异常。分配在堆区域的对象。堆内存空间的分配是在运行是进行的,由于堆空间也是有限的,在栈区域内试图同时分配大量的对象会导致分配失败,通常情况会抛出运行时异常或者返回一个没有意义的值(通常是0)。
初始化(Initialization)
这一阶段是对象创建过程中最神秘的一个阶段,也是最容易被忽视的一个阶段。要想知道这一阶段具体完成那些任务,关键是要区分两个容易混淆的概念:初始化 (Initialization)和赋值(Assignment)。初始化早于赋值,它是随着对象的诞生一起进行的。而赋值是在对象诞生以后又给予它一个新的值。这里我想到了一个很好的例子:任何一个在医院诞生的婴儿,在它诞生的同时医院会给它一个标识,以防止和其他的婴儿混淆,这个标识通常是婴儿母亲所在床铺的编号,医院给婴儿一个标识的过程可以看作是初始化。当然当婴儿的父母拿到他们会为他们起个名字,起名字的过程就可以看作是赋值。经过初始化和赋值后,其他人就可以通过名字来标识他们的身份了。区分了这两个概念后,我们再转到对对象初始化的分析上。对类对象的初始化,实际上是对类对象内的所有数据成员进行初始化。C++已经为我们提供了对类对象进行初始化的能力,我们可以通过实现构造函数的初始化列表(memberinitialization list)来实现。
初始化列表先于构造函数体内的代码执行;
初始化列表确实执行的是数据成员的初始化过程,这个可以从成员对象的构造函数被调用看的出来。
赋值(Assignment)
对象经过初始化以后,我们仍然可以对其进行赋值。和类对象的初始化一样,类对象的赋值实际上是对类对象内的所有数据成员进行赋值。C++也已经为我们提供了这样的能力,我们可以通过构造函数的实现体(即构造函数中由"{}"包裹的部分)来实现。这一点也可以从上面的汇编代码中成员对象的赋值操作符 (operator=)被调用得到印证。
随着构造函数执行完最后一行代码,可以说类对象的创建过程也就顺利完成了。
由以上的分析可以看出,构造函数实现了对象的初始化和赋值两个过程:对象的初始化是通过初始化列表来完成,而对象的赋值则才是通过构造函数,或者更准确的说应该是构造函数的实现体。
虚函数表指针(VTable Pointer)
我们怎么可能会忽视虚函数表指针呢?如果没有它的话,C++世界会清净很多。我们最关心的是对于那些拥有虚函数的类,它们的类对象中的虚函数表指针是什么时候赋值的?我们没有任何代码,也没有任何能力(当然暴力破解的方法除外)能够在类对象创建的时候给其虚表指针赋值,给虚表指针赋值是编译器偷偷完成的,具体的时机是在进入到虚函数后,在给对象的数据成员初始化和赋值之前,编译器偷偷的给虚表指针赋值。我们可以清晰的看到,在构造函数的最开始,在进入构造函数体内部,甚至是在进入初始化列表之前,编译器会插入代码用当前正在被构造的类的虚表地址给虚表指针赋值。
后记
如果不是亲自实践和分析,很难想象一个简单的类对象创建过程竟然蕴涵了这么多秘密。了解了这些秘密为我们解决其他的一些问题打开了胜利之门。试试下面的一些问题,不知道在你看完本文后是否能够有一种豁然开朗的感觉:
1. 为什么C++需要提供初始化列表?那些情况下必须实现初始化列表? (提示:有些情况下只能初始化不能赋值)
2. 构造函数可以是虚函数呢?在构造函数中调用虚函数会有什么样的结果? (提示:虚表指针是在构造函数的最开始初始化的)
3. 构造函数和赋值操作符operator=有什么区别? (提示:区分初始化和赋值)
对象初始化顺序:
当无虚基类的时候:按照继承时候声明的顺序进行基类构造函数,这个是为了将继承的部分进行初始化;按照成员对象在类中的声明顺序进行成员对象的初始化,这是因为在创建对象之前必须对成员进行初始化,这部分可以在构造函数初始化列表中进行;执行子类的构造函数体,对各部分进行初始化。
当有虚基类的时候:第一步应当是对于所有虚基类的子对象,按照他们在类中定义出现的位置,从上到下,从左导游进行初始化,之后就按照以上步骤进行初始化即可。
- #include <iostream>
- using namespace std;
- class A{
- public:
- A(int i){
- cout << i << " Constructor of A" << endl;
- }
- A(){
- cout << "Constructor of A" << endl;
- }
- ~A(){
- cout << "Desconstructor of A" << endl;
- }
- };
- class B:public A{
- public:
- //注意静态成员对象比较特殊,会在编译期间进行初始化
- static A a1;
- A a2;
- A a3;
- B():a2(A(2)){
- cout << "constructor of B" << endl;
- }
- ~B(){
- cout << "Desconstructor of B" << endl;
- }
- };
- A B::a1=A(1);
- int main()
- {
- B b;
- return 0;
- }
-
前段时间被人问及“初始化列表和构造有什么区别?”我竟一时语塞,只好回头
拿起几本C++的大部头书,打开VS2012和vim开始倒腾。最后总结出如下几点,希望
对大家理解C++能有些帮助。(题外话:我认为好的技术书籍和师者对人最大的帮助
就是:帮助学者节省时间。)
综合而言,C++中类的初始化操作有四个部分组成:
1.初始化列表:所有类非静态数据成员都可以在这里初始化,
所有类静态数据成员都不能在这里初始化
2.构造函数体:对于类非静态数据成员:
const型成员不能在这里初始化
引用型成员不能在这里初始化
没有默认构造函数的成员不能在这里初始化
对于类静态数据成员:
可以在这里修改可修改的静态成员,但静态成员必须已经在类外部初始化
3.类外初始化:除一个特例外,所有类static数据成员必须在这里初始化,
特例是类static const int数据成员可以在这里初始化,也可以在成员的声明处初始化
4.类中声明时直接赋值:类static const int数据成员可以选在这里初始化。
直接罗列这样的规则,是我国大多数教科书的展开方式,记得经典的三部曲吗?
(1)定义
(2)定理
(3)例题
至于来龙去脉就只能靠我们这些学子的悟性了。何其苦载!事实证明需要理清
一些定理和思想的来龙去脉往往需要比这个定理更加广阔的知识和视野,让学生拿
着空洞的课本靠领悟?(不要意思,又吐槽了)
让我们从一段简单的代码开始:
- class A {
- const int x;
- public:
- A() {
- this->x = 1; /* Error! */
- }
- };
对很多人而言,这是什么直观写法,为什么就错了呢?其实这本质上相当于写:
- const int x;
- x = 1;
所以我们只能按如下方式声明其初始化:
- class A {
- const int x;
- public:
- A() : x(1) {
- }
- };
再来看一段简单的代码:
- class A {
- int &x;
- public:
- A(int k) {
- this->x = k; /* Error! */
- }
- };
同理这这本质上相当于写:
- int &x;
- x = k;
所以我们只能按如下方式声明其初始化:
- class A {
- const int x;
- public:
- A(int k) : x(k) {
- }
- };
有了上面两个简单例子作为引子,我们开始进一步讨论C++初始化的全过程。其实我相信很多人还是怀着这样一些疑问“写在初始化列表里就相当于int &x=k;吗?”
且让我们来看看C++类的初始化的全过程:
(1)静态成员初始化阶段:所有类的静态成员应该都是在这个阶段初始化的。
注意初始化的顺序,就是操作语句的顺序,例如你有一个Test类:
- int Test::x = 2;
- int Test::y = 3;
需要注意的是2点,一是初始化语句不再需要static关键字,二是执行顺序就是
语句的顺序,这里是先初始化t1,再初始化t2。执行顺序的问题在静态成员是类的时候
就关系到构造函数的调用顺序了。另外需要注意的是,这些静态成员的初始化在任何具
体实例被创建前就已经完成了。
(2)实例初始化列表工作阶段:
需要说的是,在用户使用new或者其他方法开始构建实例的时候,第一步首先是向操作系统申请内存,初始化列表是在申请成功后才开始工作的。
然后,根据非静态成员的声明顺序开始执行如下操作:
1.如果该成员没有出现在初始化列表中:
1)如果是内置非const且非引用类型,不设定初值
2)如果是const类型,报错,必须在这里给定初值
3)如果是引用类型,报错,必须在这里给定初值
4)如果是class类型,就调用默认构造函数,进行初始化操作
2.如果该成员出现在初始化列表中:
1)如果是内置类型,就按初始化列表指定的值设定初值
2)如果是const类型,就按初始化列表指定的值设定初值3)如果是引用类型,就按初始化列表指定的值设定初值
4)如果是class类型,就调用初始化列表指定的构造函数进行初始化操作
(3)计算阶段:
根据构造函数的函数体进行赋值操作,或者修改操作,
在这里,静态和非静态数据都可以赋值和修改
下面用一段代码来测试这个过程:
- class Test1 { /*用于测试Test2中含有没有默认构造函数的成员时的情况*/
- public:
- int i;
- Test1(int a): i(a){} /*这就取消了Test1的默认构造函数*/
- };
- class Test2 {
- public:
- int a; //int a = 1;Error:不允许数据成员初始值设定项
- const int b;
- static int c;
- static const int d = 4;//正确,这样赋值也是可以的,也可以选在类声明外进行赋值
- //但是如果不赋值,则程序中没有使用d不出错,使用了就会有link error
- //无法解析的外部命令
- //static const float ff = 4.0; Error:只有静态常量整形数据成员才可以在类中初始化
- int &e;
- const int &f;
- static int &g;
- static const int &h;
- //static const int &h = x_h; Error:只有静态常量整形数据成员才可以在类中初始化
- Test1 t1;
- const Test1 t2;
- static Test1 t3;
- const static Test1 t4;
- Test2(int b, int e, int f, Test1 t1, Test1 t2)
- : b(b),
- //d(4), Error: d不是类的非静态成员或基类
- e(e),//如果没有这句,Error:Test2:e没有提供初始化值
- f(f),
- t1(t1),//如果没有这句,Error:Test1没有默认构造函数
- t2(t2)
- {
- a = 1;
- //b = 2; //Error:表达式必须是可修改的左值,b是左值,不能修改
- c = 3;
- //d = 4; //Error:表达式必须是可修改的左值,d是左值,但不能修改
- }
- };
- //int Test2::a = 1; //Error:非静态数据成员不能在其类的外部定义
- //int Test2::b = 2; //Error:非静态数据成员不能在其类的外部定义
- int Test2::c = 3; //如果没有这句,会出现无法解析的外部符号public:static int A::c
- //int Test2::d = 4; //Error: int与声明const int不兼容
- //int const Test2::d = 4; //和在类声明里面直接写赋值等价
- int x_g = 5; /*这个全局变量主要用户后续的静态成员赋值*/
- int x_h = 6; /*这个全局变量主要用户后续的静态成员赋值*/
- Test1 x_t3(7);/*这个全局变量主要用户后续的静态成员赋值*/
- Test1 x_t4(8);/*这个全局变量主要用户后续的静态成员赋值*/
- int& Test2::g = x_g;
- const int& Test2::h = x_h;
- Test1 Test2::t3 = x_t3;
- const Test1 Test2::t4 = x_t4;
前面讲了这么多具体的细节,我个人建议按如下简化规则来记忆:
(1)所有static成员变量在类外初始化(不管它是const,是引用,还是没默认构造函数的对象)
(2)普通成员变量,是const,是引用,是没默认构造函数的,必须在初始化列表初始化
(3)普通成员变量,需要复杂运算的初始化变量,应该在构造函数内初始化,否则尽量在
初始化列表中初始化。
另外补充2个小点:
(1)初始化列表的使用可能提高性能
- class Test3 {
- public:
- int a;
- Test3() {
- a = 0;
- puts("Test3 constructor");
- }
- Test3(Test3 &t3) {
- this->a = t3.a;
- puts("Test3 copy constructor");
- }
- Test3& operator=(Test3 &t) {
- puts("Test3 assign operator");
- this->a = t.a;
- return *this;
- }
- ~Test3() { }
- };
- class Test4 {
- public:
- Test3 t3;
- //Test4( Test3 &t3) : t3(t3) { //这种方式和下面的方式有相同的效果,不同的效率
- //}
- Test4( Test3 &t3) {
- this->t3 = t3;
- };
- };
(2)成员是按照他们在类中出现的顺序进行初始化的,而不是按照他们在初始化列表出现的顺序初始化的
参考如下代码
- struct foo
- {
- int i ;
- int j ;
- foo(int x):i(x), j(i){}; // ok, 先初始化i,后初始化j
- };
再看下面的代码
- struct foo
- {
- int i ;
- int j ;
- foo(int x):j(x), i(j){} // i值未定义
- };
这里i的值是未定义的因为虽然j在初始化列表里面出现在i前面,但是i先于j定义,所以先初始化i,但i由j初始化,此时j尚未初始化,所以导致i的值未定义。所以,一个好的习惯是,按照成员定义的顺序进行初始化。也就是说相当于实际执行顺序是:
i(j);
j(x);
所以会出现错误。
欢迎大家拍砖指正。