Traits(类型萃取)
需要预先学习:
1. Traits(类型萃取)
写泛型代码时,你迟早会遇到一个问题:模版参数 T 到底是个什么类型? 是指针还是普通对象?是整数还是浮点数?去掉引用之后是什么?这些信息在编译期是存在的,但到了运行期已经"擦除"——if 只能根据值做判断,判断不了类型。
**Traits(类型萃取)**就是解决这个问题的技术:定义一类专门的 traits 类,把"关于类型的信息"编码成类成员——你给它一个类型,它告诉你一个编译期的事实(“是不是指针”),或给出一个关联的类型(“去掉指针后剩下的类型”)。“萃取”(extraction)指的正是从类型中提取这些隐藏信息的过程。
注:
typeid虽然能在运行期给出类型名,但泛型代码需要的是能参与编译期计算的答案——用来开分支、声明变量、约束模版,这些typeid都做不到。
1.1 用偏特化实现编译期分支
从一个需求出发:写一个泛型函数 reset——如果 T 是指针,就 delete 并置空;否则赋一个默认值。这个判断必须发生在编译期,而普通的 if 做不到:它的两个分支都要通过编译,delete x 对非指针类型直接报错。
答案藏在模版基础里讲过的偏特化(partial specialization)中。手写一个判断"T 是不是指针"的 traits:
#include <iostream>
// 主模版:默认情况——不是指针
template <typename T>
struct is_pointer
{
static constexpr bool value = false;
};
// 偏特化:当 T 的形式是 U* 时匹配
template <typename T>
struct is_pointer<T*>
{
static constexpr bool value = true;
};
int main()
{
std::cout << std::boolalpha;
std::cout << is_pointer<int>::value << '\n'; // false
std::cout << is_pointer<int*>::value << '\n'; // true
std::cout << is_pointer<double**>::value << '\n'; // true,匹配 T* 后 T = double*
}
编译并运行代码后,看到这样的输出:
false
true
true
工作过程:
- 使用
is_pointer<X>时,编译器先检查偏特化能否匹配X int不符合T*的模式,落到主模版(primary template),value为falseint*符合T*的模式(此时T = int),选择偏特化,value为true
本质上,模版偏特化就是编译期的 if-else:主模版是 else 分支,偏特化是 if 分支。标准库的 std::is_pointer 核心实现与此相同。traits 类通常没有任何数据成员和行为,它只是信息的载体,在运行期不占任何空间——一切都发生在类型系统里。
1.2 值 traits 与类型 traits
按回答的问题分,traits 有两种形态。
1. 值 traits:回答"是不是 / 是多少"
成员是一个编译期常量,比如 is_pointer 的 value。类似的还能表达"对齐是多少""是不是空类"等数值信息。
2. 类型 traits:回答"关联的类型是什么"
成员是一个类型别名,比如"去掉指针后剩下的类型":
#include <iostream>
// 主模版:没有指针可去,原样返回
template <typename T>
struct remove_pointer
{
using type = T;
};
// 偏特化:T 的形式是 U* 时,交出 U
template <typename T>
struct remove_pointer<T*>
{
using type = T;
};
int main()
{
remove_pointer<int*>::type x = 10; // x 的类型是 int
std::cout << x << '\n'; // 输出 10
}
命名约定很固定:值 traits 的常量叫 value,类型 traits 的别名叫 type——整个标准库都遵守这个约定。
1.3 标准库的 type_traits
常用的类型问题,标准库已经封装在 <type_traits> 头文件中:
| 类别 | 代表 traits | 回答的问题 |
|---|---|---|
| 类型属性 | is_pointer、is_integral、is_class、is_empty |
T 具有什么性质 |
| 类型关系 | is_same、is_base_of、is_convertible |
两个类型是什么关系 |
| 类型变换 | remove_reference、remove_pointer、remove_const、decay |
由 T 得到关联类型 |
| 编译期选择 | conditional、enable_if、common_type |
按条件选出类型 |
#include <fstream>
#include <iostream>
#include <type_traits>
int main()
{
std::cout << std::boolalpha;
std::cout << std::is_integral_v<char> << '\n'; // true
std::cout << std::is_floating_point_v<int> << '\n'; // false
std::cout << std::is_base_of_v<std::istream, std::ifstream> << '\n'; // true
}
标准库还提供了两个便捷后缀,替你省去 ::value 和 ::type:
| 原写法 | 后缀写法 | 引入版本 |
|---|---|---|
is_pointer<T>::value |
is_pointer_v<T> |
C++17 |
remove_pointer<T>::type |
remove_pointer_t<T> |
C++14 |
其中 decay 值得单独一提:它一次性完成"按值传参时发生的所有类型变化"——去引用、去顶层 const、数组退化为指针等。完美转发 里 std::forward 的实现,就用到了 remove_reference_t。
1.4 iterator_traits:萃取的经典应用
STL 算法是泛型代码,只操作迭代器、不关心容器。但算法经常需要知道迭代器指向的元素是什么类型——比如对区间求和,得用"元素类型"声明累加变量。
STL 的约定是:迭代器类内部自带 value_type 等别名。但裸指针(如 int*)也是合法迭代器,它没有内部别名可提供。std::iterator_traits 统一了这两个世界(简化实现):
// 主模版:真正的迭代器类——直接向它要
template <typename Iter>
struct iterator_traits
{
using value_type = typename Iter::value_type;
};
// 偏特化:原生指针——元素类型就是 T
template <typename T>
struct iterator_traits<T*>
{
using value_type = T;
};
typename 前缀是必需的:Iter::value_type 依赖模版参数 Iter,编译器看到它时不知道这是类型还是静态成员,typename 明确告诉它"这是个类型名"。
用它写一个对任意迭代器都有效的求和函数:
#include <iostream>
#include <iterator>
#include <list>
#include <vector>
template <typename Iter>
typename std::iterator_traits<Iter>::value_type sum(Iter first, Iter last)
{
using T = typename std::iterator_traits<Iter>::value_type;
T result{};
for(Iter it = first; it != last; ++it)
result += *it;
return result;
}
int main()
{
std::vector<int> v{1, 2, 3, 4};
std::list<double> l{1.5, 2.5};
int arr[]{1, 2, 3};
std::cout << sum(v.begin(), v.end()) << '\n'; // 10
std::cout << sum(l.begin(), l.end()) << '\n'; // 4
std::cout << sum(std::begin(arr), std::end(arr)) << '\n'; // 6
}
vector<int>::iterator 和 list<double>::iterator 是正经的类,走主模版;arr 退化为 int*,走偏特化。同一个 sum 对三类迭代器都能工作,这就是 traits 作为"类型适配层"的价值。
注:
iterator_traits还提供iterator_category(迭代器类别),std::advance、std::distance等算法据此在编译期选择最优实现——随机访问迭代器直接+= n,前向迭代器只能循环++。思路与is_pointer完全一致。
1.5 enable_if:让 traits 约束模版
traits 的查询结果还能反过来约束模版:只有满足条件的类型才允许使用某个函数。C++20 concepts 出现之前,std::enable_if 是主要手段:
#include <iostream>
#include <type_traits>
// 只对指针类型开放:取出指向的值,空指针则返回默认值
template <typename T, typename = std::enable_if_t<std::is_pointer_v<T>>>
auto deref_or(T p, std::remove_pointer_t<T> fallback = {})
{
return p ? *p : fallback;
}
int main()
{
int x = 10;
int* p = &x;
int* q = nullptr;
std::cout << deref_or(p) << '\n'; // 10
std::cout << deref_or(q, 99) << '\n'; // 99
std::cout << deref_or(x) << '\n'; // 编译错误:T = int 不是指针
}
调用 deref_or(x) 时,编译器输出:
t6.cpp:13:26: error: no matching function for call to 'deref_or(int&)'
13 | std::cout << deref_or(x) << '\n';
| ~~~~~~~~^~~
t6.cpp:5:6: note: candidate 1: 'template<class T, class> auto deref_or(T, std::remove_pointer_t<T>)'
原理:enable_if_t<条件, T> 在条件为 true 时就是 T;条件为 false 时根本不存在 ::type。模版实参替换失败不算硬错误(SFINAE,Substitution Failure Is Not An Error,替换失败并非错误)——这个重载被悄悄移出候选集,调用处得到一条直截了当的"没有匹配的函数"。
如果不加 enable_if,deref_or(x) 也能完成模版推导(T = int),错误会推迟到函数体内部的 *p 才爆发,报出一长串模版实例化信息。约束把拒绝提前到了替换阶段,错误信息更短、位置更准。
1.6 if constexpr:编译期分支
C++17 起,很多"按类型选行为"的场景可以用 if constexpr 配合 traits 完成,可读性比 enable_if 好得多。它正好解决开头的 reset 需求:
#include <iostream>
#include <type_traits>
template <typename T>
void reset(T& x)
{
if constexpr(std::is_pointer_v<T>)
{
delete x; // 指针:释放并置空
x = nullptr;
}
else
{
x = T{}; // 普通类型:赋默认值
}
}
int main()
{
int* p = new int(10);
int v = 20;
reset(p);
reset(v);
std::cout << (p == nullptr) << '\n'; // 1
std::cout << v << '\n'; // 0
}
if constexpr 与普通 if 的区别在于:未被选中的分支不会被实例化。当 T = int 时,delete x 分支被编译器直接丢弃,即使它对 int 并不合法;当 T = int* 时,轮到 else 分支被丢弃。普通 if 的两个分支都必须通过编译——这正是开头无法直接写出 reset 的原因。
注:traits 负责把类型信息变成编译期常量,
if constexpr负责据此裁剪分支,两者配合覆盖了大多数"编译期分派"的需求。若要约束的是模版整体的可用类型,C++20 的 concepts 是更现代的写法。


