 万能容器:std::function 如何终结 C++ 回调接口的混乱?)
目录一包装器——functionbind1.1function1.1.1function的概念1.1.2function的使用1.1.3 function在成员函数中的使用Tips为什么成员函数必须显式使用 取地址1. 消除“调用”与“取地址”的歧义2. 成员函数名并不代表真实地址1.1.4function的真正强大之处1.1.4.1古人的做法1.1.4.2现代人的做法——集C11各特性之大成1.1.5 std::function 底层的真相多态包装与类型擦除1.1.5.1仿函数本质与统一接口1.1.5.2底层逻辑从“原料”到“调用”1.1.5.3严谨的调用模型 INVOKE 语义2.2bind绑定2.2.1bind的概念2.2.2bind的使用Tips命名空间placeholders与通用占位符定义期 (bind)调用期 (Executing)2.2.3bind的作用2.2.3.1优化function的调用逻辑2.2.3.2年化利计算器场景一包装器——functionbindC包装器是一种通过封装现有底层接口来提供更高级、安全或符合惯用语的类 或函数。简化复杂调用从而提升代码的可维护性与易用性。1.1function1.1.1function的概念std::function 是一个类模板也是一个包装器。std::function 的实例对象可以包装存储其他的可以调用对象包括函数指针、仿函数、lambda、bind 表达式等。存储的可调用对象被称为std::function 的目标。若 std::function 不含目标则称它为空。调用空 std::function 的目标导致抛出std::bad_function_call 异常。template class T class function; // undefined template class Ret, class... Args class functionRet(Args...);以上是 function 的原型不必理会后面会怎么用就行它被定义在functional 头文件中。std::function - cppreference.com 是 function 的官方文件链接。函数指针、仿函数、lambda 等可调用对象的类型各不相同std::function 的优势就是统一类型对他们都可以进行包装这样在很多地方就方便声明可调用对象的类型。——概念看不懂我们用一用就知道什么回事了。——1.1.2function的使用如下一个全局函数包装进function,这个全局函数的返回值和参数类型使得我们的function就必须按照这种规矩来书写functionint(int, int)创建它的实例然后把函数f给它这里会执行一个隐式的函数退化成函数指针的行为是C继承自C的特性int f(int a, int b){ return a b; } functionint(int, int) f1 f;//包装c函数于是乎如同使用f一样使用被包装的f1就变得可行。但是这里我不得不提醒大家的是包装函数的时候参数类型、参数个数、返回值类型都必须紧紧依着被包装的函数的样子。coutf1(1,2);抑或是如下的结构体包装方法相同无非是把函数变成了结构对象。这里就不多赘述了。struct Functor{ public: int operator()(int a, int b){ return a b; } }; functionint(int, int) f2 Functor();//包装结构体类//lambda也可以被包装 functionint(int, int) f3 [](int a, int b) {return a b; };1.1.3 function在成员函数中的使用我必须着重强调的地方来了成员函数的包装并不通于一般的包装那样因为它有this指针。如果将成员函数按照有无this指针分成两类静态成员函数。普通成员函数。首先静态成员函数没有this指针也就不需要担心那么多。functionint(int, int) f4 Plus::plusi;如同全局函数那样直接包装。#includefunctional class Plus{ public: Plus(int n 10) :_n(n){} static int plusi(int a, int b){ return a b; } private: int _n; }; int main(){ // 包装静态成员函数 // 成员函数要指定类域并且前面加才能获取地址 functionint(int, int) f4 Plus::plusi; cout f4(1, 1) endl; }但是成员函数的包装就不一样了首先必须取得地址有人可能要问了为什么成员函数要取地址而全局和静态成员函数不需要取地址Tips为什么成员函数必须显式使用取地址在 C 中全局函数可以省略但成员函数非静态必须写成ClassName::FunctionName。这背后的设计逻辑主要基于以下两点1. 消除“调用”与“取地址”的歧义在类域内符号的含义非常复杂。对于全局函数f在 C 语言习惯中如果不带括号几乎没有其他用途。因此为了兼容 C编译器默认当你只写f时就是想要获取它的地址即隐式退化为指针。对于成员函数假设类里有一个成员函数func。如果你只写func在 C 的解析规则里这可能被识别为一个合法的表达式或占位符。安全锁机制为了防止开发者手抖误写C 标准委员会ISO C规定获取成员函数地址必须显式使用且必须带上类名如Test::func。这种“繁琐”的设计实际上是一种强类型保护。2. 成员函数名并不代表真实地址这是最底层的技术原因。全局函数名在编译后它直接对应代码段Code Segment里的一个固定内存地址。成员函数名它在类定义中更像是一个偏移量符号而非简单的地址。*依赖性非静态成员函数必须依赖this指针才能工作。*虚函数挑战如果是虚函数它在编译期甚至没有固定地址它对应的是**虚函数表vtable**里的一个索引Index。当你在代码中写下Test::func时编译器并不是简单地返还一个内存编号而是生成一个复杂的**“成员函数指针结构体”**。这个结构体里记录了该函数是否为虚函数函数的内存偏移量是多少是否需要调整this指针结论如果不加编译器就无法触发这个复杂的“构建过程”也无法确定你到底是要引用这个符号还是获取这个复杂的指针结构。类型隐式退化 (不加)物理本质语法要求全局函数允许指向代码段的直接指针f或f均可静态成员函数允许指向代码段的直接指针T::f或T::f均可普通成员函数禁止包含偏移量/虚表索引的结构体必须使用T::f于是回到刚才的代码这里就必须使用指针包装。此外由于成员函数参数列表首个参数是this指针在包装的时候必须显示的留一个位置这个位置需要传递一个对象的指针、引用、或者是值传递。以便function形如调用成员函数一样被调用。funcion内部的伪代码return (arg1-*method_ptr)(arg2, arg3);#includefunctional class Plus{ public: Plus(int n 10) :_n(n){} double plusd(double a, double b){ return (a b) * _n; } private: int _n; }; int main(){ functiondouble(Plus*, double, double) f5 Plus::plusd; Plus pd; cout f5(pd, 1.1, 1.1) endl; functiondouble(Plus, double, double) f6 Plus::plusd; cout f6(pd, 1.1, 1.1) endl; cout f6(pd, 1.1, 1.1) endl; functiondouble(Plus, double, double) f7 Plus::plusd; cout f7(move(pd), 1.1, 1.1) endl;//传递一个右值 cout f7(Plus(), 1.1, 1.1) endl;//传递匿名对象只限制右值引用因为匿名对象是右值 }1.1.4function的真正强大之处如果你读到这里还是对function的作用感到模糊那么接下来这里例子一定能为你拨开云雾。逆波兰表达式求值-力扣逆波兰式Reverse Polish NotationRPN或逆波兰记法也叫后缀表达式将运算符写在操作数之后。简单讲一下这道题的思路我们遍历这样一个string倘若遍历到数字便入栈。倘若遍历到操作符便执行“出栈两个数字”“计算”“结果再入栈”。以此类推。于是不同时期的两代人就有了两种做法1.1.4.1古人的做法class Solution { public: int evalRPN(vectorstring tokens) { stackint st; // 核心容器利用栈“先进后出”的特性处理后缀表达式 for (auto str : tokens) { // 第一步判定当前字符串是“运算符”还是“数字” if (str || str - || str * || str /) { // 第二步如果是运算符从栈中取出两个操作数 // 注意先弹出的是右操作数后弹出的是左操作数减法和除法顺序很重要 int right st.top(); st.pop(); int left st.top(); st.pop(); // 第三步古法核心——使用 switch-case 硬编码运算逻辑 // 这种做法的缺点是每增加一个运算符就得改一次这里的 case 逻辑耦合度极高 switch (str[0]) { case : st.push(left right); break; case -: st.push(left - right); break; case *: st.push(left * right); break; case /: st.push(left / right); break; } } else { // 第四步如果是数字字符串直接转换成整型并入栈 st.push(stoi(str)); } } // 最后栈底剩下的唯一一个数字就是最终计算结果 return st.top(); } };1.1.4.2现代人的做法——集C11各特性之大成上面的 switch-case 做法在工程实践中显得非常臃肿。首先它将控制流如何解析 tokens与运算逻辑加减乘除强行耦合在一起其次维护性较差——每增加一种新的运算符如取模 % 或幂运算 ^都必须深入函数内部修改代码。下面的代码则更加优雅。首先我们需要用一个map创建映射关系。map使用string进行查找value是一个包装器而包装器的背后是一个个lambda。根据传递过来的string是/-/*还是/去找到我应该调用什么样的lambda来完成运算。主要逻辑遍历字符串 token如果是数字则入栈如果是运算符则去map中寻找映射的lambda并对出栈的数字进行运算返回值再次入栈。直到运行结束——栈顶的数据自然是最终结果。值得一提的是这个map集成了我们前面讲的initializer list初始化function包装器lambda三大C11新特性。对于初学者来说是个不错的练手。class Solution { public: int evalRPN(vectorstring tokens) { stackint st; // function 作为 map 的映射值可实现高度的类型统一 mapstring, functionint(int, int) opFuncMap { {, [](int x, int y) { return x y; }}, {-, [](int x, int y) { return x - y; }}, {*, [](int x, int y) { return x * y; }}, {/, [](int x, int y) { return x / y; }} }; for (auto str : tokens) { if (opFuncMap.count(str)) // 操作符 { int right st.top(); st.pop(); int left st.top(); st.pop(); int ret opFuncMap[str](left, right); st.push(ret); } else { st.push(stoi(str)); } } return st.top(); } };我忍不住想要再多夸几句这种运用方式在网络编程中非常常见。他需要进行命令-操作的映射。数量多了是这个更好而不是说——我去写几百个case语句如果你想要追加更多的运算符只需要再map中写下你所想要的即可就像这样大大提升了可维护性 mapstring, functionint(int, int) opFuncMap {{“”, [](int x, int y) { return x y; }},{“-”, [](int x, int y) { return x - y; }},{“*”, [](int x, int y) { return x * y; }},{“/”, [](int x, int y) { return x / y; }},{“”, [](int x, int y) { return x y; }},{“|”, [](int x, int y) { return x | y; }},{“^”, [](int x, int y) { return x ^ y; }}};1.1.5 std::function 底层的真相多态包装与类型擦除如果你读到这里恭喜你已经掌握了std::function的核心用法与工程实践。但你可能仍有一个深层疑惑std::function为何如此“全能”它底层究竟是如何统一调用不同形态指针、引用、值的函数的1.1.5.1仿函数本质与统一接口实际上std::function本质上是一个模板类仿函数。它的核心是一组重载的operator()。当我们调用包装器对象f(args...)时本质上是在触发这个类内部的成员函数。1.1.5.2底层逻辑从“原料”到“调用”operator()内部是如何实现调用的呢答案在于一个隐藏的中间层std::function内部维护了一个调用器指针通常称为invoker这个指针会指向一个根据包装类型动态生成的执行逻辑。当你将Plus::plusd赋值给它时它会根据你定义的第一个参数类型在底层生成对应的调用指令。1.1.5.3严谨的调用模型 INVOKE 语义根据 C 标准定义的INVOKE规约底层通过以下逻辑将你传递的“锚点对象”转换为真正的this指针并执行调用1.以值或引用传递对象Plus或Plus底层通过成员指针运算符.*实现(pd.*pf)(args...)注如果是值传递pd是包装器内部管理的副本如果是引用传递pd则是外部原始对象的别名。以指针传递对象Plus*底层通过间接成员指针运算符-*实现(pd-*pf)(args...)注这里不需要手动解引用*pd编译器通过-*语法直接完成了从对象地址到成员函数的寻址调用。总结std::function的强大在于它抹平了“对象如何提供this指针”的差异。无论你提供的是地址、引用还是拷贝它都能在底层通过.*或-*准确地把成员函数挂载到正确的内存对象上。2.2bind绑定2.2.1bind的概念1 simple(1) 2 template class Fn, class... Args 3 /* unspecified */ bind (Fn fn, Args... args); 4 5 with return type (2) 6 template class Ret, class Fn, class... Args 7 /* unspecified */ bind (Fn fn, Args... args);bind 是一个函数模板它也是一个可调用对象的包装器可以把它看做一个函数适配器对接收的fn 可调用对象进行处理后返回一个可调用对象。bind 可以用来调整参数个数和参数顺序。bind 也在 functional 这个头文件 中。调用 bind 的一般形式auto newCallable bind(callable, arg_list);其中 newCallable 本身是一个可调用对象arg_list 是一个逗号分隔的参数列表对应给定的 callable 的参数。当我们调用 newCallable 时newCallable 会调用 callable并传给它 arg_list 中的参数。arg_list 中的参数可能包含形如 _n 的名字其中 n 是一个整数这些参数是占位符表示 newCallable 的参数它们占据了传递给 newCallable 的参数的位置。数值 n 表示生成的可调用对象中参数的位置_1 为 newCallable 的第一个参数_2 为第二个参数以此类推。_1/_2/_3…这些占位符放到 placeholders 的一个命名空间中。这样讲实在有些晦涩难懂我们用一用就知道bind是什么回事了2.2.2bind的使用Tips命名空间placeholders与通用占位符C库中专门为bind设置了一个命名空间placeholders里面存放了诸如_1,_2,_3这些占位符。当我们在使用bind需要用到占位符的时候只需要using拆开来调用即可。#include functional #include iostream using namespace std; using namespace std::placeholders; // 测试函数 int Sub(int a, int b) { return (a - b) * 10; } int SubX(int a, int b, int c) { return (a - b - c) * 10; } int main() { // ---- 场景 A改变参数顺序 ---- // sub2(10, 5) - 这里的 10 是第一个参数(_1)5 是第二个(_2) // 但 bind 写的顺序是 (_2, _1)所以实际执行的是 Sub(5, 10) auto sub2 bind(Sub, _2, _1); cout sub2(10, 5) endl; // 结果-50 // ---- 场景 B固定参数个数最常用 ---- // 1. 固定第一个参数为 100 auto sub3 bind(Sub, 100, _1); cout sub3(5) endl; // 实际执行Sub(100, 5) - 950 // 2. 在三元函数中自由“填坑” auto sub5 bind(SubX, 100, _1, _2); // 固定第一个位置 auto sub6 bind(SubX, _1, 100, _2); // 固定中间位置 auto sub7 bind(SubX, _1, _2, 100); // 固定末尾位置 cout sub5(5, 1) endl; // SubX(100, 5, 1) - 940 }理解bind的关键在于区分定义期和调用期。定义期 (bind)这是你作为开发者“设计接口”的时候。你需要决定哪些参数是固定死的例如硬编码数字100。哪些位置是留给用户填的使用占位符_1,_2。调用期 (Executing)这是函数被真正触发的时候。传入的实参会严格按照顺序填入对应的占位符。第一个实参 → 填入所有出现 _1 的地方。第二个实参→ 填入所有出现 _2 的地方。关键认知点_1、_2 本身并不代表原函数的第几个参数而是代表新函数被调用时传入的参数序号。还是强调这里的_1_2表示的是第几个实际参数。2.2.3bind的作用2.2.3.1优化function的调用逻辑2.2.3.2年化利计算器场景固定住利率函数利率年数后用户传递金额数量即可计算得到结果