做短视频的网站收益qq群推广引流免费网站
因为篇幅原因分成了两篇文章,这篇是上一篇文章相关的附加内容,算是补充材料,作为知识点了解一下。
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, 10jmp rsi
因为编译器在编译invoke_in_c()时不知道函数指针参数会指向哪一个函数,因此无法对回调函数使用内联优化,也就是在调用函数指令中:jmp rsi,寄存器rsi指向一个函数入口,但是指向哪一个函数是不知道的,只有在运行时才知道,属于动态绑定。
.LC0:.string "%d\n"
foo(int):mov esi, edixor eax, eaxmov edi, OFFSET FLAT:.LC0jmp printf
test1():mov esi, OFFSET FLAT:foo(int)mov edi, 42jmp 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, 52mov edi, OFFSET FLAT:.LC0xor eax, eaxjmp 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 rbpmov rbp, rsipush rbxmov rbx, rdisub rsp, 8call rdxtest al, alcmovne rbx, rbpadd rsp, 8mov rax, rbxpop rbxpop rbpret
int const& nmax<int, bool (*)(int const&, int const&)>(bool (*)(int const&, int const&), int const&, int const&):push rbpmov rax, rdimov rbp, rdxpush rbxmov rbx, rsimov rsi, rdx ; 调整参数位置mov rdi, rbx ; 调整参数位置sub rsp, 8call raxtest al, alcmovne rbx, rbpadd rsp, 8mov rax, rbxpop rbxpop rbpret
在x86环境中的函数调用约定,第1、2、3参数位置的参数分别使用寄存器rdi、rsi和rdx来传递,为了符合调用约定,nmax编译后完的汇编代码中,会比mmax编译后的汇编代码中多两条指令,用来调整参数的传参寄存器:
mov rsi, rdxmov 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, 42jmp void invoke_by_value<empty_obj>(int, empty_obj)void invoke_by_value<empty_obj>(int, empty_obj):push raxmov esi, edilea rdi, [rsp + 7] ; 传递函数对象this指针call empty_obj::operator()(int)pop raxrettest_by_forward():push raxlea rsi, [rsp + 7] ; 传递函数对象this指针mov edi, 42call void invoke_by_forward<empty_obj>(int, empty_obj&&)pop raxretvoid invoke_by_forward<empty_obj>(int, empty_obj&&):mov eax, edimov rdi, rsimov esi, eaxjmp 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, 42jmp 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 raxlea rsi, [rsp + 7]mov edi, 42call void invoke_by_forward<empty_obj>(int, empty_obj&&)pop raxretvoid 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) consttest_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) consttest_by_value()::$_0::operator()(int) const:lea rdi, [rip + .L.str]mov esi, 42xor eax, eaxjmp printf@PLTtest_by_forward()::$_0::operator()(int) const:lea rdi, [rip + .L.str]mov esi, 42xor eax, eaxjmp 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表达式而不是定义空函数对象类,不但编写代码方便,而且性能还更好。
综上。