CTTCG 19 Implementing Traits
Trait 分类
不一定大家都这么叫,但是为了方便,我规定下面的术语:
- Type Trait:包含一个 type alias。
- Value Trait:包含一个 value 静态成员,可以是任何基本类型,含义和具体 Trait 相关。
- Predicate Trait:包含一个 value 静态成员,类型为 bool,含义为条件是否被满足。
书中有分为 property trait 和 policy trait 两大类。前者包含我所称的 type trait 和 value trait,含义是类型本身的固有信息;后者表示根据一个特定类型选择某种映射,其含义和要实现的行为有关,文中的例子是对 size 小的参数选择原类型,对 size 大的参数选择引用类型。
Traits v.s. Policies
例子是累加算法的模板实现。
Traits 一般表示和类型有关的静态的信息;Policies 表示行为。累加例子中 Traits 包含加法结果的类型,而 Policies 则包含了加法的执行过程。
std::iterator_traits
标准库中的一个工具类模板。
可以看出它能提供迭代器的各种信息,方便我们用函数模板实现算法。
Transformation Traits
- 可以加减引用(加引用的时候需要区分左右引用,去引用的时候不区分)、volatile、const、decay 等等。
- C++20 新增了
std::remove_cvref_t
,是对原来std::remove_reference_t
和std::remove_cv_t
的组合。 std::decay_t
相当于:函数退化 + 数组退化 +std::remove_cvref_t
技巧:通过 public
继承减少代码量
由于 type
或者 value
这些在 type traits 中需要的属性是作为静态成员提供的,因此可以提前写好 trait 可能的几种匹配结果,然后用 public
继承简化代码,这样基类的 trait 属性就会在子类(提供出来的接口)中可见。
Predicate Traits
可以获取 true
或 false
的值。就算不获取值,其包装类型 的 std::true_type
和 std::false_type
也可以进行 tag dispatching。需要注意 C++17 才引入非类型模板参数,所以更早的版本用的都是类型作为 tag。
包装类型:通过 type traits 获取的信息都是在模板类中的,需要访问 type 或者 value 来获得,或者通过 alias template 获得。
用 SFINAE 实现 Traits
SFINAE-Friendly Traits
讨论 Trait 实现的安全性(safety):Trait 本身不应该引发编译错误,所以需要确保被 SFINAE 保护。比如我们要实现一个 PlusResult
的 Type Trait 类,如果直接使用 using
声明 Type
为某两个值用 +
操作符相加的结果的类型,则可能在类模板被实例化时因为找不到匹配的 +
操作符而编译失败。
安全的 Trait 类模板通常先用一个 Predicate Trait 检查,再结合类模板偏特化,把条件不通过的部分给筛掉。这样的实现方式中一种形式的表达式经常需要出现两次(一次是先检查存在性,第二次是真正使用)。
除了类模板偏特化之外,函数模板重载也能提供安全性。 但是要小心避免同时匹配上多个函数且无法区分优先级,这样会让函数重载选择失败(ambiguous)。
std::enable_if_t
和 std::void_t
用 std::void_t
检查表达式合法性或类型存在性
std::void_t
只有 _t
后缀版本,是 gnu++11 或 C++17 才加入的。它的定义相当简单,是一个对于任何 parameter pack,都生成 void 类型的 alias template。但是它能够用于对表达式或类型合法性进行筛选:
- 实现表达式筛选时,表达式可以用
decltype
转为类型。 - 对于类型存在性检查,可以用
std::void_t<typename T::SomeTypeAlias>
。 - 对于成员存在性检查,可以用
std::void_t<decltype(std::declval<T>().AnyMember)>
。 std::void_t
接受多个参数,所以可以一次写多个约束条件!
多个约束条件的例子:
常常被用来实现 Predicate Trait。
检查类型完整性
用 decltype
来推导类型时,并不要求得到的类型是一个完整类型。如果还需要加上“类型完整”这个约束,可以在 decltype
里面加上 sizeof
运算符。
书中给出的解决办法是 decltype(Expr, 0)
,即:使用逗号表达式要求前面的非引用非指针非 void 表达式必须具有完整类型。实测 decltype
对 std::declval
有特殊处理,如果是 declval
的返回值不完整,且用到了形如 decltype(Expr, 0)
的表达式,Expr
会被忽略;如果是 declval
返回结果上再做成员访问,则不完整性可以被检查出来。而使用 sizeof
约束则更加严格。
用 std::enable_if
进行条件筛选
std::enable_if_t
是基于 std::enable_if
的 alias template,能够对条件(predicates)进行筛选。 常常借助 Predicate Trait 工作。
为什么要实现 Predicate Trait?std::void_t
也能够直接被嵌入在模板参数内,这样就没有必要再专门写一个 Predicate Trait。但是通过实现一些经常被使用的 Predicate Trait,通过 std::enable_if
的组合,可以使得代码更易读,写起来也更容易。
类名注入也会影响类型存在性查询
假设我们想要实现一个判断类中是否有一个名为 size_type
的 type alias。如果有个类的基类是 size_type,由于类名注入,改 Trait 也应该会将其判断为 std::true_type
。
std::conditional
模板的安全使用
条件模板通过一个 bool 条件来选择类型。但是由于本质上是模板,需要确定下来所有的参数,所以 std::conditional
必须要先把两个分支都计算一次!这会导致那些仅在条件成立的情况下良构(well-formed)的分支在条件不成立时 ill-formed。比如:
std::make_unsigned
模板对参数 T 有要求,其中之一是 T 不能是 bool 类型,否则是 ill-formed。但是在上面的代码中,这个类型是始终需要被计算的。
上面的代码将 std::make_unsigend
的使用包装在了 MakeUnsignedT
模板中。这样只有模板类型本身被评估。其成员只有真正访问时才评估,确保了程序是 well-formed 的。
问:为什么要写一个 MakeUnsignedT,直接用 std::make_unsigned 但是延缓对 type 的访问不行吗?
不行,因为 C++ 标准明确规定了连这个 Type Trait (不仅仅是 alias template)在其他情况下使用都是 undefined (C++20 以前) / ill-formed (C++20) 的。
判断 T 是否为函数
gcc 标准库的实现方式是:
- 尝试给 T 加 const,加成功了(被
is_const
认定成功)就不是函数,否则就是函数。 - 特例是左值引用和右值引用一定不是函数。
在这个实现中,指向函数的指针不是函数。作为比较,书中指出的偏特化方法实现起来很累,因为有一大堆修饰 this 的 Abominable 函数。
判断 T 是否为 Class Type
union 也是 class type,但不是 class。因而 union 也能有指向成员的指针。比如:
#include <iostream>
union X {
int a;
unsigned b;
};
int main() {
auto p = &X::a;
X x{.a = 1};
x.*p = 4; // 注意指向成员指针的用法!!
std::cout << x.a << '\n';
}
输出为 4。除此之外,还能给 union 定义成员函数。
gcc 标准库中有内置的判断是否为 class、union 等的模板,而且 C++ 标准库的 std::is_class
不是指 class type,所以严格不包含 union。通过在偏特化参数中构造指向成员的指针只能做到判断是否为 class type,比起内置实现效率和稳健性上都要差了很多。
判断是否 T 为枚举类型
可以通过大量谓词做排除。
一些非常特殊的 Traits
std::char_traits
std::iterator_traits
std::numeric_limits
std::allocator_traits