可变参数模板(variadic templates)是C++11引入的重大特性之一,其对元编程(metaprogramming)产生了极为深远的影响。
...
可变参数函数
对C语言有了解的读者一定对这个符号不陌生:其用于表示一个可变参数函数(variadic
function)。
C语言中定义的va_list
、va_start
、va_arg
和va_end
几个宏都和可变参数的访问有关;出于兼容性和可变参数模板的长期缺失C++也支持了这种写法。
声明一个可变参数函数的语法为:在其最后一个形参后添加一个,...
,最经典的莫过于printf
的函数签名:
1
int printf(const char* format,...);
实际上
...
前的,
是可选的,但C++26起将不再接受这种写法。原因之一是若不加,
以区分之,碰上现代C++的 variadic template 会出现难以分辨二者的情况,如:
(不要笑,这种写法确实在特定场景(如显式指定模板参数)还是有意义的,当然自动推导模板参数时,可变参数函数包会因优先级靠后而置空)
1
2
3
4
5 template<class... Ty>
void fun(Ty...,...);
template<class... Ty>
void foo(Ty......); //???
C/早期C++中的许多“陋习”在更新的C/C++版本中都逐渐被废弃,除可变函数参数声明外,其它的典例包括隐式返回值(早期C定义函数时可以不声明返回值,此时默认返回类型是
int
,C99后已不支持此写法)、指针类型的隐式转换(C中支持void*
向任意指针类型的隐式转换,但C++要求必须显式指明之)等。
正是因为可变参数函数的存在,我们方能向printf
和其它函数扔一大堆乱七八糟的东西传递不同数目的参数。其使得即使在一门编译期语言中,我们亦无需为仅存在传参个数之差分的同个函数,编写不同的重载。
但C语言的一些“包袱”实际上对C++,尤其是现代C++而言不够适用,推动着C++去不断发展推进,以新的语法/库特性解决这些诉求。
参数包大小问题
其一,其自身并不携带任何的大小信息(某种意义上也算C语言的“通病”了,包括C样式字符串、C样式数组(还有指针+长度的组合),虽然在效率和性能开销上确实做到了极致,但从开发和维护的角度来看过于极端),导致对可变参数的访问是不安全的(容易“漏掉”信息、越界或被栈溢出攻击pwn警告)。
事实上C语言对此从语言层面上几乎毫无办法,只能在实现中直接或间接地传递参数个数:间接的典例就是printf
的格式串,每个%
格式说明符(escape-character%%
除外)代表了一个需要被访问的参数。
若
%
的个数和传递参数的个数不一致则会导致栈溢出(参数的访问混乱,以至越界)。
类型安全问题
其二,除了越界访问的问题,可变参数访问时的类型安全性同样没有保障,参考下面一段代码:
1 | int sum(int count,...) |
先解读一下
va_list
、va_arg
、va_start
和va_end
的作用:
va_list
本质上是一个指针类型,实现中多为char*
或void*
,以保证其能以1Byte的最小“分度”访问内存。
va_start
接受两个参数,第一为va_list
类型,第二为代表参数包大小的int
,如va_start(ls,count);
意为给ls
赋值,使之可以安全地访问count
个参数。因为可变参数...
会通过函数栈进行传递,va_start
时赋ls
以栈的起始地址。
va_arg(ls,int)
调用时,会将ls
往后“移动”sizeof(int)
,返回一个int
型的值,底层来看就是以int
型的方式读取了va_arg
开始的一段内存。参考以下代码:
但如此的问题也很显然:要么存在某种方法传递给定参数的类型,要么就只能“希望”函数调用者能够正确地传递所有类型——C语言中如此已经算是一种奢求,更无论类型乱飞的C++了。
1
2
3
4
5
6
7
8
9
10
11
12 //Suppose that ls is of type char* and sizeof(char) == 1
//In this "function", actually the parameter should be a secondary pointer(so that the value of "ls" can be modified externally)
//So a macro function(similar to templates) would be like:
//va_arg(ls,int) is like:
int fun(char** ptr)
{
int ret = *(int*)(*ptr);
*ptr += sizeof(int);
return ret;
}
还是可以举
printf
的例子:如格式说明符中%f
、%lf
和%sf
之区别极为重要,因为va_list
不仅不知道该如何解读当前的这段内存(只能看格式说明符),还不知道两个相邻参数的 分隔点在哪,只能靠+sizeof(Current_type)
来“摸瞎”式地前进,一旦一个类型参数传递错误,其会直接导致后续内存读取全部出错(传参界的OFB,失去同步警告)
类与对象概念的举步维艰
其三,不同于“同是内存,众生平等”的C语言,C++中类与对象的概念在其中变得非常棘手。
因为...
毕竟是源于C的概念,其在栈参数传递时只会简单粗暴地将原对象的内存空间数据memcpy
过来。但与类的动态内存管理必须使用new
/delete
而非malloc
/free
一样,对于类和对象如此操作亦会导致一系列的问题,如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Bar
{
public:
Bar()
{
std::println("ctor @ {}",static_cast<void*>(this));
}
~Bar()
{
std::println("dtor @ {}",static_cast<void*>(this));
}
};
void fun(int x,...)
{
va_list ls{};
va_start(ls, x);
for (int i = 0;i < x;++i)
{
std::println("addr = {}", static_cast<void*>(std::addressof(va_arg(ls, Bar))));
}
va_end(ls);
}
int main()
{
Bar x{};
fun(3, x, x, x);
return 0;
}
1
2
3
4
5ctor @ 0x891b9ff714
addr = 0x891b9ff6f8
addr = 0x891b9ff700
addr = 0x891b9ff708
dtor @ 0x891b9ff714
构造/析构函数只对在0x891b9ff714
的Bar
生效过:明明va_arg
“像是”访问了三个Bar
的内存空间,但它们根本没调用过构造/析构函数——以C++之理明之,即“尚不算一个对象”,因为它们是被malloc
+memcpy
的组合“构建”起来的(而非operator new
)。
严格地,此时访问这三个
Bar
的成员函数实际上是UB(Undefined Behavior)。
还是...
,但是template
C++11引入的可变参数模板正是对在可变参数需求下,满足访问安全、类和对象语义明确的“解药”。
与“可变参数函数”不同的是,可变参数模板更像一个静态/编译期的策略(因为
template
本身就是一个编译期概念),而可变参数函数更像动态/运行时的策略(函数寻址、传参等都是运行时的概念和行为,参考虚函数的实现)。
声明
可变参数模板通过...
声明,其必须是模板参数列表的最后一个参数,位置在class
/typename
后,标识符之前;函数、变量、类以及concepts
都可以使用之。
使用时Types
被称为模板参数包(Template paramter
pack),其大小可以是零或任意正数。类型为模板参数包的函数形参,称为函数参数包(Function
parameter pack):
1 | template<class ...Types> |
大小
对于一个参数包,C++11引入了sizeof...
运算符。其特性和sizeof
类似(作为编译期运算符),应用于一个模板参数包或者函数参数包以获取其长度,如:
1 | template<class ...Types> |
扩展
上述例中的Types
是模板参数包,需要将之扩展(expand)才能正常使用,类似于Python中对一个可迭代对象使用解引用运算符*
来将之展开。
作为一门强编译型语言,C++26前,对参数包只有获取大小和扩展两个合法操作,后者相当于临时“放弃”了其作为参数包的特性。
C++中,对一个参数包触发扩展的方式为在其标识符后跟上...
;扩展同样也是编译期操作。如:
1 | template<class ...Types> |
最后一行无法编译的原因:不同于Python等解释型语言,C++在编译期必须确定将调用的表达式(此时为printf(8)
),随后校验发现这条语句是非法的(很显然)。
拓展模板参数包的实例:
1 | template<class ...Types> |
若我们希望同时拓展函数类型和函数参数(常见于函数“转发”中),则应将两个参数包“打包”后再写...
,此时会将两者解包后再应用对应的函数。如最著名的std::forward
:
1
2
3
4
5
6
7
8template<class Types>
void func(Types&&... args)
{
fun(std::forward<Types>(args)...); //Expanded both Types and args
}
double x{};
func(1); //Invokes fun(std::forward<int&&>(1));
func("",x); //Invokes fun(std::forward<const char*&>(""),std::forward<double&>(x));
关于
std::forward
、函数转发时的类型保持问题、Types&&...
的来由等,可参见C++——右值引用和移动语义。Types&&
亦有“万能参数包”之名。
应通过多编写代码实践来理解“拓展”法的精髓,便于灵活运用的同时,也有助于学习C++17的 fold expression。
通过()
把握“拓展”的时机非常重要,需根据编写代码的意图来判断:
1 | template<class Ty> |
假设调用print_stuffs(A,B,C)
,那么第一种(错误的)写法相当于扩展成了:
1
print_stuffs(debug_rep(A,B,C));
1
print_stuffs(debug_rep(A),debug_rep(B),debug_rep(C));
同时C++规定对于每个实例化的参数包,若其被使用(传递)则必须在其函数体/类体内被扩展(expanded);但sizeof...
恰好相反(不允许扩展解包,必须应用于整个参数包上):
1 | template<class ...Types> |
递归调用
当我们对一个包只能扩展之时,如何才能使用它呢?C++11给出的解法是使用参数个数差分+递归调用。
具体地,我们定义一个print
函数:(万能引用炫技警告)
1
2
3
4
5
6
7
8
9
10
11
12template<class Ty,class ...Types>
void print(Ty&& arg,Types&&... args)
{
std::cout << std::forward<Ty>(arg) << std::endl;
print(std::forward<Types>(args)...);
}
template<class Ty>
void print(Ty&& arg)
{
std::cout << std::forward<Ty>(arg) << std::endl;
}
1
print(1,2,3);
print<int,int,int>(1,2,3)
->
print<int,int>(2,3)
->
print<int,int>(3)
。
其中,最后一次由于可变参数包为空,在进行模板重载决议时,编译器会选择非可变参数版本的print
,进而终止递归。
如此调用时,非可变参数版本的
print()
而不能通过编译(上述调用链也是在编译时即确定)。
可变模板递归的应用:以std::tuple
为例
上善若水,水善利万物而不争。
很多C++11引入的库特性都离不开 variadic
template,对其深入了解亦有助于理解一些看似较为奇怪的语法规定。
最为经典的例子莫过于std::tuple
。其就依靠了递归定义来实现不然你怎么往里面塞远多于,下述为MSVC中std::pair
的两个类型std::tuple
的实现节选:
1 | template <> |
其中_Tuple_val
是一个用于储存单个元素的struct
模板。显然其利用了空tuple
终止递归的特性。
tuple
的应用极为广泛,如需要返回多个返回值时,其是除了单独定义一个struct
外的最佳选择;标准库中的大量函数返回值也应用到了tuple
(尤其是C++20后的
ranges library)。同时为了保证 cvref
属性正确传递、接受多种可行的构造方式等,结合 ranges library
的
C++23中zip
风波,std::tuple
的构造函数数量已经达到了恐怖的26个。
至于应用上的缺点,除了递归模板定义导致的代码膨胀外,访问std::tuple
中的元素也并非易事毕竟这不是Python,不能直接。operator[]
C++11中一般采用std::get
或std::tie
来获取元素,前者需要指定一个编译期常量作为其第一模板参数,后者结合std::ignore
可以实现对不同参数的选择性“打包”:
1 | std::tuple<int,int,int> tp{1,2,3}; |
std::tuple
的最大魅力之一在于其可以储存引用类型,这也是上述std::tie
能够实现“双向赋值”的原理之一,建议通过“手搓”一个std::tie
来理解其底层实现。注意std::tie
返回一个std::tuple
,且其只能接受左值参数。
C++17引入 structured binding
后,对tuple
内参数的获取无疑便捷了不少有Python那味了:
1 | auto [x,y,z] = tp; //x,y,z = 1,2,3 |
注意此处
x
、y
、z
的类型实际上是int&&
——其由 structured binding 的特性所致。
详见C++——结构绑定当然目前这个链接还打不开,因为还在咕
前行:fold expression 和 pack indexing
而上述递归调用的缺点也非常明显,即为模板膨胀(template inflation)导致的调用效率偏低和代码膨胀,同时模板重载的可读性也欠佳。
模板膨胀常发生在递归调用模板处。
以上述的print<int,int,int>
、print<int,int>
和print<int>
的代码。
对于较简单的函数调用实例尚且如此,一些递归定义的类模板等膨胀将更为严重(特别是涉及嵌套类、模板类内的模板函数等时)。
因此C++17中提出了 fold expression
,让许多使用可变模板参数的递归实现都可改为“迭代”实现,在进一步提高可读性和维护性的同时,极大提高了可变参数模板的使用效率。
如,上述print
函数使用 fold expression
可以写作:
1
2
3
4
5template<class ...Types>
void print(Types&&... args)
{
(std::cout << ... << std::forward<Types>(args));
}print(1,2,3)
会展开作:std::cout << std::forward<int&&>(1) << std::forward<int&&>(2) << std::forward<int&&>(3)
。
有关 fold expression 的更多内容可移步:C++——折叠表达式
这个链接同样还在咕,遥遥无期的那种
当然 fold expression
也并非万金油,也存在大量的不能用其简化调用的情况。
C++26在对参数包的访问上更进了一步,提出了 pack indexing
的概念,允许了通过编译期的size_t
对参数包进行寻址的方法,效果概览如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15template<class ...Tys>
void print(Tys&&... args)
{
if constexpr(sizeof...(args))
{
std::println("The last argument is: {}",std::forward<Tys...[sizeof...(Tys)-1]>(args...[sizeof...(Tys) - 1]));
}
else
{
std::println("The argument pack is empty!");
}
}
print(1,2,3); //Output: The last argument is: 3
print(); //Output: The argument pack is empty!
事实上C++26前结合模板的
size_t
参数可以实现类似下标访问的效果(不然你猜C++11就有的),如:std::get
是干啥的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 template<size_t N,class Type,class ...Types>
auto&& visit(Type&& arg,Types&&... args)
{
if constexpr(N == 0)
{
return std::forward<Type>(arg);
}
else if constexpr(N < sizeof...(args))
{
return visit<N-1>(std::forward<Types>(args));
}
else
{
static_assert(false,"Pack index out of range");
}
}
结语
有言称C++可以分成不带模板的C++和带模板的C++两种语言;而可变参数模板无疑是后者中极重要的支柱之一。
熟能生巧、深入理解之能极大地提高自身的代码开发效率,并能面对繁杂的标准库和三方库时游刃有余。