在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
个人主页:huangjin007_
🔥 文章收录专栏:零基础入门C++
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇

C++11篇(三) —— 可变参数模版详解

  本篇文章将由浅入深,带你循序渐进地掌握C++11中的可变参数模版,全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧


1. 什么是可变参数模板

  C++11 引入的可变参数模板,简单说就是:让函数模板和类模板可以接受任意数量、任意类型的参数。以前我们只能为每种参数个数写一个重载,现在一个模板就能搞定。

比如:

template <class... Args>
void Func(Args... args) {}

  这里 Args 就是一个模板参数包,可以代表 0 个或多个模板类型参数;而 args函数参数包,对应着 0 个或多个函数参数。

  和普通模板参数不一样,参数包前面有省略号 ...class... Args 表示 Args 是一组类型的集合(可以没有,也可以有很多)。Args... args 则是一组值的集合。


2. 基本语法与原理

2.1 声明一个可变参数函数模板

看几个常见的写法:

// 1. 按值传递参数包
template <class ...Args>
void Func(Args... args) {}

// 2. 左值引用
template <class ...Args>
void Func(Args&... args) {}

// 3. 万能引用(也叫转发引用)
template <class ...Args>
void Func(Args&&... args) {}

  重点解释一下第三种,Args&&... args。这里的 && 不是单纯的右值引用,因为 Args 是模板参数包,在类型推导时会发生引用折叠

  • 如果传入左值 int xArgs 被推导为 int&,折叠后 int& && -> int&,所以参数是左值引用。
  • 如果传入右值 string("xxx")Args 被推导为 string,那么就是 string&&,绑定右值。

  这样一来,第三种写法几乎可以接收任何类型的实参,同时保留其左右值属性,非常灵活。

2.2 获取参数包的大小:sizeof…

  我们可以用 sizeof...(args) 来获取参数包中元素的个数,它是一个编译期常量

一个简单的例子:

template<class ...Args>
void Print(Args&&... args)
{
    cout << sizeof...(args) << endl; // 计算参数包中参数的个数
}

int main()
{
    double x = 1.11;
    Print();                     // 包里有0个参数
    Print(1);                    // 包里有1个参数
    Print(1, string("xxx"));     // 包里有2个参数
    Print(1, string("xxx"), x);  // 包里有3个参数
    return 0;
}

2.3 实例化原理——编译器悄悄做了重载

  可变参数模板并不是运行时动态处理参数,而是编译时通过模板实例化,生成一个个具体参数个数的重载函数。

  以上面 Print 为例,编译器会根据调用,实例化出以下四个函数(应用引用折叠规则后):

void Print();                                           // 无参数
void Print(int&& arg1);                                 // 一个右值
void Print(int&& arg1, string&& arg2);                  // 两个右值
void Print(int&& arg1, string&& arg2, double& arg3);   // 两个右值+一个左值引用

  从本质上说,可变参数模板只是在类型泛化的基础上,又给我们叠加了一层“数量泛化”。没有它,我们得手动写一堆重载:

void Print();

template<class T1>
void Print(T1&& arg1);

template<class T1, class T2>
void Print(T1&& arg1, T2&& arg2);

template<class T1, class T2, class T3>
void Print(T1&& arg1, T2&& arg2, T3&& arg3);
// 还有更多...

  有了可变参数模板,一个 template<class... Args> void Print(Args&&... args) 就全搞定了,不仅代码量骤减,而且扩展性大大提高。

2.4 不能用循环或下标访问参数包

很多人会尝试这样访问参数包:

// 错误示例1:范围for
template<class ...Args>
void Print(Args&&... args)
{
    for (auto&& e : args...)  // 错误!参数包不是容器,不能这样遍历
        cout << e << " ";
}

或者:

// 错误示例2:下标访问
template<class ...Args>
void Print(Args&&... args)
{
    for (size_t i = 0; i < sizeof...(args); i++)
        cout << args[i] << " "; // 错误!args不是数组,没有下标操作
}

  这两种想法很自然,但都是错误的。原因在于:

  • 参数包是编译时概念,在代码编译后,args... 就变成了具体的多个参数,根本不存在一个运行时的集合让你遍历。
  • 下标操作需要运行期索引,而每个参数的类型可能不同,编译器无法在运行期才去决定访问哪个类型。

  那我们想处理每一个参数该怎么办?答案就是下一节的重头戏——包扩展


3. 包扩展(参数包展开)

  包扩展是我们能对参数包做的唯一“处理动作”。扩展一个包,就是把它拆成一个一个的元素,并对每个元素应用某个模式,最后生成一个逗号分隔的列表。

  基本语法就是在模式的右边放一个省略号 ... 来触发扩展。
举个直观的模式:GetArg(args)...,如果 argsx, y, z,就会扩展成 GetArg(x), GetArg(y), GetArg(z)

3.1 递归展开

  最常见的包扩展方式是用递归函数,每次从参数包里获得第一个参数,剩下的参数包继续递归,直到包为空时调用一个终止函数。

看例子:

// 递归终止函数:参数包为空时匹配此版本
void ShowList()
{
    cout << endl;
}

// 递归函数:每次输出第一个参数,剩余参数包继续递归
template<class T, class... Args>
void ShowList(T&& x, Args&&... args)
{
    cout << x << " ";
    ShowList(args...);   // 包扩展:将 args 原封不动地展开传递给下一次调用
}

// 对外接口:转发参数包
template<class ...Args>
void Print(Args&&... args)
{
    ShowList(args...);
}

  调用 Print(1, string("xxxx"), 2.2); 时发生了什么?我们来跟踪编译器的推导过程:

  1. Print(1, string("xxxx"), 2.2) 调用 ShowList(args...),此时 args...1, string("xxxx"), 2.2
  2. 第一次匹配 ShowList(T&& x, Args&&... args)
    • T 推导为 intx 绑定到 1
    • 剩余的 Args 推导为 [string, double],参数包 args 包含 string("xxxx")2.2
    • 输出 1 后调用 ShowList(string("xxxx"), 2.2)
  1. 第二次匹配:
    • T 推导为 stringx 绑定 "xxxx"
    • 剩余 Argsdoubleargs 包含 2.2
    • 输出 xxxx 后调用 ShowList(2.2)
  1. 第三次匹配:
    • T 推导为 doublex 绑定 2.2
    • 剩余 Args 为空包。
    • 输出 2.2 后调用 ShowList()
  1. ShowList() 匹配到无参的终止版本,输出换行。

  整个过程中,编译器递归地生成了三份 ShowList 的实例

// 实例1:三参数版本 int, string, double
void ShowList(int&& x, string&& a, double&& b)
{
    cout << x << " ";
    ShowList(a, b); // 传给两参数版本
}

// 实例2:两参数版本 string, double
void ShowList(string&& x, double&& b)
{
    cout << x << " ";
    ShowList(b); // 传给单参数版本
}

// 实例3:单参数版本 double
void ShowList(double&& x)
{
    cout << x << " ";
    ShowList(); // 传给空参终止函数
}

  而顶层的 Print 实质上变成了:

void Print(int x, string y, double z) 
{
    ShowList(x, y, z);
}

  这样,通过编译时递归,我们实现了对每个参数的处理。整个过程没有循环,没有运行期开销,全部在编译期完成。

在这里插入图片描述

3.2 更灵活的模式扩展:直接作为实参列表

  包扩展不仅能在递归中传递包本身,还可以对一个函数调用应用模式,比如把所有元素经过某个函数转换后再传给另一个函数。

看这个例子:

template <class T>
const T& GetArg(const T& x)
{
    cout << x << " ";
    return x;
}

template <class ...Args>
void Arguments(Args... args)
{}

template<class ...Args>
void Print(Args... args)
{
    // 对 args 中每一个元素调用 GetArg,然后把所有返回值组成参数包传给 Arguments
    Arguments(GetArg(args)...);
}

  当调用 Print(1, string("xxxx"), 1.1); 时,包扩展 GetArg(args)... 会展开成:

Arguments(GetArg(1), GetArg(string("xxxx")), GetArg(1.1));

  所以 GetArg 会被依次调用,输出每个参数,并返回它的引用,这些返回值再作为实参传给 Arguments
  编译器最终会将该模板实例化为类似这样的函数:

void Print(int x, string y, double z)
{
    Arguments(GetArg(x), GetArg(y), GetArg(z));
}

  这种模式非常强大,可以用在各种需要对参数包进行逐元素转换的场景。

3.3 包扩展的通用理解

  一句话总结:扩展一个包,就是把它拆开,每个元素套上左边的模式,最后拼成一个逗号分隔的列表。

常见形式:

  • args... :直接展开成 arg1, arg2, arg3
  • &args... :展开成 &arg1, &arg2, &arg3 (取地址)
  • func(args)... :展开成 func(arg1), func(arg2), func(arg3)
  • std::forward<Args>(args)... :展开成 std::forward<Arg1>(arg1), std::forward<Arg2>(arg2), ... (完美转发)

4. emplace 系列接口 —— 直接构造,高效插入

  C++11 给 STL 容器增加了 emplace 系列接口,比如 vectorlist 都支持:

template <class... Args>
void emplace_back(Args&&... args);

template <class... Args>
iterator emplace(const_iterator position, Args&&... args);

4.1 功能对比:push_back vs emplace_back

  emplace_back 不仅兼容 push_back 的全部功能,还多了一个“绝活”:可以直接传递构造元素所需的参数,在容器内部直接构造对象,省去临时对象的创建和移动/拷贝。

  这里我们用之前模拟实现的命名空间为 hj 的string类来测试:

list<hj::string> lt;
hj::string s1("1111");

// 1. 传左值 —— 与 push_back 一样,都是拷贝构造
lt.emplace_back(s1);   // 一次拷贝构造
lt.push_back(s1);      // 一次拷贝构造

// 2. 传右值 —— 与 push_back 一样,都是移动构造
lt.emplace_back(move(s1)); // 一次移动构造
lt.push_back(move(s1));    // 一次移动构造

// 3. 直接传字符串字面量 "1111" —— push_back 做不到这种!
lt.emplace_back("1111");
// 内部直接用 "1111" 作为参数,在节点构造时调用 hj::string(const char*),一步到位

// 而 push_back("1111") 必须先隐式转换出一个临时 hj::string,
// 再移动/拷贝到容器,多一次临时对象的构造和析构
lt.push_back("1111");

在这里插入图片描述

对于 pair 这类复合类型,优势更明显:

list<pair<hj::string, int>> lt1;
pair<hj::string, int> kv("苹果", 1);

// 正常传 pair 对象
lt1.emplace_back(kv);       // 拷贝构造
lt1.emplace_back(move(kv)); // 移动构造

// 而 emplace_back 允许直接传 pair 的构造参数!
lt1.emplace_back("苹果", 1);
// 节点内直接调用 pair< hj::string, int >("苹果", 1),原地构造

在这里插入图片描述

  这种方式不仅少写了 make_pair 或临时对象,效率也更高,减少了不必要的临时对象产生。

4.2 实现原理:完美转发参数包

  emplace_back 为什么这么厉害?我们模拟实现一个简化的 list,看看它的内部是如何工作的。

核心在于:

  • 可变参数模板接收任意构造参数;
  • 通过 std::forward 完美转发这些参数到节点的构造函数;
  • 节点构造函数再转发给存储类型的构造函数,直接原地构造。

代码骨架如下:

namespace hj
{
	template<class T>
	struct ListNode
	{
		ListNode<T>* _next;
		ListNode<T>* _prev;
		T _data;
		ListNode(T&& data)
			:_next(nullptr)
			, _prev(nullptr)
			, _data(move(data))
		{ }

		template <class... Args>
		ListNode(Args&&... args)
			: _next(nullptr)
			, _prev(nullptr)
			, _data(std::forward<Args>(args)...)
		{ }
	};

	template<class T, class Ref, class Ptr>
	struct ListIterator
	{
		typedef ListNode<T> Node;
		typedef ListIterator<T, Ref, Ptr> Self;
		Node* _node;
		ListIterator(Node* node)
			:_node(node)
		{ }
		// ++it;
		Self& operator++()
		{
			_node = _node->_next;
			return *this;
		}
		Self& operator--()
		{
			_node = _node->_prev;
			return *this;
		}
		Ref operator*()
		{
			return _node->_data;
		}
		bool operator!=(const Self& it)
		{
			return _node != it._node;
		}
	};

	template<class T>
	class list
	{
		typedef ListNode<T> Node;
	public:
		typedef ListIterator<T, T&, T*> iterator;
		typedef ListIterator<T, const T&, const T*> const_iterator;
		iterator begin()
		{
				return iterator(_head->_next);
		}
		iterator end()
		{
			return iterator(_head);
		}
		void empty_init()
		{
			_head = new Node();
			_head->_next = _head;
			_head->_prev = _head;
		}
		list()
		{
			empty_init();
		}
		void push_back(const T& x)
		{
			insert(end(), x);
		}
		void push_back(T&& x)
		{
			insert(end(), move(x));
		}
		iterator insert(iterator pos, const T& x)
		{
			Node* cur = pos._node;
			Node* newnode = new Node(x);
			Node* prev = cur->_prev;
			// prev newnode cur
			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = cur;
			cur->_prev = newnode;
			return iterator(newnode);
		}

		iterator insert(iterator pos, T&& x)
		{
			Node * cur = pos._node;
			Node* newnode = new Node(move(x));
			Node* prev = cur->_prev;
			
			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = cur;
			cur->_prev = newnode;
			return iterator(newnode);
		}

		template <class... Args>
		void emplace_back(Args&&... args)
		{
			insert(end(), std::forward<Args>(args)...);
		}

		template <class... Args>
		iterator insert(iterator pos, Args&&... args)
		{
			Node* cur = pos._node;
			Node* newnode = new Node(std::forward<Args>(args)...);
			Node* prev = cur->_prev;
			// prev newnode cur
			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = cur;
			cur->_prev = newnode;
			return iterator(newnode);
		}
	private:
		Node* _head;
	};
}

注意转发时的写法是:

std::forward<Args>(args)...

这行代码会展开成:

std::forward<Arg1>(arg1), std::forward<Arg2>(arg2), ...

为什么必须用 std::forward
  因为 Args&&... args 是万能引用,传入的左值会推导为左值引用,右值会推导为右值引用。但形参 args 本身是个变量,它一定是左值。如果不加 forward,参数包展开后直接传 args...,那么所有参数都会变成左值,丢失了原始的值类别。

  完美转发保证了:你传左值我就给你左值,你传右值我就给你右值,原封不动地传递下去。

4.3 与 push_back 的调用路径对比

  以 lt.emplace_back("1111")lt.push_back("1111") 为例:

push_back 路径:

  1. "1111" 首先隐式转换成临时 string 对象(调用 string(const char*) 构造)。
  2. push_back(const T&)push_back(T&&) 接收这个临时对象。
  3. 在节点内调用 data 的拷贝或移动构造。
  4. 析构临时对象。

emplace_back 路径:

  1. emplace_back 接收 const char* 参数。
  2. 通过完美转发,将 const char* 原样传给节点的构造函数。
  3. 节点构造函数在初始化列表里直接调用 string(const char*) 构造 data
  4. 完全没有临时对象。

  很明显,emplace_back 少了一次构造和一次析构,所以效率更高。尤其在构造对象代价高昂的时候,提升非常可观。


5. 面试常见考点

5.1 面试可能被问到的问题

Q1:可变参数模板如何展开参数包?
A:编译器通过递归模板实例化,每次分离出第一个参数和剩余参数包,直到包为空时调用终止函数。这是编译时递归,不依赖运行期循环。

Q2:sizeof...(args) 和普通 sizeof 有什么区别?
A:sizeof... 是专门用于参数包的运算符,返回参数个数,是编译期常量。普通 sizeof 用于求类型或表达式的字节大小。

Q3:为什么不能用循环遍历参数包?
A:参数包是编译时概念,编译实例化后就是独立的多个参数,不存在运行时可遍历的集合,且各个参数类型可能不同,无法用统一的循环体处理。

Q4:emplace_backpush_back 快吗?什么时候快?
A:当你直接传递构造参数而非现成对象时,emplace_back 可以避免创建临时对象,从而减少一次拷贝/移动构造和析构。如果你已经有一个现成的对象,两者性能一样。

Q5:完美转发参数包的写法及必要性?
A:写法是 std::forward<Args>(args)...。必要性:因为形参都是左值,不转发就丢失了右值信息,导致本该移动的变成拷贝。


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐