前置知识: C++

类型萃取深水区:detect 框架与三种分派写法

7 min高级

承接 350 进入萃取工程:读 iterator_traits 与 allocator_traits、void_t 探测装配成 detect 框架、tag dispatch / SFINAE / if constexpr 三版并排、conjunction/disjunction 组合与短路实例化。

前置知识

与 350 的分工再念一遍:350 主教学——最小工具与 SFINAE 原则;本文深水区——标准库萃取、detect 框架、三种分派并排、traits 组合。示例不重复:350 全程 serialize,本文换成排行榜与容器。

学习目标

读完本文你将能够:

  1. 读 std::iterator_traits 的萃取结果,说出 _t/_v 后缀的读法;
  2. 默写 detect 框架的两层结构,为任意成员组装「问句」;
  3. 对同一需求写出 tag dispatch、SFINAE、if constexpr 三版,并说出适用边界;
  4. 用 conjunction/disjunction 组合 traits,说出与手写 && 在实例化上的差别。

预计 45 到 60 分钟,包含 2 组动手实验、1 道预测题与 1 道挑战题。

1. 你现在要解决什么问题

排行榜模块里,正式榜单用 std::vector(可以随机跳),候选池用 std::list(只能一步一步爬)。你要写「让迭代器前进 n 步」的通用函数:

cpp
#include <list>

template<typename Iter>
void advance_steps(Iter& it, int n) {
    it += n;   // vector 的迭代器支持,list 的不支持
}
// 放进 main 里对 std::list 的迭代器调用,编译即炸

编译(g++ 13):

text
error: no match for 'operator+=' (operand types are
    'std::_List_iterator<int>' and 'int')

函数体是一刀切,而你要的是先问迭代器是谁,再决定怎么走。答案在 std::iterator_traits 里——这就是萃取。

2. 核心概念:萃取的读法——iterator_traits 与 allocator_traits

iterator_traits<Iter> 暴露五个成员:value_type(元素类型)、difference_type(距离类型)、iterator_category(类别标签)、pointer、reference。类别字段决定「能怎么走」:

cpp
#include <iterator>
#include <list>
#include <type_traits>

template<typename Iter>
using category_t = typename std::iterator_traits<Iter>::iterator_category;

static_assert(std::is_same_v<category_t<std::list<int>::iterator>,
                             std::bidirectional_iterator_tag>);

预期输出:编译通过,退出码 0。萃取的读法三条:问类型用 ::value_type 这类成员别名;问真假用 ::value(350 篇 has_serialize<T>::value 同款);C++14 起分别有 _t、_v 后缀替你写掉 typename ... ::type。

allocator_traits(内存分配器的萃取,<memory>)同理,还多一层兜底:分配器缺 pointer 它补 value_type*,缺 construct 用 placement new——rebind_alloc<U> 能把 allocator<int> 换成 allocator<U>。直接问成员会硬错误,问 traits 永远有答案;源码里见 std::allocator_traits<A>:: 按「问分配器有什么本事」理解。

3. detect 框架:探测任意成员的通用模具

350 的 has_serialize 每探测一个成员就要抄一遍。把「问句」抽成参数,就是通用 detect 框架(cppreference「Detection idiom」简化版):

cpp
#include <type_traits>
#include <vector>

// 第一层:detect 只回答「Op<T> 这个问法有没有答案」
template<typename T, template<typename...> class Op, typename = void>
struct detect : std::false_type {};

template<typename T, template<typename...> class Op>
struct detect<T, Op, std::void_t<Op<T>>> : std::true_type {};

template<typename T, template<typename...> class Op>
inline constexpr bool detect_v = detect<T, Op>::value;

// 第二层:问句写成别名模板,一个问句一行
template<typename T>
using has_reserve_t = decltype(std::declval<T&>().reserve(0));

static_assert(detect_v<std::vector<int>, has_reserve_t>);
static_assert(!detect_v<int, has_reserve_t>);

int main() { return 0; }

预期输出:无报错,退出码 0。逐层拆:主模板永远匹配、答 false;偏特化把 Op<T> 交给 void_t——问句合法则 void_t<...> 是 void,偏特化胜出答 true;不合法则替换失败、偏特化被 SFINAE 出局,回退主模板。此后新成员检测一行一个:写个别名问句,交给 detect_v 裁判。

4. 同一需求三版写法:tag dispatch、SFINAE、if constexpr

需求:批量灌榜前,有 reserve 的容器先预留容量,没有的跳过。用 has_reserve_t 当开关,三版并排(同一函数只能存在一版)。

版本 A,tag dispatch——条件变值参数,路由交给重载决议:

cpp
template<typename T>
void prefill(T& c, int n, std::true_type)  { c.reserve(c.size() + n); }
template<typename T>
void prefill(T& c, int,    std::false_type) {}
// 调用:prefill(c, n, std::bool_constant<detect_v<T, has_reserve_t>>{});

版本 B,SFINAE——条件写进签名,不合格的重载整体出局:

cpp
template<typename T, std::enable_if_t<detect_v<T, has_reserve_t>, int> = 0>
void prefill(T& c, int n) { c.reserve(c.size() + n); }

template<typename T, std::enable_if_t<!detect_v<T, has_reserve_t>, int> = 0>
void prefill(T&, int) {}

版本 C,if constexpr(C++17)——条件在函数体,落选分支不实例化:

cpp
template<typename T>
void prefill(T& c, int n) {
    if constexpr (detect_v<T, has_reserve_t>) {
        c.reserve(c.size() + n);
    }
}
写法一句话适用边界
tag dispatch条件变值参数,路由靠重载决议策略摊成多个函数;标准库 advance 的老传统
SFINAE条件写进签名,出局发生在重载集要影响「重载存不存在」
if constexpr条件在函数体,落选分支不实例化单函数内部分支;没法让某个重载消失

现代替代一句话:C++20 直接写 concept Reservable = requires(T& c, std::size_t n) { c.reserve(n); }——问句写进语言,detect 模具退休,详见 C++20 概念。

5. traits 组合:conjunction 与 disjunction

多个 traits 求与或,别手写 &&/||,用 C++17 逻辑元函数:

cpp
#include <type_traits>

template<typename... Ts>
inline constexpr bool all_arithmetic_v = std::conjunction_v<std::is_arithmetic<Ts>...>;

static_assert(all_arithmetic_v<int, double, char>);
static_assert(all_arithmetic_v<>);    // 空包:conjunction 为 true
static_assert(!std::disjunction_v<>); // 空包:disjunction 为 false

int main() { return 0; }

预期输出:无报错,退出码 0。... 把参数包逐个喂给 traits(见 变参模板),析取用 std::disjunction、否定用 std::negation。与手写 && 的真正差别在短路实例化:a_v<T> && b_v<T> 两个模板都会实例化,conjunction 遇到第一个 false 就停,后面的 traits 根本不实例化——当后面的特征对某些类型是硬错误时,这就是编译成败的差别。

6. 修改实验

实验一(10 分钟):照第 3 节再写问句 has_empty_t(探测 .empty()),预测 detect_v<std::vector<int>, has_empty_t> 与 detect_v<int, has_empty_t>。参考结果:true 与 false——vector 有 empty(),int 没有。

实验二(10 分钟):把版本 A 的调用改成 prefill(c, n, std::true_type{}),再用 std::list 调用。参考结果:编译失败——list 没有 reserve,true_type 分支实例化即硬错误。tag 必须来自萃取结果,不能拍脑袋硬传。

7. 常见错误与调试实录

错误一:依赖名前面忘了 typename。把第 2 节的 category_t 写歪成直接引用:

cpp
template<typename Iter>
std::iterator_traits<Iter>::value_type first(Iter it) { return *it; }

报错原文(g++ 13):

text
error: need 'typename' before 'std::iterator_traits<Iter>::value_type'
because 'std::iterator_traits<Iter>' is a dependent scope

读报错三步:报错点名缺 typename——Iter 是模板参数,推导前编译器不知道它是类型还是值;修复是加 typename,更省事的是用 _t 别名(C++20 还有 std::iter_value_t 等现成别名)。

错误二:在普通 constexpr 表达式里「用」可能不存在的成员:

cpp
template<typename T>
inline constexpr bool bad_v =
    std::is_class_v<T> && sizeof(typename T::value_type) > 0;

static_assert(!bad_v<int>);   // 想要 false,实际直接硬错误

报错原文:

text
error: 'int' is not a class, struct, or union type

原因:&& 只短路求值,不短路实例化——bad_v<int> 替换时右边表达式整体要成立,int::value_type 就是硬错误。修法是让右边走 detect 框架:using value_type_t = typename T::value_type;(模板形式)然后 std::is_class_v<T> && detect_v<T, value_type_t>,对 int 得 false,不炸。

8. 实际项目中的使用场景

  • 泛型算法按迭代器类别分派:随机访问走 it += n,双向只能 ++/--,标准库 advance、distance 内部就是版本 A;
  • 库作者的能力探测:序列化库探测 save/load,适配器探测 reserve,detect 框架一行一个;
  • 什么时候不用:探测一两个成员,if constexpr 加手写 traits 够了;能上 C++20 就用 requires 表达式。

9. 小练习

预测题(5 分钟,先写答案再编译验证):

cpp
struct fast_tag {};
struct safe_tag : fast_tag {};

template<typename It>
void step(It& it, fast_tag);   // 只 ++
template<typename It>
void step(It& it, safe_tag);   // ++ 前先检查边界

safe_tag s;
step(v.begin(), s);   // 预测:调用哪个重载?为什么?

挑战题(半小时,脱离示例实现):仿照第 3、4 节实现 has_empty_t 与 report_empty(Container)——有 empty() 时打印「空」或「非空」,没有时打印「无法判断」。验收断言:

cpp
static_assert(detect_v<std::vector<int>, has_empty_t>);
static_assert(!detect_v<int, has_empty_t>);

提示:问句一行,if constexpr 一个分支即可。

参考答案(先做完再看):预测题调 safe_tag 版——实参与形参完全匹配优于转基类,标准库 tag 层级正靠这条规则选中最特化实现。挑战题核心三行:问句 using has_empty_t = decltype(std::declval<T&>().empty());;if constexpr (detect_v<C, has_empty_t>) 内打印 c.empty() ? "空" : "非空";否则「无法判断」。

10. 与之前和之后的知识的关系

11. 官方文档

12. 自我检查

  • 能读 iterator_traits 五件套,说出 _t/_v 后缀各替你写掉了什么;
  • 能默写 detect 两层结构,并为新成员一行写出问句;
  • 能写出三版分派,并说出「要让重载消失」时只能选 SFINAE;
  • 能解释 bad_v<int> 硬错误的成因,并用 detect 修复。

本章总结

萃取读法三条:::value_type 问类型、::value 问真假、_t/_v 是糖;allocator_traits 展示萃取层兜底。detect 框架把问句变成参数,成员检测一行一个;tag dispatch、SFINAE、if constexpr 是同一开关的三种接法,分界线在「要不要影响重载集」;conjunction/disjunction 组合 traits 且自带短路实例化。C++20 requires 表达式最终接走这一切——但标准库与存量代码里,这套模具仍是通用语。

下一步

进入 变参模板:void_t<Args...> 里的参数包,那里才是它的完整形态。