类型擦除的std::function与虚拟函数调用的开销
Overhead of std::function vs virtual function call for type erasure
假设我有一个模板化的类,它封装了它的模板参数以提供一些额外的功能,比如将对象的状态持久化到磁盘的能力:
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_snapshot
、save
和load
基本上都是函数指针并且不需要分配给单个类!带
struct PersistentAPI {
std::function<void(int)> take_snapshot;
std::function<void(int)> save;
std::function<void(int)> load;
};
,作为一个开发人员,完全有理由相信PersistentAPI
是,意味着要调度到多个对象,而不仅仅是一个*单个*。例如,take_snapshot
可以分派到一个自由函数,而save
和load
可以分派到两个不同的类。这就是你想要的灵活性吗?那么这就是你应该使用的。通常,我会通过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
类似,现在您可能还可以看到类型擦除是如何在后台使用多态性的。你可以在这里看到它是如何使用的。
- 类型擦除的std::function与虚拟函数调用的开销
- 为什么构造函数的虚拟函数调用有时有效,但其他调用却无效
- 虚拟函数调用的性能作为 for 循环中的上限
- 编译器 虚拟函数调用的优化
- MSVC 在不应该调用时内联虚拟函数调用
- 替代基础析构函数上的虚拟函数调用
- 排除外部错误R6025-纯虚拟函数调用
- 为什么这个虚拟函数调用如此昂贵
- C 从虚拟函数调用静态函数
- C++纯虚拟函数调用的性能可变性
- 使用gcc进行纯虚拟函数调用时发生链接器错误
- 子类中的虚拟函数调用父函数一次
- 虚拟函数调用始终比普通函数调用快.为什么
- 错过了一个虚拟函数调用
- 纯虚拟函数调用错误
- R6025纯虚拟函数调用(从sf::InputStream派生的类)
- 通过中断purevirt.c调试R6025纯虚拟函数调用
- 为什么非虚拟函数调用即使在dynamic_cast失败后仍然成功
- 纯虚拟函数调用有趣的案例
- static_cast如何影响虚拟函数调用