类型擦除的std::function与虚拟函数调用的开销

Overhead of std::function vs virtual function call for type erasure

本文关键字:虚拟 函数调用 开销 function std 类型 擦除      更新时间:2023-10-16

假设我有一个模板化的类,它封装了它的模板参数以提供一些额外的功能,比如将对象的状态持久化到磁盘的能力:

template<typename T>
class Persistent {
std::unique_ptr<T> wrapped_obj;
public:
Persistent(std::unique_ptr<T> obj_to_wrap);
void take_snapshot(int version);
void save(int to_version);
void load(int to_version);
}

我想要另一个类,让我们称之为PersistentManager,存储这些模板化的Persistent对象的列表,并在不知道其模板参数的情况下调用其成员方法。我可以看到两种方法:使用std::函数从每个方法中删除模板类型,或者使用抽象基类和虚拟函数调用。

使用std::function,每个Persistent对象都能够返回绑定到其成员的std::函数束:

struct PersistentAPI {
std::function<void(int)> take_snapshot;
std::function<void(int)> save;
std::function<void(int)> load;
}
template<typename T>
PersistentAPI Persistent<T>::make_api() {
using namespace std::placeholders;
return {std::bind(&Persistent<T>::take_snapshot, this, _1),
std::bind(&Persistent<T>::save, this, _1),
std::bind(&Persistent<T>::load, this, _1)}
}

然后PersistentManager可以存储一个PersistentAPI的列表,并有一个类似的方法:

void PersistentManager::save_all(int version) {
for(PersistentAPI& bundle : persistents) {
bundle.save(version);
}
}

使用继承,我会创建一个没有模板参数的抽象类,将Persistent的每个方法定义为虚拟的,并使Persistent从中继承。然后PersistentManager可以存储指向这个基类的指针,并通过虚拟函数调用调用Persistent方法:

class AbstractPersistent {
public:
virtual void take_snapshot(int version) = 0;
virtual void save(int to_version) = 0;
virtual void load(int to_version) = 0;
}
template<typename T>
class Persistent : public AbstractPersistent {
...
}
void PersistentManager::save_all(int version) {
for(AbstractPersistent* obj : persistents) {
obj->save(version);
}
}

这两种方法都为来自PersistentManager的函数调用增加了一些开销:它们不需要将函数调用直接分派到Persistent实例,而是需要经过一个中间层,即std::函数对象或AbstractPersistent中的虚拟函数表。

我的问题是,哪种方法可以减少开销?由于这两个部分都是标准库中相当不透明的部分,所以我不太清楚如何"昂贵的";通过基类指针将std::函数调用与虚拟函数调用进行比较。

(我在这个网站上发现了一些其他问题,询问std::函数的开销,但它们都缺乏可供比较的具体替代方案。)

我有点犹豫要不要回答这个问题,因为它很容易归结为意见。我一直在一个项目中使用std::function,所以我不妨分享我的两分钱(您可以决定如何处理输入)。

首先,我想重复一下评论中已经说过的话。如果你真的想看看性能,你必须做一些基准测试。只有在基准测试之后,你才能得出结论。

幸运的是,您可以使用quickbench进行快速基准测试(!)。我为基准测试提供了您的两个版本,添加了一个为每次调用增加的状态,以及一个为变量增加的getter:

// Type erasure:
struct PersistentAPI {
std::function<void(int)> take_snapshot;
std::function<void(int)> save;
std::function<void(int)> load;
std::function<int()> get;
};
// Virtual base class
class AbstractPersistent {
public:
virtual void take_snapshot(int version) = 0;
virtual void save(int to_version) = 0;
virtual void load(int to_version) = 0;
virtual int get() = 0;
};

每个函数只是在相应的类中增加一个整数,并用get()返回(希望编译器不会删除所有不必要的代码)。

结果是有利于虚拟功能,对于Clang和GCC,我们的速度相差约1.7(https://quick-bench.com/q/wUbPp8OdtzLZv8H1VylyuDnd2pU,您可以更改编译器并重新检查)。

现在进行分析:为什么抽象类看起来更快?好吧,std::function有更多的间接寻址,但之前的包装中也有另一个间接寻址,当我们调用std::bind(!)时。听Scott Meyers的话,lambdas更喜欢std::bind,这不仅是因为它们的语法对人们来说很容易(std::placeholders并不漂亮),而且是因为它们对编译器的语法!lambda调用对内联更容易。

内衬对性能非常重要。如果可以通过添加我们调用的代码来避免显式调用,我们可以节省一些周期!

std::bind更改为lambdas,然后再次执行,std::function和继承(对于Clang和GCC)之间的性能非常相似:https://quick-bench.com/q/HypCbzz5UMo1aHtRpRbrc9B8v44.

那么,为什么它们相似?对于Clang和GCC,std::function在内部使用继承。类型擦除,正如它在这里实现的那样,只是隐藏多态性。

(请注意,这个基准测试可能会产生误导,因为对这两种情况的调用都可以完全内联,因此根本不使用间接方法。测试用例可能要想欺骗编译器,就必须更加棘手。)

假设您有Clang和GCC作为编译器,您应该使用哪种方法?

PersistentAPI更灵活,因为实际上take_snapshotsaveload基本上都是函数指针并且不需要分配给单个类!带

struct PersistentAPI {
std::function<void(int)> take_snapshot;
std::function<void(int)> save;
std::function<void(int)> load;
};

,作为一个开发人员,完全有理由相信PersistentAPI,意味着要调度到多个对象,而不仅仅是一个*单个*。例如,take_snapshot可以分派到一个自由函数,而saveload可以分派到两个不同的类。这就是你想要的灵活性吗?那么这就是你应该使用的。通常,我会通过API使用std::function,让用户将回调注册到所选的任何可调用对象。

如果您想使用类型的擦除,但出于某种原因想要隐藏继承的话,您可以构建自己的版本。std::function接受所有具有operator()的类型,我们可以构建一个接受所有具有接口"的类的类型;take_snapshot、保存和加载";。练习很好!

// probably there is a better name for this class
class PersistentTypeErased {
public:
template<typename T>
PersistentTypeErased(T t) : t_(std::make_unique<Model<T>>(t)) {}
void take_snapshot(int version) { t_->take_snapshot(version); }
void save(int to_version) { t_->save(to_version); }
void load(int to_version) { t_->load(to_version); }
private:
struct Concept
{
virtual void take_snapshot(int version) = 0;
virtual void save(int to_version) = 0;
virtual void load(int to_version) = 0;
};
template<typename T>
struct Model : Concept
{
Model(T t) : t_(t) {}
void take_snapshot(int version) { t_.take_snapshot(version); }
void save(int to_version) { t_.save(to_version); }
void load(int to_version) { t_.load(to_version); }
T t_;
};
std::unique_ptr<Concept> t_;
};

该技术与std::function类似,现在您可能还可以看到类型擦除是如何在后台使用多态性的。你可以在这里看到它是如何使用的。