前言的前言
之前写的太草了,估计只有我自己看得懂。这篇文章的出发点是从汇编的角度看对象的创建。首先,我们先思考一下,从汇编的视角看来,一个对象怎么创建呢?这个问题其实和结构体如何创建是类似的,在某段内存给相应的数据成员设置初始值;和结构体不同的是,类对象的创建需要调用构造函数。分析汇编代码,可以看到汇编中会将一个地址传给构造函数,然后构造函数在那个地址上将对象设置好。接下来,我们来思考另一个问题,函数返回一个对象。从汇编的视角来看,函数怎么返回一个对象呢?对象怎么返回?对象不像普通类型 int,可以直接放到寄存器作为返回值。那么它是如何返回的呢?原理和构造函数是类似的,将一个地址传进去,然后在那个地址上将对象构建好即可。有的朋友可能会说:不对,这是 NRV 优化后的代码。其实,关闭了 NRV 优化,产生的代码也是一样的。不然,你应该如何返回一个对象呢?NRV 优化开不开的区别在于:开启了 NRV 优化,传进去的地址正好是最终要存放对象的地址;关闭了 NRV 优化,传进去的地址是临时的,这是一个 “临时对象”,在这个临时地址上构建出一个对象之后,还要调用一次拷贝构造函数将对象复制到返回地址,最后还要再拷贝一次到最终存放对象的地址。这是区别,需要注意。
前言
很久以前阅读了 CSAPP 这本书,可惜看过的东西基本都忘记了,只知道一些工具可以帮助我分析。今天突然对 “返回对象的函数” 很感兴趣,于是分析了一下汇编。
返回对象的函数如下,现在有一种叫做 NRV 优化的技术可以避免低效率,如果把它关掉,它将会执行以下过程:创建一个对象 apple,然后返回一个 apple,发生一次拷贝构造函数,所以存在执行效率问题;如果你还写了 Apple a = GetApple();
,那么还将会发生一次赋值拷贝构造。
Apple GetApple() {
Apple apple{};
return apple;
}
代码:
class Apple {
public:
Apple() {
}
~Apple() {
}
Apple(const Apple& apple) {
this->a = apple.a;
this->b = apple.b;
}
Apple& operator=(const Apple& apple) {
this->a = apple.a;
this->b = apple.b;
return *this;
}
void Print() {
cout << a << " " << b << endl;
}
int a = 1;
int b = 2;
};
Apple GetApple() {
Apple apple{};
return apple;
}
构造函数的执行过程分析
构造函数的执行过程:即使是无参构造函数,调用之前仍然要传参。传入的是一个地址,需要在函数运行结束的时候,这个地址上有了一个对象。
main 函数如下:
int main() {
Apple a;
int x = a.a;
x = 10;
return 0;
}
生成的汇编代码:
# main 函数构造对象的地方:
pushq %rbp # rbp 压栈
movq %rsp, %rbp # rsp 替换 rbp
pushq %rbx # rbx 压栈,保存现场
subq $24, %rsp # rsp 自减 24,栈向下增长,相当于扩展 24 字节
leaq -24(%rbp), %rax # 将地址赋值给 rax,rax 是 Apple 对象开始的地方
movq %rax, %rdi # rdi 传参
call _ZN5AppleC1Ev
movl -28(%rbp), %eax # 返回之后,会执行 int x = a.a;
movl %eax, -20(%rbp) # 取第一个字节赋值给 x
movl $10, -20(%rbp) # 直接使用 10 赋值给 x;写着行的目的只是为了确定 x 的位置
# 默认构造器:
pushq %rbp # rbp 压栈
movq %rsp, %rbp # rsp 替换 rbp
movq %rdi, -8(%rbp) # rdi 是前面 rax 的值
movq -8(%rbp), %rax # 设置 rax 为 rbp 减 8
movl $1, (%rax) # 间接寻址,设置 4 字节为 1
movq -8(%rbp), %rax # rax 其实还是一样的
movl $2, 4(%rax) # 变址寻址,其实就是间接寻址加一个偏移量,设置 4 字节为 2
nop
popq %rbp
ret # 于是最终 rdi 开始,向下的 8 字节是 Apple 对象
返回对象函数的分析
从汇编的视角来看,调用构造器和调用 “返回对象” 的函数是一样的。它的执行过程是:即使是两个函数都是无参的,它仍然会进行一次传参。传入的是一个地址,需要在函数调用结束后,这个地址上有了一个对象。从汇编的角度来看,对象就是一堆数据的排列,比如说最普通的对象就是数据成员按照声明顺序直接排列。
举个实际点的例子。Apple 这个类,有两个成员,a 和 b。调用了构造器或者 “返回对象” 的函数,先传入一个地址,之后函数里面会在这个地址上存放两个数,分别是 a 和 b 的值,然后返回。此时,这个地址上就有了 a 和 b,虽然机器看不到对象,但是对我们来说,它就是一个对象。
如果关闭了 NRV 优化,那么首先会传入一个地址,然后构造一个对象,这个对象将会被拷贝构造到传入的地址上,返回。之后如果调用了一次赋值,那么还要将这个地址上的对象复制构造给栈上的变量。整个过程,实际上存在两个临时对象,发生了一次构造、一次复制构造、一次赋值构造。
main:
.LFB3535:
pushq %rbp
movq %rsp, %rbp
pushq %rbx
subq $40, %rsp
leaq -36(%rbp), %rax
movq %rax, %rdi
call _ZN5AppleC1Ev
movl -36(%rbp), %eax
movl %eax, -20(%rbp)
movl $10, -20(%rbp)
leaq -28(%rbp), %rax # 这里之前的汇编是一样的,调用 GetApple
movq %rax, %rdi # rdi,传参
call _Z8GetApplev
leaq -28(%rbp), %rdx # rax 已经存放对象,-28 的位置有对象
leaq -36(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
call _ZN5AppleaSERKS_
leaq -28(%rbp), %rax
movq %rax, %rdi
call _ZN5AppleD1Ev
movl $0, %ebx
leaq -36(%rbp), %rax
movq %rax, %rdi
call _ZN5AppleD1Ev
movl %ebx, %eax
addq $40, %rsp
popq %rbx
popq %rbp
# GetApple 函数的汇编代码
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movq %rdi, -24(%rbp) # 分配空间,然后将传过来的 rdi 放进去
leaq -8(%rbp), %rax
movq %rax, %rdi # 前面分析过了,调用回来时 rax 开始的 8 字节就是对象
call _ZN5AppleC1Ev
leaq -8(%rbp), %rdx # 取 rax 地址到 rdx
movq -24(%rbp), %rax # 取 rdi 地址到 rax
movq %rdx, %rsi # rsi 已经有对象了
movq %rax, %rdi # rsi 和 rdi 都用来传参
call _ZN5AppleC1ERKS_ # 调用拷贝构造函数
leaq -8(%rbp), %rax # rax 上有对象了
movq %rax, %rdi # rdi 传参,准备调用析构函数
call _ZN5AppleD1Ev
nop
movq -24(%rbp), %rax # 前面调用拷贝构造前的地址,这个地址有对象了
leave
ret
# 拷贝构造函数
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp) # rdi 无对象
movq %rsi, -16(%rbp) # rsi 有对象
movq -16(%rbp), %rax # 把对象放到 rax
movl (%rax), %edx # 把第一属性(4 字节)移动到 edx
movq -8(%rbp), %rax
movl %edx, (%rax) # 把第一属性移动到 rdi
movq -16(%rbp), %rax
movl 4(%rax), %edx # 把第二属性移动到 edx
movq -8(%rbp), %rax
movl %edx, 4(%rax) # 把第二属性移动到 rdi 上
movq -8(%rbp), %rax # rdi 上有了对象,rax 也设置一个
popq %rbp
ret