1. 可变参数模版
1.1 概念
可变参数模板允许我们定义能接受可变数目模板参数的模板。简单来说,就好比一个函数可以接受任意个数的实际参数一样,可变参数模板能应对不同数量的模板参数情况。比如,我们可以有一个模板类或者模板函数,有时候传入 3 个类型参数,有时候传入 5 个,而可变参数模板都能妥善处理。
1.2 语法
下面是一个简单的可变参数模板函数的基本语法形式:
template<typename... Args>
void myFunction(Args... args) {// 函数体逻辑
}
在这里,typename... Args
声明了一个可变参数模板参数包(parameter pack),它代表了零个或多个类型参数。而 Args... args
则是函数参数包,对应着实际传入的参数,这些参数的类型由前面的类型参数包决定。
例如,我们可以这样调用这个函数:
myFunction(1, 2.5, "hello");
在这个调用中,编译器会自动推导出 Args
分别为 int
、double
和 const char*
,args
则依次为对应类型的实际参数值。
可变参数模板类的语法也类似,以下是一个示例:
template<typename... T>
class MyTemplateClass {// 类的成员定义等相关逻辑
};
我们可以根据不同的类型参数实例化这个类,像 MyTemplateClass<int, double>
或者 MyTemplateClass<std::string, char, int>
等等。
1.3 展开
可变参数模板定义好了,关键还得知道怎么使用里面的参数,也就是要展开参数包。常用的展开方式有几种:
例如,我们想要实现一个函数来打印所有传入的参数,可以这样做:
// 递归终止函数
void printArgs() {std::cout << std::endl;
}
template<typename T>
void printArg(T arg) {std::cout << arg << " ";
}// 可变参数模板函数,用于展开参数包
template<typename T, typename... Args>
void printArgs(T first, Args... rest) {printArg(first);printArgs(rest...);
}
int main()
{printArgs("111");printArgs("111",1);printArgs("111",1,1.2);printArgs("111", 1, 1.2,'a');return 0;
}
在这个例子中,printArgs
函数不断地取出参数包中的第一个参数,调用 printArg
进行处理(这里只是简单打印),然后再递归调用自身处理剩下的参数,直到参数包为空,最终会调用 printArgs
这个无参函数,结束递归。
template<typename... Args>
void anotherPrintArgs(Args... args) {int dummy[] = { (std::cout << args << " ", 0)... };(void)dummy; // 避免编译器警告,因为我们只是利用数组初始化的副作用来展开参数包
}
这里利用了逗号表达式的特性,在初始化列表中,每个元素都是一个逗号表达式 (std::cout << args << " ", 0)
,它先执行 std::cout << args << " "
输出参数内容,然后返回 0 用于初始化数组元素,通过这种方式依次展开参数包中的所有参数进行处理。
其中使用逗号表达式还要还有一个原因就是 C++的容器存储的类型必须相同,而可变参数模版支持多种不同的类型,所以我们这里返回 0 统一处理。
最后需要强调的是,虽然我们可以通过 sizeof...
获取可变模版参数的个数,但不能通过其看看可变参数:
template<typename T, typename... Args>
void printArgs(T first,Args... rest) {cout << sizeof...(rest) << endl;//Args中参数个数错误示例://for (int i = 0; i < sizeof...(args); i++)//{// cout << args[i] << " "; //打印参数包中的每个参数//}//cout << endl;
}
当然你可能还会有这个疑惑,那就是那既然可变参数模版是通过递归实现的,那么我们的递归结束条件是不是可以以下方式表示:
template<typename T>
void printArg(T arg) {std::cout << arg << " ";
}
// 可变参数模板函数,用于展开参数包
template<typename T, typename... Args>
void printArgs(T first, Args... rest) {if (sizeof..(args) == 0)//errorreturn;printArg(first);printArgs(rest...);
}
因为函数模板的推演是编译时逻辑,需根据传入实参类型生成对应函数才能被调用,而该判断属于运行时逻辑,当编译时推演到参数包参数个数为 0 时,仍要按编译逻辑继续推演传入 0 个参数时的函数,可此时函数要求至少传入一个参数,就会产生报错,二者逻辑阶段不同所以不能这样处理。
2. emplace_back
在 C++ 中,像 std::vector
这样的标准容器,我们之前常用的向容器中添加元素的方式有 push_back
。例如对于一个存储 int
类型元素的 std::vector
:
std::vector<int> myVector;
myVector.push_back(10);
push_back
是将一个已经构造好的对象复制(如果对象所属类没有合适的移动构造函数等情况时)或者移动到容器的末尾位置来完成元素添加。但对于一些复杂类型,尤其是包含资源管理的自定义类型,这种先构造再赋值/移动的过程可能会带来不必要的开销。
而 C++11 引入了可变参数模板这一强大特性,而 emplace_back
函数充分利用了它来实现更高效、更灵活的元素添加机制。
比如说std::vector
(以及很多其他标准容器)的 emplace_back
函数被定义为可变参数模板函数,其基本语法形式大致如下(以 std::vector
为例):
template <typename... Args>
void emplace_back(Args&&... args);
这里的 Args&&... args
就是可变参数模板的参数包,其中 Args
代表了零个或多个不同的类型,args
则是对应这些类型的右值引用形式的实际参数。
例如,假设有一个自定义的类 Person
,构造函数接受 string
类型的姓名和 int
类型的年龄作为参数:
class Person {
public:Person(std::string name, int age) : m_name(std::move(name)), m_age(age) {}
private:std::string m_name;int m_age;
};
使用 emplace_back
向存储 Person
对象的 vector
中添加元素时,可以这样做:
std::vector<Person> people;
people.emplace_back("Alice", 25);
在这个调用过程中:
- 基于可变参数模板的特性,编译器会根据传入的
"Alice"
(类型为const char*
,会隐式转换为std::string
)和25
这两个参数,自动推导出Args
包含std::string
和int
这两个类型。 - 然后
emplace_back
不是像push_back
那样先构造一个Person
对象然后再复制或者移动到容器中,而是直接在容器内部(也就是vector
管理的内存空间里)利用传入的参数就地构造Person
对象。这避免了额外的构造和可能的复制/移动开销,尤其是对于那些构造过程相对复杂、开销较大的对象来说,能显著提高性能。
以下通过一个更具体的示例来对比二者的区别:
#include <iostream>
#include <vector>
#include <string>class ResourceHolder {
public:ResourceHolder() {//std::cout << "Default constructor called" << std::endl;}ResourceHolder(const ResourceHolder& other) {std::cout << "Copy constructor called" << std::endl;}ResourceHolder(ResourceHolder&& other) {std::cout << "Move constructor called" << std::endl;}~ResourceHolder() {// std::cout << "Destructor called" << std::endl;}
};int main() {// 使用push_backstd::vector<ResourceHolder> vecPush;ResourceHolder rh1;vecPush.push_back(rh1); // 这里会调用拷贝构造函数,因为 rh 是左值// 使用emplace_backstd::vector<ResourceHolder> vecEmplace;ResourceHolder rh2;vecEmplace.emplace_back(std::move(rh2)); // 直接调用默认构造函数在容器内就地构造对象return 0;
}
在上述示例中:
- 当使用
push_back
时,由于传入的是一个已经存在的左值对象rh
,所以会调用拷贝构造函数将该对象复制到vecPush
容器中,有额外的构造副本的开销。 - 而使用
emplace_back
时,直接在vecEmplace
容器内部按照需要的构造方式(这里是调用默认构造函数)就地构造对象,避免了不必要的拷贝构造过程,更加高效。
其实emplace_back
本质就是利用可变模板参数(类型为万能引用),可接收左值对象、右值对象或参数包。传入左值对象时,需先实例化左值对象,用定位新表达式初始化空间时匹配拷贝构造函数;传入右值对象时,先实例化右值对象,初始化空间时匹配移动构造函数;传入参数包可直接调用函数插入,初始化空间时匹配构造函数。
3. lambda 表达式
3.1 用法
Lambda 表达式是 C++11 引入的一项重要特性,它提供了一种简洁、灵活的方式来定义匿名函数,使得在需要使用函数对象的场景中,代码更加紧凑和易于理解。Lambda 表达式可以在需要函数的地方就地定义,而无需像传统方式那样先定义一个具名函数或函数对象。
书写格式为 [capture-list](parameters)mutable->return-type{statement}
。
[capture-list]
(捕捉列表):总是位于lambda
函数开头,用于捕捉上下文中变量供函数使用。
[var]
表示值传递捕捉变量var
。[=]
表示值传递捕获所有父作用域中的变量(成员函数包括this
指针)。[&var]
表示引用传递捕捉变量var
。[&]
表示引用传递捕捉所有父作用域中的变量(成员函数包括this
指针)。[this]
表示值传递方式捕捉当前的this
指针。- 捕捉列表可由多个捕捉项组成并用逗号分割,但不允许变量重复传递,且全局
lambda
函数的捕捉列表必须为空,块作用域中的lambda
函数仅能捕捉父作用域中的局部变量。(parameters)
(参数列表):与普通函数参数列表一致,若无需参数传递可连同括号一起省略。mutable
:默认lambda
函数为const
函数,该修饰符可取消常量性,使用时参数列表不可省略(即使参数为空)。->return-type
(返回值类型):用追踪返回类型形式声明函数返回值类型,无返回值时可省略,返回值类型明确时也可省略由编译器推导。{statement}
(函数体):在其中可使用参数及所有捕获到的变量,函数体格式上可换行但末尾要有分号,且若 lambda 表达式要直接调用,需借助auto
赋值给一个变量使其像普通函数一样使用。
我们可以一个简单的交换两数的方式,具体来介绍一下不同捕捉列表的使用方法:
首先我们以引用的方式捕捉所有父作用域中的变量,省略参数列表和返回值类型。比如:
int main()
{int a = 10, b = 20;auto Swap = [&]{int tmp = a;a = b;b = tmp;};Swap(); //交换a和breturn 0;
}
lambda表达式会自动对变量 a
,b
进行捕捉,然后进行我们的交换逻辑。并且我们可以发现在交换的过程中,我们只使用了 a
,b
两个变量,所以我们也可以只捕捉 a
,b
。
int main()
{int a = 10, b = 20;auto Swap = [&a, &b]{int tmp = a;a = b;b = tmp;};Swap(); //交换a和breturn 0;
}
实际当我们以[&]
或[=]
的方式捕获变量时,编译器也不一定会把父作用域中所有的变量捕获进来,编译器可能只会对lambda表达式中用到的变量进行捕获,因为没有必要把用不到的变量也捕获进来,当然这个主要取决于编译器的具体实现。
还有一点值得注意的是:如果以传值方式进行捕捉,那么首先编译肯定不会通过,因为传值捕获到的变量属性是 const
,即使使用关键字mutable
其实也意义不大,并且此时参数列表也不可省略。比如:
int main()
{int a = 10, b = 20;auto Swap = [a, b]()mutable{int tmp = a;a = b;b = tmp;};Swap(); return 0;
}
因为是传值捕捉,所以即使 lambda
表达式内部交换了两数,也对父作用域的变量 a
,b
没有任何影响。
3.2 原理
接下来我们来探究一下 lambda 表达式究竟是怎样实现的呢?其实并不是很复杂:
- 编译器会自动为每个 Lambda 表达式生成一个独一无二的类。这个类包含了Lambda 表达式所需的各种信息和功能实现。
- 类中的成员变量用于存储
Lambda
表达式捕获的变量(如果有捕获的话)。例如,如果Lambda 表达式通过值捕获了某个变量,那么在生成的类中会有一个成员变量来保存该变量的值;如果是通过引用捕获,类中会有相应的引用成员(通常是对外部变量的引用)。- 最重要的是,编译器会在生成的类中重载函数调用运算符
()
。Lambda 表达式的函数体就被转化为这个重载的operator()
函数的实现。- 当调用 Lambda 表达式时,实际上就是在调用这个生成类的重载函数
operator()
。- Lambda 表达式的参数列表中的参数会被传递给生成类的
operator()
函数作为其参数。这样在函数体中就可以像使用普通函数参数一样使用这些参数进行计算等操作。
比如下面我们实现一个简单的交换类,并在其中重载 operator()
,然后再实现一个lambda表达式,对比其汇编:
#include <iostream>// 交换类(仿函数)
class SwapClass {
public:void operator()(int& a, int& b) {int temp = a;a = b;b = temp;}
};int main() {int x = 5, y = 10;// 使用交换类进行交换SwapClass swapObj;swapObj(x, y);std::cout << "After using SwapClass: x = " << x << ", y = " << y << std::endl;// 使用 lambda 表达式进行交换auto lambdaSwap = [](int& a, int& b) {int temp = a;a = b;b = temp;};lambdaSwap(x, y);std::cout << "After using lambda expression: x = " << x << ", y = " << y << std::endl;return 0;
}
通过汇编我们就可以看出 lambda其实本质就是一个仿函数。并且在 VS 下,lambda 表达式底层被处理为类名包含保证唯一性的 uuid
的函数对象(可以通过 typeid().name()
观察类型),因此每个 lambda 表达式类型不同,所以每一个 lambda 表达式之间也不能相互赋值。
4. function包装器
4.1 基本概念
function是一种函数包装器,本质是一个类模板。它可以对多种可调用对象进行包装,包括函数指针、仿函数、lambda表达式、类的成员函数等。其类模板原型为:
template <class T> function; // undefined
template <class Ret, class... Args>
class function<Ret(Args...)>;
其中,Ret
表示被包装的可调用对象的返回值类型,Args...
表示被包装的可调用对象的形参类型。其语法形式如下:
std::function<返回值类型(参数类型列表)> func_name;
例如,我们要定义一个可以包装接受两个 int
参数并返回 int
值的可调用对象的 function
,可以这样写:
std::function<int(int, int)> my_func;
4.2 使用示例
比如说,我们不同方式实现一个加法方法,然后用包装器 function 包装。
int f(int a, int b) { return a + b; }
struct Functor {int operator()(int a, int b) { return a + b; }
};
class Plus {
public:static int plusi(int a, int b) { return a + b; }double plusd(double a, double b) { return a + b; }
};int main() {// 1、包装函数指针(函数名)function<int(int, int)> func1 = f;cout << func1(1, 2) << endl;// 2、包装仿函数(函数对象)function<int(int, int)> func2 = Functor();cout << func2(1, 2) << endl;// 3、包装lambda表达式function<int(int, int)> func3 = [](int a, int b) { return a + b; };cout << func3(1, 2) << endl;// 4、类的静态成员函数// function<int(int, int)> func4 = Plus::plusi;function<int(int, int)> func4 = &Plus::plusi; // &可省略cout << func4(1, 2) << endl;// 5、类的非静态成员函数function<double(Plus, double, double)> func5 = &Plus::plusd; // &不可省略cout << func5(Plus(), 1.1, 2.2) << endl;return 0;
}
其中需要注意的是:取静态成员函数的地址可以不用取地址运算符“&”,但取非静态成员函数的地址必须使用取地址运算符“&”。包装非静态的成员函数时也需要注意,非静态成员函数的第一个参数是隐藏this指针,因此在包装时需要指明第一个形参的类型。
并且我们也可以通过 function
来避免因可调用对象类型不同而产生多次不必要的函数模板实例化,比如以下代码:
// 函数模板,用于对可调用对象进行一些操作,并返回操作后的结果
// 这里假设可调用对象接受一个int参数,返回一个int结果
template<class F, class T>
T operateOnValue(F f, T x)
{static int count = 0;std::cout << "count: " << ++count << std::endl;std::cout << "count: " << &count << std::endl;return f(x);
}// 普通函数,将传入的整数乘以2
int multiplyByTwo(int num)
{return num * 2;
}// 仿函数类,将传入的整数加上5
class AddFive {
public:int operator()(int n) const {return n + 5;}
};
如果我们不使用 functional 对不同类型进行统一管理,那么我们在分别调用multiplyByTwo
,AddFive()
,以及 lambda 表达式时,最终 operateOnValue
函数模板会被实例化 三次,从打印 count
的地址不同以及 count
的值都没有递增可以看出。
int main()
{std::cout << operateOnValue(multiplyByTwo, 10) << std::endl;std::cout << operateOnValue(AddFive(), 10) << std::endl;std::cout << operateOnValue([](int n) { return n * n; }, 10) << std::endl;return 0;
}
如果使用了 function
对不同类型但有着相同参数和返回值类型的可调用对象进行了统一包装,最终 operateOnValue
函数模板只会被实例化一次,从打印 count
的地址相同以及 count
的值会逐次累加(从 1
开始每次加 1
,表示调用次数)就可以看出来,避免了因可调用对象类型差异而产生的多次不必要的实例化。
// 主函数,展示如何使用function来统一类型,避免函数模板的多次实例化
int main()
{// 使用std::function包装普通函数std::function<int(int)> func1 = multiplyByTwo;std::cout << operateOnValue(func1, 10) << std::endl;// 使用std::function包装仿函数std::function<int(int)> func2 = AddFive();std::cout << operateOnValue(func2, 10) << std::endl;// 使用std::function包装lambda表达式,这里的lambda表达式实现将传入整数平方的功能std::function<int(int)> func3 = [](int n) { return n * n; };std::cout << operateOnValue(func3, 10) << std::endl;return 0;
}
5. bind包装器
5.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
是逗号分隔的参数列表。arg_list
中的参数可能包含形如_n
的占位符(n
是整数),表示新生成可调用对象的参数位置。我们一般需要使用 placeholders
进行绑定,比如说使用 bind 绑定一个加法函数:
int Plus(int a, int b)
{return a + b;
}
int main()
{//无意义的绑定function<int(int, int)> func = bind(Plus, placeholders::_1, placeholders::_2);cout << func(1, 2) << endl; //3return 0;
}
5.2 绑定固定值
我们也可以将函数的某些参数绑定为固定值。例如,将Plus
函数的第二个参数固定绑定为10:
int Plus(int a, int b) { return a + b; }
int main() {// 绑定固定参数function<int(int)> func = bind(Plus, placeholders::_1, 10);cout << func(2) << endl; // 12return 0;
}
以后每次使用时,我们就只需要传第一个参数,第二个参数就固定为 10。
5.3 调整顺序
对于类成员函数,可以通过控制占位符的位置来调整参数传递顺序。例如,交换Sub
类中sub
成员函数两个参数的顺序:
class Sub {
public:int sub(int a, int b) { return a - b; }
};
int main() {// 调整传参顺序function<int(int, int)> func = bind(&Sub::sub, Sub(), placeholders::_2, placeholders::_1);Sub bj;//也可以传对应实例化的地址function<int(int, int)> func = bind(&Sub::sub, &bj, placeholders::_2, placeholders::_1);cout << func(1, 2) << endl; // 1return 0;
}
其中需要注意的是:取静态成员函数的地址可以不用取地址运算符“&”,但取非静态成员函数的地址必须使用取地址运算符“&”。包装非静态的成员函数时,非静态成员函数的第一个参数是隐藏 this
指针,因此在包装时需要指明第一个形参的类型。