C++ 类型萃取:重生之我在幼儿园修炼类型学

我叫张三,是一名苦逼牛马程序员。

上一秒我还在工位上对着满屏的 typename std::enable_if_t<std::is_same_v<T, std::vector<std::map<...>::iterator>> 喷出一口老血,下一秒我就重生了。

好消息是,我没穿到异世界,也没穿到古代当王爷。

坏消息是,我穿进了一所幼儿园,名叫编译期幼儿园。

园长是个叫 Bjarne 的老头,笑呵呵地递给我一张胸牌,上面写着:"类型萃取小班"。

旁边一个小女孩跑过来,冲我喊道:"你连 T 是 const 还是 volatile 都分不清吗?老师说今天要考的!"

我低头一看,自己浑身光溜溜的,没有类型,没有名字,只有胸口印着一个大大的 auto。

那一瞬间我悟了,上辈子我被模板错误虐得死去活来,原来根源在这儿。

我连最基础的类型都没搞明白,就敢在泛型代码里裸奔,编译器看到后不给我写小作文才怪!

于是,我决定在幼儿园从头修炼 《类型学》 ,并把我的修炼笔记公开如下。

基础概念

咳咳,该正经的地方咱还是得正经点,不能丢份儿。

1. 类型萃取

类型萃取(Type Traits)嘛,就是可以在编译期获取和操作类型信息。

它不问"这个对象是什么值",而是问"这个类型本身有什么属性"。

比如经典的:

c++ 复制代码
std::is_pointer<int*>::value // true,是根指针,指哪儿打哪儿
std::is_pointer<int>::value // false,就是老实巴交的整数

std::is_pointer 本身就是一个萃取器,用于判断某个类型是否为指针类型。

它把 int* 扔进自己的检测仪,然后吐出 true(以静态常量 value 的形式)。

整个过程在编译期完成,一滴运行时开销都没有,一滴都没有哦。

2. 元函数

元函数听着怪吓人的,其实就是编译期函数。

普通函数:我们给它 (int a, int b),它运行时算出 a+b 返回。

元函数:我们给它 <typename T, typename U>,它编译时 算出 constexpr bool 或者一个新的类型返回。

模板就是元函数的语法载体。比如:

c++ 复制代码
template<typename T>
struct add_const 
{
    using type = const T; // 返回一个加过const的新类型
};

这里 add_const 是个元函数,输入 int,输出 const int(通过 add_const<int>::type 拿结果)。

当然,标准库也预装一套常用类型萃取元函数,省得我们自己手搓这些轮子。

3. 模板特化

如果元函数是函数,那它总得有条件分支吧?

但编译期没有 if 语句,这时候模板特化就扛着品如的衣柜进来啦。

我们来自己实现一个std::is_pointer(简化版):

c++ 复制代码
// 主模板:默认情况,不是指针
template<typename T>
struct is_pointer 
{
    static constexpr bool value = false;
};

// 偏特化:匹配 T* 的情况
template<typename T>
struct is_pointer<T*> 
{
    static constexpr bool value = true;
};

编译器遇到 is_pointer<int*> 时,先看主模板:哦,你要一个 T。

然后发现有个特化版本 is_pointer<T*>,并且 int* 完美契合 T* 的模式。

啪的一下!很快奥,上来就是一个左正蹬,一个右鞭腿,一个左刺拳。

嘿,您猜怎么着?直接匹配成功,选择特化版,value 变 true。

遇到 is_pointer<int>,模式 T* 不匹配,退回主模板,value=false。

这就是编译期的模式匹配

模板特化是元编程的唯一控制流机制,没有它,元函数只能返回一样的值,跟复读机似的。

4. 为什么叫"萃取"不叫"检测"?

因为萃取经常不只是返回个 bool,还能把类型的零件拆出来。

c++ 复制代码
std::remove_pointer<int*>::type // 得到 int
std::add_pointer<int>::type // 得到 int*

假如我们有个橘子,我们想让萃取器帮我们检查这正不正宗,味儿足不足。

它不仅能告诉我们这橘子可真是个橘子,还能顺带帮我们把皮剥了。

甚至还能去籽,然后榨汁一条龙服务。

5. 总结一下

  • 元函数:编译期干活的函数,输入类型/常量,输出类型/常量。
  • 类型萃取:标准库提供的一堆实用元函数,专门榨取类型信息。
  • 模板特化:元函数写 if-else 的语法,没有它,元函数就是个残废。

标准库类型萃取概览

我们对 <type_traits> 里的东西按用途进行分类。

这些东西随便扫几眼就行,需要的时候再查。

1. 第一类:类型属性查询

这帮萃取器专门回答:"这类型是干嘛的?你是什么成分?你的祖上又是什么成分?"

c++ 复制代码
std::is_void<T> // 是不是void?空无一物?

std::is_integral<T> // 是不是整数?(bool/char/int/long都算)

std::is_floating_point<T> // 是不是浮点?float/double/long double

std::is_array<T> // 是不是数组?int[5]算,int*不算!

std::is_enum<T> // 是不是枚举?编译器知道,我们未必看得出

std::is_union<T> // 是不是联合体?上古遗产检测仪

std::is_class<T> // 是不是class/struct?注意:int不算,std::string算

std::is_function<T> // 是不是函数类型?int(int, char)这种,不是函数指针

std::is_pointer<T> // 是不是原生指针?

std::is_lvalue_reference<T> // 是不是左值引用&?

std::is_rvalue_reference<T> // 是不是右值引用&&?

std::is_member_pointer<T> // 是不是成员指针?int MyClass::*

还有修饰符检测系列:

c++ 复制代码
std::is_const<T> // 是不是const?

std::is_volatile<T> // 是不是volatile?(见过吗?我也没怎么见过)

std::is_trivial<T> // 是不是平凡类型?能像C结构体那样memcpy?

std::is_trivially_copyable<T> // 同上,但允许有非平凡构造,只要拷贝安全

std::is_standard_layout<T> // 是不是标准布局?内存排布和C兼容?

std::is_pod<T> // C++17后已弃用,老代码里常见,等于trivial+standard_layout

std::is_abstract<T> // 有没有纯虚函数?不能实例化

std::is_final<T> // C++14,是不是final类?

std::is_empty<T> // 是不是空类?无成员变量,sizeof可能为1

我们把身份证给编译器检查,编译器扫了一眼:"良民,大大滴良民"。

2. 第二类:类型转换

这帮萃取器能把一种类型变成另一种,削掉引用、剥掉const、加个指针等。

c++ 复制代码
// 去修饰符
std::remove_const<T> // 把const T变成T

std::remove_volatile<T> // 同上

std::remove_cv<T> // 一起剥,const volatile全干掉

std::remove_reference<T> // T&或T&&变成T

std::remove_pointer<T> // T*变成T

std::remove_extent<T> // int[5]变成int,只去掉第一层[]

std::remove_all_extents<T> // int[3][4]变成int

// 加修饰符
std::add_const<T> // T变成const T

std::add_volatile<T> // ...

std::add_cv<T> // 双重加料

std::add_lvalue_reference<T> // T变成T&

std::add_rvalue_reference<T> // T变成T&&

std::add_pointer<T> // T变成T*

// 特殊变形
std::decay<T> // 值传递时的退化:数组变指针,函数变指针,去掉引用和顶层const

std::make_signed<T> // int变int,unsigned变int,char变signed char

std::make_unsigned<T> // 反过来

std::enable_if<B, T> // B为true时才有type=T,否则type缺失

std::conditional<B, T, F> // B为true得T,false得F

std::common_type<Ts...> // 一堆类型里选一个大家都能隐式转换到的公共类型

std::underlying_type<T> // 枚举的底层整数类型

std::result_of<F(Args...)> // C++17已弃用,用invoke_result替代,函数调用返回类型

3. 第三类:类型关系

判断两个类型之间能不能赋值?继承?构造?

c++ 复制代码
std::is_same<T, U> // 是不是完全一样?包括const/volatile修饰

std::is_base_of<Base, Derived> // Base是不是Derived的基类?公开/保护/私有继承都算

std::is_convertible<From, To> // From能不能隐式转换成To?

std::is_constructible<T, Args...> // 能不能用Args...构造T?

std::is_trivially_constructible<T, Args...> // 平凡构造

std::is_nothrow_constructible<T, Args...> // 构造不抛异常

// 类似还有 is_assignable, is_copy_constructible, is_move_constructible 等

举个栗子:

c++ 复制代码
template<typename T>
std::enable_if_t<std::is_base_of<Animal, T>::value, void> feed(T& animal) 
{
    animal.eat();
}
// 只有Animal的子类才能喂食

4. 辅助别名_t和变量模板_v(C++ 14)

4.1 变量模板:_v

以前写:

c++ 复制代码
if (std::is_pointer<T>::value) { ... }

现在写:

c++ 复制代码
if constexpr (std::is_pointer_v<T>) { ... }

_v 后缀直接把 ::value 给我们取出来了。

4.2 别名模板:_t

以前写:

c++ 复制代码
typename std::remove_reference<T>::type elem; // 最前面的typename能消歧义

现在写:

c++ 复制代码
std::remove_reference_t<T> elem; // 干净利落

实现它们也很简单:

c++ 复制代码
template<typename T>
using remove_reference_t = typename remove_reference<T>::type;

template<typename T>
inline constexpr bool is_pointer_v = is_pointer<T>::value;

所以它们只是用语法糖包装了一下。

一些常见类型萃取的实现原理

我们看几个经典的萃取期源码,当然还是简化版的。

1. std::is_same

功能:判断两个类型是不是完全一样,连const、引用这种细节都对得上。

实现:

c++ 复制代码
template<typename T, typename U>
struct is_same : std::false_type {};  // 默认:不相同

template<typename T>
struct is_same<T, T> : std::true_type {}; // 特化:相同

这里 std::true_type 和 std::false_type 是啥?其实就是:

c++ 复制代码
using true_type = std::integral_constant<bool, true>;
using false_type = std::integral_constant<bool, false>;

而 integral_constant<T, v> 是一个包装了编译期常量的类模板,提供 ::value 静态成员和类型别名 ::type。

它主要的作用是在编译期存储和表示一个常量值。

其值是存储在 ::value 静态成员中的。

小芝士介绍完了,我们来看看原理:

  • 主模板接收两个独立类型参数T和U,继承false_type(即value=false)。
  • 偏特化版本:is_same<T, T>,当第二个参数和第一个参数字面相同时,编译器会选中这个更匹配的偏特化,继承true_type(value=true)。

用法

c++ 复制代码
static_assert(is_same<int, int>::value); // true
static_assert(!is_same<int, const int>::value); // false

(其实 is_same 实现是从我之前的一篇文章中直接复制过来的,这就叫代码复用,嘿嘿)

2. std::remove_const

功能:把const T变成T,如果不是const就原样返回。

实现:

c++ 复制代码
template<typename T>
struct remove_const 
{
    using type = T; // 默认:啥也不干
};

template<typename T>
struct remove_const<const T> 
{
    using type = T; // 特化:匹配到const T,把const剥了
};

这个 remove_const 只剥顶层 const。

比如 const int* 的 const 修饰的是 int,不是指针本身,所以 const int* 匹配不到 const T。

3. std::is_pointer

功能 :检查类型是不是原生指针(包括int 、void、const int*等)。

我们之前实现过,这次再精确点:

c++ 复制代码
template<typename T>
struct is_pointer : std::false_type {};

template<typename T>
struct is_pointer<T*> : std::true_type {}; // 捕获普通指针

template<typename T>
struct is_pointer<T* const> : std::true_type {}; // 捕获const指针

template<typename T>
struct is_pointer<T* volatile> : std::true_type {}; // 捕获volatile指针

template<typename T>
struct is_pointer<T* const volatile> : std::true_type {};

我们用四个偏特化覆盖所有指针的cv限定组合,其余全部掉入主模板返回false。

真实标准库还会多考虑一些,但思路就这样。

4. std::conditional

功能:if (B) then T else F,但发生在编译期。

实现:

c++ 复制代码
template<bool B, typename T, typename F>
struct conditional 
{
    using type = F; // 默认:false分支
};

template<typename T, typename F>
struct conditional<true, T, F> 
{
    using type = T; // 特化:true分支
};

测试(使用我们之前实现的 is_same):

c++ 复制代码
is_same<conditional<true, int, float>::type, int>::value; // true
is_same<conditional<false, int, float>::type, float>::value; // true

应用:比如写一个 std::make_unsigned,如果 T 是 int 得到 unsigned int,否则保持原样(简化版)。

c++ 复制代码
template<typename T>
struct make_unsigned 
{
    using type = std::conditional_t<
        std::is_same_v<T, int>,
        unsigned int,
        T
    >;
};

5. std::void_t(C++ 17)

这是最有趣的一个,它本身是个空壳子,但配合 SFINAE 能检测任意表达式是否合法。

定义:

c++ 复制代码
template<typename...> 
using void_t = void; // 无论给什么参数,我都生成void

利用 SFINAE 规则"模板参数替换失败不是错误"。

当我们在一个偏特化里写 void_t< decltype(表达式) > 时,如果 decltype(表达式) 不合法,整个偏特化就被静默丢弃,编译器退回到主模板。

经典案例:检测一个类型是否有.size()成员函数。

c++ 复制代码
// 主模板:默认false
template<typename, typename = void>
struct has_size : std::false_type {};

// 偏特化:如果T有.size()成员,偏特化被选中
template<typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>> 
    : std::true_type {};

测试:

c++ 复制代码
has_size<std::vector<int>>::value; // true
has_size<int>::value; // false

std::declval<T>():一个编译期工具,假装创建一个 T 的右值引用,让我们能在不求值上下文里调用成员函数,获得返回类型。

它本身没有定义,只能在decltype里用。

有点偏题了,讲到 SFINAE 去了(好像也没有偏题?)。

6. 再啰嗦几句

标准库里那些看起来很厉害的萃取器,剥开来基本都是这几个套路的排列组合。

现在我们自己去看那些萃取器的源码,也能看懂个七七八八的。

ok,就先拆解到这了,接下来就开始我们的手搓环节。

自定义类型萃取

标准库里的萃取器虽然多种多样,但总有它触及不到的地方,这时候就需要我们亲自动手。

1. 什么时候该自己动手?

别急着撸袖子。开始之前先想想这些问题:

  1. 标准库有吗?能偷懒就偷懒嘛,没有再搓轮子。
  2. 概念(Concepts,C++20)能更干净地解决吗?如果我们能用 std::integral 直接约束,就别写一坨enable_if。
  3. 我们真的需要在编译期分支吗?如果只是重载决议,有时候一个普通的函数重载比萃取更直观。

如果说:"不,我就要在模板里根据某个类的特定成员决定走哪条路"。

那自定义萃取就是我们的好朋友。

2. 核心工具

无论检测什么,都离不开这三位大爷:

  • std::void_t<...>:SFINAE的开关,C++17起标准提供,但自己写一行也行。
  • std::declval<T>():假装创建一个T的实例,让我们能在decltype里调用它的成员或运算符,永远不能出现在求值上下文里。
  • decltype(...):捕获表达式的类型。

把它们组合起来,模式如下:

c++ 复制代码
// 默认返回false的主模板
template<typename, typename = void>
struct MyTrait : std::false_type {};

// 偏特化:如果某个表达式合法,void_t成功变成void,匹配此特化,返回true
template<typename T>
struct MyTrait<T, std::void_t<decltype( /* 我们的表达式 */ )>> 
    : std::true_type {};

这就是自定义萃取的万能模板,后面咱们所有的花样都是往 decltype 里填不同的东西。

3. 检测成员类型

比如我们想知道一个类内部有没有定义value_type。

c++ 复制代码
template<typename, typename = void>
struct has_value_type : std::false_type {};

template<typename T>
struct has_value_type<T, std::void_t<typename T::value_type>> 
    : std::true_type {};

测试:

c++ 复制代码
static_assert(has_value_type<std::vector<int>>::value); // true
static_assert(!has_value_type<int>::value); // false

typename T::value_type 如果存在,它就是合法类型,void_t 得到 void;如果不存在,模板替换失败,偏特化被丢弃,落回主模板。

辅助别名(方便直接用):

c++ 复制代码
template<typename T>
inline constexpr bool has_value_type_v = has_value_type<T>::value;

4. 检测成员函数

这是最常见的需求。

我们之前也实现过一个检测有没有无参的.size()方法。

这次就来实现一个检测有没有 void clear(int):

c++ 复制代码
template<typename T, typename = void>
struct has_clear_int : std::false_type {};

template<typename T>
struct has_clear_int<T, 
    std::void_t<decltype(std::declval<T>().clear(std::declval<int>()))>
> : std::true_type {};

测试:

c++ 复制代码
struct A { void clear(int); };
struct B { void clear(double); };
struct C { int clear; }; // 成员变量不是函数

static_assert(has_clear_int<A>::value); // true
static_assert(!has_clear_int<B>::value); // false,参数类型不匹配
static_assert(!has_clear_int<C>::value); // false

5. 检测操作符

检测一个类型是否支持 + 运算符(两个const T&相加):

c++ 复制代码
template<typename T, typename = void>
struct is_addable : std::false_type {};

template<typename T>
struct is_addable<T, 
    std::void_t<decltype(std::declval<const T&>() + std::declval<const T&>())>
> : std::true_type {};

通用版:检测任意二元运算符(比如 << 流输出):

c++ 复制代码
template<typename T, typename Stream = std::ostream, typename = void>
struct is_streamable : std::false_type {};

template<typename T, typename Stream>
struct is_streamable<T, Stream,
    std::void_t<decltype(std::declval<Stream&>() << std::declval<T>())>
> : std::true_type {};

6. 复合条件

有时候我们需要一个萃取回答"这个类既有begin()又有end(),而且它们的返回类型能比较"。

这就得组合多个表达式。

方法一:void_t里塞多个decltype

c++ 复制代码
template<typename T, typename = void>
struct is_range : std::false_type {};

template<typename T>
struct is_range<T, std::void_t<
    decltype(std::declval<T>().begin()),
    decltype(std::declval<T>().end()),
    // 可以加更多要求,比如 begin() 返回的迭代器支持 != 比较
    decltype(std::declval<T>().begin() != std::declval<T>().end())
>> : std::true_type {};

void_t 是个变参模板,它会把所有 decltype 都求值一遍(实际是检查合法性)。

只要有一个非法,整个替换就失败,偏特化丢弃。

方法二:逻辑串联,用 std::conjunction

先来简单介绍一下 std::conjunction:

它是 C++17 引入的模板工具,能将一组类型按顺序组合成一个新的类型。

只有当所有类型的 value 都为 true 时,其结果才为 true。

比如 std::conjunction<A, B, C>,只有当 A,B,C 萃取出的 value 都为 true 时,结果才为 true。

它与 && 一样,也有短路行为,一旦值可以确定就会停止。

也就是说当 A 为 false 时,就会直接停止。

我们来使用一下:

c++ 复制代码
template<typename T>
using is_vector_like = std::conjunction<
    has_size<T>, // 有 .size()
    has_value_type<T>, // 有 value_type 嵌套类型
    std::is_same<typename T::value_type, 
                 std::decay_t<decltype(std::declval<T>()[0])>> // 下标返回类型与value_type一致
>;

这样is_vector_like::value就是三个条件的逻辑与。

7. 最后的最后

  1. 我们可以与 C++14 的 _v 和 _t 别名一样,自己写的萃取也可以模仿。
  2. 如果我们发现自己在写一个检测"有 getFoo 且返回 const Bar* 且 Bar 有嵌套 Baz......"的五层萃取,可能是设计上该用虚函数概念重构了。
  3. 当我们的萃取意外失败时,错误信息会是一大坨。这时候我们可以先从 decltype 里的表达式一行行注释掉,看哪一步炸的。

结尾

(差不多该收工了)

所以我们看,类型萃取这玩意儿,说穿了就是编译期的摸骨算命。

我们写个模板,它不放心,非得把传进来的类型扒光了瞅瞅:"嗯,是个指针,骨重六两;带 const,命里带锁;有个叫 size() 的成员,能吃容器这碗饭。"

学这东西最大的好处,不是写库,也不是炫技。

是以后看源码时,不再觉得自己在看天书。

相关推荐
比昨天多敲两行4 小时前
C++11新特性
开发语言·c++
xiaoye-duck4 小时前
【C++:C++11】核心特性实战:详解C++11列表初始化、右值引用与移动语义
开发语言·c++·c++11
睡一觉就好了。4 小时前
二叉搜索树
c++
whitelbwwww4 小时前
C++进阶--类和模板
c++
今天又在学代码写BUG口牙4 小时前
MFC 定时器轮询实现按住按钮进度条增加(鼠标悬停/长按检测)
c++·mfc·定时器·鼠标·轮询·长按事件
AIminminHu5 小时前
OpenGL渲染与几何内核那点事-项目实践理论补充(三-1-(3):番外篇-当你的CAD打开“怪兽级”STL时:从内存爆炸到零拷贝的极致优化)
开发语言·c++·线程·多线程
j_xxx404_5 小时前
力扣题型--链表(两数相加|两两交换链表中的节点|重排链表)
数据结构·c++·算法·leetcode·蓝桥杯·排序算法
kyle~5 小时前
FANUC 机械臂 --- 配置字
网络·c++·机器人·ros2
oldmao_20005 小时前
第八章 设计并发代码
开发语言·c++·多线程编程·并发编程