为什么函数对象作为函数参数时,一般使用值类型形式?-番外篇
因为篇幅原因分成了两篇文章,这篇是上一篇文章相关的附加内容,算是补充材料,作为知识点了解一下。
1、C语言中的回调函数
看一下C语言中回调函数的参数传递的情况,因为C语言中没有模板,而且函数也没有类型,因此不能通过定义函数类型来直接传递函数。所以在C语言中,传递回调函数时没有别的选择,只能以函数指针的形式来传参,在调用回调函数时也无法进一步优化。比如:
// 在一个C源文件中定义,作为公共实现
void invoke_in_c(int x, void(*pf)(int)) {
pf(x+10);
}
// 在另一个C源文件中
static void foo(int x) {
printf("%d\n", x);
}
extern void invoke_in_c(int x, void(*pf)(int));
void test1() {
invoke_in_c(42, foo);
}
使用-O2优化选项,gcc生成的相关汇编指令代码如下:
invoke_in_c:
add edi, 10
jmp rsi
因为编译器在编译invoke_in_c()时不知道函数指针参数会指向哪一个函数,因此无法对回调函数使用内联优化,也就是在调用函数指令中:jmp rsi,寄存器rsi指向一个函数入口,但是指向哪一个函数是不知道的,只有在运行时才知道,属于动态绑定。
.LC0:
.string "%d\n"
foo(int):
mov esi, edi
xor eax, eax
mov edi, OFFSET FLAT:.LC0
jmp printf
test1():
mov esi, OFFSET FLAT:foo(int)
mov edi, 42
jmp invoke_in_c(int, void (*)(int))
因为调用invoke_in_c()函数时需要传入一个函数指针,尽管函数foo()非常短小,而且使用static修饰了,因为无法内联优化它,只能生成foo()的汇编代码,并且把它的入口地址作为参数传递给invoke_in_c()函数。
看一下使用CPP的模板函数时,和前面使用C语言回调实现的功能完全一样:
static void foo(int x) {
printf("%d\n", x);
}
template<typename T>
void invoke_in_cpp(int x, T t) {
t(x+10);
}
void test2() {
invoke_in_cpp(42, foo);
}
使用相同的编译选项,生成的汇编代码:
.LC0:
.string "%d\n"
test2():
mov esi, 52
mov edi, OFFSET FLAT:.LC0
xor eax, eax
jmp printf
foo()函数直接被内联优化了,它的函数体直接在test2()函数中展开,成为test()函数体的一部分,节省了函数调用跳转以及栈帧的创建和销毁等操作,因为是static修饰的,只会在当前文件内使用,所以编译器也没有为它生成汇编代码。
所以,这就是为什么在C++中的sort,要比C中的qsort性能要好了,因为C++中的比较函数(一般很短小,大概率被内联优化),可以在sort()内被编译器内联优化,而C语言只能使用函数指针,它抑制了编译器的内联优化。
2、回调函数对象的参数位置
在上一篇处列出了一些的标准库中的算法:
template< class T, class Compare >
const T& max( const T& a, const T& b, Compare comp );
template< class RandomIt, class Compare >
void sort( RandomIt first, RandomIt last, Compare comp );
template< class InputIt, class T, class BinaryOp >
T accumulate( InputIt first, InputIt last, T init, BinaryOp op );
可以看出,算法函数中的那些回调函数参数,它们全部都放在了参数列表的最后一个位置,这样设计应该也是有意为之的。
我们分析一下这样做的目的,下面模仿了std::max()算法,定义了两个功能相同的算法函数模板,但回调函数参数在参数列表中处在不同位置:
// cppreference中推荐的实现:
template<class T, class Compare>
const T& mmax(const T& a, const T& b, Compare comp) {
return (comp(a, b)) ? b : a;
}
//Compare comp放在第一个参数位置:
template<class T, class Compare>
const T& nmax(Compare comp, const T& a, const T& b) {
return (comp(a, b)) ? b : a;
}
// 一个比较函数
__attribute__((noinline))
bool comp(const int &a, const int &b) {
return a < b;
}
编译器为两种传参形式的算法函数,生成的汇编指令代码如下:
int const& mmax<int, bool (*)(int const&, int const&)>(int const&, int const&, bool (*)(int const&, int const&)):
push rbp
mov rbp, rsi
push rbx
mov rbx, rdi
sub rsp, 8
call rdx
test al, al
cmovne rbx, rbp
add rsp, 8
mov rax, rbx
pop rbx
pop rbp
ret
int const& nmax<int, bool (*)(int const&, int const&)>(bool (*)(int const&, int const&), int const&, int const&):
push rbp
mov rax, rdi
mov rbp, rdx
push rbx
mov rbx, rsi
mov rsi, rdx ; 调整参数位置
mov rdi, rbx ; 调整参数位置
sub rsp, 8
call rax
test al, al
cmovne rbx, rbp
add rsp, 8
mov rax, rbx
pop rbx
pop rbp
ret
在x86环境中的函数调用约定,第1、2、3参数位置的参数分别使用寄存器rdi、rsi和rdx来传递,为了符合调用约定,nmax编译后完的汇编代码中,会比mmax编译后的汇编代码中多两条指令,用来调整参数的传参寄存器:
mov rsi, rdx
mov rdi, rbx
mmax在传参调用回调函数comp时,它的的第1个参数a也是comp回调函数的第1个参数,第2个参数b也是comp的第2个参数,mmax和comp的参数位置是一一对应的,不需要做调整;而nmax的第1个参数是回调函数comp的函数指针,第2个参数a是comp的第1个参数,第3个参数b是comp的第2个参数,因此,在nmax中调用comp时,需要把nmax的第2个参数rsi和第3个参数rdx分别赋值到rdi和rsi寄存器中,以符合调用约定,同时第1个参数rdi存放的是comp的函数指针,还得要把这个rdi寄存器腾让出来,以存放comp的第1个参数,因此会有更多的指令来调整这些参数的位置存放。
当然,这些细微的差别在内联优化编译时可能都不存在了,也可能算法函数的实现非常复杂,这些调整参数位置的指令开销比起总体开销来也微不足道,不过这些算法函数毕竟是标准库的公共函数,是构成上层应用程序的基础,每一处细节上的性能开销都是应该考虑的,性能提升再微不足道也是值得的。
因此,我们在定义算法函数模板时,如果算法函数有多个参数,一般要把回调函数放在参数列表的最后一个位置,而且如果回调函数要用到前面这些参数的话,这些参数也应该按照回调函数的参数列表的顺序来放置参数,这样在算法中调用回调函数时,有可能会避免一些寄存器分配的微调整,避免一些没有必要的性能损失。
3、函数对象的static成员函数
在上文空函数对象的测试用例中,如果把编译器换成CLANG编译器,即使使用-O3最高的优化选项,编译后的调用成员函数的汇编代码,因为没有像GCC编译器的ipa-sra优化选项,也不会把this指针这个没有用到的参数去掉。
为了阅读方便,把测试空函数对象的代码列在下面:
template<typename T>
__attribute__((noinline)) // 测试时如果允许内联,注释该行
void invoke_by_value(int x, T t) {
t(x);
}
template<typename T>
__attribute__((noinline)) // 测试时如果允许内联,注释该行
void invoke_by_forward(int x, T &&t) {
t(x);
}
struct empty_obj { // 空函数对象
__attribute__((noinline))
void operator()(int x) {
printf("%d\n", x);
}
};
void test_by_value() {
invoke_by_value(42, empty_obj{});
}
void test_by_forward() {
invoke_by_forward(42, empty_obj{});
}
下面使用CLang -O3编译后的汇编指令:
test_by_value():
mov edi, 42
jmp void invoke_by_value<empty_obj>(int, empty_obj)
void invoke_by_value<empty_obj>(int, empty_obj):
push rax
mov esi, edi
lea rdi, [rsp + 7] ; 传递函数对象this指针
call empty_obj::operator()(int)
pop rax
ret
test_by_forward():
push rax
lea rsi, [rsp + 7] ; 传递函数对象this指针
mov edi, 42
call void invoke_by_forward<empty_obj>(int, empty_obj&&)
pop rax
ret
void invoke_by_forward<empty_obj>(int, empty_obj&&):
mov eax, edi
mov rdi, rsi
mov esi, eax
jmp empty_obj::operator()(int)
上面invoke_by_value()和test_by_forward()函数中的指令lea rsi, [rsp + 7]
就是调用函数对象的成员函数时在传递它的this指针,尽管没有什么用。因为要使用rdi寄存器来传递this指针,需要对rdi的原值进行缓存,不得不花费额外的指令。
因为要保证C++的语义(成员函数的第一个参数是隐藏的this指针参数),必须要这么做,除非像GCC那样可以进行“过程间分析(IPA)”,知道调用的函数有一个参数没有使用,把它去掉,即ipa-sra优化,这些优化都是编译器自己的优化选项,不是标准规定的,不是所有编译器都支持。不过,在C++23中提出了函数对象类中的static成员函数特性,可以利用这个特性,对空函数对象进行优化,以去掉用不到的this指针。例如,把类empty_obj 中的operator()操作符重载函数加上static修饰:
struct empty_obj { // 空函数对象
__attribute__((noinline))
static void operator()(int x) {
printf("%d\n", x);
}
};
使用CLANG -std=c++23 -O2编译后:
test_by_value():
mov edi, 42
jmp void invoke_by_value<empty_obj>(int, empty_obj)
void invoke_by_value<empty_obj>(int, empty_obj):
jmp empty_obj::operator()(int)
test_by_forward():
push rax
lea rsi, [rsp + 7]
mov edi, 42
call void invoke_by_forward<empty_obj>(int, empty_obj&&)
pop rax
ret
void invoke_by_forward<empty_obj>(int, empty_obj&&):
jmp empty_obj::operator()(int)
代码简单多了,按照C++语义,static函数不需要传递this指针,因此在调用static成员函数时,也就不需要传递this参数了。但是,因为在test_by_forward()中,回调函数的参数类型是引用,还得要传递一个empty_obj对象引用进去,此时需要把它的对象指针(即this指针)传递进去,即lea rsi, [rsp + 7]指令,这是C++的引用语义规定,尽管毫无用处。这样,传递函数对象引用类型的test_by_forward(),反而比传递值类型的test_by_value()性能要稍差一些。
4、没有捕捉变量的lambda表达式
对于没有捕捉外部变量的lambda表达式,我们知道它等同于空函数对象,如:
void test_by_value() {
invoke_by_value(42, [](int x) {
printf("%d\n", x);
});
}
void test_by_forward() {
invoke_by_forward(42, [](int x) {
printf("%d\n", x);
});
}
在CLNAG -std=c++11 -O2编译时生成的汇编代码:
test_by_value():
jmp void invoke_by_value<test_by_value()::$_0>(int, test_by_value()::$_0)
void invoke_by_value<test_by_value()::$_0>(int, test_by_value()::$_0):
jmp test_by_value()::$_0::operator()(int) const
test_by_forward():
jmp void invoke_by_forward<test_by_forward()::$_0>(int, test_by_forward()::$_0&&)
void invoke_by_forward<test_by_forward()::$_0>(int, test_by_forward()::$_0&&):
jmp test_by_forward()::$_0::operator()(int) const
test_by_value()::$_0::operator()(int) const:
lea rdi, [rip + .L.str]
mov esi, 42
xor eax, eax
jmp printf@PLT
test_by_forward()::$_0::operator()(int) const:
lea rdi, [rip + .L.str]
mov esi, 42
xor eax, eax
jmp printf@PLT
很显然,test_by_value()和test_by_forward()都非常简单,而调用lambda对象的operator()成员函数时,没有使用任何参数,在GCC编译器中也是如此。可见对于没有捕捉外部变量的lambda表达式,编译器会对它们进行特殊对待,毕竟它们都是就地声明就地使用的匿名对象,也不可能在别的地方使用,编译器可以充分的进行优化,而不会影响到别的地方(空函数对象不同,它的operator()有可能在别的地方调用,不可能优化的这么彻底)。把上面的汇编代码反汇编成等效的C++代码的话,如下:
void test_by_value() {
invoke_by_value<test_by_value()::$_0>();
}
void invoke_by_value<test_by_value()::$_0>() {
test_by_value()::$_0::operator()(int) const
}
void test_by_value()::$_0::operator()(int) const {
printf("%d\n", 42);
}
void test_by_forward() {
invoke_by_forward<test_by_forward()::$_0>();
}
void invoke_by_forward<test_by_forward()::$_0>() {
test_by_forward()::$_0::operator()(int) const;
}
void test_by_forward()::$_0::operator()(int) const {
printf("%d\n", 42);
}
由此可见,编译器编译完之后的核心代码是printf(“%d\n”, 42);,中间没有任何对象的创建,没有任何参数(包括this指针)的传递,直接把常量42传播到lambda表达式的operator()(int)函数中。由此可见,即使没有C++23中函数对象的static成员函数特性,使用lambda表达式也能达到优化掉this指针的目的,优先使用lambda表达式而不是定义空函数对象类,不但编写代码方便,而且性能还更好。
综上。