最近写代码恰好用到了C++模板元编程的类型检测能力,以前对其原理有个大概的印象,但随着C++11/C++17等新特性的加入,很多做法和以前不同了,借此机会重新梳理一下这方面的知识点。
void_t 的引入
在 C++17 之前,模板编程中通常需要编写复杂的部分特化和重载来检测类型特征。C++17 引入了 std::void_t
,简化了这一过程。其定义如下:
cpp
template< class... >
using void_t = void;
这个定义看似简单,但实际上它为模板编程打开了新的可能性。
void_t 在 SFINAE 中的应用
SFINAE是Substitution Failure Is Not An Error的缩写,直译为:匹配失败不是错误。属于C++模板编程中的高级技巧,但属于模板元编程中的基本技巧。SFINAE 是一种模板实例化过程中的规则,当模板参数替换失败时,并不会产生编译错误,而是导致模板实例被丢弃,编译器会继续寻找其他匹配的模板实例。
使用 void_t
,可以创建一些检测类型特征的工具,例如:
检测类型成员
我们可以编写一个模板结构体来检测一个类型是否包含某个成员类型 type
:
cpp
template <class, class = std::void_t<>>
struct has_type : std::false_type {};
template <class T>
struct has_type<T, std::void_t<typename T::type>> : std::true_type {};
检测成员变量
同样的技巧可以用来检查一个类型是否有某个特定的成员变量 a
:
cpp
template <class, class = std::void_t<>>
struct has_a_member : std::false_type {};
template <class T>
struct has_a_member<T, std::void_t<decltype(std::declval<T>().a)>> : std::true_type {};
检测迭代器
我们还可以检验一个类型是否可迭代,即是否有 begin()
和 end()
方法:
cpp
template <typename, typename = void>
constexpr bool is_iterable = false;
template <typename T>
constexpr bool is_iterable<T, std::void_t<decltype(std::declval<T>().begin()), decltype(std::declval<T>().end())>> = true;
检测成员函数
同样可以检查一个类型是否有某个特定的成员函数 hello
:
cpp
template <class T, class = void>
struct has_hello_func : std::false_type {};
template <class T>
struct has_hello_func<T, std::void_t<decltype(std::declval<T>().hello())>> : std::true_type {};
std::declval的作用
std::declval
是一个在 <utility>
头文件中定义的函数模板,它的主要作用是在不实例化对象的情况下获取该类型的引用,以便在编译时期在表达式中使用。这在模板元编程和类型萃取中特别有用,因为它允许我们对某个类型的成员进行操作,而不需要构造实际的对象。
std::declval
通常与 decltype
结合使用,用于推导表达式的类型。它只能在不被求值的上下文中使用(如 decltype
或 sizeof
中),因为它实际上没有定义,只是一个声明。如果在运行时尝试使用 std::declval
,将会导致链接错误。
让我们来仔细解释上面出现过的这段代码:
cpp
template <class T, class = void>
struct has_hello_func : std::false_type {};
这里定义了一个模板结构体 has_hello_func
,它默认继承自 std::false_type
。这个结构体的作用是用于检查类型 T
是否有成员函数 hello
。这里使用了一个非类型模板参数,其默认值是 void
。这是为了利用 SFINAE 规则准备的,如果 T
不满足某些条件,这个基础版本将会被选择。
cpp
template <class T>
struct has_hello_func<T, std::void_t<decltype(std::declval<T>().hello())>> : std::true_type {};
这是 has_hello_func
的一个特化版本。它只会在模板参数 T
满足 decltype(std::declval<T>().hello())
是一个有效表达式的情况下实例化。这个表达式的作用是尝试调用类型 T
的 hello
成员函数,而不实际构造一个 T
的实例。如果该成员函数存在,decltype
将成功推导出其类型,并且 std::void_t<decltype(...)>
将等价于 void
,从而使得这个特化版本满足 SFINAE 条件,成为被选择的模板。
如果 T
有成员函数 hello
,那么 std::void_t<decltype(std::declval<T>().hello())>
就不会导致替换失败,这个特化版本会被实例化,结构体将从 std::true_type
继承,其 value
成员将是 true
。如果 T
没有 hello
函数,那么表达式 std::declval<T>().hello()
会导致替换失败,因此基础版本(继承自 std::false_type
)将会被选择,其 value
成员将是 false
。
C++ 20的做法
随着 C++20 标准的推出,类型检测在 C++ 中进入了一个新的时代。C++20 引入了两个关键特性:Constraints(约束)和 Concepts(概念),它们为类型检测提供了官方的语言支持,极大地简化了模板编程。
Concepts
Concepts 是对模板参数所需特性的正式规定。它们是可编译的规范,定义了类型必须满足的接口和语义要求。Concepts 允许开发者以声明性的方式指定模板参数应该遵循的约束,这使得模板代码更加清晰和容易理解。使用 Concepts,编译器可以提供更清晰的错误信息,因为它可以检查类型是否符合概念的要求,并在不符合时报错。
例如,如果你想要定义一个只接受迭代器类型的模板函数,你可以这样做:
cpp
#include <concepts>
template <typename T>
requires std::input_iterator<T>
void myFunction(T iter) {
// ...
}
或者使用新的语法糖来简化:
cpp
template <std::input_iterator T>
void myFunction(T iter) {
// ...
}
Constraints
Constraints 是概念的实际表达式,它们是概念要求的具体化。Constraints 可以用来指定模板参数必须符合的条件。它们可以是简单的表达式也可以是复杂的布尔逻辑。C++20 中的 requires
表达式用来指定 constraints,提供了一种更简洁和灵活的方式来指定模板参数的要求。
例如,可以使用 requires
表达式来直接在模板参数列表中对类型进行约束:
cpp
template <typename T>
requires std::integral<T>
T add(T a, T b) {
return a + b;
}
这个函数 add
要求模板参数 T
必须是一个整数类型(满足 std::integral
的概念)。
C++20 概念的好处
- 更清晰的代码:概念使得模板的意图更加明确,代码可读性大大提高。
- 更好的编译器诊断:当类型不满足模板要求时,编译器可以提供更具体和有用的错误信息。
- 更好的性能:在某些情况下,因为类型检测更加精确,编译器可以生成更优化的代码。
- 更简单的类型检测 :不再需要编写繁琐的
std::void_t
类型特征模板来检查类型属性,概念本身就定义了类型应该具备的属性。
C++20 的 Constraints 和 Concepts 出现后,开发者可以利用标准库提供的预定义概念,或者定义自己的概念,以更自然、清晰和直观的方式来编写模板代码。
结语
std::void_t
的引入极大简化了模板元编程中的类型检测,通过利用 SFINAE 原则和模板特化优先级,可以编写出既简洁又强大的类型特征检查工具。C++发展到C++20之后,检查类型约束的清晰度和灵活性大大增加,但是目前大部分模板库主流还是使用SFINAE相关技术,也许再过段时间C++20的Constraints(约束)和 Concepts(概念)才会普及。