深入探索c++对象模型(八、成员们的初始化队伍)

今天初六了,明天打工人就上班了,早上再抓紧时间卷一波。

这次分析的是成员们的初始化队列。就是在构造函数后面加上:然后进行初始化的操作,很奇怪的语法,今天我们就来分析一波。

8.1 何时必须要用成员初始化队列

在我们前面写的代码中,就没有使用过成员初始化队列。那我们就来看看何时才必须要用成员初始化队列。

8.1.1 成员是引用时

第一种情况就是,如果类成员中包含一个引用,那这个引用怎么初始化?答案就是需要用到成员初始化队列。

#include <iostream>

using namespace std;

class A 
{
public:
    A(int a) : m_a(a)
    {
    }


    int &m_a;
};


int main(int argc, char **argv)
{

    A a(1);

    cout << a.m_a << endl;
    return 0;
}

引用只能在成员初始化队伍中进行初始化,在其他地方都是不能初始化的。

8.1.2 成员是const常量

如果成员是const常量时,怎么初始化,直接在类中赋值可以不?或者在类外面赋值呢?我们来试试:

  1. 直接赋值

    #include <iostream>
    
    using namespace std;
    
    class A 
    {
    public:
        A(int a) : m_a(a)
        {
    
        }
    
    
        int &m_a;
        const int m_cb = 1;   //  直接赋值,这个是尝试,真正代码还是不要这样
    };
    
    
    //int gg = 1;
    //int A::m_a = gg;
    
    int main(int argc, char **argv)
    {
    
        A a(1);
    
        cout << a.m_cb << endl;
    
        A b(2);
        cout << b.m_cb << endl;
        return 0;
    }
    

    直接赋值既然也可以,不过编译的时候编译器会报警告:

    root@ubuntu:~/c++_mode/08# g++ 8_1.cpp -o 8_1
    8_1.cpp:15:22: warning: non-static data member initializers only available with -std=c++11 or -std=gnu++11
         const int m_cb = 1;
    

    警告主要是说这个不是静态成员变量,还是需要注意。

    运行之后,发现a,b对象的m_cb都是1,这种操作是不允许的,如果所有对象都一样,就直接定义静态的ok了。

  2. 在类外面初始化

    int A::m_cb = 1;
    

    如果这样定义的话,就会报错,静态成员变量才可以这么定义:

    8_1.cpp:18:8: error: ‘int A::m_cb’ is not a static data member of ‘class A’
     int A::m_cb = 1;
    
  3. 在构造函数内初始化

    还有一种是在构造函数内部定义:

    A(int a) : m_a(a)
    {
    	m_cb = 1;
    }
    

    编译的时候还是报错了,因为这是常量,定义了不能修改初值,跟上面讲的引用也是一样。

    8_1.cpp: In constructor ‘A::A(int)’:
    8_1.cpp:10:14: error: assignment of read-only member ‘A::m_cb’
             m_cb = 1;
    

    所以正确玩法就是要在成员初始化队列中。

  4. 在成员初始化队伍中初始化

    class A 
    {
    public:
        A(int a, int b) : m_a(a), m_cb(b)
        {
            
        }
    
    
        int &m_a;
        const int m_cb;
    };
    

    这种玩法是正常的。

8.1.3 父类中只有有参构造函数

一个子类继承父类,其中父类中只有有参数的构造函数,这时候就需要调用父类中的有参构造函数。怎么调用呢?

class B 
{
public:
    B(int a, int b)
    {
        m_ba = a;
        m_bb = b;
    }

    int m_ba;
    int m_bb;
};


class A : public B 
{
public:
    A(int a, int b) : m_a(a), m_cb(b), B(a, b)	// 继承直接用类名来初始化
    {
        
    }


    int &m_a;
    const int m_cb;
};

使用类名来调用父类的构造函数,然后参数就填在里面。

8.1.4 成员是类类型对象且构造函数是有参

成员是包含一个类类型的对象,并且这个类只有有参构造函数,编译器自己默认生成的都是无参构造函数,所以有参构造函数需要我们程序员自己调用。

class B 
{
public:
    B(int a, int b)
    {
        m_ba = a;
        m_bb = b;
    }

    int m_ba;
    int m_bb;
};


class A //: public B 
{
public:
    A(int a, int b) : m_cb(b), c(a, b)  // 组合需要用类成员来初始化
    {
        
    }


    //int &m_a;
    const int m_cb;
    int m_a;
    int m_b;
    B c;		// 组合
};

需要使用类变量来调用构造函数,有点想成员初始化一样。

8.1.5 总结

目前值有这四种情况是必须使用成员初始化队列,其他的情况既可以用成员初始化队列也可以在构造函数中进行初始化。那在成员初始化队列中有什么优势?我们下面来分析。

8.2 在成员初始化队列中优势

为啥我们要在成员初始化队列中初始化,是不是有什么优势?确实是,效率比较高。

8.2.1 优化前代码

那怎么高了?我们就来用例子证明一下。

#include <iostream>

using namespace std;

class B 
{
public:
    B()
    {
        cout << "构造函数" << endl;
    }

    B(int a)
    {
        cout << "a 构造函数" << endl;
    }

    B& operator=(const B& temp)
    {
        cout << "等号运算符" << endl;
    }

    B& operator=(int temp)
    {
        cout << "int 等号运算符" << endl;
    }

    ~B()
    {
        cout << "析构函数" << endl;
    }

    int m_ba;
    int m_bb;
};


class A
{
public:
    A(int a, int b) : m_cb(b)
    {
        c = 10;		// 如果是这样赋值
    }

    const int m_cb;
    int m_a;
    int m_b;
    B c;
};


int main(int argc, char **argv)
{

    A a(1, 444);

    cout << a.m_cb << endl;

    A b(2, 666);
    cout << b.m_cb << endl;
    return 0;
}

我们平时写代码,很有可能是这样进行类对象的赋值,这样赋值有问题?

没有问题,这种写法,编译器是支持的,只不过是效率有点低,我们运行看看:

root@ubuntu:~/c++_mode/08# ./8_1
构造函数
int 等号运算符
444
构造函数
int 等号运算符
666
析构函数
析构函数
root@ubuntu:~/c++_mode/08# 

明显是不是多了一个等号运算符操作,其实这个好像是g++编译器优化过了,所以只调用了一次等号运算符。

这个代码从编译器的视角来看,是这样的:

B c;
c.B::operator=(10);
c.B::~B();   // 这个是函数退出的时候调用的

好像效率还过的去,侯捷老师写的会产生一个临时对象,然后在通过这个临时对象给c赋值,最后析构掉临时对象,这样子的话,效率差了很多,这个不知道是g++编译器优化了,还是我写的例子的问题,这个问题就先这样,接下来我们看看优化后的代码。

8.2.2 优化后的代码

其实说是优化后的代码,还不如说是把初始化操作放到成员初始化队列中:

class A //: public B 
{
public:
    A(int a, int b) : m_cb(b), c(10)
    {
       // c = 10;
    }


    //int &m_a;
    const int m_cb;
    int m_a;
    int m_b;
    B c;
};

就是这么简单,接着我们编译运行看看:

root@ubuntu:~/c++_mode/08# ./8_1
a 构造函数
444
a 构造函数
666
析构函数
析构函数
root@ubuntu:~/c++_mode/08# 

明显就少了一步等号运行符的操作,也就是说在成员初始化队列中,编译器是直接构造函数调用。

这段部分从编译器角度看:

B c(10);
c.B::~B();   // 这个是函数退出的时候调用的

只调用一个构造函数,效率是真的提升了。

8.2.3 基本类型变量如何

如果是基本类型的变量,比如int,float这些,其实放在成员初始化列表中,或者放在构造函数体中效率差别不是很大,但是:成员变量初始化尽量放在初始化列表里,这样高端,大气上档次,还可以装逼,并且面试官也喜欢这样。

8.2.4 模板中使用

侯捷老师提到的陷阱最有可能发生在这种形式的template code中:

template <class type>
T<type>::T(type t)
{
    // 可能是(也可能不是)个好主意
    // 视type的真正类型而定
    _t = t;
}

意思是说,如果这个type是类类型,那这个代码的效率就不高?如果是普通类型,就差别不大的意思么????

感觉有点难看懂文字。然后我就在成员初始化列表中试了一下,发现也是可以的:

template <class type>
class T {
public:
    T(type t) : _t(t) {};	// 可以直接在成员初始化列表中初始化

    type _t;
};

可能就是我说的这个意思吧。

8.3 成员初始化队列细节分析

经过这一次分析之后,是不是我们初始化成员变量都是要在成员初始化队列中,那我们成员初始化队列中发生了什么?我们接下来就分析分析。

8.3.1 反汇编分析

为了更好的研究问题,我调整了一下代码:

#include <iostream>
#include <string>

using namespace std;

class B 
{
public:
    B()
    {
        cout << "构造函数" << endl;
    }

    B(int a)
    {
        cout << "a 构造函数" << endl;
    }

    B& operator=(const B& temp)
    {
        cout << "等号运算符" << endl;
    }

    B& operator=(int temp)
    {
        cout << "int 等号运算符" << endl;
    }

    ~B()
    {
        cout << "析构函数" << endl;
    }


    int m_ba;
    int m_bb;
};


class A //: public B 
{
public:
    A(int a, int b) : m_cb(b), m_a(a), m_b(b), c(10)  // 这样把成员都初始化了
    {
       m_a = 66;		// 另外也在构造函数中,在做一次函数初始化
       m_b = 77;
    }


    const int m_cb;
    int m_a;
    int m_b;
    B c;
};


int main(int argc, char **argv)
{

    A a(1, 444);

    return 0;
}

我们直接看反汇编代码:

main函数的反汇编代码就不看了,没啥意思,我们就直接看类A构造函数的反汇编代码:

_ZN1AC2Eii:
.LFB971:
	.cfi_startproc
	.cfi_personality 0x3,__gxx_personality_v0
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp			# 16字节的栈帧
	movq	%rdi, -8(%rbp)		# a对象的引用
	movl	%esi, -12(%rbp)		# int a
	movl	%edx, -16(%rbp)		# int b
	movq	-8(%rbp), %rax      # 获取a对象
	movl	-16(%rbp), %edx     # 获取b的值到 edx寄存器
	movl	%edx, (%rax)		#  这个就是用b的值直接赋值给a对象引用,这个地址刚好就是m_cb的值,为什么?Data语义学再介绍
	movq	-8(%rbp), %rax		# 获取a对象
	movl	-12(%rbp), %edx		# 获取int a的值
	movl	%edx, 4(%rax)		# 赋值给m_a,m_a刚好是a对象偏移4字节
	movq	-8(%rbp), %rax		# 获取a对象
	movl	-16(%rbp), %edx		# 获取int b的值
	movl	%edx, 8(%rax)		# 直接赋值给m_b,m_b刚好是a对象偏移8字节
	movq	-8(%rbp), %rax		# 获取a对象引用
	addq	$12, %rax			# 指针偏移12字节,刚好是c对象的引用,在准备类B构造函数的调用了
	movl	$10, %esi			# 立即数10赋值
	movq	%rax, %rdi			
	call	_ZN1BC1Ei			# 调用类B的构造函数,rdi是c对象的引用,esi是10
	movq	-8(%rbp), %rax		# 后面就是我们自己在构造函数中定义的语句了
	movl	$66, 4(%rax)		# 直接赋值66给m_a
	movq	-8(%rbp), %rax		
	movl	$77, 8(%rax)		# 直接赋值77给m_b
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

这一次汇编的分析很详细了,想不到我既然能看懂汇编代码了,哈哈哈,高兴。

通过上面的反汇编代码分析,总结出以下几点:

  1. 成员初始化队列是安插在我们自己写的代码之前
  2. 基本类型的赋值,确实和写在构造函数内部的效率差不多
  3. 类类型的初始化,也的确是在调用构造函数的时候赋值的

ok,这一点就介绍到这里,大家可以好好看看汇编代码,注释很详细。

8.3.2 初始化顺序问题

在8.3.1中我们详细分析了反汇编代码,这下对成员们的初始化队列熟悉了吧。那接下来我们了解一下,这个初始化顺序问题。

不知道大家是否记得,我们之前写的构造函数语义的时候,调用构造函数语义是根据类类型对象在对象中定义的顺序,那我们现在学习了成员初始化队列,那现在初始化顺序,还是按照成员定义的顺序?还是按照成员初始化队列的顺序呢?

我们接下来写个代码分析分析:

A(int a, int b) : m_b(b), m_a(m_b), m_cb(m_b), c(10)
{

}

我调整了一下成员初始化队列中的顺序,把m_b提前,然后再把m_b的值赋值给m_a和m_cb,那接下来我们编译运行看看结果:

(gdb) p a
$1 = {m_cb = 4196736, m_a = 4196736, m_b = 444, c = {m_ba = 0, m_bb = 0}}

还是用gdb来打印吧,人一旦偷懒,就一直偷懒,哈哈哈。

从打印中看出m_cb和m_a的值都不等m_b,是不是很奇怪?

其实不奇怪,类成员变量的初始化顺序是按类定义的。

m_b定义在后面,这时候还没初始化,这样赋值是没用改的,所以成员初始化队列不能瞎换顺序,还是老老实实按照类定义顺序来搞,这样保证正确,并且这种定义,编译器既然没报错,还真是个不容易找的bug。

如果真的要用m_b赋值给其他的类变量,可以在构造函数中赋值:

A(int a, int b) : m_cb(b), m_b(b), c(10)
{
    m_a = m_b;
    // m_cb = m_b;  // 不好意思,这个不能在这里赋值
}

是需要这样赋值就对了,这个例子举了一个const不太好,const不能在构造函数中赋值,小尴尬。

8.3.3 调用函数设置初始化值

最后剩一个问题了,大家在作死路上越走越远,最后一个问题是,在成员初始化队伍中调用函数来设初值,哎,真是头大,啥骚操作都要搞。

我们来看看代码:

class A //: public B 
{
public:
    A(int a, int b) : m_cb(b), m_a(foo(a)), m_b(b), c(10)
    {
    }

    int foo(int a) {return a;}
    const int m_cb;
    int m_a;
    int m_b;
    B c;
};

真的是骚操作不断啊,不过这样编译器既然也是通过编译的,编译器也是心大。

侯捷老师给我们的忠告:

请使用"存在于构造函数内的一个变量",而不要使用"存在于成员初始化列表中的成员变量",来为另一个成员变量设定初值。

你并不知道xfoo()对X object的依赖性有多高,如果你把xfoo()放在构造函数体内,那么对于"到底是哪一个member在xfoo()执行时被设立初值"这件事,就可以确保不会发生模棱两可的情况。

上面使用成员方法是合法的,这是因为类中的this指针已经建构妥当,所以构造函数会被编译器转化成这样:

A(int a, int b) 
{
    m_cb = b;
    m_a = this->foo(a);   // this已经构建好了
    m_b = b;
    c(10);
}

这种玩法也是支持的,在最后侯捷老师又提到了一个情况:

A(int a, int b) : m_cb(b), m_a(foo(a)), m_b(b), c(foo(a))   // 用foo(a)的返回值初始化父类
{
	
}

编译器转化的结果:

A(int a, int b)
{
	m_cb = b;
	m_a = this->foo(a);
	m_b = b;
	int ret = this->foo(a);
	B::B(c, ret);
}

感觉也毫无违和感,这里又跟侯捷老师说的不一样了,是不是又是编译器不一样了????,再次尴尬。

我们可以分析一下汇编代码,感觉应该是我上面的理解:

_ZN1AC2Eii:
.LFB971:
	.cfi_startproc
	.cfi_personality 0x3,__gxx_personality_v0
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp
	movq	%rdi, -8(%rbp)
	movl	%esi, -12(%rbp)
	movl	%edx, -16(%rbp)
	movq	-8(%rbp), %rax
	movl	-16(%rbp), %edx
	movl	%edx, (%rax)
	movl	-12(%rbp), %edx
	movq	-8(%rbp), %rax
	movl	%edx, %esi
	movq	%rax, %rdi
	call	_ZN1A3fooEi
	movq	-8(%rbp), %rdx
	movl	%eax, 4(%rdx)
	movq	-8(%rbp), %rax
	movl	-16(%rbp), %edx
	movl	%edx, 8(%rax)
	movl	-12(%rbp), %edx		#  获取int a的值
	movq	-8(%rbp), %rax		#  获取a对象
	movl	%edx, %esi			# 函数第二个参数
	movq	%rax, %rdi			# 函数第一个参数
	call	_ZN1A3fooEi			# foo函数
	movq	-8(%rbp), %rdx		#  获取a对象
	addq	$12, %rdx			# 便宜12,指向对象c
	movl	%eax, %esi			# 返回值ret作为函数第二个参数
	movq	%rdx, %rdi			# 对象c作为函数第一个参数
	call	_ZN1BC1Ei			# 调用构造函数
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

看了下汇编代码,应该没理解错,算了,先这样把,以后发现问题再回来改。

8.4 总结

成员初始化队列就分析到这里吧,感觉这一篇也写多了,不过多分析分析也是可以的,哈哈。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值