1. 介绍
C++ 包装器是一种用于给其他编程接口提供更一致或更合适的接口的技术。它可以包装任何类型的可调用实体,如普通函数,函数对象,lambda表达式、类的成员函数等。C++ 包装器有多种类型,如bind, mem_fn, reference_wrapper, function等。
包装器的本质是一种类模板,它可以提高模板的效率,简化代码,增强可读性和可维护性。
下面介绍C++中的两种包装器:function包装器和bind包装器。
2. function包装器
2.1 介绍
function类模板的原型:
template <class T> function; // undefined template <class Ret, class... Args> class function<Ret(Args...)>;
其中:
Ret
:被包装的可调用对象的返回值类型。Args...
:被包装的可调用对象的形参类型。
2.2 示例1
下面用function包装器对函数、函数对象、lambda表达式进行包装。
#include <iostream> using namespace std; // 简单函数 int add(int a, int b) { return a + b; } // 函数对象 struct subtract { int operator()(int a, int b) { return a - b; } }; int main() { function<int(int, int)> op; // 创建函数对象 op = add; // 简单函数 cout << "op(10, 5) = " << op(10, 5) << endl; // prints 15 op = subtract(); // 函数对象 cout << "op(10, 5) = " << op(10, 5) << endl; // prints 5 op = [](int a, int b) { return a * b; }; // lambda表达式 cout << "op(10, 5) = " << op(10, 5) << endl; // prints 50 return 0; }
输出:
op(10, 5) = 15 op(10, 5) = 5 op(10, 5) = 50
用法
如function<int(int, int)> op
:
<int(...)>
,表示返回值类型是int型;<...(int, int)>
,表示形参类型都是int型;function<int(int, int)> op
,表示创建一个包装器对象
此时包装器还未初始化,如果将函数、函数对象、lambda表达式等赋值给包装器对象op,那么就相当于把函数、函数对象、lambda表达式等装进了一个名为op的盒子,通过这个盒子就能以一个统一的方式使用函数、函数对象、lambda表达式等。
这个统一的方式就是像普通函数一样调用、传参、取返回值。当然,在创建包装器对象的同时也可以初始化。
2.3 示例2
除此之外,包装器还能接受类的成员函数(指针):
class Cpt { public: static int add(int a, int b) { return a + b; } double subtract(double a, double b) { return a - b; } }; int main() { function<int(int, int)> op1 = &Cpt::add; // 静态成员 cout << op1(1, 2) << endl; function<double(Cpt, double, double)> op2 = &Cpt::subtract; // 非静态成员 cout << op2(Cpt(), 1, 2) << endl; return 0; }
输出:
3 -1
成员函数指针是一种指向类的非静态成员函数的指针。它的类型声明需要加上类名
注意静态成员函数和非静态成员函数的使用方式:
- 静态成员函数:取出静态成员函数的地址时,需要通过类名,但
&
不是必须的; - 非静态成员函数:取出非静态成员函数的地址时,需要通过类名,但
&
是必须的。非静态成员函数的第一个参数是this指针(它是隐藏的),因此在包装时需要指明第一个形参的类型为类的类型。
2.4 function包装器的功能
统一类型
下面定义一个函数模板:
template<class F, class T> T useF(F f, T x) { static int count = 0; cout << "count: " << ++count << endl; cout << "&count: " << &count << endl; return f(x); }
其中:
F
:表示任意的可调用对象,比如函数指针、仿函数、lambda表达式等;T
:表示(包装器)传入的参数。
在函数模板内部定义了静态变量count,通过打印它的地址来验证每次调用的是否是同一个函数。
useF函数会有几份取决于编译器如何实例化模板函数。实例化是指编译器根据模板函数和具体的类型参数生成一个特定的函数。一般来说,每种类型参数都会生成一个不同的实例,所以如果你用了三种类型参数,就会有三份useF函数。
// 普通函数 double f(double d) { return d / 2; } // 函数对象 struct Functor { double operator()(double d) { return d / 4; } }; // lambda表达式 auto l = [](double d) { return d / 8; }; int main() { cout << useF(f, 12.12) << endl; // 函数指针 cout << useF(Functor(), 12.12) << endl; // 函数对象 cout << useF(l, 12.12) << endl; // lambda表达式 return 0; }
输出:
count: 1 &count: 0x1028b4000 6.06 count: 1 &count: 0x1028b4004 3.03 count: 1 &count: 0x1028b400c 1.515
结果表明,编译器通过不同的模板参数实例化出了3份不同的函数。因为代码中使用了函数模板useF,并且用三种不同类型的参数(函数指针、函数对象和lambda表达式)调用了它。这意味着编译器会为每种参数类型生成一个useF的特化版本。
模板实例化是从一个模板声明和一个或多个模板参数创建一个函数、类或类成员的新定义的过程。创建的定义称为特化。
然而,我们使用包装器的目的是能以一种统一的方式使用函数指针、函数对象和lambda表达式等功能。三次调用useF函数时传入的可调用对象虽然是不同类型的,但这三个可调用对象的返回值和形参类型都是相同的,显然这会对效率造成一定影响。
如果想让编译器只实现一份useF,从而调用函数指针、函数对象和lambda表达式,你可以使用显式实例化。显式实例化是指在代码中明确地指定要生成哪些类型的模板函数,而不是让编译器自动推断。这样可以避免重复或冗余的实例化,并提高编译效率。
例如,在useF函数定义后加上这样一行代码:
template double useF<double(*)(double), double>(double(*)(double), double);
这就告诉编译器只生成一个接受double类型的函数指针和double类型的值作为参数的useF函数,这样就会强制编译器只生成一个useF的特化版本,并且只接受相同类型的参数。然后你就可以用这个函数来调用函数指针、函数对象和lambda表达式了,因为它们都符合这个类型签名。
不过,可以使用包装器,将它们都放进一个盒子里,然后通过这个唯一的盒子实例化出唯一的函数。
int main() { function<double(double)> op; // 实例化包装器 op = f; // 函数指针 cout << useF(op, 8.88) << endl; op = Functor(); // 函数对象 cout << useF(op, 8.88) << endl; op = l; // lambda表达式 cout << useF(op, 8.88) << endl; return 0; }
输出:
count: 1 &count: 0x10011c004 4.44 count: 2 &count: 0x10011c004 2.22 count: 3 &count: 0x10011c004 1.11
由于useF的第一个参数的类型都是function类型的,所以只实例化出一个函数。
简化代码
function包装器可以简化代码,因为它们可以让用户使用不同类型的可调用目标作为参数或返回值,而不需要显式地转换或重载。它们还可以让用户绑定一些参数或修改调用顺序,从而创建自定义的函数对象。
例如,逆波兰表达式求值
使用栈和Switch语句可以解决,但是这样会有局限性:增加新的运算也要增加新的选择分支,且分支都取决于根据运算方式。考虑使用包装器简化代码:
- 建立运算符与其动作(通过函数实现)之间的映射关系,当需要执行某一运算时就可以直接通过运算符匹配相应函数。
- 当运算类型增加时,就只需要建立新增运算符与其对应函数之间的映射关系即可。
class Solution { public: int evalRPN(vector<string>& tokens) { stack<int> st; map<string, function<long long(long long, long long)>> opMap = { { "+", [](long long a, long long b){return a + b; } }, { "-", [](long long a, long long b){return a - b; } }, { "*", [](long long a, long long b){return a * b; } }, { "/", [](long long a, long long b){return a / b; } } }; for (const auto& str : tokens) { long long left = 0, right = 0; if (str == "+" || str == "-" || str == "*" || str == "/") { right = st.top(); st.pop(); left = st.top(); st.pop(); st.push(opMap[str](left, right)); } else { st.push(stoi(str)); } } return st.top(); } };
2.5 意义
function包装器是一种特殊的类模板,它可以包装一个可调用的目标,并提供一个统一的接口(明确了可调用对象的返回值和形参类型)。它的意义在于可以让你使用不同类型的函数或函数对象,而不需要关心它们的具体实现或参数类型。它也可以让你更容易地复用和组合现有的函数或函数对象。
3. bind包装器
bind包装器是一种函数对象,它可以将一个可调用对象和一些参数绑定在一起,形成一个新的可调用对象,本质是一个函数模板。可以使用std::bind或std::bind_front和std::bind_back来创建bind包装器。
3.1 介绍
bind函数模板的原型:
template <class Fn, class... Args> /* unspecified */ bind(Fn&& fn, Args&&... args); template <class Ret, class Fn, class... Args> /* unspecified */ bind(Fn&& fn, Args&&... args);
其中:
fn
:可调用对象。args...
:要绑定的参数列表:值或占位符。
形式:auto newCallable = bind(callable, arg_list);
其中:
callable
:需要包装的可调用对象。newCallable
:生成的新的可调用对象。arg_list
:逗号分隔的参数列表,对应给定的callable的参数。当调用newCallable时,newCallable会调用callable,并传给它arg_list中的参数。
占位符:参数列表中可能有形如_1
、_2
…_n
这样的参数,这些参数被称为占位符。
占位符是一种特殊的对象,它们可以用来表示bind包装器中的可变参数。它们的类型是不确定的,但是它们都可以被默认构造和复制。
当你创建一个bind包装器时,你可以使用占位符来指定哪些参数会在调用时被传递给被绑定的函数对象。占位符的顺序决定了参数的顺序。例如,
auto add5 = bind(add, 5, std::placeholders::_1);
中,第一个参数是固定为5,而第二个参数是占位符_1,表示它会被调用时传入的第一个参数替换。所以add5(10)相当于add(5, 10)。你可以使用不同编号的占位符来改变参数的顺序或者重复使用某些参数。例如,
auto mul = bind(std::multiplies<int>(), std::placeholders::_1, std::placeholders::_2);
中,两个占位符_1和_2分别对应调用时传入的第一个和第二个参数。所以mul(3, 4)相当于std::multiplies()(3, 4)。但是如果你写成
auto mul = bind(std::multiplies<int>(), std::placeholders::_2, std::placeholders::_1);
那么两个占位符就交换了位置,所以mul(3, 4)相当于std::multiplies()(4, 3)。还有一种情况是你可以使用同一个占位符多次来重复使用某个参数。例如,
auto square = bind(std::multiplies<int>(), std::placeholders::_1, std::placeholders::_1);
中,两个占位符都是_1,表示它们都会被调用时传入的第一个参数替换。所以square(5)相当于std::multiplies()(5, 5)。
即占位符表示newCallable的参数占据了传递给newCallable的参数的位置。数值n表示生成的可调用对象中参数的位置,例如_1为newCallable的第一个参数,_2为第二个参数,以此类推。
包装后的可调用对象的类型可以用auto接收,也可以用function类型指明返回值和形参类型后接收。
3.2 bind包装器的功能
绑定固定参数
下面以函数和参数绑定为例:
#include <iostream> using namespace std; int add(int a, int b) { return a + b; } int subtract(int a, int b) { return a - b; } int main() { function<int(int, int)> f1 = bind(add, placeholders::_1, placeholders::_2); cout << f1(1, 2) << endl; function<int(int)> f2 = bind(add, placeholders::_1, 2); cout << f2(1) << endl; function<int(int)> f3= bind(add, 1, placeholders::_1); cout << f3(2) << endl; function<int(int, int)> f4= bind(subtract, placeholders::_2, placeholders::_1); cout << f4(2, 1) << endl; return 0; }
输出:
3 3 3 -1
其中:
- f1是无意义的绑定,因为这和直接调用函数传参没有区别。
- f2和f3能提现占位符的作用:占位符
_n
中的n
就是包装器中的参数传入绑定的对象的顺序。 - f4说明了占位符可以不必按照顺序填写(注意
_2
在_1
之前,传入的参数是2,1
,结果是(1-2=)-1)。然而这种特性并不常用。
3.3 意义
bind包装器的意义是用来绑定函数调用的某些参数,将可调用对象保存起来,然后在需要的时候再调用。它可以支持普通函数、函数对象和成员函数,并且可以使用占位符来灵活地指定参数。
bind包装器最大的作用就是将某些固定的参数和可调用对象绑定在一起,也可以在某些情况下赋予可调用对象以默认值。