当前位置: 首页 > news >正文

为什么函数对象作为函数参数时,一般使用值类型形式?-番外篇

因为篇幅原因分成了两篇文章,这篇是上一篇文章相关的附加内容,算是补充材料,作为知识点了解一下。

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表达式而不是定义空函数对象类,不但编写代码方便,而且性能还更好。

综上。


文章转载自:
http://amphimictic.apjjykv.cn
http://caseose.apjjykv.cn
http://backroom.apjjykv.cn
http://antiblack.apjjykv.cn
http://adeptness.apjjykv.cn
http://ait.apjjykv.cn
http://ajc.apjjykv.cn
http://berserker.apjjykv.cn
http://bighorn.apjjykv.cn
http://antigen.apjjykv.cn
http://attributively.apjjykv.cn
http://accessory.apjjykv.cn
http://bonus.apjjykv.cn
http://bestrew.apjjykv.cn
http://administer.apjjykv.cn
http://ceq.apjjykv.cn
http://catholically.apjjykv.cn
http://bolognese.apjjykv.cn
http://carrageenan.apjjykv.cn
http://analogise.apjjykv.cn
http://adult.apjjykv.cn
http://algarroba.apjjykv.cn
http://accommodationist.apjjykv.cn
http://cavalier.apjjykv.cn
http://azotic.apjjykv.cn
http://balanceable.apjjykv.cn
http://carding.apjjykv.cn
http://braciola.apjjykv.cn
http://choosing.apjjykv.cn
http://actinotheraphy.apjjykv.cn
http://www.dtcms.com/a/110192.html

相关文章:

  • 企业数据危机频发,该如何提前预防数据泄露发生?
  • Java 集合 Map Stream流
  • [Linux]从零开始的vs code交叉调试arm Linux程序教程
  • 蛋白设计 ProteinMPNN
  • 【Json-Rpc #3】项目设计
  • OpenCV 图形API(16)将极坐标(magnitude 和 angle)转换为笛卡尔坐标(x 和 y)函数polarToCart()
  • XT-912在热交换站的应用
  • 8.6考研408内部排序算法比较与应用知识点深度解析
  • BEV感知中如何使用相机内外参?
  • 深度学习训练camp-第R7周:糖尿病预测模型优化探索
  • Flutter PopupMenuButton 深度解析:从入门到架构级实战
  • PyTorch数据加载流程解析
  • 基于embedding进行语义相似度检索全流程实践
  • PostgreSQL中根据另一表的值来更新一个字段
  • Linux操作系统与冯·诺依曼体系结构详解
  • 【机器学习的定义】
  • 【Linux网络编程九】网络原理之TCP协议【传输层】
  • 嵌入式硬件篇---JSON通信以及解析
  • 给Android Studio配置本地gradle和maven镜像地址,加快访问速度
  • Vue3 视频播放与截图功能实现
  • 第六章、Isaacsim中的资产:usda文件详解(1)
  • 基姆拉尔森计算公式
  • 车辆投保日期查询API:快速获取想要的车辆保险日期
  • [王阳明代数讲义]琴语言类型系统工程特性
  • Tracing the thoughts of a large language model 简单理解
  • AI比人脑更强,因为被植入思维模型【41】反作用力思维模型
  • Python 爬虫突破反爬虫机制实战
  • 文献分享: DESSERT基于LSH的多向量检索(Part1——原理与实现)
  • C++中std::priority_queue的使用说明
  • #MySQL 语句大全(完整实用教程)