【C++11】可变模板参数
目录
可变模板的定义方式
参数包的展开方式
递归的方式展开参数包
STL中的emplace相关接口函数
STL容器中emplace相关插入接口函数
编辑
模拟实现:emplace接口
C++11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板,相比 C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改 进。
像之前学习的printf就是一个函数参数的可变参数,它可以接收多个任意类型,但它们只函数参数的可变参数,并不是模板的可变参数
printf的使用方法:
int printf( const char *format , ... );
本博客讲解的是函数模板的可变参数,不会涉及到类模板的可变参数
可变模板的定义方式
函数的可变参数模板定义方式如下:
template<class ...Args> //Args全称:arguments
返回类型 函数名(Args... args)
{
//函数体
}
下面就是一个基本可变参数的函数模板
template <class ...Args>
void ShowList(Args... args)
{}
Args:是一个可变模板参数包
args:是一个函数形参参数包
说明一下:
模板参数Args前面有省略号,代表它是一个可变模板参数,我们将带省略号的参数称为 “参数包”,这个参数包中可以包含0到任意个模板参数,args则是一个函数形参参数包
现在我们可以向这个函数中传入多个不同的类型,并且可以通过sizeof算出参数包的参数个数
以下例代码为例:
template<class ...Args>
void ShowList(Args... args)
{
cout << sizeof...(args) << endl;
}
int main()
{
ShowList(1);
ShowList(1, 2);
ShowList(1, 2, string("dict"));
map<string, int> m1;
ShowList(1, 2, 3, m1);
return 0;
}
我们无法直接获取参数包args中的每个参数的, 只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。
由于C++11语法不支持使用args[i]这样方式获取可变q参数,所以我们的用一些奇招来一一获取参数包的值。
错误示例:
template<class ...Args>
void ShowList(Args... args)
{
//error
for (int i = 0; i < sizeof...(args); ++i)
{
cout << args[i] << endl;
}
}
参数包的展开方式
递归的方式展开参数包
方式如下:
1.给函数模板新增一个参数,这样就可以从接收到的参数包分离出来一个参数
2.在函数模板中进行递归,不断的分离参数包中的参数
3.直到接收到最后一个参数结束
结束条件;
->1. 可以创建一个无参的函数来终止递归:当参数包中的参数为0时会调用该函数终止循环
void _ShowList()
{
cout << endl;
}
template<class T, class ...Args>
void _ShowList(T value, Args... args)
{
cout << value << ' ';
_ShowList(args...);
}
int main()
{
_ShowList(1, 2, string("dict"));
return 0;
}
->2. 可以创建一个参数的函数来终止递归:当参数包中的参数为1时会调用该函数终止循环
template<class T>
void _ShowList(const T& t)
{
cout << t << endl;
}
template<class T, class ...Args>
void _ShowList(T value, Args... args)
{
cout << value << ' ';
_ShowList(args...);
}
int main()
{
_ShowList(1, 2, string("dict"));
return 0;
}
但是使用该方法有一个弊端:我们在调用ShowList函数时必须至少传入一个参数,否则就会报错,因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数
使用sizeof...(args)算出参数个数的特性,利用它的特性做一个递归结束条件可以吗?不行!
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << ' ';
if (sizeof...(args))
{
return;
}
ShowList(args...);
}
函数模板并不能调用,函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数,这个生成的函数才能够被调用。
而这个推演过程是在编译时进行的,当推演到参数包args中参数个数为0时,还需要将当前函数推演完毕,这时就会继续推演传入0个参数时的ShowList函数,此时就会产生报错,因为ShowList函数要求至少传入一个参数。
这里编写的if判断是在代码编译结束后,运行代码时才会所走的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑。
还有一种特殊的方式,该方法比较抽象,就是使用逗号表达式展开参数包
->3. 逗号表达式展开参数包
template<class T>
void CPPprint(const T& value)
{
cout << value << ' ';
}
template<class ...Args>
void ShowList(Args... args)
{
int array[] = {( CPPprint(args), 0)...};
cout << endl;
}
当我们在数组中不标注元素个数时,编译器会帮我们自动推导元素个数,这时它会帮我们展开参数包
如下:
int array[] = {( CPPprint(args), 0), CPPprint(args), 0), CPPprint(args), 0), CPPprint(args), 0)};
在调用CPPprint函数的同时,利用逗号运算符的特性进行对数组的初始化
其实也可以不使用逗号运算符完成该操作
template<class T>
int CPPprint(const T& value)
{
cout << value << ' ';
return 0;
}
template<class ...Args>
void ShowList(Args... args)
{
int array[] = { (CPPprint(args))... };
cout << endl;
}
将被调用的函数设置一个返回值,调用之后返回0,这样就可以在编译器展开参数包调用函数时,通过返回值初始化
STL中的emplace相关接口函数
以便大家更好的理解emplace,先给大家看一段代码,可变模板参数的使用场景:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Data()~构造函数" << endl;
}
Date(const Date& d)
:_year(d._year)
, _month(d._month)
, _day(d._day)
{
cout << "Date()~拷贝构造" << endl;
}
private:
int _year;
int _month;
int _day;
};
template<class ...Args>
Date* Init(Args&&... args)
{
Date* ret = new Date(args...);
return ret;
}
int main()
{
Date* p1 = Init();
Date* p2 = Init(2024);
Date* p3 = Init(2024, 11);
Date* p4 = Init(2024, 11, 12);
Date d1(2, 3, 3);
Date* p5 = Init(d1);
return 0;
}
我们通过将参数传入参数包在编译期间通过将参数包展开的操作进行对象的构造
STL容器中emplace相关插入接口函数
C++11标准STL中的容器增加emplace版本的插入接口,比如list容器的push_front,push_back和insert函数,都增加了对应的emplace_front,emplace_back,emplace函数。如下:
emplace接口全部都是使用的可变参数模板
注意:两个&&是万能引用并不是右值引用
对比list中的push_back和emplace_back,对于emplace系列接口而言,它的主要优势就是直接在容器内部构造元素可以结合我上面给的场景进行理解,而不是构造一个临时对象在复制或移动到容器中可以有效的避免拷贝和移动操作
以emplace和push_back为例:
调用push_back函数插入元素时,可以传入左值对象或者右值对象,也可以使用列表初始化
调用emplace时可以传左值对象或者右值对象,但是不能使用列表初始化,emplace系列最大的特点就是,插入元素时可以传入用于构造元素的参数包
比如:
int main()
{
list<pair<nxbw::string, int>> mylist;
pair<nxbw::string, int> kv("nxbw", 10);
mylist.emplace_back(kv); //传左值
mylist.emplace_back(make_pair("nxbw", 10)); //传右值
mylist.emplace_back("nxbw", 10); //传参数包
mylist.push_back(kv); //传左值
mylist.push_back(make_pair("nxbw", 10)); //传右值
mylist.push_back({ "nxbw", 10 }); //使用列表初始化
return 0;
}
原地构造:使用emplace,你可以提供构造元素所需的参数,容器会直接在emplace接口的实现中构造该对象
emplace系列接口的工作流程
emplace系列接口的工作流程如下:
- 先通过空间配置器为新结点获取一块内存空间,注意这里只会开辟空间,不会自动调用构造函数对这块空间进行初始化。
- 然后调用allocator_traits::construct函数对这块空间进行初始化,调用该函数时会传入这块空间的地址和用户传入的参数(需要经过完美转发)。
- 在allocator_traits::construct函数中会使用定位new表达式,显示调用构造函数对这块空间进行初始化,调用构造函数时会传入用户传入的参数(需要经过完美转发)。
- 将初始化好的新结点插入到对应的数据结构当中,比如list容器就是将新结点插入到底层的双链表中。
emplace系列接口的意义
由于emplace系列接口的可变模板参数的类型都是万能引用,因此既可以接收左值对象,也可以接收右值对象,还可以接收参数包。
- 如果调用emplace系列接口时传入的是左值对象,那么首先需要先在此之前调用构造函数实例化出一个左值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,会匹配到拷贝构造函数。
- 如果调用emplace系列接口时传入的是右值对象,那么就需要在此之前调用构造函数实例化出一个右值对象,最终在使用定位new表达式调用构造函数对空间进行初始化时,就会匹配到移动构造函数。
- 如果调用emplace系列接口时传入的是参数包,那就可以直接调用函数进行插入,并且最终在使用定位new表达式调用构造函数对空间进行初始化时,匹配到的是构造函数。
总结一下:
- 传入左值对象,需要调用构造函数+拷贝构造函数。
- 传入右值对象,需要调用构造函数+移动构造函数。
- 传入参数包,只需要调用构造函数。
当然,这里的前提是容器中存储的元素所对应的类,是一个需要深拷贝的类,并且该类实现了移动构造函数。否则在调用emplace系列接口时,传入左值对象和传入右值对象的效果都是一样的,都需要调用一次构造函数和一次拷贝构造函数。
实际emplace系列接口的一部分功能和原有各个容器插入接口是重叠的,因为容器原有的push_back、push_front和insert函数也提供了右值引用版本的接口,如果调用这些接口时如果传入的是右值对象,那么最终也是会调用对应的移动构造函数进行资源的移动的。
emplace接口的意义:
emplace系列接口最大的特点就是支持传入参数包,用这些参数包直接构造出对象,这样就能减少一次拷贝,这就是为什么有人说emplace系列接口更高效的原因。
但emplace系列接口并不是在所有场景下都比原有的插入接口高效,如果传入的是左值对象或右值对象,那么emplace系列接口的效率其实和原有的插入接口的效率是一样的。
emplace系列接口真正高效的情况是传入参数包的时候,直接通过参数包构造出对象,避免了中途的一次拷贝。
通过下面的场景我们来验证一下:
namespace nxbw
{
class string
{
public:
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
cout << "string(char* str)" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// s1.swap(s2)
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
// 赋值重载
string& operator=(const string& s)
{
cout << "string& operator=(string s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
return *this;
}
// 移动构造
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()
{
delete[] _str;
_str = nullptr;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
//string operator+=(char ch)
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
const char* c_str() const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
}
int main()
{
list<pair<nxbw::string, int>> mylist;
pair<nxbw::string, int> kv("nxbw", 10); //构造
mylist.emplace_back(kv); //传左值,
mylist.emplace_back(pair<nxbw::string, int>("nxbw", 10)); //传右值
mylist.emplace_back("nxbw", 10); //传参数包
return 0;
}
由于我们在string的构造函数、拷贝构造函数和移动构造函数当中均打印了一条提示语句,因此我们可以通过控制台输出来判断这些函数是否被调用。
下面我们用一个容器来存储模拟实现的string,并以不同的传参形式调用emplace系列函数。比如:
说明一下:
模拟实现string的拷贝构造函数时复用了构造函数,因此在调用string拷贝构造的后面会紧跟着调用一次构造函数。
为了更好的体现出参数包的概念,因此这里list容器中存储的元素类型是pair,我们是通过观察string对象的处理过程来判断pair的处理过程的。
这里也可以以不同的传参方式调用push_back函数,顺便验证一下容器原有的插入函数的执行逻辑。比如:
int main()
{
list<pair<nxbw::string, int>> mylist;
pair<nxbw::string, int> kv("nxbw", 10);
mylist.push_back(kv); //传左值
mylist.push_back(pair<nxbw::string, int>("nxbw", 10)); //传右值
mylist.push_back({ "nxbw", 10 }); //使用列表初始化
return 0;
}
模拟实现:emplace接口
namespace nxbw
{
// 模拟实现list在之前的章节有提过,这里只是将原来的代码多增加一些接口的片段代码
// 这是list需要用到的节点类
template<class T>
struct __list_node
{
__list_node(const T& val = T())
:_data(val), _prev(nullptr), _next(nullptr)
{}
// 这里需要在原来的基础上需要增加一个可变模板参数模板的构造函数,方便下面使用new
template<class ...Args>
__list_node(Args&& ...args)
: _data(std::forward<Args>(args)...), _prev(nullptr), _next(nullptr)
{}
T _data;
__list_node* _prev;
__list_node* _next;
};
template<class T>
struct list
{
template<class ...Args>
iterator emplace(iterator position, Args&&... args)
{
node* cur = position._node;
node* prev = cur->_prev;
// 函数参数包的完美转发
node* newnode = new node(forward<Args>(args)...);
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(cur);
}
template<class ...Args>
void emplace_back(Args&&... args)
{
// 函数参数包的完美转发
emplace(end(), forward<Args>(args)...);
}
// 获取节点函数,这里更新成了万能引用版的
template<class T>
node* get_node(T&& val = T())
{
node* new_node = new node(forward<T>(val)); // 完美转发
new_node->_prev = new_node;
new_node->_next = new_node;
return new_node;
}
private:
__list_node<T>* _head; // 指向节点类的指针
};
};
emplace系列和push_back以及insert的区别
效率方面:对于左值引用版本的push_back和insert来说确实有很大的效率提升,对于右值引用版本的push_back和insert来说效率其实差不多,因为移动赋值/拷贝代价足够小
构造复杂对象:当元素的构造比叫复杂时,emplace可以让代码更简洁,直接传入构造参数即可