目录
一、可变参数模板的概念
可变参数模板是C++11新增的最强大的特性之一,它对参数高度泛化,能够让我们创建可以接受可变参数的函数模板和类模板。
- 在C++11之前,类模板和函数模板中只能包含固定数量的模板参数,可变模板参数无疑是一个巨大的改进,但由于可变参数模板比较抽象,因此使用起来需要一定的技巧。
- 在C++11之前就有可变参数的概念,比如 printf 函数就能够接收任意多个参数,但这是函数参数的可变参数,并不是模板的可变参数。
二、可变参数模板的定义方式
函数的可变参数模板定义方式如下:
template<class... Args>
返回类型 函数名(Args... args)
{
//函数体
}
例如:
template<class... Args>
void Print(Args... args)
{
}
说明一下:
- 模板参数 Args 前面有省略号,代表它是一个可变模板参数,我们把带省略号的参数称为参数包,参数包里面可以包含0到N ( N ≥ 0 ) 个模板参数,而 args 则是一个函数形参参数包。
- 模板参数包 Args 和函数形参参数包 args 的名字可以任意指定,并不是说必须叫做 Args 和args(args是arguments的缩写)
现在调用 Print 函数时就可以传入任意多个参数了,并且这些参数可以是不同类型的。比如:
int main()
{
Print();
Print(1);
Print(1, 'A');
Print(1, 'A', string("hello"));
return 0;
}
我们可以在函数模板中通过sizeof计算参数包中参数的个数。比如:
template<class... Args>
void Print(Args... args)
{
cout << sizeof...(args) << endl;
}
但是此时我们无法直接获取参数包中的每个参数,只能通过展开参数包的方式来获取,这是使用可变参数模板的一个主要特点,也是最大的难点。
特别注意,语法并不支持使用 args[i]
的方式来获取参数包中的参数。比如:
template<class ...Args>
void Print(Args... args)
{
//错误示例:
for (int i = 0; i < sizeof...(args); i++)
{
cout << args[i] << " "; //打印参数包中的每个参数
}
cout << endl;
}
因此要获取参数包中的各个参数,只能通过展开参数包的方式来获取,一般我们会通过递归或逗号表达式来展开参数包。
三、参数包展开方式
1. 递归展开
递归展开参数包的方式如下:
- 给函数模板增加一个模板参数,这样就可以从接收到的参数包中分离出一个参数出来。
- 在函数模板中递归调用该函数模板,调用时传入剩下的参数包。
- 如此递归下去,每次分离出参数包中的一个参数,直到参数包中的所有参数都被取出来。
比如我们要打印调用函数时传入的各个参数,那么函数模板可以这样编写:
//展开函数
template<class T, class ...Args>
void Print(T value, Args... args)
{
cout << value << " "; // 打印分离出的第一个参数
Print(args...); // 递归调用,将参数包继续向下传
}
如果只是这样,那么主函数调用Print函数时,会出现没有匹配的重载函数问题:
因为我们的递归函数没有出口,即递归到参数包为0时,再继续调用Print函数时就会没有适合的形参为空的Print重载函数,所以我们需要编译无参的递归终止函数。
编写无参的递归终止函数
我们可以在刚才的基础上,再编写一个无参的递归终止函数,该函数的函数名与展开函数的函数名相同。如下:
// 递归终止函数
void Print()
{
cout << endl;
}
// 展开函数
template<class T, class ...Args>
void Print(T value, Args... args)
{
cout << value << " "; // 打印分离出的第一个参数
Print(args...); // 递归调用,将参数包继续向下传
}
这样一来,当递归调用 Print 函数模板时,如果传入的参数包中参数的个数为0,那么就会匹配到这个无参的递归终止函数,这样就结束了递归。
但如果外部调用 Print 函数时就没有传入参数,那么就会直接匹配到无参的递归终止函数。而我们本意是想让外部调用 Print 函数时匹配的都是函数模板,并不是让外部调用时直接匹配到这个递归终止函数。
鉴于此,我们可以将展开函数和递归调用函数的函数名改为 PrintArg,然后重新编写一个Print 函数模板,该函数模板的函数体中要做的就是调用 PrintArg 函数展开参数包。
比如:
// 递归终止函数
void PrintArg()
{
cout << endl;
}
// 展开函数(一直递归)
template<class T, class ...Args>
void PrintArg(T value, Args... args)
{
cout << value << " "; // 打印分离出的第一个参数
PrintArg(args...); // 递归调用,将参数包继续向下传
}
// 供外部使用函数
template<class ...Args>
void Print(Args... args)
{
PrintArg(args...);
}
此时 F5 断点调试 + F11 就可以看到 Print() 函数不是直接调用递归出口函数,而是调用供外部使用的 Print 函数。
这时无论外部调用时传入多少个参数,最终匹配到的都是同一个带有可变参数模板的函数了。
编写带参的递归终止函数
除了编写无参的递归终止函数,也可以编写带参数的递归终止函数来终止递归,比如这里编写带一个参数的递归终止函数:
// 递归终止函数
template<class T>
void PrintArg(const T& t)
{
cout << t << endl;
}
// 展开函数(一直递归)
template<class T, class ...Args>
void PrintArg(T value, Args... args)
{
cout << value << " "; // 打印分离出的第一个参数
PrintArg(args...); // 递归调用,将参数包继续向下传
}
// 供外部使用函数
template<class ...Args>
void Print(Args... args)
{
PrintArg(args...);
}
这样一来,在递归调用过程中,如果传入的参数包中参数的个数为1,那么就会匹配到这个递归终止函数,这样也就结束了递归。但是需要注意,这里的递归终止函数需要带上函数模板,因为我们并不知道最后一个参数是什么类型的。
但该方法有一个弊端就是,我们在调用 Print 函数时必须至少传入一个参数,否则就会报错。因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数。
判断参数包中参数的个数(不可行!)
既然我们可以通过 sizeof 计算出参数包中参数的个数,那我们能不能在ShowList函数中设置一个判断,当参数包中参数个数为0时就终止递归呢?比如:
//错误示例
template<class T, class ...Args>
void Print(T value, Args... args)
{
cout << value << " "; //打印传入的若干参数中的第一个参数
if (sizeof...(args) == 0)
{
return;
}
Print(args...); //将剩下参数继续向下传
}
这种方式是不可行的,原因如下:
- 函数模板并不能调用,函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数,这个生成的函数才能够被调用。
- 而这个推演过程是在编译时进行的,当推演到参数包args中参数个数为0时,还需要将当前函数推演完毕,这时就会继续推演传入0个参数时的ShowList函数,此时就会产生报错,因为ShowList函数要求至少传入一个参数。
- 这里编写的if判断是在代码编译结束后,运行代码时才会所走的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑。
在可变参数模板的函数体内定义一个静态局部变量
template<class T, class ...Args>
void Print(T value, Args... args)
{
static int cnt = 0;
cnt++;
Print(args...); //将剩下参数继续向下传
}
思考:因为我们定义了静态的变量cnt,它在全局区,所以每次递归后cnt不会重复定义,并且会保留cnt的值,递归几次就cnt++几次,所以cnt就等同于参数包内的参数个数 ?
答案并不是! 因为这里带有函数模板,根据实参传递不同类型的值,模板函数会实例化出不同的函数!也就是说由于参数包的不同,Print 函数递归调用的函数不是同一个函数,所以每一个递归都是调用的新函数,所以每个函数内的cnt都是独立的,cnt 最终都只可能为1。
2. 逗号表达式展开参数包
通过列表获取参数包中的参数
数组可以通过列表进行初始化,比如:
int a[] = {1, 2, 3, 4};
int b[] {1, 2, 3, 4}; // C++11的统一列表初始化
除此之外,如果参数包中各个参数的类型都是整型,那么也可以把这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了。比如:
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { args... }; //列表初始化
//打印参数包中的各个参数
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
}
但C++并不像Python这样的语言,C++规定一个容器中存储的数据类型必须是相同的,因此如果这样写的话,那么调用ShowList函数时传入的参数只能是整型的,并且还不能传入0个参数,因为数组的大小不能为0,因此我们还需要在此基础上借助逗号表达式来展开参数包。
通过逗号表达式展开参数包
虽然我们不能用不同类型的参数去初始化一个整型数组,但我们可以借助逗号表达式。
- 逗号表达式会从左到右依次计算各个表达式,并且将最后一个表达式的值作为返回值进行返回。
- 将逗号表达式的最后一个表达式设置为一个整型值,确保逗号表达式返回的是一个整型值。
- 将处理参数包中参数的动作封装成一个函数,将该函数的调用作为逗号表达式的第一个表达式。
这样一来,在执行逗号表达式时就会先调用处理函数处理对应的参数,然后再将逗号表达式中的最后一个整型值作为返回值来初始化整型数组。比如:
//处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
说明一下:
- 我们这里要做的就是打印参数包中的各个参数,因此处理函数当中要做的就是将传入的参数进行打印即可。
- 可变参数的省略号需要加在逗号表达式外面,表示需要将逗号表达式展开,如果将省略号加在args的后面,那么参数包将会被展开后全部传入PrintArg函数,代码中的 { (PrintArg(args), 0)... } 将会展开成 { ( PrintArg(arg1), 0 ), ( PrintArg(arg2), 0 ), ( PrintArg(arg3), 0 ), etc... }
这时调用ShowList函数时就可以传入多个不同类型的参数了,但调用时仍然不能传入0个参数,因为数组的大小不能为0,如果想要支持传入0个参数,也可以写一个无参的ShowList函数。比如:
//支持无参调用
void ShowList()
{
cout << endl;
}
//处理函数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
实际上我们也可以不用逗号表达式,因为这里的问题就是初始化整型数组时必须用整数,那我们可以将处理函数的返回值设置为整型,然后用这个返回值去初始化整型数组也是可以的。比如:
//支持无参调用
void ShowList()
{
cout << endl;
}
//处理函数
template<class T>
int PrintArg(const T& t)
{
cout << t << " ";
return 0;
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { PrintArg(args)... }; //列表初始化
cout << endl;
}
四、STL库中容器的emplace相关接口函数
emplace版本的插入接口
C++11 标准给STL中的容器增加 emplace 版本的插入接口,比如 list 容器的 push_front、push_back 和 insert 函数,都增加了对应的 emplace_front、emplace_back 和 emplace 函数。如下:
这些emplace版本的插入接口支持模板的可变参数,比如list容器的emplace_back函数的声明如下:
注意: emplace系列接口的可变模板参数类型都带有“&&”,这个表示的是万能引用,而不是右值引用。
emplace系列接口的使用方式
emplace 系列接口的使用方式与容器原有的插入接口的使用方式类似,但又有一些不同之处。
以 list 容器的 emplace_back 和 push_back 为例:
- 调用 push_back 函数插入元素时,可以传入左值对象或者右值对象,也可以使用列表进行初始化。
- 调用 emplace_back 函数插入元素时,也可以传入左值对象或者右值对象,但不可以使用列表进行初始化。
- 除此之外,emplace系列接口最大的特点就是,插入元素时可以传入用于构造元素的参数包。
比如:
int main()
{
list<pair<int, string>> mylist;
pair<int, string> kv(10, "111");
mylist.push_back(kv); //传左值
mylist.push_back(pair<int, string>(20, "222")); //传右值
mylist.push_back({ 30, "333" }); //列表初始化
mylist.emplace_back(kv); //传左值
mylist.emplace_back(pair<int, string>(40, "444")); //传右值
mylist.emplace_back(50, "555"); //传参数包
return 0;
}
emplace系列接口的意义
由于emplace系列接口的可变模板参数的类型都是万能引用,因此既可以接收左值对象,也可以接收右值对象,还可以接收参数包。
如果调用emplace系列接口时传入的是左值对象,那么首先需要先在此之前调用构造函数实例化出一个左值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,会匹配到拷贝构造函数。
如果调用emplace系列接口时传入的是右值对象,那么就需要在此之前调用构造函数实例化出一个右值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,就会匹配到移动构造函数。
如果调用emplace系列接口时传入的是参数包,那就可以直接调用函数进行插入,并且最终在使用定位new表达式调用构造函数对空间进行初始化时,匹配到的是构造函数。
总结一下:
- 传入左值对象,需要调用构造函数+拷贝构造函数。
- 传入右值对象,需要调用构造函数+移动构造函数。
- 传入参数包,只需要调用构造函数。
当然,这里的前提是容器中存储的元素所对应的类是一个需要深拷贝的类,并且该类实现了移动构造函数。否则调用emplace系列接口时,传入左值对象和传入右值对象的效果都是一样的,都需要调用一次构造函数和一次拷贝构造函数。
实际emplace系列接口的一部分功能和原有各个容器插入接口是重叠的,因为容器原有的push_back、push_front和insert函数也提供了右值引用版本的接口,如果调用这些接口时传入的是右值对象,那么最终也是会调用对应的移动构造函数进行资源的移动的。
emplace接口的意义:
- emplace系列接口最大的特点就是支持传入参数包,用这些参数包直接构造出对象,这样就能减少一次拷贝,这就是为什么有人说emplace系列接口更高效的原因。
- 但emplace系列接口并不是在所有场景下都比原有的插入接口高效,如果传入的是左值对象或右值对象,那么emplace系列接口的效率其实和原有的插入接口的效率是一样的。
- emplace系列接口真正高效的情况是传入参数包的时候,直接通过参数包构造出对象,避免了中途的一次拷贝。
验证
如果要验证我们上述对emplace系列接口的说法,需要借助一个深拷贝的类,下面模拟实现了一个简化版的string类,类当中只编写了我们需要用到的成员函数。
代码如下:
namespace test_string
{
class string
{
public:
typedef char* iterator;
iterator begin()
{
return _str; // 返回字符串中第一个字符的地址
}
iterator end()
{
return _str + _size; // 返回字符串中最后一个字符的后一个字符的地址
}
//构造函数
string(const char* str = "")
{
cout << "string(const char*) -- 构造函数" << endl;
_size = strlen(str); // 初始时,字符串大小设置为字符串长度
_capacity = _size; // 初始时,字符串容量设置为字符串长度
_str = new char[_capacity + 1]; // 为存储字符串开辟空间(多开一个用于存放'\0')
strcpy(_str, str); // 将C字符串拷贝到已开好的空间
}
//交换两个对象的数据
void swap(string& s)
{
//调用库里的swap
::swap(_str, s._str); // 交换两个对象的C字符串
::swap(_size, s._size); // 交换两个对象的大小
::swap(_capacity, s._capacity); // 交换两个对象的容量
}
//拷贝构造函数(现代写法)
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(const string& s) -- 拷贝构造" << endl;
string tmp(s._str); // 调用构造函数,构造出一个C字符串为s._str的对象
swap(tmp); // 交换这两个对象
}
// 移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s); // 交换这两个对象
}
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
swap(s); // 交换这两个对象
return *this; // 返回左值(支持连续赋值)
}
//赋值运算符重载(现代写法)
string& operator=(const string& s)
//string& operator=(string tmp)
{
cout << "string& operator=(const string& s) -- operator=拷贝" << endl;
string tmp(s); // 用s拷贝构造出对象tmp
swap(tmp); // 交换这两个对象
return *this; // 返回左值(支持连续赋值)
}
//析构函数
~string()
{
delete[] _str; // 释放_str指向的空间
_str = nullptr; // 及时置空,防止非法访问
_size = 0; // 大小置0
_capacity = 0; // 容量置0
}
//[]运算符重载
char& operator[](size_t i)
{
assert(i < _size); //检测下标的合法性
return _str[i]; //返回对应字符
}
//改变容量,大小不变
void reserve(size_t n)
{
if (n > _capacity) //当n大于对象当前容量时才需执行操作
{
char* tmp = new char[n + 1]; // 多开一个空间用于存放'\0'
strncpy(tmp, _str, _size + 1); // 将对象原本的C字符串拷贝过来(包括'\0')
delete[] _str; // 释放对象原本的空间
_str = tmp; // 将新开辟的空间交给_str
_capacity = n; // 容量跟着改变
}
}
// 尾插字符
void push_back(char ch)
{
if (_size == _capacity) // 判断是否需要增容
{
reserve(_capacity == 0 ? 4 : _capacity * 2); // 将容量扩大为原来的两倍
}
_str[_size++] = ch; // 将字符尾插到字符串
_str[_size] = '\0'; // 字符串后面放上'\0'
}
// +=运算符重载
string& operator+=(char ch)
{
push_back(ch); // 尾插字符串
return *this; // 返回左值(支持连续+=)
}
// 返回C类型的字符串
const char* c_str()const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
由于我们在string的构造函数、拷贝构造函数和移动构造函数当中均打印了一条提示语句,因此我们可以通过控制台输出来判断这些函数是否被调用。
下面我们用一个容器来存储模拟实现的string,并以不同的传参形式调用emplace系列函数。比如:
int main()
{
list<pair<int, test_string::string>> mylist;
pair<int, test_string::string> kv(1, "one");
mylist.emplace_back(kv); //传左值
cout << endl;
mylist.emplace_back(pair<int, test_string::string>(2, "two")); //传右值
cout << endl;
mylist.emplace_back(3, "three"); //传参数包
return 0;
}
说明一下:
- 模拟实现string的拷贝构造函数时复用了构造函数,因此在调用string拷贝构造的后面会紧跟着调用一次构造函数。
- 为了更好的体现出参数包的概念,因此这里list容器中存储的元素类型是pair,我们是通过观察string对象的处理过程来判断pair的处理过程的。
- 传左值:构造pair时,在pair类的初始化列表处会调用string的构造函数,所以会先输出一个构造函数语句。然后在list的emplace_back内部new新节点时,由于我们传递的是左值,所以会在Node构造函数的初始化列表处调用pair的构造函数,pair构造函数的初始化列表又会调用成员string的拷贝构造(new的时候使用了完美转发,所以Node 的构造函数的初始化列表处知道kv是左值),所以输出第二条语句拷贝构造。由于拷贝构造复用了构造函数,所以输出流第三条语句。
- 传右值:先构造pair的匿名对象,在pair类的构造函数的初始化列表处调用string的构造函数,所以输出第一条语句。然后在list的emplace_back内部new新节点时,由于我们传递的是右值,所以会在Node构造函数的初始化列表处调用pair的构造函数,在pair的构造函数的初始化列表中调用成员string的移动构造函数,输出第二条语句。
- 传参数包:因为是可变参数模板接收,所以会将参数包一路传递下去,然后再new新节点时,将参数包传递给Node类的构造函数,在Node类的构造函数的初始化列表调用pair的构造函数,在pair的构造函数的初始化列表内用参数包进行构造string,所以输出一个构造函数语句。综上所述,传参数包的情况,会将参数包一路传递下去,最终在string只需要一次构造。
这里也可以以不同的传参方式调用push_back函数,顺便验证一下容器原有的插入函数的执行逻辑。比如:
int main()
{
list<pair<int, cl::string>> mylist;
pair<int, cl::string> kv(1, "one");
mylist.push_back(kv); //传左值
cout << endl;
mylist.push_back(pair<int, cl::string>(2, "two")); //传右值
cout << endl;
mylist.push_back({ 3, "three" }); //列表初始化
return 0;
}
综上来看,emplace_back 和 push_back 在传递左值和右值的情况下相同,在传递参数包是略有不同,emplace_back 只调用一次构造函数,push_back 需要调用一次构造函数和一次移动构造,但是两个效率可以说是完全相同,因为移动构造的代价太小了!也就是说,在需要深拷贝的自定义类型情况下,可以说 emplace_back 和 push_back没有任何效率上的差异,只有在不需要深拷贝的自定义类型或内置类型情况下,才会略显 emplace_back 效率的高效。