C和C++在泛型编程方面最大的区别可能在于类型安全性。在C语言中可以编写泛型代码 —— 标准函数qsort()就是一个完美的例子 —— 可对任意类型的值进行排序,这些值通过void*指针传入,而void指针实际上可以指向类型。当然,开发者必须知道实际的类型,并将指针转换为正确的类型。而在泛型C++程序中,类型要么在实例化时显式指定,要么自动推导,泛型类型的类型系统与普通类型的类型系统一样强大,除非需要一个参数数量未知的函数。在C++11之前,唯一的方法是使用传统的C风格可变参数函数,这种情况下编译器完全不知道参数的类型;开发者只能依靠自己的知识,正确地解包可变参数。
C++11引入了现代版的可变参数函数 —— 可变参数模板。现在可以声明一个具有任意数量参数的泛型函数:
template <typename ... T> auto sum(const T& ... x);
这个函数接受一个或多个可能具有不同类型参数,并计算总和。返回类型不容易确定,但可以让编译器来推断 —— 只需将返回类型声明为auto。那么,该如何实现这个函数,以对数量未知且类型无法明确(甚至无法作为泛型类型命名)的值进行求和呢?在C++17中很容易实现,因为折叠表达式:
// Example 08a
template <typename ... T> auto sum(const T& ... x) {
return (x + ...);
}
sum(5, 7, 3); // 15, int
sum(5, 7L, 3); // 15, long
sum(5, 7L, 2.9); // 14.9, double
可以验证结果的类型确实如我们所言:
static_assert(std::is_same_v<
decltype(sum(5, 7L, 2.9)), double>);
在C++14以及C++17中,当折叠表达式不够用时(仅在有限的上下文中适用,主要是在参数通过二元或一元运算符进行组合的情况下),标准的做法是使用递归,这在模板编程中一直非常流行:
// Example 08b
template <typename T1> auto sum(const T1& x1) {
return x1;
}
template <typename T1, typename ... T>
auto sum(const T1& x1, const T& ... x) {
return x1 + sum(x ...);
}
第一个重载(非偏特化!)用于只有一个任意类型参数的 sum() 函数,直接返回该值。第二个重载用于处理多个参数,将第一个参数显式地加上剩余参数的总和。递归会持续进行,直到只剩一个参数,此时会调用另一个重载,递归从而终止,这是展开可变参数模板中参数包的标准技术。编译器会内联所有递归函数调用,并生成直接将所有参数相加的简洁代码。
类模板也可以是可变参数的 —— 可以拥有任意数量的类型参数,并能由不同类型的多个对象构建类。其声明方式与函数模板类似。例如,让构建一个名为 Group 的类模板,可以容纳数量不同类型对象,并在转换为它所包含的某种类型时返回正确的对象:
// Example 09
template <typename ... T> struct Group;
此类模板的常规实现再次采用递归方式,通常使用深度嵌套的继承,尽管有时也可能实现非递归版本。
当只剩一个类型参数时,递归必须终止。这通过偏特化来实现,可将之前展示的通用模板仅保留为声明,并为单个类型参数定义一个特化版本:
template <typename ... T> struct Group;
template <typename T1> struct Group<T1> {
T1 t1_;
Group() = default;
explicit Group(const T1& t1) : t1_(t1) {}
explicit Group(T1&& t1) : t1_(std::move(t1)) {}
explicit operator const T1&() const { return t1_; }
explicit operator T1&() { return t1_; }
};
这个类持有一个类型T1的值,通过复制或移动进行初始化,并在转换为T1类型时返回对该值的引用。针对任意数量类型参数的特化版本,将第一个类型作为数据成员,包含相应的初始化和转换方法,并从剩余类型的Group类模板继承:
template <typename T1, typename ... T>
struct Group<T1, T ...> : Group<T ...> {
T1 t1_;
Group() = default;
explicit Group(const T1& t1, T&& ... t) :
Group<T ...>(std::forward<T>(t) ...), t1_(t1) {}
explicit Group(T1&& t1, T&& ... t) :
Group<T...>(std::forward<T>(t)...),
t1_(std::move(t1)) {}
explicit operator const T1&() const { return t1_; }
explicit operator T1&() { return t1_; }
};
对于 Group 类中包含的每一种类型,都有两种可能的初始化方式 —— 复制或移动,不必为每一种复制和移动操作的组合显式写出构造函数。相反,我们为第一个参数(存储在特化版本中的那个)的两种初始化方式提供了两个版本的构造函数;对于剩余的参数,可以使用完美转发。
现在,可以使用 Group 类模板来存储一些不同类型(无法处理同一类型的多个值,尝试检索该类型时会产生歧义)的值:
Group<int, long> g(3, 5);
int(g); // 3
long(g); // 5
显式写出所有组类型,并确保它们与参数类型匹配会很麻烦。C++17 中,可以使用推导指引来实现从构造函数中推导类模板参数:
template <typename ... T> Group(T&&... t) -> Group<T...>;
Group g(3, 2.2, std::string("xyz"));
int(g); // 3
double(g); // 2.2
std::string(g); // "xyz"
C++17 之前,解决此问题的常用方法是使用一个辅助函数模板(当然,是一个可变参数模板)来利用模板参数推导:
template <typename ... T> auto makeGroup(T&& ... t) {
return Group<T ...>(std::forward<T>(t) ...);
}
auto g = makeGroup(3, 2.2, std::string("xyz"));
C++ 标准库中包含一个类模板 std::tuple,是 Group 类的一个功能更完整、更全面的版本。
可变参数模板也可以包含非类型参数,makeGroup 模板可以用任意数量的参数进行实例化。通常,这些非类型参数包会与 auto(自动推导)类型结合使用。例如,以下是一个模板,保存了一个不同类型编译时常量值的列表:
// Example 10
template <auto... Values> struct value_list {};
如果没有 auto(即在 C++17 之前),由于必须显式指定类型,几乎无法声明这样的模板。这就是整个模板:将常量值作为其定义的一部分进行存储。为了提取这些值,需要另一个可变参数模板:
template <size_t N, auto... Values>
struct nth_value_helper;
template <size_t n, auto v1, auto... Values>
struct nth_value_helper<n, v1, Values...> {
static constexpr auto value =
nth_value_helper<n - 1, Values...>::value;
};
template <auto v1, auto... Values>
struct nth_value_helper<0, v1, Values...> {
static constexpr auto value = v1;
};
template <size_t N, auto... Values>
constexpr auto nth_value(value_list<Values...>) {
return nth_value_helper<N, Values...>::value;
}
模板函数 nth_value 从 value_list 参数的类型中推导出参数包 Values(参数本身不包含数据,只关心其类型)。然后使用递归实例化部分类特化来遍历参数包,直到获取到第 N 个值。要以这种方式存储浮点数常量,则需要 C++20。
可变参数模板可以与双重模板参数结合使用,以解决某些问题,当标准库容器用作双重模板参数的替换参数时产生的问题。一个简单的解决方案是,将参数声明为接受任意数量的类型:
template <template <typename...> class Container,
typename... T>
void print(const Container<T...>& container);
std::vector<int> v{ ... };
print(v);
std::vector 模板有两个类型参数。在 C++17 中,一项标准更改使得它可以有效匹配 Container 模板模板参数中指定的参数包。大多数编译器甚至更早就允许这种匹配。
可变参数模板,尤其是与完美转发结合使用时,对于编写非常通用的模板类极为有用 —— 例如,一个 vector 可以包含任意类型的对象,为了就地构造这些对象而不是复制它们,必须使用不同数量的参数来调用构造函数。在编写 vector 模板时,无法预知初始化 vector 将包含的对象需要多少个参数,因此必须使用可变参数模板(事实上,std::vector 的就地构造函数,如 emplace_back,就是可变参数模板)。
我们还必须提到 C++ 中另一种类似模板的实体,其兼具类和函数的特征 —— 即 Lambda 表达式。下一节将专门介绍这一内容。