省略号在 C 和 C++ 中有多种用法。 其中包括函数的变量参数列表。 C 运行时库中的 printf() 函数是最著名的示例之一。

——Microsoft Learn

以下代码均在C++20标准下运行,若有CV工程师发现代码无法运行,请手动指定C++标准为>=20。

CMake下添加/更改此行。

set(CMAKE_CXX_STANDARD 20)

形参包

声明

//模板头
template<typename... Types>
//下跟class/struct/function/variable

参数包与包展开

...在参数名称的左侧,表示“参数包”,...在参数名称的右侧,将参数包扩展为单独的名称。

template<typename... Args>
//声明了名为args的参数包
void f(Args... args) {
//将参数包args展开
f(args...);
}

用例

仅仅看上面的语法会比较抽象,让我们通过实例来讲解。

不妨设现在有一需求,实现一函数,对传入函数的所有参数(参数有各种类型)进行加和,并返回结果。

template<typename Arg, typename... Args>
auto sum(Arg arg, Args... args) {
return arg + sum(args...);
}

这样写看起来非常完美,也酷似递归,但实际上略加思考就会发现,递归是有边界条件的,而这个函数却没有规定边界条件。

如是我们可以改为如下:

//当参数为0个时直接返回0,结束“递归”
/*
auto sum() {
return 0;
}
*/

//或参数个数为1时返回这个参数的值,结束“递归”
/*
template<typename Arg>
auto sum(Arg arg) {
return arg;
}
*/

//两种结束“递归”的方式任选其一即可

template<typename Arg, typename... Args>
auto sum(Arg arg, Args... args) {
return arg + sum(args...);
}

另一种写法:

template<typename Arg, typename... Args>
auto sum(Arg arg, Args... args) {
if constexpr(sizeof...(args) > 0) {
//参数包内参数个数大于0时继续展开
return arg + sum(args...);
} else {
//等于0时返回arg
return arg;
}
}

关于sizeof…我们会在后文介绍。

在上文中,请注意我说“酷似递归”,以及注释中“递归”的引号,因为这种形式实际上并不是严格意义上的递归,因为其调用的函数标签不同。

C++ Insights (cppinsights.io)中,我们将代码段贴入,看看编译器是如何帮助我们处理的。

Source

template<typename Arg, typename... Args>
auto sum(Arg arg, Args... args) {
if constexpr(sizeof...(args) > 0) {
return arg + sum(args...);
} else {
return arg;
}
}

int main() {
auto res = sum(6.1, 5, 4, 30, 5.1);
}

Insight

template<typename Arg, typename ... Args>
auto sum(Arg arg, Args... args)
{
if constexpr(sizeof...(args) > 0) {
return arg + sum(args... );
} else /* constexpr */ {
return arg;
}

}

#ifdef INSIGHTS_USE_TEMPLATE
template<>
double sum<double, int, int, int, double>(double arg, int __args1, int __args2, int __args3, double __args4)
{
if constexpr(true) {
return arg + sum(__args1, __args2, __args3, __args4);
} else /* constexpr */ {
}

}
#endif


#ifdef INSIGHTS_USE_TEMPLATE
template<>
double sum<int, int, int, double>(int arg, int __args1, int __args2, double __args3)
{
if constexpr(true) {
return static_cast<double>(arg) + sum(__args1, __args2, __args3);
} else /* constexpr */ {
}

}
#endif


#ifdef INSIGHTS_USE_TEMPLATE
template<>
double sum<int, int, double>(int arg, int __args1, double __args2)
{
if constexpr(true) {
return static_cast<double>(arg) + sum(__args1, __args2);
} else /* constexpr */ {
}

}
#endif


#ifdef INSIGHTS_USE_TEMPLATE
template<>
double sum<int, double>(int arg, double __args1)
{
if constexpr(true) {
return static_cast<double>(arg) + sum(__args1);
} else /* constexpr */ {
}

}
#endif


#ifdef INSIGHTS_USE_TEMPLATE
template<>
double sum<double>(double arg)
{
if constexpr(false) {
} else /* constexpr */ {
return arg;
}

}
#endif


int main()
{
double res = sum(6.0999999999999996, 5, 4, 30, 5.0999999999999996);
return 0;
}

总结来说,在我们调用sum时,template为我们生成了<double, int, int, int, double><int, int, int, double><int, int, double><int, double><double>,这五个特化模板函数。他们的函数参数也各不相同,并不符合递归的定义。

展开形式

依旧以上面的函数为例子

template<typename Arg, typename... Args>
auto sum(Arg arg, Args... args) {
if constexpr(sizeof...(args) > 0) {
return arg + sum(args...);
} else {
return arg;
}
}

我们同样可以传入引用,或者限定符。

template<typename Arg, typename... Args>
auto sum(const Arg& arg, const Args&... args) {
if constexpr(sizeof...(args) > 0) {
return arg + sum(args...);
} else {
return arg;
}
}

如此展开args,得到的类型都是const Args&,在处理基础类型时无需关心此类情况,但涉及自定义类型struct/class时需要考虑值类别,而const T&是可以接取右值的,它可以接取左值也可以接取右值,当然我们可以使用T&&来生成万能引用。

实战

至此,我们可以自己封装一个类似CRT(C 运行时库)printf的函数,且不需要格式限定符。

Code

#include <iostream>

namespace mylib {
void printf() {
std::cout << std::endl;
}

template<typename Arg>
void printf(const Arg& arg) {
std::cout << arg << std::endl;
}

template<typename Arg, typename... Args>
void printf(const Arg& arg, const Args&... args) {
std::cout << arg << ',';
printf(args...);
}
}

int main() {
mylib::printf("Hello world!", 5201314, 3.1415926, 'U');
}

Console

C:\Users\17253\CLionProjects\params\cmake-build-debug\params.exe
Hello world!,5201314,3.14159,U

进程已结束,退出代码为 0

形参包索引

Pack indexing is a C++2c extension.

我们可以通过下标运算符[]来实现对形参包特定索引下元素的访问。

已经验证,gcc/g++不支持此拓展,msvc暂不明确。

std::tuple

类模板 std::tuple 是固定大小的异质值的汇集。它是 std::pair 的泛化。

——cppreference

std::make_tuple可创建一个 tuple 对象,其类型根据各实参类型定义。

借助std::tuple,我们可以将参数包进行存储并访问。

std::get可元组式访问指定的元素。

auto first_plus_second(auto... args) {
auto tuple = std::make_tuple(args...);
//or std::tuple tuple(args...);
return std::get<0>(tuple) + std::get<1>(tuple);
}

我们不难想到一种意外情况:args参数包内的参数数量不足2时,我们使用std::get<1>(tuple)会产生意想不到的后果。

因此我们需要获取并验证参数包的长度。

sizeof…运算符

sizeof...运算符与sizeof运算符不同,注意不要混淆。

sizeof...运算符用于获取形参包长度。

#include <iostream>

auto first_plus_second(auto... args) {
static_assert(sizeof...(args) >= 2,
"Parameters pack is to small to call this function, its size must be 2 at least.");
auto tuple = std::make_tuple(args...);
return std::get<0>(tuple) + std::get<1>(tuple);
}

auto first_plus_last(auto... args) {
static_assert(sizeof...(args) >= 2,
"Parameters pack is to small to call this function, its size must be 2 at least.");
auto tuple = std::make_tuple(args...);
return std::get<0>(tuple) + std::get<sizeof...(args)-1>(tuple);
}

int main() {
//编译通过
std::cout << first_plus_second(1, 3, 5);

//编译失败:Parameters pack is to small to call this function, its size must be 2 at least.
std::cout << first_plus_second(1);
}

折叠表达式

一元折叠

依旧回到我们的sum函数代码并以此为例。

template<typename Arg, typename... Args>
auto sum(Arg arg, Args... args) {
if constexpr(sizeof...(args) > 0) {
return arg + sum(args...);
} else {
return arg;
}
}

我们将其改为:

template<typename... Args>
auto sum(Args... args) {
return (... + args);
}

一下就简明了许多,发生了什么?首先我们需要参阅下表。

Syntax Name
(形参包 运算符… ) 一元右折叠
( …运算符 形参包) 一元左折叠
(形参包 运算符运算符 初值) 二元右折叠
(初值 运算符运算符 形参包) 二元左折叠

我们现在对其进行解释:

折叠表达式的实例化按以下方式展开成表达式 E:

  1. 一元右折叠 (E 运算符 ...)

  2. 一元左折叠 (... 运算符 E)

  3. 二元右折叠 (E 运算符 ... 运算符 I)

  4. 二元左折叠 (I 运算符 ... 运算符 E)

template<typename... Args>
auto sum(Args... args) {
return (... + args);
}

//上述代码的return语句将被展开为

//return (((args1+args2)+...)+argsn)

二元折叠

那么关于二元折叠的情况呢?我们回到自己封装的printf函数:

void printf() {
std::cout << std::endl;
}

template<typename Arg>
void printf(const Arg& arg) {
std::cout << arg << std::endl;
}

template<typename Arg, typename... Args>
void printf(const Arg& arg, const Args&... args) {
std::cout << arg << ',';
printf(args...);
}

注意到<<为链式调用,且全局对象std::cout为初值,即

那么我们可以参考二元左折叠,对我们的printf进行改造:

template<typename... Args>
void printf(const Args&... args) {
(std::cout << ... << args);
}

Code

#include <iostream>

namespace mylib {
template<typename... Args>
void printf(const Args&... args) {
(std::cout << ... << args);
}
}

int main() {
mylib::printf("Hello world!", 5201314, 3.1415926, 'U');
}

Console

C:\Users\17253\CLionProjects\params\cmake-build-debug\params.exe
Hello world!52013143.14159U
进程已结束,退出代码为 0

我们发现逗号消失了,这并不是我们想要的。

仔细观察,会发现我们需要的是

不妨化简为

发现其符合一元左折叠的形式,我们依此对printf改造

Code

#include <iostream>

namespace mylib {
template<typename... Args>
void printf(const Args&... args) {
((std::cout<< args << ","), ...);
std::cout<< std::endl;
}
}

int main() {
mylib::printf("Hello world!", 5201314, 3.1415926, 'U');
}

Console

C:\Users\17253\CLionProjects\params\cmake-build-debug\params.exe
Hello world!,5201314,3.14159,U,

进程已结束,退出代码为 0

结果符合我们的预期。

C++ Insights下的代码

普通展开

#include <iostream>

namespace mylib
{
void printf()
{
std::cout.operator<<(std::endl);
}
template<typename Arg>
void printf(const Arg & arg)
{
(std::cout << arg) << std::endl;
}

/* First instantiated from: insights.cpp:16 */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
void printf<char>(const char & arg)
{
std::operator<<(std::cout, arg).operator<<(std::endl);
}
#endif

template<typename Arg, typename ... Args>
void printf(const Arg & arg, const Args &... args)
{
(std::cout << arg) << ',';
printf(args... );
}

/* First instantiated from: insights.cpp:21 */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
void printf<char[13], int, double, char>(const char (&arg)[13], const int & __args1, const double & __args2, const char & __args3)
{
std::operator<<(std::operator<<(std::cout, arg), ',');
printf(__args1, __args2, __args3);
}
#endif


/* First instantiated from: insights.cpp:16 */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
void printf<int, double, char>(const int & arg, const double & __args1, const char & __args2)
{
std::operator<<(std::cout.operator<<(arg), ',');
printf(__args1, __args2);
}
#endif


/* First instantiated from: insights.cpp:16 */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
void printf<double, char>(const double & arg, const char & __args1)
{
std::operator<<(std::cout.operator<<(arg), ',');
printf(__args1);
}
#endif


#ifdef INSIGHTS_USE_TEMPLATE
template<>
void printf<char>(const char & arg);
#endif


}

int main()
{
mylib::printf("Hello world!", 5201314, 3.1415926000000001, 'U');
return 0;
}

折叠表达式

#include <iostream>

namespace mylib
{
template<typename ... Args>
void printf(const Args &... args)
{
(((std::cout << args) << ",") , ...);
std::cout.operator<<(std::endl);
}

/* First instantiated from: insights.cpp:12 */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
void printf<char[13], int, double, char>(const char (&__args0)[13], const int & __args1, const double & __args2, const char & __args3)
{
(std::operator<<(std::operator<<(std::cout, __args0), ",")) , ((std::operator<<(std::cout.operator<<(__args1), ",")) , ((std::operator<<(std::cout.operator<<(__args2), ",")) , (std::operator<<(std::operator<<(std::cout, __args3), ","))));
std::cout.operator<<(std::endl);
}
#endif


}

int main()
{
mylib::printf("Hello world!", 5201314, 3.1415926000000001, 'U');
return 0;
}

结论

不难看出普通展开生成了多个特化,折叠表达式仅生成一个。

因此我们可以得出结论:

折叠表达式在效率上高于普通展开。