引言
很多时候,选择单一,事情做来不会有多少阻力,选择太多 ,倒是举棋难定了。
C++ 复杂性的一方面就体现在选择太多,对于同一种需求,可能存在数十种不同的方式都能够解决,此时每种方式的优劣便是学习的难点。
std::function
, 函数指针, std::bind
, Lambda 就是这样的一些组件,使用频率不低,差异细微,许多人不清楚何时使用何种方式,常常误用,致使程序性能出现瓶颈。
本文全面地对比了这些组件间的细微差异,并评估不同方式的性能优劣,提出使用建议及一些实践经验。
首先要明确谁与谁对比,理清可替代对象,这样对比起来才有意义。
std::function
的对比对象是函数指针,它们主要是为了支持函数的延迟调用;std::bind
的对比对象是 Lambda 和 std::bind_front ,主要是为了支持参数化绑定。
本文会全面对比这些方式的运行时间、编译时间、内存占用和指令读取总数。
旧事
函数若是不想被立即执行,在 C 及 C++11 以前存在许多方式,函数指针是最普遍的一种方式。看个例子:
void foo(int x) {
std::cout << "Function called with " << x << '\n';
}
void bar(void (*pf)(int), int value) {
pf(value); // delayed invocation
}
int main() {
bar(foo, 10);
}
通过函数指针实现了函数的延迟调用,这在回调函数、事件处理、惰性计算等场景下被广泛使用。C++11 之前,提供了仿函数来代替函数指针,于是上述示例可以等价写成:
struct functor {
void operator()(int x) const {
std::cout << "Function called with " << x << '\n';
}
};
void bar(const functor& func, int value) {
func(value); // delayed invocation
}
int main() {
bar(functor(), 10);
}
相比函数指针,仿函数具有更好的灵活性和安全性,它可以持有状态,可以有成员函数和成员变量,并且更加容易被编译器优化。而函数指针涉及间接调用,编译器不会对其进行内联优化,还有可能出现类型转换错误。
由于函数指针无法持有状态,C 里面一般会增加一个状态参数来捕获状态,例如:
typedef int (*add_pf)(void*, int);
int add_with_state(void* state, int x) {
int increment = *(int*)state;
return x + increment;
}
int bar(add_pf func, void *state, int value) {
return func(state, value); // delayed invocation
}
int main() {
int increment = 5;
add_pf add = add_with_state;
return bar(add, &increment, 10); // return 15
}
仿函数则稍微简单一点,等价写法为:
class add_functor {
int increment;
public:
add_functor(int inc) : increment(inc) {}
int operator()(int x) const {
return x + increment;
}
};
int bar(const add_functor& func, int value) {
return func(value); // delayed invocation
}
int main() {
add_functor add(5);
return bar(add, 10); // return 15
}
相较之下,仿函数捕获状态方便很多,语法也更加清晰简洁。
早期 C++ 还提供 std::bind1st
和 std::bind2nd
来绑定函数,以下是一个例子:
int add(int x, int y) {
return x + y;
}
int main() {
auto bound_func = std::bind1st(std::ptr_fun(add), 5);
return bound_func(10); // return 15
}
不过如今都已废弃,std::bind1st
被 std::bind
代码,std::ptr_fun
被 std::function
代替。
旧事且过,来看新的方法。
std::function vs. Function pointer
std::function
是 C++11 对于可调用体的高度抽象组件,不仅能够持有普通函数和成员函数,还能够持有仿函数、Lambda 和其他类型的可调用体。
一个组件的抽象层次越高,考虑的越周全,额外的工作也就越多,开销也会更大。
下面通过一个简单的例子,对比一下 std::function
和函数指针的生成代码。
////////////////////////////////
// function pointer
int add(int x, int y) {
return x + y;
}
int bar(int (*func)(int, int), int x, int y) {
return func(x, y);
}
int main() {
return bar(add, 5, 10); // return 15
}
////////////////////////////////
// std::function
int add(int x, int y) {
return x + y;
}
int bar(std::function<int(int, int)> func, int x, int y) {
return func(x, y);
}
int main() {
return bar(add, 5, 10); // return 15
}
在 GCC 13.2 最高级别的优化下,函数指针( https://godbolt.org/z/vno8WaYTK )生成的汇编代码只有 11 行,而std::function
( https://godbolt.org/z/W71bWo3qj )生成的却有 60 行,差异巨大。
实际 Benchmarks 一下,测试代码为:
int add(int x, int y) {
return x + y;
}
int bar_function_ptr(int (*func)(int, int), int x, int y) {
return func(x, y);
}
int bar_function(std::function<int(int, int)> func, int x, int y) {
return func(x, y);
}
static void function_ptr_bench(benchmark::State& state) {
for (auto _ : state) {
int result = bar_function_ptr(add, 5, 10);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(function_ptr_bench);
static void function_bench(benchmark::State& state) {
for (auto _ : state) {
int result = bar_function(add, 5, 10);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(function_bench);
结果不出所料,std::function
的运行开销要远远大于函数指针。
既然函数指针效率这么高,那还要 std::function
干嘛?
除了旧事一节提到的关于函数指针的缺点,还有一个很大的不同在于一致性,std::function
能持有普通函数、成员函数、仿函数、Lambda 等等可调用体,灵活性突出,函数指针可没有这个能力,是以适用性更低。
请注意,尽管本节的对比结果表明函数指针效率更高,但却并非是说推荐使用函数指针。
std::bind vs. std::bind_front vs. Lambda vs. Function pointer
std::bind
和 Lambda 都是 C++11 入的标准,然而,它们的功能重叠性很高,Lambda 几乎可以完全替代 std::bind
。
std::bind_front
则是 C++20 用来替代 std::bind
的新特性,其灵活性和便捷性更好。
本篇的核心是对比性能,关于它们之间区别的文章已指不胜屈,只是缺少性能分析方面的文章,故这里不会赘述已有内容。
先来测试一下基本性能,测试例子如下:
#include<functional>
#include<iostream>
int add(int x, int y) {
return x + y;
}
typedef int (*pf)(int, int);
static void func_ptr(benchmark::State& state) {
int val = 42;
pf add_func = add;
for (auto _ : state) {
int result = add_func(val, 10);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(func_ptr);
static void lambda(benchmark::State& state) {
int val = 42;
const auto lam = [val](int y) {
return val + y;
};
for (auto _ : state) {
int result = lam(10);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(lambda);
static void bind(benchmark::State& state) {
int val = 42;
const auto bind = std::bind(add, val, std::placeholders::_1);
for (auto _ : state) {
int result = bind(10);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(bind);
static void bind_front(benchmark::State& state) {
int val = 42;
const auto bind = std::bind_front(add, val);
for (auto _ : state) {
int result = bind(10);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(bind_front);
编译器 GCC 13.2,不开优化,对比结果如下图所示。
可见,在设计上,Lambda 并不会比函数指针更慢,而 std::bind
却将近慢了二十倍,std::bind_front
则比 std::bind
效率高许多,只慢了近十倍。
注意这是在未开优化的情况下,事实上,如今的编译器优化能力很强,示例相对过于简单,优化后的效率是一样的。但若是换成早期的编译器,或是更加复杂的例子,效率和未开优化的情况基本是一致的。
可以换一种编译器,并降低其版本来观察不同优化级别下的表现。编译器切换为 Clang 10.0。
O0 级别优化,对比结果如下图所示。
O1 级别优化,对比结果如下图所示。
O2 级别优化效果,结果如下图所示。
到这个优化级别,四种方式的性能已经持平。
虽说不同编译器的数值有所差异,但对比结果的整体趋势基本一致。这个结果表明 std::bind
的确是性能杀手,应该优先使用 Lambda 或 std::bind_front
代替。
Lambda vs. Functor
Lambda 就是一个可以携带状态的函数。
其实现是一个含有 operator()
重载的匿名类,捕获的参数作为匿名类的数据成员直接初始化。Lambda 使用时调用的便是这个重载的 operator()
,返回的类型就是匿名类的类型,称为 closure type。
Lambda 就是为简化仿函数(即函数对象)而来,无需在其他地方创建一个仿函数,直接原地构造。因此,它们的性能基本是一致的。
加上以下测试代码,和前面的 Lambda 代码进行对比,验证结果。
struct Functor {
int x;
auto operator()(int y) const {
return x + y;
}
};
static void functor(benchmark::State& state) {
int val = 42;
Functor functor(val);
for (auto _ : state) {
int result = functor(10);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(functor);
对比结果如下图所示。
结果表明结论正确。
Lambda vs std::function
Lambda 和 std::function
得分两种情况进行对比,一种是无需存储可调用体,一种是需要存储可调用体。
先看第一种情况,测试代码为:
int callable_with_lambda(auto func) {
return func(1, 2);
}
int callable_with_funtional(std::function<int(int, int)> func) {
return func(1, 2);
}
static void pass_callable_with_lambda(benchmark::State& state) {
for (auto _ : state) {
int result = callable_with_lambda([](int a, int b) {
return a + b;
});
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(pass_callable_with_lambda);
static void pass_callable_with_funtional(benchmark::State& state) {
for (auto _ : state) {
int result = callable_with_funtional([](int a, int b) {
return a + b;
});
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(pass_callable_with_funtional);
测试环境依旧是 GCC 13.2,不开优化。对比结果如下图。
由此可知,Lambda 的开销要比 std::function
小很多,应该优先使用泛型 Lambda 传递可调用体。
再来看第二种情况,这种情况需要存储可调用体,然而 Lambda 为 Closure type,只有使用 auto
或 decltype()
才能推导出具体类型,它是无法存储的。
此时,你只能使用std::function
或函数指针。具体使用哪种方式,便需要在性能、便捷性、灵活性等方面作出取舍。若是倾向于最大的便捷性和灵活性,前者是更好的选择;若是追求最大化性能,函数指针则是更好的方式。但需注意,若是选择函数指针,调用者将无法再使用 Lambda 和 std::bind
等常见方式传递参数。
Lambda vs. Function Pointer
对比内容前文已涉,本节作为补充。
Lambda 是可以隐式转换为函数指针的,只需将形式写成 +[]{}
(注意不能捕获状态)。其性能与函数指针无异,这也是 Lambda 被广泛使用的原因之一。Lambda 也可以携带状态,并和 std::invocable
Concept 配合起来接受可调用对象,集灵活性和高性能于一身。
函数指针涉及间接调用,无法被编译器优化,是以既无法内联,也无法重新排序。它可能指向不同的函数,编译器无法优化这些调用的具体细节,必须按照特定的调用约定进行处理。而 Lambda 在编译时就可知道具体实现,编译器可以直接生成高效的调用代码,无需遵循通用的调用约定,这将带来巨大的优化空间。
此外,只要满足 constexpr function
的条件,Lambda 会隐式 constexpr
,因此可以在编译期评估。
编译时间、内存占用、指令读取:std::bind vs. std::bind_front vs. Lambda
前文只是对比了这些方式在运行时间方面的性能,本节再对比编译时间和内存占用。
对比示例,代码如下:
// bind.cpp
//////////////////////////////
#include<functional>
#include<iostream>
int add(int x, int y) {
// std::cout << "x: " << x << " y:" << y << '\n';
return x + y;
}
int main() {
int val = 42;
const auto fun = std::bind(add, val, std::placeholders::_1);
for (int i = 0; i < 1000000; ++i) {
fun(i);
}
}
// bind_front.cpp
//////////////////////////////
#include<functional>
#include<iostream>
int add(int x, int y) {
// std::cout << "x: " << x << " y:" << y << '\n';
return x + y;
}
int main() {
int val = 42;
const auto fun = std::bind_front(add, val);
for (int i = 0; i < 1000000; ++i) {
fun(i);
}
}
// lambda.cpp
//////////////////////////////
#include<functional>
#include<iostream>
int add(int x, int y) {
// std::cout << "x: " << x << " y:" << y << '\n';
return x + y;
}
int main() {
int val = 42;
const auto lam = [val](int y) {
return add(val, y);
};
for (int i = 0; i < 1000000; ++i) {
lam(i);
}
}
首先,来看编译时间和内存占用情况。如下图所示。
可以看到,Lambda 消耗的时间最短,只有 1.27 秒,Bind 消耗的时间最多,1.34 秒;Lambda 的最大常驻内存大小为 96640KB,Bind Front 为 98436KB,而 Bind 是 100100KB。
其次,再来对比一下它们的指令读取情况。如下图。
其中,Lambda 运行期间指令总共读取了 32,265,989 次,Bind 是 390,268,192 次,而 Bind Front 是 262,267,908 次。可见,Lambda 比其他两种方式的指令读取次数少了一个数量级,Bind Front 较 Bind 也减少了非常多次。
最后,不难得出,无论是在运行时间,还是编译时间、内存占用和指令读取方面,Lambda 的性能都是最好的,其次是 Bind Front,最后是 Bind。
总结
本文全面对比了 Lambda、std::bind
、std::bind_front
、std::function
和函数指针的性能,针对不同场景分析不同方式的优劣,以能够根据场景灵活选择适当的实现方式。
Lambda 的性能(运行时间、编译时间、内存占用、指令读取总数)最好,和函数指针基本持平,其次是 std::bind_front
,最后是 std::bind
。std::bind
是失败的设计,任何时候,都要优先使用 Lambda 或 std::bind_front
。
当不需要具体的可调用对象类型时,使用模板和 Lambda 的方式要优于 std::function
,其保留了灵活性和高性能;当需要具体的类型时,std::function
能够提供最大的灵活性和便捷性,此时若想追求最大化性能,可考虑函数指针(将失去所有灵活性)。