Chapter 36: Bind Mem Fn and Invoke
C++ 标准库里存在一组看起来分散的调用工具:std::bind 负责把可调用对象和部分参数保存起来,std::placeholders 负责描述未来实参的位置,std::mem_fn 负责把成员指针包装成普通可调用对象,std::invoke 负责给普通函数、函数对象、成员函数指针和成员数据指针提供统一调用入口。本章讨论的主问题是:泛型代码如何把这些不同形态统一成一个 callable 判断模型。
这里的可调用对象(callable)指能进入一次调用表达式的对象或实体,包括函数指针、函数对象、lambda、成员函数指针、成员数据指针和调用包装器。成员指针的特殊点在于它只描述“某个类的成员”,真正调用时还需要一个对象作为接收者。std::invoke 正是把“调用目标”和“接收者”整理到同一套规则里的标准入口。
本章用一个贯穿材料串起全部小节:一个 Job 对象持有任务状态,一个普通函数负责组合参数,一个成员函数负责读取对象状态。后续每节都回到这组材料,观察参数保存、位置映射、成员指针包装、统一调用和 lambda 替代边界。
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>
struct Job {
int base = 10;
int score(int bonus) const {
return base + bonus;
}
void add(int delta) {
base += delta;
}
};
int combine(int left, int middle, int right) {
return left * 100 + middle * 10 + right;
}
读完本章后,应能定位一个调用包装器到底保存了什么对象,判断一次调用时实参如何映射到目标函数,区分 bind、mem_fn、invoke 和 lambda 的适用位置,并能在模板代码中用 std::invoke / std::is_invocable 建立可调用性检查。
36.1 std::bind
std::bind 的工作定义是:接收一个可调用对象和一组绑定参数,返回一个未指定类型的函数对象;以后调用这个函数对象时,它再把保存的内容和当次传入的实参合成一次真正调用。这个返回对象通常可称为 bind expression,标准只承诺它的行为和若干 trait 结果,具体类型由标准库实现决定。
最小例子先看普通函数。combine 需要三个 int,std::bind 可以固定中间参数,并把未来调用的第一个、第二个实参放到目标函数的指定位置。
#include <functional>
#include <iostream>
int combine(int left, int middle, int right) {
return left * 100 + middle * 10 + right;
}
int main() {
using namespace std::placeholders;
auto adapter = std::bind(combine, _2, 7, _1);
std::cout << adapter(3, 4) << '\n'; // combine(4, 7, 3) -> 473
}
这个例子里,std::bind 创建阶段保存了两类东西:目标函数 combine,以及三个绑定槽位 _2、7、_1。调用 adapter(3, 4) 时,_2 取当次调用的第二个实参 4,普通绑定值 7 直接作为中间实参,_1 取当次调用的第一个实参 3。最终目标调用就是 combine(4, 7, 3)。
从实现形状看,std::bind 返回对象内部通常保存 std::decay_t<F> 形式的目标可调用对象,并为每个绑定参数保存一个衰变后的成员对象。衰变(decay)会去掉引用,把数组和函数类型调整成指针等常见传值形态。这个规则解释了一个高频边界:普通绑定参数默认被拷贝或移动进返回对象,后续调用时以保存对象参与调用。
#include <functional>
#include <iostream>
void print_pair(int current, int captured) {
std::cout << current << ',' << captured << '\n';
}
int main() {
using namespace std::placeholders;
int value = 1;
auto by_value = std::bind(print_pair, _1, value);
auto by_ref = std::bind(print_pair, _1, std::ref(value));
value = 9;
by_value(3); // 3,1
by_ref(3); // 3,9
}
by_value 保存的是创建 bind expression 时的 value 副本,所以之后修改外部 value 不影响第二个参数。by_ref 保存的是 std::reference_wrapper<int>,调用时通过 get() 取出原对象引用,所以它能观察到后续修改。这个差异来自 std::bind 对绑定参数的四类处理规则:引用包装器按引用传递,嵌套 bind expression 先求值,placeholder 从当次实参取值,普通参数按保存对象传入。
std::bind 对 STL 工程的意义在于它把“调用配置”变成一个对象。这个对象可以被放入 std::function,可以交给算法,也可以作为回调注册到事件系统中。代价同样来自对象化:保存状态需要构造成员对象;目标调用通常经过一层包装;placeholder 让实参位置从函数声明处转移到 bind expression 里,阅读时需要重建映射关系。
36.2 std::placeholders
std::placeholders 提供 _1、_2、_3 等占位符对象,用来描述 bind expression 在未来调用时从哪个实参位置取值。_1 表示当次调用的第一个实参,_2 表示第二个实参,以此类推;占位符的位置出现在绑定列表中,决定它被送到目标调用的哪个参数槽位。
继续使用 combine,下面这段代码把未来实参的顺序反转,并固定中间参数。
#include <functional>
#include <iostream>
int combine(int left, int middle, int right) {
return left * 100 + middle * 10 + right;
}
int main() {
using namespace std::placeholders;
auto reversed = std::bind(combine, _3, _2, _1);
auto fixed_middle = std::bind(combine, _2, 5, _1);
std::cout << reversed(1, 2, 3) << '\n'; // combine(3, 2, 1) -> 321
std::cout << fixed_middle(8, 4, 99) << '\n'; // combine(4, 5, 8) -> 458
}
fixed_middle(8, 4, 99) 中第三个实参 99 没有被任何 placeholder 选中。标准语义是:这些未被选中的调用实参仍然完成求值,然后被丢弃。这个规则在回调接口适配中有用。例如某个框架固定传入三个参数,而业务函数只关心其中两个,bind expression 可以留下关心的槽位,同时接收并丢弃额外实参。
placeholder 的重复使用会让同一个未来实参进入多个目标参数位置。对于普通可拷贝对象,这通常只是多次读取同一个实参;对于 move-only 对象或会被消耗的右值,重复 placeholder 会把同一个资源语义送入多个槽位,结果容易变成编译失败或语义不稳定的调用。工程判断上,重复 placeholder 适合只读或廉价拷贝的实参,涉及所有权转移时应改用 lambda 写出明确的局部变量处理。
#include <functional>
#include <iostream>
#include <string>
void show_twice(const std::string& left, const std::string& right) {
std::cout << left << ':' << right << '\n';
}
int main() {
using namespace std::placeholders;
auto twice = std::bind(show_twice, _1, _1);
std::string name = "build";
twice(name); // show_twice(name, name)
}
这段代码安全的原因是 show_twice 接收 const std::string&,重复传入同一个 name 只产生两次只读绑定。若目标函数要求两个独立的可移动对象,_1, _1 的表达能力就不足以表达“先复制、再移动”这类策略。placeholder 只描述位置映射,资源策略仍由目标函数参数类型、实参类别和包装器内部保存方式共同决定。
std::placeholders 的阅读顺序应固定为三步:先看目标函数的形参顺序,再看绑定列表中每个槽位使用的是普通值、引用包装器还是 placeholder,最后把当次调用的实参按 _N 映射回目标函数。用这个顺序读 bind expression,可以把看似跳跃的表达式还原成一次普通函数调用。
36.3 std::mem_fn
std::mem_fn 的工作定义是:接收一个成员指针,返回一个可调用包装器;调用这个包装器时,第一个实参提供对象或指向对象的句柄,后续实参提供成员函数参数。它解决的是成员指针调用语法难以直接进入通用算法和回调接口的问题。
成员函数指针的原始调用语法需要 .* 或 ->*。这个语法携带两个角色:左侧对象提供接收者,右侧成员指针提供要访问的成员。std::mem_fn 把右侧成员指针保存起来,让调用方用普通函数对象形式传入接收者。
#include <functional>
#include <iostream>
#include <memory>
struct Job {
int base = 10;
int score(int bonus) const {
return base + bonus;
}
};
int main() {
Job job{20};
auto score = std::mem_fn(&Job::score);
std::cout << score(job, 3) << '\n'; // 23
std::cout << score(&job, 4) << '\n'; // 24
auto owned = std::make_shared<Job>(Job{30});
std::cout << score(owned, 5) << '\n'; // 35
}
这个包装器没有拥有 job。它只保存 &Job::score,每次调用时从第一个实参取得接收者。第一个实参可以是对象引用、对象指针,也可以是能解引用得到对象的智能指针。由此可见,std::mem_fn 的生命周期边界很清楚:成员指针包装器本身可以长期保存,真正对象的存活期由每次调用提供者负责。
std::mem_fn 同样可以包装成员数据指针。包装成员数据指针时,调用结果是对该数据成员的访问;对象可修改时结果可以作为左值参与赋值,对象为 const 时结果按 const 访问规则受限。
#include <functional>
#include <iostream>
struct Job {
int base = 10;
};
int main() {
Job job{12};
auto base = std::mem_fn(&Job::base);
std::cout << base(job) << '\n';
base(job) = 40;
std::cout << job.base << '\n';
}
成员数据包装器让“取字段”也能进入通用调用位置。它常用于算法适配、排序 key 提取、回调注册等场景。现代代码中,[](const Job& job) { return job.base; } 往往更直观;std::mem_fn(&Job::base) 的优势是表达短、可由成员指针直接生成,并且和 std::invoke 的成员指针规则完全对齐。
从标准库实现角度看,std::mem_fn 是一个轻量包装器。它保存成员指针,operator() 中把所有实参完美转发给 INVOKE 规则。这个形状说明了它和 std::function 的区别:std::mem_fn 保留具体类型,通常不引入类型擦除;std::function 把不同可调用对象封装到统一运行时类型中,可能产生间接调用和分配成本。
36.4 std::invoke
std::invoke 是 C++17 引入的统一调用函数模板,定义在 functional 头文件中。它的核心动作是:接收一个调用目标 f 和一组实参 args...,按照标准 INVOKE 规则完成调用。普通函数、函数对象、lambda、成员函数指针和成员数据指针都可以落入这套规则。
先看普通可调用对象。对于函数指针、函数对象和 lambda,std::invoke(f, args...) 的效果等价于把 f 当作普通函数对象调用。
#include <functional>
#include <iostream>
int twice(int value) {
return value * 2;
}
struct PlusOne {
int operator()(int value) const {
return value + 1;
}
};
int main() {
auto lambda = [](int value) { return value - 3; };
std::cout << std::invoke(twice, 5) << '\n';
std::cout << std::invoke(PlusOne{}, 5) << '\n';
std::cout << std::invoke(lambda, 5) << '\n';
}
成员指针进入 std::invoke 时,第一个实参被解释为接收者。对于成员函数指针,后续实参进入成员函数参数列表;对于成员数据指针,调用表达式只需要接收者,结果是成员访问。
#include <functional>
#include <iostream>
struct Job {
int base = 10;
int score(int bonus) const {
return base + bonus;
}
};
int main() {
Job job{20};
std::cout << std::invoke(&Job::score, job, 3) << '\n'; // (job.*pmf)(3)
std::cout << std::invoke(&Job::score, &job, 4) << '\n'; // (ptr->*pmf)(4)
std::cout << std::invoke(&Job::base, job) << '\n'; // job.*pmd
}
这个统一入口对模板代码尤其关键。模板作者面对的 F 可能是 lambda,也可能是成员指针。直接写 f(args...) 只能覆盖普通可调用对象;写 std::invoke(std::forward<F>(f), std::forward<Args>(args)...) 可以把成员指针也纳入同一条路径。返回类型可用 std::invoke_result_t<F, Args...> 表示,可调用性可用 std::is_invocable_v<F, Args...> 判断,异常承诺可用 std::is_nothrow_invocable_v<F, Args...> 表达。
#include <functional>
#include <type_traits>
#include <utility>
template<class F, class... Args>
decltype(auto) call_once(F&& f, Args&&... args)
noexcept(std::is_nothrow_invocable_v<F, Args...>)
{
static_assert(std::is_invocable_v<F, Args...>);
return std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}
call_once 展示了 callable 统一模型的基本骨架:用 trait 检查表达式是否成立,用 std::invoke 执行表达式,用 decltype(auto) 保留返回值类别,用 noexcept 让包装器的异常承诺跟随底层调用。这个模式会在 traits、concepts、ranges 和泛型算法中反复出现。
36.5 成员函数调用统一化
成员函数调用统一化的关键在于把“成员指针”和“接收者对象”拆开看。成员函数指针本身不保存对象,它只保存某个类中成员函数的位置语义;真正调用时,第一个实参必须提供可访问该成员的对象、对象指针、引用包装器或可解引用句柄。
下面的例子把同一个成员函数指针应用到不同接收者形态上。代码的目标是观察接收者变化时,目标成员函数本身保持一致。
#include <functional>
#include <iostream>
#include <memory>
struct Job {
int base = 10;
int score(int bonus) const {
return base + bonus;
}
};
int main() {
auto pmf = &Job::score;
Job job{10};
auto ref = std::ref(job);
auto ptr = &job;
auto owned = std::make_unique<Job>(Job{30});
std::cout << std::invoke(pmf, job, 1) << '\n'; // object
std::cout << std::invoke(pmf, ref, 2) << '\n'; // reference_wrapper
std::cout << std::invoke(pmf, ptr, 3) << '\n'; // pointer
std::cout << std::invoke(pmf, owned, 4) << '\n'; // smart pointer through dereference
}
统一化并不放宽成员函数自身的类型规则。const 成员函数可以在 const 对象上调用,非 const 成员函数需要可修改接收者,带有 ref-qualifier 的成员函数还会要求接收者的值类别匹配。std::invoke 负责选择正确调用语法,底层成员函数类型仍然决定最终表达式是否成立。
#include <functional>
#include <utility>
struct Counter {
int value = 0;
int get() const {
return value;
}
void add(int delta) {
value += delta;
}
int finish() && {
return value;
}
};
int main() {
Counter counter{1};
const Counter frozen{2};
std::invoke(&Counter::get, frozen);
std::invoke(&Counter::add, counter, 3);
std::invoke(&Counter::finish, Counter{4});
// std::invoke(&Counter::add, frozen, 3); // const 接收者不匹配
// std::invoke(&Counter::finish, counter); // 需要右值接收者
}
这段代码说明了成员函数统一调用的检查顺序。第一步看成员指针指向成员函数还是成员数据;第二步看第一个实参是否能提供目标类对象;第三步看 const、volatile、引用限定和值类别是否匹配;第四步看后续实参数量和类型是否能传入成员函数。这个顺序比直接记忆 .*、->* 和包装器细节更稳定。
std::bind 和 std::mem_fn 最终也会回到这条路径。std::bind(&Job::score, _1, 5) 保存成员函数指针和 placeholder,调用时让 _1 供应接收者;std::mem_fn(&Job::score) 保存成员函数指针,调用时把第一个实参作为接收者。两者表面 API 不同,成员函数调用的标准语义同源。
36.6 callable 统一模型
callable 统一模型由三个层次组成:表达式层负责“这次调用怎么写”,trait 层负责“这次调用是否成立以及结果类型是什么”,包装器层负责“是否需要把调用配置保存成对象”。std::invoke 位于表达式层,std::is_invocable / std::invoke_result 位于 trait 层,std::bind、std::mem_fn、std::function 和 lambda 位于包装器或适配层。
泛型算法最需要这套模型。算法通常只关心“给定一组实参能否调用”,并且希望普通函数、函数对象、lambda 和成员指针都能工作。下面的 project_one 接收一个投影器和一个对象,把投影器应用到对象上。
#include <functional>
#include <type_traits>
#include <utility>
template<class Proj, class T>
requires std::is_invocable_v<Proj, T>
decltype(auto) project_one(Proj&& proj, T&& value)
{
return std::invoke(std::forward<Proj>(proj), std::forward<T>(value));
}
project_one(&Job::base, job) 可以访问成员数据,project_one([](const Job& j) { return j.base; }, job) 可以调用 lambda,project_one(std::mem_fn(&Job::score), job) 需要补齐 score 的额外参数,因此这个调用形态不成立。这个结果展示了 trait 的价值:它检查完整调用表达式,检查范围包含目标对象、接收者、额外参数、返回类型和异常属性。
#include <functional>
#include <iostream>
#include <type_traits>
#include <utility>
struct Job {
int base = 10;
int score(int bonus) const {
return base + bonus;
}
};
template<class F, class... Args>
requires std::is_invocable_v<F, Args...>
decltype(auto) call_checked(F&& f, Args&&... args)
{
return std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}
int main() {
Job job{20};
std::cout << call_checked(&Job::base, job) << '\n';
std::cout << call_checked(&Job::score, job, 5) << '\n';
std::cout << call_checked([](int x) { return x * 2; }, 7) << '\n';
}
在 C++20 及之后,std::invocable concept 可以替代 std::is_invocable_v 写进模板约束;在 C++17 中,std::is_invocable_v 常配合 std::enable_if_t、if constexpr 或 static_assert 使用。版本边界应写清:std::bind、std::placeholders 和 std::mem_fn 来自 C++11,std::invoke 与 std::is_invocable 系列进入 C++17,concept 形式的约束进入 C++20,std::invoke_r 进入 C++23。
工程判断顺序可以固定为五步。先判断是否需要保存调用配置;需要保存时在 lambda、std::bind、std::mem_fn、std::function 中选包装器。再判断是否需要类型擦除;需要统一运行时类型时使用 std::function 或相关包装器。随后判断目标是否可能是成员指针;模板代码默认用 std::invoke。接着用 std::is_invocable 或 concept 检查可调用性。最后把异常承诺、返回类型和值类别跟随底层调用表达式传递出去。
36.7 为什么现代 C++ 更推荐 lambda
现代 C++ 更推荐 lambda 的原因在于它把捕获状态、参数列表、返回表达式和局部控制逻辑放在同一个语法结构里。读者看到 lambda 时,可以直接从捕获列表判断对象所有权,从参数列表判断调用接口,从函数体判断实参如何进入目标调用。bind expression 把这些信息分散在目标函数、绑定列表、placeholder 和当次调用实参之间,阅读时需要额外还原。
把前面的 std::bind 例子改写成 lambda,差异非常直接。lambda 版本显式命名了参数,也显式丢弃了第三个参数。
#include <functional>
#include <iostream>
int combine(int left, int middle, int right) {
return left * 100 + middle * 10 + right;
}
int main() {
using namespace std::placeholders;
auto by_bind = std::bind(combine, _2, 7, _1);
auto by_lambda = [](int first, int second, int extra) {
static_cast<void>(extra);
return combine(second, 7, first);
};
std::cout << by_bind(3, 4, 99) << '\n';
std::cout << by_lambda(3, 4, 99) << '\n';
}
lambda 对资源捕获也更清晰。std::bind 的普通参数默认按衰变后的对象保存,需要引用语义时使用 std::ref;lambda 直接在捕获列表中写 [value]、[&value] 或 C++14 起支持的初始化捕获。涉及 move-only 对象时,初始化捕获能表达所有权转移,并让后续调用体清楚显示对象如何被使用。
#include <iostream>
#include <memory>
struct Job {
int base = 10;
};
int main() {
auto owned = std::make_unique<Job>(Job{42});
auto reader = [job = std::move(owned)]() {
return job->base;
};
std::cout << reader() << '\n';
}
std::bind 仍然有明确位置。它适合维护旧代码、接入需要 bind expression trait 的接口、快速做固定参数和位置重排,或在少量无状态适配中减少样板代码。std::mem_fn 适合把成员指针直接交给算法或统一包装器,尤其在成员指针来自模板参数或类型萃取结果时很方便。std::invoke 的位置更底层:泛型库代码应优先使用它表达“按标准 INVOKE 规则调用”。
工程选型可以压缩成一句判断:业务代码写适配器时优先 lambda,成员指针短适配可用 std::mem_fn,旧接口或简单重排可保留 std::bind,模板库内部统一调用时使用 std::invoke 配合可调用性 trait。这样区分后,四个工具不会互相抢位置:lambda 负责可读的局部适配,bind 负责对象化参数绑定,mem_fn 负责成员指针对象化,invoke 负责标准调用规则。
最小自检任务
阅读下面代码,判断每个调用最终对应哪一次底层调用,并说明哪些对象在创建包装器时被保存,哪些对象在调用时才提供。
#include <functional>
#include <iostream>
struct Job {
int base = 10;
int score(int bonus) const {
return base + bonus;
}
};
int combine(int left, int middle, int right) {
return left * 100 + middle * 10 + right;
}
int main() {
using namespace std::placeholders;
Job job{20};
auto a = std::bind(combine, _2, 6, _1);
auto b = std::bind(&Job::score, _1, 5);
auto c = std::mem_fn(&Job::base);
std::cout << a(3, 4, 99) << '\n';
std::cout << b(job) << '\n';
std::cout << c(job) << '\n';
std::cout << std::invoke(&Job::score, job, 7) << '\n';
}
答案要点
a 创建时保存目标函数 combine,并保存 _2、普通值 6、_1 三个绑定槽位;a(3, 4, 99) 调用时 _2 映射到 4,_1 映射到 3,99 求值后丢弃,底层调用为 combine(4, 6, 3),输出 463。
b 创建时保存成员函数指针 &Job::score,保存 _1 作为未来接收者,保存普通值 5 作为成员函数参数;b(job) 调用时 job 作为接收者,底层调用为 (job.*&Job::score)(5),输出 25。
c 创建时只保存成员数据指针 &Job::base;c(job) 调用时 job 才提供接收者,底层效果是访问 job.base,输出 20。
std::invoke(&Job::score, job, 7) 没有创建长期包装器,它在当前表达式中直接按 INVOKE 规则调用成员函数指针,job 是接收者,7 是成员函数参数,输出 27。
本章知识点总结
- bind 保存:
std::bind把目标可调用对象和绑定参数保存进一个未指定类型的函数对象。 - 参数衰变:普通绑定参数默认按衰变后的对象保存,引用语义需要通过
std::ref或std::cref表达。 - 延迟调用:bind expression 被调用时才把保存槽位和当次实参合成一次 INVOKE 调用。
- placeholder 映射:
_1、_2等 placeholder 按当次调用实参位置取值,并送入绑定列表指定的目标参数槽位。 - 额外实参:未被 placeholder 选中的调用实参仍会求值,随后不进入底层目标调用。
- 重复 placeholder:重复 placeholder 会把同一个未来实参送入多个参数位置,所有权转移场景应改用更显式的 lambda。
- mem_fn 包装:
std::mem_fn保存成员指针,并把第一个调用实参解释为成员访问的接收者。 - 成员数据访问:
std::mem_fn(&T::member)调用结果对应成员数据访问,可修改性由接收者类型决定。 - invoke 入口:
std::invoke把普通调用、函数对象调用、成员函数指针调用和成员数据指针访问统一到一套标准规则下。 - 接收者规则:成员指针调用时第一个实参提供对象、指针、引用包装器或可解引用句柄,成员函数自身的 cv 和引用限定仍然生效。
- trait 检查:
std::is_invocable、std::invoke_result和std::is_nothrow_invocable能把 callable 判断写进模板接口。 - 版本边界:
bind、placeholders、mem_fn属于 C++11 工具,invoke与 invocable trait 属于 C++17 统一调用基础。 - lambda 选型:业务适配代码优先使用 lambda,因为捕获、参数和调用逻辑集中在同一个语法结构中。
- 工具分层:lambda 负责局部适配,
bind负责参数绑定对象化,mem_fn负责成员指针对象化,invoke负责泛型库统一调用。