C++ Type Traits:在编译期认识和处理类型
写泛型代码时,常会遇到这样的问题:T 是不是整数?能不能用一组参数构造?移动构造是否可能抛异常?去掉引用和 const 后是什么类型?这些问题取决于类型,通常要在编译期回答。标准库 <type_traits> 提供的 type traits(类型特征),就是一组回答这类问题的模板。
先看最常见的两种结果:
1 | |
is_integral_v<T> 得到一个编译期布尔值;remove_cvref_t<T> 得到一个类型。判断性质与变换类型是理解 traits 的两条主线。它们不会在运行时检查对象,也不会改变对象本身。
什么时候需要 type traits
假设一个模板想对整数和其他类型走不同的实现:
1 | |
if constexpr 根据编译期条件选择分支。实例化 describe<std::string> 时,整数分支会被舍弃,因而不会要求 std::to_string(std::string) 合法。普通 if 没有这种效果:两个分支都需要通过类型检查。
不过,能够判断不等于应该大量分支。如果代码只是要求参数支持某个操作,C++20 起通常用 Concepts 在接口处表达约束;traits 更适合查询标准库提供的性质、构造新类型,以及给少量确实不同的实现做编译期分派。
读懂 traits 的写法
标准库里许多布尔 trait 是类模板,常见形式如下:
1 | |
二者表达同一个布尔值。_v 是 C++17 引入的变量模板快捷写法。类型变换则通过成员 type 给出结果:
1 | |
二者都是 const int。_t 是 C++14 起提供的别名模板写法。旧代码里常见 ::value、::type;新代码一般优先用 _v、_t。在依赖于模板参数的 Trait<T>::type 前要写 typename,而 _t 写法不需要。
支撑 traits 的几个基础构件
上面的 ::value 和 ::type 不是偶然采用了相似的名字。<type_traits> 里有几种很小的模板,分别负责把常量放进类型、按条件选类型、按条件提供类型和把合法的类型表达式映射成 void。理解它们,再看具体的 is_... trait 就容易得多。
integral_constant:让一个值也能作为类型使用
std::integral_constant<T, v> 本身是一个类型,并提供一个类型为 T、值为 v 的静态编译期常量。例如:
1 | |
Answer::value 直接读取值;Answer::type 指回 Answer 自己。最后两行分别使用它到 int 的转换,以及它的 operator()。所以 Answer{} 看起来能像值、像函数一样使用,但它首先是一个类型的对象。
布尔版本最常见:std::bool_constant<B> 是 std::integral_constant<bool, B> 的别名;std::true_type 和 std::false_type 分别对应真、假。自定义布尔 trait 继承其中一个,就能直接得到统一的 ::value 接口:
1 | |
这里的“真或假”既能作为布尔值使用,也能作为两个不同的类型参与模板匹配。后文的 my_is_same 就使用同一机制。
conditional_t:按条件选择类型
std::conditional_t<B, T, F> 在编译期根据布尔值 B 得到 T 或 F:
1 | |
它选择的是类型,不是运行时对象,也不执行某个分支。T 和 F 作为模板实参时都必须先是合法类型,不能用它隐藏一侧无效的类型表达式。
enable_if_t:条件不成立时不提供类型
std::enable_if_t<B, T> 在 B 为真时得到 T;为假时,enable_if<B, T> 没有成员 type。在依赖模板参数的替换位置,这可以让某个重载退出候选集:
1 | |
twice(3.0) 找不到这个重载;若直接写 std::enable_if_t<false, int>,则会因不存在 type 而报错。C++20 起,单纯限制模板参数通常直接用 Concepts / requires,但理解 enable_if 有助于读懂旧式 SFINAE 代码。
示例里第二个模板参数可以拆开看:std::enable_if_t<std::is_integral_v<T>, int> 是参数的类型,= 0 是默认值,合起来是一个未命名的非类型模板参数。
条件为真时,enable_if_t<..., int> 展开为 int,形参列表相当于 template <class T, int = 0>:一个带默认值的占位参数,调用方写 twice(3) 即可,无需关心它。条件为假时,enable_if_t<false, int> 无法形成类型,替换失败,这个重载被移出候选集。参数本身没有名字,因为函数体用不到它。
占位类型为什么是 int?因为非类型模板参数的类型不能是 void,= 0 只是给占位参数一个默认值。返回类型位置常见的 enable_if_t<B, void> 写法,在这里不可用。
把条件挂在额外的模板参数上,而不是返回类型或某个函数参数上,好处有三:
- 函数签名保持原样,声明对调用方干净;
- 一组仅条件不同的重载可以共存;
- 对没有返回类型的构造函数、转换运算符同样适用。
一个容易误会的点:仅默认模板实参不同的两个模板视为重复定义,int = 0 与 int = 1 并不能区分重载。上面的重载能并存,靠的是条件写进了模板参数的类型本身。
void_t:检测类型表达式能否成立
std::void_t<Ts...> 在所有模板实参都是合法类型时得到 void。单独看它很简单:
1 | |
关键是把 decltype(某个表达式) 放进去:表达式有效,偏特化就可以匹配;表达式无效,替换失败后回到主模板。后文的 has_size 会展示完整写法。void_t 本身不检查成员或调用,它只把已成功形成的类型统一映射为 void。
conjunction、disjunction、negation:组合布尔 traits
它们分别表示“全部为真”“至少一个为真”和“取反”,接收的是 std::is_integral<T> 这样的trait 类型,不是 _v 得到的布尔值:
1 | |
conjunction 和 disjunction 还会在结果已确定时停止实例化后续 trait;当后续检查只对某些类型有意义时,这一点很有用。
常用判断:类型属于什么类别
| 问题 | 常用 trait | 示例 |
|---|---|---|
| 是不是某一类基本类型 | is_integral_v<T>、is_floating_point_v<T> | int 是整数类型,double 是浮点类型 |
| 是不是指针或引用 | is_pointer_v<T>、is_reference_v<T> | int* 是指针,int& 是引用 |
| 是不是数组、枚举、类 | is_array_v<T>、is_enum_v<T>、is_class_v<T> | int[3] 是数组 |
是否带顶层 const | is_const_v<T> | const int 为真 |
| 是否为某个精确类型 | is_same_v<T, U> | int 与 const int 不相同 |
这里的“整数类型”是语言类型分类,包含 bool 和字符类型;如果业务只接受算术意义上的整数,还需要额外约束。
最容易看错的是 const、引用与模板推导的关系:
1 | |
引用类型本身没有顶层 const;const int& 的 const 修饰的是被引用的 int。remove_cv_t 只去顶层 cv 限定,不会先去掉引用。要比较“去掉引用和顶层 cv 之后的类型”,用 remove_cvref_t。
常用判断:类型具有什么能力
类型分类只回答“它是什么”;能力类 traits 更接近泛型接口真正关心的问题:
| 想确认的能力 | trait |
|---|---|
| 能否由一组参数构造 | is_constructible_v<T, Args...> |
| 能否复制或移动构造 | is_copy_constructible_v<T>、is_move_constructible_v<T> |
| 能否赋值 | is_assignable_v<T&, U> |
| 构造、赋值是否不抛异常 | is_nothrow_constructible_v<T, Args...>、is_nothrow_assignable_v<T&, U> |
| 能否以给定参数调用 | is_invocable_v<F, Args...> |
| 调用结果类型 | invoke_result_t<F, Args...> |
注意 is_assignable_v<T&, U> 的左边通常是 T&:赋值发生在一个左值对象上。可调用性也要写出真实的调用方式,F& 与 F 对带引用限定的调用运算符可能得到不同结果。
1 | |
is_move_constructible_v<T> 的含义是“能从 T&& 构造”,不保证真的调用了移动构造函数。如果某个类型只有可接受右值的复制构造函数,它仍可能返回真。类似地,is_nothrow_move_constructible_v<T> 查询的是这次构造表达式是否不抛异常,不能当作“类型一定有独立的 noexcept 移动构造函数”。
常用判断:类型之间的关系与对象性质
is_same_v<T, U> 要求类型完全一致,包括引用和 cv 限定。is_convertible_v<From, To> 判断隐式转换是否成立,不检查转换是否丢失精度,例如 double 到 int 的隐式转换可以成立,但数值可能变化。
is_base_of_v<Base, Derived> 讨论继承关系,包括私有继承;它并不表示 Derived* 在当前上下文一定能隐式转换成 Base*。若接口需要的是“公开、无歧义地派生”,C++20 的 std::derived_from 更贴近这个要求。
对象性质相关的 traits 常用于决定实现策略:
is_trivially_copyable_v<T>:查询是否满足“平凡可复制”要求。符合条件的对象可以按标准规定的方式复制其对象表示,但这不等于可以直接把内存字节当作跨平台文件或网络格式;填充字节、字节序和指针值仍要处理。is_standard_layout_v<T>:查询是否满足标准布局条件;不能据此推断一个类型的全部 ABI 布局。is_nothrow_move_constructible_v<T>:可用于移动与复制的异常安全策略,标准库的std::move_if_noexcept就体现了类似取舍。
这些性质都有精确定义和前置条件,不能仅凭类型名称或“看起来简单”推测结果。某些 traits 对类类型要求其定义已经完整;在前向声明阶段查询,可能不满足标准库对该 trait 的要求。实际使用时先让待查询类型完整,或核对对应 trait 的要求。
类型变换:从 T 计算出另一个类型
| 目标 | 常用变换 | 示例结果 |
|---|---|---|
| 去掉引用 | remove_reference_t<T> | const int& → const int |
去掉顶层 const / volatile | remove_cv_t<T> | const int → int |
| 同时去掉引用和顶层 cv | remove_cvref_t<T>(C++20) | const int& → int |
| 去掉一层指针 | remove_pointer_t<T> | int** → int* |
| 取得数组元素类型 | remove_extent_t<T> | int[3][4] → int[4] |
| 按值传参式退化 | decay_t<T> | int[3] → int* |
| 从枚举取得底层类型 | underlying_type_t<E> | 依枚举声明而定 |
remove_cvref_t 和 decay_t 不能互换。前者保留数组与函数的类型形状;后者还会把数组变为元素指针、函数变为函数指针,模拟按值传参时的类型调整:
1 | |
需要保留数组长度时,不能先用 decay_t 把它变成指针。前面介绍的 conditional_t<condition, A, B> 用于按条件选类型;common_type_t<T, U> 则用于求满足其规则的公共类型,结果不一定保留引用。需要公共引用语义时,再考虑 common_reference_t<T, U>。
traits、if constexpr 与 Concepts 如何分工
可以按“我要做什么”选择工具:
| 目的 | 优先考虑 |
|---|---|
| 查询一个标准类型性质 | std::is_..._v<T> |
| 从类型计算新类型 | std::..._t<T> |
| 在函数体内选择不同实现 | if constexpr |
| 限制模板实参、改善接口与报错 | C++20 Concepts / requires |
例如接口只接受整数类型,直接使用标准概念:
1 | |
这里 std::integral 底层表达的分类与 std::is_integral_v<T> 相近,但它把约束放在模板入口,错误会更接近调用点。若需求是“支持某个成员操作”,直接表达该操作,通常比猜测类型类别更准确:
1 | |
SizedLike 说明接口真正依赖的是 size() 的可用性及返回值可转换性,不要求类型属于某个预设类别。若只为约束模板,在现代 C++ 中一般不必再写前面介绍的 std::enable_if_t。
自定义 trait 为什么能工作
标准布尔 traits 常利用模板特化,把不同类型映射到不同结果。下面用 is_same 的简化版本展示核心机制:
1 | |
主模板给出默认的假;当两个模板实参能匹配 T, T 时,偏特化给出真。这是编译期类型匹配,没有运行时分支。
原理:实例化、匹配特化,再读取结果
编译器遇到 my_is_same<int, int>::value 时,先用实参替换模板参数,再寻找能匹配的偏特化。<T, T> 可令两处 T 都为 int,因此选中继承 std::true_type 的版本;对于 <int, const int>,两处无法匹配同一个 T,便使用主模板。::value 来自被选中版本的基类。常见的 _v 只是把这个读取动作包装成变量模板:
1 | |
类型变换沿用相同的匹配过程,只是把结果放在 ::type 中。以去引用为例:
1 | |
这里 const int& 匹配 T& 时,T 被推导为 const int,所以去掉的是引用,const 仍在。typename 告诉编译器,依赖于模板参数的 ::type 是类型名;_t 则把这一层成员类型读取封装起来。这些简化实现只用于说明机制,实际标准库实现还要满足对应 trait 的完整规范。
原理:有类型与没有类型如何控制选择
conditional 的两种结果可以由一个偏特化表示;enable_if 则只在条件为真时提供 type:
1 | |
my_conditional<false, int, double>::type 选出 double,但 T、F 在传给模板前都必须能形成类型。my_enable_if<false, int> 则根本没有 type;只有当这个缺失发生在允许替换失败的模板推导位置时,候选模板才会被移除。在普通代码里直接访问缺失的 ::type,仍是编译错误。区别在于错误发生的位置,不是 enable_if 把错误变成了 false。
把前面介绍的 std::void_t 用在偏特化中,就能检测某个表达式能否形成类型:
1 | |
对 has_size<Sized>,默认的第二个参数是 void,而 decltype(std::declval<const Sized&>().size()) 得到 int,void_t<int> 也得到 void,于是偏特化匹配。对 Unsized,替换表达式时找不到 size(),该偏特化被移出候选,回到主模板。这就是 SFINAE(替换失败并非错误)的典型用途;它只覆盖规定的替换上下文,模板内部其他实例化错误不保证被这样吞掉。decltype 不执行调用,std::declval 也不会构造对象。C++20 起,这类接口检测通常直接写成 requires,意图更清楚。
能力类 traits 也围绕假设某个表达式存在来判断:构造、赋值或调用是否成立,要按所用实参的引用类别、cv 限定和访问规则检查。若还要查询是否可能抛异常,可以在已确认表达式合法之后,对同一表达式使用 noexcept(表达式);它同样不执行表达式。这解释了为什么“能从 T&& 构造”不等于“一定存在独立的移动构造函数”:编译器判断的是构造表达式能否成立,而不是构造函数的名字。
原理:逻辑组合为什么能短路
conjunction 可以按参数逐层处理:先读取第一个 trait 的 ::value;若为假,直接继承这个 trait,否则才继续读取剩余参数。下面的简化版本展示了延迟发生的位置:
1 | |
这里 my_and<Rest...> 作为类型实参可以先形成;只有选中它并读取继承而来的 ::value 时,才需要继续实例化下一层。因此第二个参数没有 value 也不妨碍上面的断言成立。短路保证的是后续 trait 的 ::value 不必实例化,并不允许传入本身就无法形成的模板实参。
不要通过特化标准库的现成 traits 来“修正”其答案。标准库对这些模板的用户特化有明确限制,随意特化会导致未定义行为。自定义规则应放在自己的 trait 或 concept 中。
为什么有些 traits 需要编译器支持
上面的 my_is_same 只要比较模板实参能否匹配,就能用普通模板特化实现。但并非每个类型性质都能这样推出来。例如:一个类型是否以 union 声明、特殊成员函数是否平凡,涉及编译器已经掌握的类型定义和语言规则;在 C++20/23 中,普通模板特化或 requires 表达式没有通用、可移植的办法直接读取这些信息。
1 | |
Plain 和 WithDestructor 都能析构,但后者的析构函数由用户提供,因此不满足平凡可复制类型的要求。检测“析构表达式是否合法”只能回答能不能析构,不能回答析构是否平凡。NumberBits 的 union 身份也是类型定义本身的性质,不能靠调用某个成员函数来识别。
这类 trait 通常由标准库提供统一的 std::is_... 接口,再由编译器的内建类型查询提供底层答案。例如,libstdc++ 的 is_trivially_copyable 实现会调用 __is_trivially_copyable(T)。这个双下划线名称是实现细节,并非可移植的标准 C++ 接口;应用代码应使用 std::is_trivially_copyable_v<T>。并不是所有 traits 都需要内建支持:is_same、remove_reference 之类的规则可以由模板特化表达,库实现也可以自行选择满足标准要求的实现方式。
几个容易踩的坑
- 把
T当成实参表达式的完整类型。 模板推导、引用折叠和 cv 限定会改变T的样子;需要比较规范化类型时显式使用remove_cvref_t<T>。 - 把类型性质当成业务语义。
is_integral_v<bool>为真,is_convertible_v<double, int>也可能为真;业务是否允许这些值要另行约束。 - 把“能从右值构造”理解为“存在移动构造”。
is_move_constructible_v<T>不保证调用了移动构造函数。 - 把
decay_t当成简单去限定。 它还会让数组和函数类型退化。 - 在普通
if中放本应被编译期舍弃的非法表达式。 只有if constexpr的舍弃分支才适合这种写法,而且分支中不依赖模板参数的明显错误仍不能随意写入。 - 只为读取一个值就依赖未完整定义的类。 部分 traits 需要完整类型,具体要求应按各 trait 的标准条款核对。
记住一条主线即可:先说清泛型代码需要哪项能力,再选查询、变换、分支或约束。type_traits 提供编译期的类型信息;if constexpr 用信息选择实现;Concepts 把要求写到接口上。三者配合,泛型代码才既灵活又容易读。






