夜雨聆风学习资料网

ARTICLE · 1280653

让模板学会"看人下菜碟": 类型萃取与编译期技巧

让模板学会"看人下菜碟": 类型萃取与编译期技巧
学完第 13 篇的模板入门, 你已经能写出"对任何类型一视同仁"的泛型函数. 但现实世界往往反过来: 对整型要按位运算, 对浮点要按数学运算, 对字符串要有另一套处理. 于是你开始撞见 std::is_same, std::enable_if, decltype 这些词, 每个都眼熟, 连起来却一头雾水.
本篇把它们串成一条线, 讲清楚四件事: 怎么在编译期向类型提问, 怎么在编译期分流, 怎么不创建对象就推导出类型, 怎么处理任意数量的参数. 全部基于 C++17, 语法拿来就能用, 不需要任何外部库. 先建立一个直觉: 在模板的世界里, "判断"和"分支"都发生在编译期, 运行期代码里根本看不到这些痕迹. 带着这个直觉, 下面四节会顺畅很多. 这套技巧最典型的用途, 就是写容器包装类, 序列化库这类"要兼容各种类型"的公共代码.

1. 类型萃取: 在编译期向类型提问

type_traits 头文件里有一族"问题函数", 名字都是 is_xxx. 运行期它们什么都不做, 编译期却能告诉你铁一般的事实. C++17 之后, 每个都附赠了 _v 后缀的变量模板版本, 写起来干净利落:
#include<iostream>#include<type_traits>template <typename T>voidshowType(){    std::cout << "integral:   " << std::is_integral_v<T> << '\n'              << "floating:   " << std::is_floating_point_v<T> << '\n'              << "same as int:" << std::is_same_v<T, int> << '\n';}intmain(){    static_assert(std::is_integral_v<int>);    static_assert(!std::is_integral_v<double>);    static_assert(std::is_same_v<int, int>);    showType<int>();     // integral: 1, floating: 0, same as int: 1    showType<double>();  // integral: 0, floating: 1, same as int: 0    return 0;}
static_assert 是编译期就拍板的检查: 条件不满足, 编译直接失败, 这就是"在编译期向类型提问"的价值: 把运行时才暴露的 bug, 提前到编译期拦下. 除了 is_integral, is_same, 还有 is_pointer, is_class, is_convertible, is_trivially_copyable 等几十个, 组合起来几乎能描述任何你想知道的类型特征. 当你写的模板需要"查类型户口"时, 第一反应就应该是这里. 比如判断两个类型是否有继承关系可以用 std::is_base_of, 判断能否构造出对象可以用 std::is_constructible, 写框架代码时经常用得上. 这些 trait 在内部就是编译期常量, 所以它们能出现在 static_assert 里, 能当模板参数, 甚至能决定一个数组的大小. 在类型面前, 它们比任何运行时的 if 都可靠: 没有运行, 就没有运行时出错.

2. if constexpr: 让模板学会"分流"

知道类型之后, 下一步是"按类型走不同的代码". C++11 时代的标准答案是 std::enable_if: 把条件塞进函数模板的返回类型或参数列表, 条件不满足就把这个重载从候选集里移除. 能工作, 但可读性一言难尽, 报错更是天书, 新手根本不敢碰.
C++17 给了现代答案: if constexpr. 规则一句话: 条件在编译期求值, 只编译满足条件的那一个分支, 另一个分支连语法合法性都不检查:
#include<iostream>#include<string>#include<type_traits>template <typename T>std::string typeName(){    ifconstexpr(std::is_integral_v<T>){        return "integral";    } else if constexpr (std::is_floating_point_v<T>) {        return "floating point";    } else {        return "something else";    }}intmain(){    std::cout << typeName<int>() << '\n';         // integral    std::cout << typeName<double>() << '\n';      // floating point    std::cout << typeName<std::string>() << '\n'; // something else    return 0;}
if constexpr 不是"快一点的 if", 而是"编译期的分支语句": 四个分支的代码可以互相矛盾而不报错, 因为编译器只保留了一条. 在函数模板里, 你就能用"普通思维"写代码, 再也不用堆模板体操. 可以说, 它取代了 90% 的 enable_if 场景, 如果你在旧代码里看到 enable_if, 先想想能不能换成 if constexpr. 小提醒: if constexpr 的条件必须能在编译期求值, 所以它的黄金搭档正是第 1 节的 is_xxx 族, 或者你自写的 constexpr 函数 - 第 9 篇学到的 constexpr, 在这里派上了大用场.

3. decltype 与 declval: 不建对象, 也知道类型

decltype(表达式) 的意思是"这个表达式的类型", 它不求值表达式, 只问类型. 最经典的用法是推导模板函数的返回类型, 让函数能处理"任何可相加的东西":
#include<iostream>#include<string>#include<type_traits>#include<utility>template <typename T, typename U>auto add(T a, U b) -> decltype(a + b) {   // 返回类型: a + b 的类型    return a + b;}int main() {    auto r = add(1, 2.5);   // int + double -> double    static_assert(std::is_same_v<decltype(r), double>);    // declval: 不真正构造对象, 也能问"它是什么类型"    using Size = decltype(sizeof(std::declval<std::string>()));    static_assert(std::is_same_v<Size, std::size_t>);    return 0;}
declval() 更妙: 它"虚构"一个 T 类型的右值引用, 但并不会真正构造任何对象. 你可以在只有类型, 没有值的情况下, 问"这个类型能做什么运算", 配合 decltype, sizeof, is_xxx 使用. 一句话: declval 是类型世界的"纸片人", 只在编译期的推理舞台上出现, 永远不会有真实本体. 再给一个高频组合: decltype(std::declval().size()) 不求值任何对象, 就能问出"类型 A 的 size() 返回什么", 这是判断"一个类型支持哪些操作"的标准姿势, 也是将来写高级约束的地基.

4. 可变参数模板: 一锅端, 以及收纳盒 tuple

函数想接收"任意数量的参数", 靠重载写不过来, 靠数组又类型单一. 可变参数模板的答案是参数包: 用 ...Args 接住一箩筐东西, 再用折叠表达式一次性展开:
#include<iostream>#include<string>#include<tuple>template <typename... Args>autosumAll(Args... args){    return (args + ...);          // 折叠: 展开成 a + b + c + d}template <typename... Args>voidprintAll(const Args&... args){    (std::cout << ... << args);   // 折叠输出流}intmain(){    std::cout << sumAll(1, 2, 3, 4) << '\n';   // 10    printAll("sum = ", 1 + 2 + 3, '\n');       // sum = 6    // tuple: 异构的收纳盒, 一个盒子装下不同类型    std::tuple<int, double, std::string> t{1, 2.5, "hello"};    std::cout << std::get<0>(t) << ' '              << std::get<1>(t) << ' '              << std::get<2>(t) << '\n';    return 0;}
折叠表达式 (args + ...) 把参数包从左到右展开, 二元操作符可以换成任何东西, 包括输出流 <<, 于是 printAll 三行搞定"打印任意数量参数". std::tuple 则是"异构容器": 一个 tuple 里可以同时装 int, double, string, 用 std::get 按位置取; C++17 的 std::apply 还能把 tuple 展开后当参数传给函数. 两者合起来, 就是"把不定数量, 不定类型的东西收进一个盒子", 这是无数高级库的地基, 从今天起你终于能读懂它们了. 折叠表达式还有一元形态, 比如 (... && args) 把参数包按逻辑与展开, "判断所有参数都为正"可以一行写完, 留给你当课后作业. 另外, std::apply 和 std::make_from_tuple 是 tuple 的黄金搭档: 前者把 tuple 拆开当参数, 后者用 tuple 直接构造对象, 强烈建议查手册自己跑一遍, 跑通你就彻底掌握 tuple 了.
回头看这条线: 类型萃取在编译期"认识"类型, if constexpr 在编译期"分流", decltype 和 declval 在编译期"推导类型", 折叠表达式与 tuple 在编译期"收纳任意参数". 四件套合起来, 你就有了把"运行时的多态"换成"编译期的决策"的能力: 同样的功能, 没有虚函数开销, 错误还提前到编译期暴露. 这, 就是模板从入门走向进阶的分水岭, 也是你在第 9 篇学到的 constexpr 精神的延伸: 能编译期做的, 绝不留到运行期.
觉得醍醐灌顶就点个"在看". 下一篇我们把镜头转向代码风格: RAII, Rule of Zero, pimpl, enum class, 看看专业项目的代码是怎么写的. 关注本号, 30 篇持续更新.

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料