C++ lambda 与 std::function 与类型擦除

Posted by Ciel's Paperplane on September 16, 2024

图片

本文还未完成。

1. lambda

(1) 简介

首先 lambda 是一个重载了 operator() 的类的语法糖,有一个网站 C++ Insights 就可以看到它的具体情况。

以下面的代码举例,两个 lambda 与它们在 C++ Insights 中会生成的实际样子如下:

int main() {
    auto f1 = [] { return 1; };
/* equals to:
    class __lambda_3_13 {
    public: 
        inline constexpr int operator()() const {
            return 1;
        }
        
        using retType_3_13 = auto (*)() -> int;
        inline constexpr operator retType_3_13 () const noexcept {
            return __invoke;
        };
        
    private: 
        static inline constexpr int __invoke() {
            return __lambda_3_13{}.operator()();
        }
    };

    __lambda_3_13 f1 = __lambda_3_13{};
*/
    int i = 2;
    auto f2 = [i] { return i; };
/* equals to:
    class __lambda_6_13 {
    public: 
        inline constexpr int operator()() const {
            return i;
        }

    private: 
        int i;

    public:
        __lambda_6_13(int& _i) : i{_i} {}
    };

    __lambda_6_13 f2 = __lambda_6_13{i};
*/
}

可以看到 f1f2 都生成了一个类,里面有一个重载了 operator() 的成员函数,这使得它们成为了可调用对象。而带捕获的 lambda 会把捕获的变量存在类内,并且注意 operator()const 成员函数,不允许修改捕获变量。如果在 lambda 上加上 mutable 关键字那 const 就会被去掉。

而 f1 对应的 lambda 由于没有捕获变量,这使得其成为了一个无状态 lambda,这样的 lambda 内部提供了 operator int(*)(),即到函数指针的隐式转换。

此外,由于 lambda 是类,自然可以被继承,比如可以仿照下例做一些自动内存管理和异常安全保证:

#include <iostream>

template<class F>
class finally : F {
public:
    explicit finally(const F& f) : F(f) {}

    explicit finally(F&& f) : F(std::move(f)) {}

    ~finally() {
        (*this)();
    }

}; // class finally

template<class F, class DecayF = typename std::decay<F>::type>
finally<DecayF> make_finally(F&& f) {
    return finally<DecayF>(std::forward<F>(f));
}

int main() {
    auto defer = make_finally([] { std::cout << "world."; });
    std::cout << "Hello ";
}

// Prints "Hello world."

(2) 回调优化

传统的回调一般是用函数指针/引用来实现的,它的问题主要在于,不同的函数都拥有着相同的函数类型,函数指针与引用同理。所以函数的内容是运行时才能确定的,这样编译器就无法对此做什么优化。

int eval(int(*callback)()) {
    return callback() * callback();
}

int f1() { return 5; }
int f2() { return 6; }

int main() {
    eval(f1);
    eval(f2);
}

这里 eval 函数接受一个函数指针,然后调用它两次。除了要调用的函数不接受形参而返回 int 值以外,它对自己将要调用的函数一无所知。毕竟 int (*)() 关联到的函数无穷无尽,那自然也不可能做内联,注意到 eval 里的两次 call 调用是无论如何避免不了的。

; https://godbolt.org/z/13GM9Gz75
;
eval(int (*)()):
    push    rbp
    mov     rbp, rdi
    push    rbx
    sub     rsp, 8
    call    rdi
    mov     ebx, eax
    call    rbp
    add     rsp, 8
    imul    eax, ebx
    pop     rbx
    pop     rbp
    ret
f1():
    mov     eax, 5
    ret
f2():
    mov     eax, 6
    ret
main:
    xor     eax, eax
    ret

但是如果是 lambda + 模板做回调,情况就完全不一样了。

void stop_inline(auto);

__attribute__((noinline)) int eval(auto&& f) {
    return f() * f();
}

int f() {
    return 5;
}

int main() {
    stop_inline(eval(f));
    stop_inline(eval([]{ return f(); }));
}
; https://godbolt.org/z/bMPfKxoor
; ...
int eval<main::{lambda()#1}>(main::{lambda()#1}&&) [clone .isra.0]:
    mov     eax, 25
    ret
int eval<int (&)()>(int (&)()):
    push    rbp
    mov     rbp, rdi
    push    rbx
    sub     rsp, 8
    call    rdi
    mov     ebx, eax
    call    rbp
    add     rsp, 8
    imul    eax, ebx
    pop     rbx
    pop     rbp
    ret
; ...

由于每个 lambda 都是一个完全不同的独立类型,被 eval 推导出类型后,编译器在编译期就能知道关于它的所有信息然后随心所欲地内联优化。注意到这里的 lambda 甚至只是把 f() 包了一层转发,也比直接传 f() 的引用要强得多。



Ciel's Paperplane

作诗中...