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

  1. 可以加减引用(加引用的时候需要区分左右引用,去引用的时候不区分)、volatile、const、decay 等等。
  2. C++20 新增了 std::remove_cvref_t,是对原来 std::remove_reference_tstd::remove_cv_t 的组合。
  3. std::decay_t 相当于:函数退化 + 数组退化 + std::remove_cvref_t

技巧:通过 public 继承减少代码量

由于 type 或者 value 这些在 type traits 中需要的属性是作为静态成员提供的,因此可以提前写好 trait 可能的几种匹配结果,然后用 public 继承简化代码,这样基类的 trait 属性就会在子类(提供出来的接口)中可见。

Predicate Traits

可以获取 truefalse 的值。就算不获取值,其包装类型std::true_typestd::false_type 也可以进行 tag dispatching。需要注意 C++17 才引入非类型模板参数,所以更早的版本用的都是类型作为 tag。

包装类型:通过 type traits 获取的信息都是在模板类中的,需要访问 type 或者 value 来获得,或者通过 alias template 获得。

用 SFINAE 实现 Traits

19.1 用 SFINAE 实现 Traits

SFINAE-Friendly Traits

讨论 Trait 实现的安全性(safety):Trait 本身不应该引发编译错误,所以需要确保被 SFINAE 保护。比如我们要实现一个 PlusResult 的 Type Trait 类,如果直接使用 using 声明 Type 为某两个值用 + 操作符相加的结果的类型,则可能在类模板被实例化时因为找不到匹配的 + 操作符而编译失败。

安全的 Trait 类模板通常先用一个 Predicate Trait 检查,再结合类模板偏特化,把条件不通过的部分给筛掉这样的实现方式中一种形式的表达式经常需要出现两次(一次是先检查存在性,第二次是真正使用)。

除了类模板偏特化之外,函数模板重载也能提供安全性。 但是要小心避免同时匹配上多个函数且无法区分优先级,这样会让函数重载选择失败(ambiguous)。

std::enable_if_tstd::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 表达式必须具有完整类型。实测 decltypestd::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 标准库的实现方式是:

  1. 尝试给 T 加 const,加成功了(被 is_const 认定成功)就不是函数,否则就是函数。
  2. 特例是左值引用和右值引用一定不是函数。

在这个实现中,指向函数的指针不是函数。作为比较,书中指出的偏特化方法实现起来很累,因为有一大堆修饰 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