概述
大家在C++中应该见过不少函数,它们既没有限制参数的类型,也没有限制参数的个数,比如vector<T>::emplace()
,make_unique<T>()
。它们都是利用C++11中的可变模板参数来实现的。对于这一新特性,需要掌握以下三点
- 可变模板参数的语法
- 参数包的展开
- 实践
前言
在讲可变模板参数之前,需要先讲C语言中的变长参数
#include<stdarg.h>
#define END (-1)
int sum(int num, ...)
{
va_list list;
va_start(list, num);
int sum = 0;
for (int cur = num; cur != END; cur = va_arg(list, int))
sum += cur;
va_end(list);
return sum;
}
int main()
{
// 输出15
cout << sum(1, 2, 3, 4, 5, END) << endl;
}
C语言中的va_arg
宏并不能判断出哪个参数参数包的末尾,所以只能通过自己设定结束位,并通过显式判断来截取有效的参数
而在C++11中,使用变长参数更简单了(好吧其实也不怎么算变长)
int sum(std::initializer_list<int> initializerList)
{
int sum = 0;
for (const int& i : initializerList)
sum += i;
return sum;
}
int main()
{
std::cout << sum({1, 2, 3, 4, 5}) << std::endl;
}
可变模板参数的语法
函数
以C++11的标准来看,声明一个可变参数的模板函数有两种方法
template<typename... Ts>
void AnyNumberOfParam(Ts... ars) {}
int main()
{
// 可以接受0及以上个参数
AnyNumberOfParam();
AnyNumberOfParam(1, 2);
AnyNumberOfParam("824", std::vector<int>(), 3);
}
template<typename T, typename... Ts>
void AnyNumberOfParam(T least, Ts... ars) {}
int main()
{
// 至少需要有一个参数
// AnyNumberOfParam();
AnyNumberOfParam(1, 2);
AnyNumberOfParam("824", std::vector<int>(), 3);
}
类
template<typename T, typename... Ts>
class TestTemplateClass {};
int main()
{
TestTemplateClass<int, std::string> t;
}
template<typename... Ts>
class TestTemplateClass {};
int main()
{
// 因为支持0及以上的参数 所以这么写是合法的
TestTemplateClass t;
}
函数参数包的展开
C++11中的展开方式
递归展开
假设我们通过设计一个函数,能逐个输出它的参数
void print()
{
std::cout << "empty" << std::endl;
}
template<typename T, typename... Ts>
void print(T first, Ts... args)
{
std::cout << first << std::endl;
print(args...);
}
int main()
{
print(1, 2, "Mike", 3.21);
std::cout << std::endl;
print();
}
以上代码的递归过程为
print(1, 2, "Mike", 3.21);
print(2, "Mike", 3.21);
print("Mike", 3.21);
print(3.21);
print();
通过递归方式展开参数包,当所有参数包展开完毕后,自然为空,所以调用到非模板的递归中止函数
当然还可以使用模板递归中止函数,这种情况就不支持空参数包了
template<typename T>
void print(T end)
{
std::cout << end << std::endl;
}
template<typename T, typename... Ts>
void print(T first, Ts... args)
{
std::cout << first << std::endl;
print(args...);
}
int main()
{
print(1, 2, "Mike", 3.21);
// print();
}
以上代码的递归过程为
print(1, 2, "Mike", 3.21);
print(2, "Mike", 3.21);
print("Mike", 3.21);
print(3.21);
而在C++17中,对递归展开法进行了优化(前提是将if
语句声明为常量表达式)
template<typename T, typename... Ts>
void print(T first, Ts... args)
{
std::cout << first << std::endl;
if constexpr(sizeof...(args) > 0)
print(args...);
}
逗号表达式搭配initializer_list展开
在C++11中通过递归展开参数包的缺点很明显,需要重载一个递归终止函数,同时还需要判定终止函数是否需要使用到模板;不仅如此,还需要确保带参数包版本的函数至少包含一个类型(T fistst
),可以说是很不便了。下面介绍逗号表达式结合initializer_list
的展开方法
template<typename... Ts>
void print(Ts&&... args)
{
auto lambda = [](auto&& data) { std::cout << data << std::endl; };
std::initializer_list<int> il = { (lambda(std::forward<Ts>(args)), 0)... };
}
// C++现代教程上的做法
template<typename... Ts>
void print(Ts... args)
{
std::initializer_list<int>{([&args]() { std::cout << args << std::endl; }(), 0)... };
}
int main()
{
print(1, 2, "Mike", 3.21);
print();
}
这种搭配initializer_list
的解法我愿称之为黑魔法,在构造初始化列表的同时完成了参数包的展开
以下示范一个求和模板函数,如果我们直接使用参数包进行操作而不展开它,那么我们将会得到报错
C++17中的折叠表达式
一元折叠表达式
先来看看如何在C++17中利用折叠表达式(...)
展开参数包实现一个求平均数的函数
template<typename... T>
int avg(T... args) {
// 右折叠
return (args + ...) / sizeof...(args);
}
int main()
{
// 输出26
cout << avg(1, 2, 5, 'a') << endl;
// 编译出错
// cout << avg() << endl;
}
对于+运算符,符合交换律,所以左折叠和右折叠的结果相同
对于-
运算符,不符合交换律,因此左右折叠的结果不同
template<typename... Ts>
decltype(auto) sub_right(Ts... args) { return (args - ...); }
template<typename... Ts>
decltype(auto) sub_left(Ts... args) { return (... - args); }
int main()
{
// (((10 - 5) - 2) - 8)
std::cout << sub_left(10, 5, 2, 8) << std::endl;
// (10 - (5 - (2 - 8)))
std::cout << sub_right(10, 5, 2, 8) << std::endl;
}
对于一元折叠表达式而言,只有,
,&&
和||
操作允许空包,其它的如果出现空包则会编译出错
When a unary fold is used with a pack expansion of length zero, only the following operators are allowed:
Logical AND (&&). The value for the empty pack is true
Logical OR (||). The value for the empty pack is false
The comma operator (,). The value for the empty pack is void()
// 一元折叠逗号表达式
template<typename ...Args>
void printer(Args&&... args)
{
(..., (std::cout << args << std::endl)); // 左折叠 且每个参数间隔输出std::endl
// ((std::cout << args), ...); // 右折叠
}
int main()
{
// (((1 << 2) << Mike) << 3.21) 左折叠
// 1
2
Mike
3.21
printer(1, 2, "Mike", 3.21);
}
二元折叠表达式
二元折叠表达式,支持空包操作。二元折叠表达式的省略号(...
)永远在中间,特例在左还是在右决定了是左折叠还是右折叠
对于std::cout
的二元表达式而言,只能使用左折叠(因为输出必须以std::cout
开头,而这也就代表了它是左折叠)
template<typename ...Args>
void printer(Args&&... args)
{
// 二元左折叠
(std::cout << ... << args);
}
template<typename ... Ts>
void printer_space(Ts&&... args)
{
auto lambda = [] (auto params)
{
cout << ends;
return params;
};
(std::cout << ... << lambda(args));
}
int main()
{
// 12Mike3.21
printer(1, 2, "Mike", 3.21);
// nothing...
printer();
// 1 2 Mike 3.21
printer_space(1, 2, "Mike", 3.21);
}
拓展:以下代码是在参数包展开完毕之后再输出std::endl
,而不是每拆一次就输出一次
template<typename ...Args>
void printer(Args&&... args)
{
(std::cout << ... << args) << std::endl;
}
如何评价
类参数包的展开
C++11中的函数参数包可以使用递归或逗号表达式来展开,C++17中则可以使用优化的递归或折叠表达式来展开
C++11中的类参数包的展开需要运用到类模板的特化(因为笔者也搞不清楚以下代码是属于偏特化还是全特化,所以统一写成特化)
递归展开
// 一个支持1及多个类型的类
template<typename T, typename... Ts>
class TestClass
{
public:
// 匿名枚举 递归展开
enum { value = TestClass<T>::value + TestClass<Ts...>::value };
};
// 对1个类型的情况进行特化 递归中止类
template<typename lastT>
class TestClass<lastT>
{
public:
enum { value = sizeof(lastT) };
};
int main()
{
// 4 + 8 + 1 = 13
std::cout << TestClass<int, double, char>::value << std::endl;
// 在64位环境下大小为32
std::cout << TestClass<std::vector<int>>::value << std::endl;
// TestClass<>::value;
}
此展开方式不支持0参数包,因此可以改写为以下方式
// 只声明一个支持0及以上个类型的类
template<typename... Args>
class TestClass;
// 对1及以上个类型进行特化
template<typename First, typename... Rest>
class TestClass<First, Rest...>
{
public:
// 递归展开
enum { value = TestClass<First>::value + TestClass<Rest...>::value };
};
// 对1个类型进行特化 即递归终止类
template<typename First>
class TestClass<First>
{
public:
enum { value = sizeof(First) };
};
// 对0个类型进行特化
template<>
class TestClass<>
{
public:
enum { value = 0 };
};
对上面的代码可能会产生以下几点疑问
-
为什么只需要声明
TestClass
主体类而不用实现它,看看以下一个简单的实例你就明白了template<typename T> class TestTemplate; template<> class TestTemplate<int> { public: int data; }; template<> class TestTemplate<std::string> { public: std::string name; }; int main() { // 只能实例化出特化的int和string类型 TestTemplate<int> it{}; // TestTemplate<char> ct{}; }
-
为什么感觉这个特化并不是很特化的样子
template<typename First, typename... Rest> class TestClass<First, Rest...> { // codes... }
因为这里是根据参数的个数进行特化,而不是根据类型进行特化
你可能会觉得用匿名enum
来扮演一个编译期常量有点捞,那么接下来使用同样具有编译期常量特性的std::integral_constant
注意是
integral
不是interger
template<typename T, typename... Ts>
class TestTemplate : public std::integral_constant<int, TestTemplate<T>::value + TestTemplate<Ts...>::value>
{
};
template<typename T>
class TestTemplate<T> : public std::integral_constant<int, sizeof(T)>
{
};
int main()
{
cout << TestTemplate<int, double, char>::value << endl;
// TestTemplate<>::value;
}
有人可能会说了,那你加一个无类型的特化不就得了吗,其实不是的。因为我们模板类的主体是template<typename T, typename... Ts>
,这也就意味着需要1及以上个的类型,那么此时针对一个不满足主体的特化肯定是不正确的。至于如何实现0个类型,上文中给出了匿名enum
版本的实现,这里不再赘述
// 错误的特化方式 编译将出错
template<>
class TestTemplate<> : public std::integral_constant<int, 0>
{
};
继承展开
C++11中的std::tuple
就是使用继承展开参数包的
std::tuple
可以看作是std::pair
的升级版,它支持0-多个类型参数
std::tuple<int, double, std::string> myTuple{1, 3.14, "pi"};
auto& [intX, doubleY, strZ] = myTuple;
intX = 200;
// 200
std::cout << std::get<0>(myTuple) << std::endl;
手撕std::tuple
的代码将放在后文的实践部分
实践
手撕std::tuple
本文实现的MyTuple
的功能十分有限,在实际应用中可能会出现各种不必要的拷贝,以及编译无法通过等问题。以下代码只当作核心功能的剖析,应当作练习看待
tuple
// 声明一个支持0及以上个参数包的模板类
template<typename... Ts>
class MyTuple;
// 空MyTuple 偷懒不做实现
template<>
class MyTuple<> {};
// MyTuple主要实现 继承展开参数包
template<typename T, typename... Ts>
class MyTuple<T, Ts...> : public MyTuple<Ts...>
{
private:
T data;
using TopTuple = MyTuple<Ts...>;
public:
MyTuple() = default;
// 通用引用有参构造函数
template<typename ThisType, typename... RestTypes>
MyTuple(ThisType&& _data, RestTypes&&... _args) : data(std::forward<ThisType>(_data)), TopTuple(std::forward<RestTypes>(_args)...) {}
// 常函数版本get<>
template<std::size_t index>
constexpr auto& get() const
{
// 静态断言防止越界访问
static_assert(index <= sizeof...(Ts), "out of range");
if constexpr (index == 0)
return data;
else
return TopTuple::template get<index - 1>();
}
template<std::size_t index>
constexpr auto& get()
{
// 调用常函数版本的get<>
using element_type = my_tuple_element_type<index, MyTuple<T, Ts...>>;
return const_cast<element_type&>((static_cast<const MyTuple<T, Ts...>&>(*this)).template get<index>());
}
};
int main()
{
MyTuple<int, std::string, double> t(1, "Jelly", 3.14);
// 输出Jelly
std::cout << t.get<1>() << std::endl;
}
实现了一个非常简单的MyTuple
,没有考虑复杂的拷贝,移动或赋值等问题。和库中的版本一样,都使用了继承的方式展开参数包,每一层MyTuple
储存一个类型的数据。
tuple_size
下面实现一个个数萃取机,能获得MyTuple
中存储的类型个数
template<typename>
struct my_tuple_size;
// 对MyTuple格式的类型进行特化
template<template<typename...> typename TupleType, typename... Ts>
struct my_tuple_size<TupleType<Ts...>> : std::integral_constant<std::size_t, sizeof...(Ts)> {};
template<typename TupleType>
inline constexpr std::size_t my_tuple_size_value = my_tuple_size<TupleType>::value;
tuple_element
再来实现一个类型萃取机,能获得第几号元素是什么类型
template<std::size_t, typename>
struct my_tuple_element;
// 对MyTuple格式的类型进行特化
template<std::size_t index, template<typename...> typename TupleType, typename T, typename... Ts>
struct my_tuple_element<index, TupleType<T, Ts...>> : my_tuple_element<index - 1, TupleType<Ts...>> {};
// 特化出一个继承终止类
template<template<typename...> typename TupleType, typename T, typename... Ts>
struct my_tuple_element<0, TupleType<T, Ts...>> {
using type = T;
};
template<std::size_t index, typename TupleType>
using my_tuple_element_type = typename my_tuple_element<index, TupleType>::type;
make_tuple
再来实现一个my_make_tuple
,下面给出一个错误实现
template<typename... Ts>
MyTuple<Ts...> my_make_tuple(Ts&&... args) {
return MyTuple<Ts...>(std::forward<Ts>(args)...);
}
乍一看好像很对,对参数包进行展开然后完美转发。这么想就忽略了通用引用的特性,对于左值类型,通用引用会推导出T&
;对于右值类型会推导出T
那么我们使用左值类型进行my_make_tuple
时,将会推导出错误的类型
int main()
{
int a = 10;
const int b = 20;
string name = "Jelly";
MyTuple<int&, const int&, std::string&, double> t = my_make_tuple(a, b, name, 3.14);
}
那么关键就是:用什么办法能去除掉类型的const
,volatile
,reference
属性等等呢,答案是退化
std::decay
在C++ 11中的右值引用中,我提到了std::remove_reference_t
,用于去除类型的引用属性,但是std::decay
要更猛一些
- 如果是引用类型,会将其消除
- 如果又是数组类型,会退化为指针
- 如果又是函数,会退化为函数指针
- 同时会消除对象的cv属性
std::ref
但是问题又来了,这样不分青红皂白的退化一个类型,也使得我们没有办法创建一个记录引用类型变量的元组。std::ref
就是为此而生的,它会将参数包装成std::reference_wrapper
对象
最终实现
因此还需要进一步特化,正确的实现为:
template<typename T>
struct remove_ref_wrap {
using type = T;
};
template<typename T>
struct remove_ref_wrap<std::reference_wrapper<T>> {
using type = T&;
};
template<typename T>
using with_ref_decay_t = typename remove_ref_wrap<std::decay_t<T>>::type;
template<typename... Ts>
constexpr MyTuple<with_ref_decay_t<Ts>...> my_make_tuple(Ts&&... args) {
return MyTuple<with_ref_decay_t<Ts>...>(std::forward<Ts>(args)...);
}
先执行一次std::decay_t<T>
,然后再萃取出std::reference_wrapper
对象,为其施加引用。std::decay
不会去掉std::reference_wrapper
,因为它是经过包装的对象而不是类型
int main()
{
int a = 10;
float height = 1.7;
MyTuple<int&, double, float> t = my_make_tuple(std::ref(a), 3.14, height);
}
不足之处
对于有隐式转换的类型,需要显式指明my_make_tuple
的类型
MyTuple<std::string> t = my_make_tuple<std::string>("Jelly");
traverse_tuple
通过传入函数对象进行对MyTuple
的遍历
// 利用折叠表达式执行回调
template<typename TupleType, typename FuncType, std::size_t... Index>
void call_tuple(const TupleType& t, const FuncType& f, std::index_sequence<Index...>) {
(f(t.template get<Index>()), ...);
}
template<template<typename...> typename TupleType, typename... Ts, typename FuncType>
void my_traverse_tuple(const TupleType<Ts...>& t, const FuncType& f) {
call_tuple(t, f, std::make_index_sequence<my_tuple_size_value<TupleType<Ts...>>>{});
}
测试代码为
int main()
{
MyTuple<int, std::string> t = my_make_tuple(1, "123");
my_traverse_tuple(t, [](auto&& data) { std::cout << data << std::endl; });
}
std::apply
c++17中引入的对std::tuple
的一种遍历方式,传入的回调函数的参数为参数包
int main()
{
std::tuple<int, std::string, float> t = std::make_tuple(1, "Jelly", 3.14);
// 1Jelly3.14
std::apply([](auto&&... params) { (std::cout << ... << params); }, t);
}
完善
经过不懈的努力,我终于掌握了如何解决上面中提到的不足之处
template<typename... Ts>
class MyTuple;
template<>
class MyTuple<> {};
template<typename T, typename... Ts>
class MyTuple<T, Ts...> : public MyTuple<Ts...>
{
private:
T data;
using TopTuple = MyTuple<Ts...>;
template<typename TupleType, std::size_t... Indices>
MyTuple(TupleType&& _copy, std::index_sequence<Indices...>) : MyTuple(_copy.template get<Indices>()...) {}
public:
MyTuple() = default;
template<typename ThisType, typename... RestTypes>
MyTuple(ThisType&& _data, RestTypes&&... _args) : data(std::forward<ThisType>(_data)), TopTuple(std::forward<RestTypes>(_args)...) {}
template<template<typename...> typename TupleType, typename... RestTypes>
MyTuple(TupleType<RestTypes...>&& _copy) : MyTuple(std::forward<TupleType<RestTypes...>>(_copy), std::make_index_sequence<my_tuple_size_value<TupleType<RestTypes...>>>{}) {}
// const MyTuple调用
template<std::size_t index>
constexpr auto& get() const
{
static_assert(index <= sizeof...(Ts), "out of range");
if constexpr (index == 0)
return data;
else
return TopTuple::template get<index - 1>();
}
// non-const MyTuple调用
template<std::size_t index>
constexpr auto& get()
{
using element_type = my_tuple_element_type<index, MyTuple<T, Ts...>>;
return const_cast<element_type&>((static_cast<const MyTuple<T, Ts...>&>(*this)).template get<index>());
}
};
太棒了,解决了一个问题之后又出现一个问题
int main()
{
MyTuple<int, std::string, float> t1 = my_make_tuple(12, "24142", 3.14);
cout << t1.template get<0>() << endl;
cout << t1.template get<1>() << endl;
cout << t1.template get<2>() << endl;
// 一个我暂时还解决不掉的问题
// auto temp = my_make_tuple(12, "24142", 3.14);
// MyTuple<int, std::string, float> t2 = temp;
}
实现一个泛型delegate
众所周知,在C#中有一个关键字是delegate
delegate int AddNumDelegate(int x, int y);
int Sum(int x, int y) { return x + y; }
AddNumDelegate addNum = Sum;
int z = addNum(10, 30);
如果你看不懂的话,翻译到C++中大概是这么个玩意(图一乐就行了)
using AddNumDelegate = std::function<int(int, int)>;
int Sum(int x, int y) { return x + y; }
int main()
{
AddNumDelegate addSum = Sum;
std::cout << addSum(10, 20) << std::endl;
}
上述例子中都显式指定了函数的参数类型,那我们在C++中实现一个泛型的delegate
。因为C#中的函数都是成员函数,所以只实现类成员函数的版本
template<typename T, typename ReturnType, typename... Params>
class MyDelegate
{
private:
using MemberFuncType = ReturnType (T::*)(Params...);
T* pClass;
MemberFuncType pMemberFunc;
public:
MyDelegate(T* _t, MemberFuncType _f) : pClass(_t), pMemberFunc(_f) {}
template<typename... Ts>
ReturnType operator()(Ts&&... _args) {
return (pClass->*pMemberFunc)(std::forward<Ts>(_args)...);
}
};
struct TestStruct
{
template<typename T>
int test_func(T&& x) { return std::forward<T>(x); }
};
int main()
{
TestStruct t;
MyDelegate d(&t, &TestStruct::test_func<int>);
// true
std::cout << std::boolalpha << is_rvalue_reference_v<decltype(d(10))> << std::endl;
}
模板题外话
template<auto>
这是C++17对非类型模板参数的自动类型推导
// C++17之前
template<std::size_t num>
void print() { std::cout << num << std::endl; }
// C++17之后
template<auto num>
void print() { std::cout << num << std::endl; }
而C++20又放宽了非类型模板参数的限制,C++20之前的非类型模板参数不能为浮点数,只能为int
, unsigned int
, long long
,char
以及指针类型等等,而C++20则支持double
和float
// C++20
template<double num>
void print() { std::cout << num << std::endl; }