C++编译器在编译程序时,只要不违背C++语义,可以对程序进行一些变换或者优化。如果C++对象是一些特殊类型的对象,当它们是一个函数的参数(当然也包括成员函数,因为成员函数隐藏着一个this指针,在调用时把对象自己作为参数传递)时,编译器在传递参数时可以进行一些特殊的优化,以提高传递函数参数和访问数据成员的性能。
传递标量对象
先看一个类的定义,类非常简单,只有两个数据成员,它们都是两个基本类型:
class small {
long long x = 42;
long long y = 24;
public:
void foo() {
printf("%d, %d\n", x, y);
}
void bar() {
printf("bar");
}
};
void foo(small a) {
a.foo();
}
void bar(small a) {
a.bar();
}
int main() {
small a{};
foo(a);
}
为了节省汇编指令的篇幅,编译时使用优化选项-O1:最初级的优化,为了能看到完整的函数编译后的汇编代码,禁止内联优化,即:gcc -std=c++11 -O1 -fno-inline。
标量替换
先看main函数的汇编指令代码,因为组成对象的成员都是标量,在调用foo(small)函数时,并没有传入一个small对象,而是通过寄存器把函数对象的数据成员直接传递进去了:
main:
sub rsp, 8
mov edi, 42 数据成员x=42,存放在寄存器rdi中
mov esi, 24 数据成员y=24,存放在寄存器rsi中
call foo(small)
mov eax, 0
add rsp, 8
ret
我们先确认一下是不是因为被优化导致的,把优化选项改为-O0重新编译,main函数的汇编代码如下:
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-16], 42
mov QWORD PTR [rbp-8], 24
mov rdx, QWORD PTR [rbp-16]
mov rax, QWORD PTR [rbp-8]
mov rdi, rdx ; 数据成员x=42,存放在寄存器rdi中
mov rsi, rax ; 数据成员y=24,存放在寄存器rsi中
call foo(small)
mov eax, 0
leave
ret
也是把数据成员x和y分开后放入两个寄存器(rdi和rsi)传递的,可见并不是因为-O1优化处理后的代码,即使不优化也会这样编译。这种没有把对象作为一个数据聚合体,而是把各个成员分离开来代替对象的方式就是标量替换。
既然没有传入this指针,那么在调用对象的成员函数时,根据C++语义,在调用成员函数时,需要传入一个隐式的this参数,那么,main在调用foo(small)时没有传入this,怎么调用成员函数void small::foo()呢?看下面的汇编代码:
foo(small):
sub rsp, 24
mov QWORD PTR [rsp], rdi ; rdi寄存器存放x
mov QWORD PTR [rsp+8], rsi ; rsi寄存器存放y
mov rdi, rsp ; rsp指向的16字节的空间位置存放了x和y的值,即small对象的this指针
call small::foo() ; this指针传入small::foo() 成员函数
add rsp, 24
ret
在foo(small)函数内部就地创建了一个small对象,然后调用成员函数,即把分散开的各个标量成员重新又组成一个聚合体结构。上面的汇编代码就是把通过寄存器传递进来的数据,作为参数创建了一个small对象,传入成员函数,相当于在foo函数本地创建了一个small对象,通过它来调用成员函数,可见编译器这么做,并没有改变C++的语义,没有任何问题。
同样,函数void bar(small)的汇编代码,也是如此:
bar(small):
sub rsp, 24
mov QWORD PTR [rsp], rdi
mov QWORD PTR [rsp+8], rsi
mov rdi, rsp ; rsp指向的16字节的空间位置存放了x和y的值,即small对象的this指针
call small::bar()
add rsp, 24
ret
可见对于标量对象在作为参数传递时,GCC编译器做了优化(经验证clang编译器也是如此),把对象的各个数据成员拆分成单个标量数据,然后分别使用寄存器进行传递。
这样做的好处就是:
1、数据成员标量替换后使用寄存器传递,比保存在内存中通过this指针传递的性能要好,直接访问寄存器,比通过this指针间接访问内存要高效的多。
2、延迟创建对象,直到真正用到时才创建对象,如果在函数void foo(small)内部并不是必须调用small的成员函数,比如根据条件分支会执行不同的路径,如果有的路径不调用,也就不用创建了。
IPA-SRA优化
从前面的例子可以看出,编译器对对象进行了标量替换,在对象作为参数传递的地方没有传递this指针,而是改成传递标量化的数据成员。但是在调用对象的成员函数就时,还得要进行对象创建,即把标量化的数据成员又还原成一个聚合结构的数据。
那么,能不能在调用成员函数时不还原为对象,用到哪个数据成员就传递哪个,继续使用标量成员呢?GCC编译器是可以的。
在GCC编译器中,如果使用O2/O3优化选项,或者O1同时加上-fipa-sra编译选项进行编译,也会把成员函数的this指针去掉,改成传递标量替换后的数据。下面看一下加上-fipa-sra编译选项后产生的汇编代码:
main()函数没有变化,仍然是使用标量替换,没有创建small对象。
main:
sub rsp, 8
mov edi, 42
mov esi, 24
call foo(small)
mov eax, 0
add rsp, 8
ret
而foo(small)函数继续优化,不再临时创建一个small对象了,而是直接调用了一个名称被修饰过的成员函数:small::foo() (.isra.0)
foo(small):
sub rsp, 8
call small::foo() (.isra.0) 传进来的寄存器rdi和rsi转发到small::foo() (.isra.0)
add rsp, 8
ret
small::foo() (.isra.0)的汇编代码如下:
small::foo() (.isra.0):
sub rsp, 8
mov rdx, rsi ; 数据成员x
mov rsi, rdi ; 数据成员y
mov edi, OFFSET FLAT:.LC1
mov eax, 0
call printf
add rsp, 8
ret
由此可见,在main->foo(small)->small::foo()整个调用链路中没有了this指针,全部使用寄存器作为传参的中介,不再把对象创建在内存中,调用空对象的成员函数就像是在调用一个普通函数。显然,这种方式提高了程序的函数调用及数据访问的性能。
以“.isra.NNN”结尾的函数名称,其中 NNN 是一些数字,如small::foo() (.isra.0),是GCC执行选项-fipa-sra编译器优化时,添加到函数名称的后缀。这种优化方式叫:Interprocedural optimization (IPA),该选项执行聚合体结构的过程间标量替换,删除未使用的参数,不过,该优化选项目前仅GCC编译器支持。
这里测试用的对象size是16字节,经过测试,即使对象的size多于16字节,并且数据成员都是基本类型的话,在传递对象作为参数时,仍然会进行标量替换,但是传递时不是通过寄存器,而是通过栈来传递的。比如:
foo(small):
sub rsp, 8
mov rsi, QWORD PTR [rsp+24] ;从栈中获取对象的标量数据
mov rdi, QWORD PTR [rsp+16] ;从栈中获取对象的标量数据
call small::foo() (.isra.0)
add rsp, 8
ret
传递空对象
空对象中一个数据成员也没有,它在作为参数传递时就更简单了,既然是空对象,意味着不可能会通过this指针去访问数据成员,那就什么也不用传递了。如果需要this指针时,也无需创建对象,直接随意指定一个地址作为this指针(哪怕是野指针、空指针)就可以了。当然,在使用IPA-SRA优化时,既然不需要this指针,成员函数中隐含的this参数也被优化掉了。实际上,空类提供的成员函数,因为不访问数据成员,等同于静态成员函数,编译器的IPA-SRA优化就是想办法把成员函数的this指针参数给优化掉。
下面看一下代码示例:
// 空类,没有任何数据成员
class small {
public:
void foo(int x, int y) {
printf("%d\n", x+y);
}
};
void foo(small a) {
a.foo(42, 24);
}
int main() {
small a{};
foo(a);
}
下面是编译后的汇编代码:
.LC1:
.string "%d\n"
small::foo(int, int) (.isra.0):
sub rsp, 8
add esi, edi
mov edi, OFFSET FLAT:.LC1
mov eax, 0
call printf
add rsp, 8
ret
foo(small):
sub rsp, 8
mov esi, 24 ; x参数
mov edi, 42 ; y参数
call small::foo(int, int) (.isra.0)
add rsp, 8
ret
main:
sub rsp, 8
call foo(small) ; 什么也没有传递
mov eax, 0
add rsp, 8
ret
如果把foo和bar函数改成传递引用类型的参数:
void foo(small& a) {
a.foo(42, 24);
}
除了main()函数之外,其它函数没有任何变化:
main:
sub rsp, 24
lea rdi, [rsp+15] ; 传入一个地址当作this指针,里面的数据没有初始化
call foo(small&) ; 即使传入this,也被foo(small&)忽视
mov eax, 0
add rsp, 24
ret
在调用foo(small&)时,没有创建任何对象,只是把rsp+15内存地址作为small参数对象的this指针,来调用foo(small&)函数,注意这个地址中的数据是什么无所谓,所以没有初始化它。
有趣的是,如果把main()测试函数改为通过野指针或者空指针来调用small的成员函数,程序也不会发生错误,因为编译器经过IPA-SRA的优化后, 把成员函数的this指针参数去掉了,当然即使没有ipa-sra的优化,也不会发生错误,因为this指针根本用不着,给它传什么值都可以。如下程序运行时没有任何问题:
int main() {
small *ptr = reinterpret_cast<small *>(0x12345678); // 野指针
ptr->foo(1,2);
ptr = nullptr; // 空指针
ptr->foo(1,2);
}
在C++标准库中的std::string_vew(C++17)和std::span(C++20),它们创建的对象都是标量对象,对象中只包含了一个指针和一个数据长度的值,它们用作函数参数传递的时候,最好定义为值类型的形式,这样传参时编译器会对它们进行标量替换,使用寄存器分别传递它们的指针及长度数据成员,以优化性能。