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() 的引用要强得多。

2. 类型擦除:以 std::function 实现举例

std::function 是 C++11 的一种通用多态函数包装器,对于一个确定了形参 Args... 与返回值 R 类型的类实例 std::function<R(Args...)>,它可以存储和调用任意的签名相同的可调用对象。

对于这种需求,类型擦除是必不可少的实现方式。因为同样签名的可调用对象千千万,它们有无数不同的类型,但是 std::function<R(Args...)> 唯一留存的信息只有形参和返回值类型,而没有它要存储的东西的类型。我们需要用一种固定的方式存储任意的东西,后面还要拿出来用,那么情况就总结成了,只有接口信息而没有具体信息,这不就是多态嘛。(除了多态,类型擦除的另一种实现方式是函数指针,不过这本质上只是手动做了一层多态罢了,所以原理并没有区别。)

具体实现来看:

(1) 我们需要一个地方来存储可调用对象。由于其类型无穷大小也不确定,所以不可能在类内预留足够的空间,一定会需要内存动态分配。

不过大部分常用的可调用对象,例如函数指针、无捕获或者只捕获了一两个数字的 lambda 等,都只有不到两三根指针的大小,所以可以用上小缓冲区优化,省下 90+% 的内存分配。

(2) 由于 std::function 可以复制,所以存储的已经被擦除类型的对象也需要想办法被调用拷贝构造函数。

(3) std::function 析构时需要析构可调用对象和释放用来存储它的堆内存(如果存在)。

(4) 调用可调用对象。

简单来看,我们需要有这样的一个基类:

template<class>
class func_base;

template<class R, class... Args>
class func_base<R(Args...)> {
public:
    // small
    virtual void clone_to(void*) const             = 0;
    virtual void destroy() noexcept                = 0;
    // large
    virtual func_base* clone() const               = 0;
    virtual void destroy_and_deallocate() noexcept = 0;
    // 调用存储的可调用对象
    virtual R operator()(Args&&...) const          = 0;
};

具体将视可调用对象类型 F 的大小,即是否使用了小缓冲区优化,来决定复制和析构时调用的是哪组函数。

存储可调用对象的工作就交给了派生类 func,注意它的模板参数中多了可调用对象的类型 F,终于可以在这里实现虚函数了。

template<class, class>
class func;

template<class F, class R, class... Args>
class func<F, R(Args...)> final : public func_base<R(Args...)> {
private:
    F f_;

public:
    explicit func(const F& f)     : f_(f) {}
    explicit func(F&& f) noexcept : f_(std::move(f)) {}

    void clone_to(void* buffer) const override {
        ::new (buffer) func(f_);
    }
    void destroy() noexcept override {
        this->~func();
    }

    func_base<R(Args...)>* clone() const override {
        return new func(f_);
    }
    void destroy_and_deallocate() noexcept override {
        delete this;
    }

    R operator()(Args&&... args) const override {
        return f_(std::forward<Args>(args)...);
    }
};

因为对于 small 情形而言,它的拷贝构造位置是固定的对面的小缓冲区地址,所以用的是 clone_to(void*) 这种形式;析构时也不需要释放堆内存所以仅是 destroy() 即可。

而对于 large 情形,因为它需要申请堆内存,而派生类 func 的类型和大小只有自己知道,所以 return new func(f_);delete this; 是必要的。

function 实现中,f_ 在不存储可调用对象时为 nullptr,存储小对象时会指向 buffer_,否则会指向 new 返回的堆内存地址。

template<class>
class function;

template<class R, class... Args>
class function<R(Args...)> {
private:
    using base_type = func_base<R(Args...)>;

    std::aligned_storage<sizeof(void*) * 3, alignof(void*)>::type buffer_;
    base_type* f_{nullptr};
}

构造函数的实现中,举两个例子即可:

// 存储任意可调用对象
template<class F, class DecayF = typename std::decay<F>::type>
function(F&& f) {
    using func_type = func<DecayF, R(Args...)>;

    if (not_null(f)) { // 如果是空函数指针之类的就不用存了
        if (is_small_object<DecayF>::value) { // 是否符合小缓冲区优化条件
            auto temp = reinterpret_cast<func_type*>(std::addressof(buffer_));

            ::new (temp) func_type(std::forward<F>(f));
            f_ = temp;

        } else {
            f_ = new func_type(std::forward<F>(f));
        }
    }
}

// 拷贝构造
function(const function& other) {
    switch (other.check_state()) { // 看 other 的 f_ 在什么状态
        case state::Null :
            break;
        case state::Small :
            other.f_->clone_to(std::addressof(buffer_));
            f_ = reinterpret_cast<func_type*>(std::addressof(buffer_));
            break;
        case state::Large :
            f_ = other.f_->clone();
    }
}

但是类型擦除的代价就回到了第一节,编译器无法看到里面的实现,所以性能一定会大打折扣。对于这种必要的需求来说,这也是无可奈何的事。

不仅如此,目前的实现中还有一个很麻烦的地方,就是在拷贝赋值中,如果本来已经存储着一个堆内存的对象,要替换的也是需要堆存储的对象,那么理论上是可以在旧对象的大小和对齐大于等于新对象时,重用那一块内存,但是目前的代码逻辑中无法做到这一点。LLVM libc++ 中的实现就是直接析构释放再重申请,所以在一些场景中还是存在着比较大的浪费的。



Ciel's Paperplane

作诗中...