📑 C++ 泛型编程系列:模板 | Type Traits | Concept

写泛型代码时,常会遇到这样的问题:T 是不是整数?能不能用一组参数构造?移动构造是否可能抛异常?去掉引用和 const 后是什么类型?这些问题取决于类型,通常要在编译期回答。标准库 <type_traits> 提供的 type traits(类型特征),就是一组回答这类问题的模板。

先看最常见的两种结果:

1
2
3
4
5
#include <type_traits>

static_assert(std::is_integral_v<int>);
static_assert(!std::is_integral_v<double>);
static_assert(std::is_same_v<std::remove_cvref_t<const int&>, int>);

is_integral_v<T> 得到一个编译期布尔值;remove_cvref_t<T> 得到一个类型。判断性质与变换类型是理解 traits 的两条主线。它们不会在运行时检查对象,也不会改变对象本身。

什么时候需要 type traits

假设一个模板想对整数和其他类型走不同的实现:

1
2
3
4
5
6
7
8
9
10
11
#include <string>
#include <type_traits>

template <class T>
std::string describe(const T& value) {
if constexpr (std::is_integral_v<T>) {
return std::to_string(value);
} else {
return "non-integral value";
}
}

if constexpr 根据编译期条件选择分支。实例化 describe<std::string> 时,整数分支会被舍弃,因而不会要求 std::to_string(std::string) 合法。普通 if 没有这种效果:两个分支都需要通过类型检查。

不过,能够判断不等于应该大量分支。如果代码只是要求参数支持某个操作,C++20 起通常用 Concepts 在接口处表达约束;traits 更适合查询标准库提供的性质、构造新类型,以及给少量确实不同的实现做编译期分派。

读懂 traits 的写法

标准库里许多布尔 trait 是类模板,常见形式如下:

1
2
std::is_integral<int>::value
std::is_integral_v<int>

二者表达同一个布尔值。_v 是 C++17 引入的变量模板快捷写法。类型变换则通过成员 type 给出结果:

1
2
typename std::remove_reference<const int&>::type
std::remove_reference_t<const int&>

二者都是 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
2
3
4
5
6
7
8
#include <type_traits>

using Answer = std::integral_constant<int, 42>;
static_assert(Answer::value == 42);
static_assert(std::is_same_v<Answer::value_type, int>);
static_assert(std::is_same_v<Answer::type, Answer>);
static_assert(Answer{} == 42);
static_assert(Answer{}() == 42);

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
2
3
4
5
6
7
8
template <class T>
struct is_int : std::false_type {};

template <>
struct is_int<int> : std::true_type {};

static_assert(is_int<int>::value);
static_assert(!is_int<double>::value);

这里的“真或假”既能作为布尔值使用,也能作为两个不同的类型参与模板匹配。后文的 my_is_same 就使用同一机制。

conditional_t:按条件选择类型

std::conditional_t<B, T, F> 在编译期根据布尔值 B 得到 T 或 F:

1
2
static_assert(std::is_same_v<std::conditional_t<true, int, double>, int>);
static_assert(std::is_same_v<std::conditional_t<false, int, double>, double>);

它选择的是类型,不是运行时对象,也不执行某个分支。T 和 F 作为模板实参时都必须先是合法类型,不能用它隐藏一侧无效的类型表达式。

enable_if_t:条件不成立时不提供类型

std::enable_if_t<B, T> 在 B 为真时得到 T;为假时,enable_if<B, T> 没有成员 type。在依赖模板参数的替换位置,这可以让某个重载退出候选集:

1
2
3
4
5
6
template <class T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
constexpr T twice(T value) {
return value + value;
}

static_assert(twice(3) == 6);

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
static_assert(std::is_same_v<std::void_t<int, double>, void>);

关键是把 decltype(某个表达式) 放进去:表达式有效,偏特化就可以匹配;表达式无效,替换失败后回到主模板。后文的 has_size 会展示完整写法。void_t 本身不检查成员或调用,它只把已成功形成的类型统一映射为 void。

conjunction、disjunction、negation:组合布尔 traits

它们分别表示“全部为真”“至少一个为真”和“取反”,接收的是 std::is_integral<T> 这样的trait 类型,不是 _v 得到的布尔值:

1
2
3
static_assert(std::conjunction_v<std::is_integral<int>, std::is_signed<int>>);
static_assert(std::disjunction_v<std::is_pointer<int*>, std::is_reference<int*>>);
static_assert(std::negation_v<std::is_integral<double>>);

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] 是数组
是否带顶层 constis_const_v<T>const int 为真
是否为某个精确类型is_same_v<T, U>int 与 const int 不相同

这里的“整数类型”是语言类型分类,包含 bool 和字符类型;如果业务只接受算术意义上的整数,还需要额外约束。

最容易看错的是 const、引用与模板推导的关系:

1
2
3
4
5
6
static_assert(std::is_const_v<const int>);
static_assert(!std::is_const_v<const int&>);
static_assert(std::is_const_v<std::remove_reference_t<const int&>>);

static_assert(std::is_same_v<std::remove_cv_t<const int&>, const int&>);
static_assert(std::is_same_v<std::remove_cvref_t<const int&>, int>);

引用类型本身没有顶层 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
2
3
4
5
6
7
8
#include <type_traits>

struct Reader {
int operator()(int) const { return 1; }
};

static_assert(std::is_invocable_v<Reader const&, int>);
static_assert(std::is_same_v<std::invoke_result_t<Reader const&, int>, int>);

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 / volatileremove_cv_t<T>const int → int
同时去掉引用和顶层 cvremove_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
2
3
4
5
#include <type_traits>

using ArrayRef = int (&)[3];
static_assert(std::is_same_v<std::remove_cvref_t<ArrayRef>, int[3]>);
static_assert(std::is_same_v<std::decay_t<ArrayRef>, int*>);

需要保留数组长度时,不能先用 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
2
3
4
5
6
#include <concepts>

template <std::integral T>
T twice(T value) {
return value + value;
}

这里 std::integral 底层表达的分类与 std::is_integral_v<T> 相近,但它把约束放在模板入口,错误会更接近调用点。若需求是“支持某个成员操作”,直接表达该操作,通常比猜测类型类别更准确:

1
2
3
4
5
6
7
#include <concepts>
#include <cstddef>

template <class T>
concept SizedLike = requires(const T& value) {
{ value.size() } -> std::convertible_to<std::size_t>;
};

SizedLike 说明接口真正依赖的是 size() 的可用性及返回值可转换性,不要求类型属于某个预设类别。若只为约束模板,在现代 C++ 中一般不必再写前面介绍的 std::enable_if_t。

自定义 trait 为什么能工作

标准布尔 traits 常利用模板特化,把不同类型映射到不同结果。下面用 is_same 的简化版本展示核心机制:

1
2
3
4
5
6
7
8
9
10
#include <type_traits>

template <class A, class B>
struct my_is_same : std::false_type {};

template <class T>
struct my_is_same<T, T> : std::true_type {};

static_assert(my_is_same<int, int>::value);
static_assert(!my_is_same<int, const int>::value);

主模板给出默认的假;当两个模板实参能匹配 T, T 时,偏特化给出真。这是编译期类型匹配,没有运行时分支。

原理:实例化、匹配特化,再读取结果

编译器遇到 my_is_same<int, int>::value 时,先用实参替换模板参数,再寻找能匹配的偏特化。<T, T> 可令两处 T 都为 int,因此选中继承 std::true_type 的版本;对于 <int, const int>,两处无法匹配同一个 T,便使用主模板。::value 来自被选中版本的基类。常见的 _v 只是把这个读取动作包装成变量模板:

1
2
3
4
template <class A, class B>
inline constexpr bool my_is_same_v = my_is_same<A, B>::value;

static_assert(my_is_same_v<int, int>);

类型变换沿用相同的匹配过程,只是把结果放在 ::type 中。以去引用为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T>
struct my_remove_reference { using type = T; };

template <class T>
struct my_remove_reference<T&> { using type = T; };

template <class T>
struct my_remove_reference<T&&> { using type = T; };

template <class T>
using my_remove_reference_t =
typename my_remove_reference<T>::type;

static_assert(std::is_same_v<
my_remove_reference_t<const int&>, const int>);

这里 const int& 匹配 T& 时,T 被推导为 const int,所以去掉的是引用,const 仍在。typename 告诉编译器,依赖于模板参数的 ::type 是类型名;_t 则把这一层成员类型读取封装起来。这些简化实现只用于说明机制,实际标准库实现还要满足对应 trait 的完整规范。

原理:有类型与没有类型如何控制选择

conditional 的两种结果可以由一个偏特化表示;enable_if 则只在条件为真时提供 type:

1
2
3
4
5
6
7
8
9
10
11
template <bool B, class T, class F>
struct my_conditional { using type = T; };

template <class T, class F>
struct my_conditional<false, T, F> { using type = F; };

template <bool B, class T = void>
struct my_enable_if {};

template <class T>
struct my_enable_if<true, T> { using type = T; };

my_conditional<false, int, double>::type 选出 double,但 T、F 在传给模板前都必须能形成类型。my_enable_if<false, int> 则根本没有 type;只有当这个缺失发生在允许替换失败的模板推导位置时,候选模板才会被移除。在普通代码里直接访问缺失的 ::type,仍是编译错误。区别在于错误发生的位置,不是 enable_if 把错误变成了 false。

把前面介绍的 std::void_t 用在偏特化中,就能检测某个表达式能否形成类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <type_traits>
#include <utility>

template <class T, class = void>
struct has_size : std::false_type {};

template <class T>
struct has_size<
T, std::void_t<decltype(std::declval<const T&>().size())>>
: std::true_type {};

struct Sized {
int size() const;
};

struct Unsized {};

static_assert(has_size<Sized>::value);
static_assert(!has_size<Unsized>::value);

对 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
2
3
4
5
6
7
8
9
template <class... Bs>
struct my_and : std::true_type {};

template <class B1, class... Rest>
struct my_and<B1, Rest...>
: std::conditional_t<bool(B1::value), my_and<Rest...>, B1> {};

struct NoValue {};
static_assert(!my_and<std::false_type, NoValue>::value);

这里 my_and<Rest...> 作为类型实参可以先形成;只有选中它并读取继承而来的 ::value 时,才需要继续实例化下一层。因此第二个参数没有 value 也不妨碍上面的断言成立。短路保证的是后续 trait 的 ::value 不必实例化,并不允许传入本身就无法形成的模板实参。

不要通过特化标准库的现成 traits 来“修正”其答案。标准库对这些模板的用户特化有明确限制,随意特化会导致未定义行为。自定义规则应放在自己的 trait 或 concept 中。

为什么有些 traits 需要编译器支持

上面的 my_is_same 只要比较模板实参能否匹配,就能用普通模板特化实现。但并非每个类型性质都能这样推出来。例如:一个类型是否以 union 声明、特殊成员函数是否平凡,涉及编译器已经掌握的类型定义和语言规则;在 C++20/23 中,普通模板特化或 requires 表达式没有通用、可移植的办法直接读取这些信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <type_traits>

struct Plain {
int value;
};

struct WithDestructor {
int value;
~WithDestructor() {}
};

union NumberBits {
int integer;
float decimal;
};

static_assert(std::is_trivially_copyable_v<Plain>);
static_assert(!std::is_trivially_copyable_v<WithDestructor>);
static_assert(std::is_union_v<NumberBits>);

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 之类的规则可以由模板特化表达,库实现也可以自行选择满足标准要求的实现方式。

几个容易踩的坑

  1. 把 T 当成实参表达式的完整类型。 模板推导、引用折叠和 cv 限定会改变 T 的样子;需要比较规范化类型时显式使用 remove_cvref_t<T>。
  2. 把类型性质当成业务语义。 is_integral_v<bool> 为真,is_convertible_v<double, int> 也可能为真;业务是否允许这些值要另行约束。
  3. 把“能从右值构造”理解为“存在移动构造”。 is_move_constructible_v<T> 不保证调用了移动构造函数。
  4. 把 decay_t 当成简单去限定。 它还会让数组和函数类型退化。
  5. 在普通 if 中放本应被编译期舍弃的非法表达式。 只有 if constexpr 的舍弃分支才适合这种写法,而且分支中不依赖模板参数的明显错误仍不能随意写入。
  6. 只为读取一个值就依赖未完整定义的类。 部分 traits 需要完整类型,具体要求应按各 trait 的标准条款核对。

记住一条主线即可:先说清泛型代码需要哪项能力,再选查询、变换、分支或约束。type_traits 提供编译期的类型信息;if constexpr 用信息选择实现;Concepts 把要求写到接口上。三者配合,泛型代码才既灵活又容易读。

参考资料