模板元编程

1. 核心心智模型:编译时计算

1.变量

编译期“变量”的分类映射

1. 模板参数 (Template Parameters) —— “函数参数”

模板参数是编译期逻辑的输入源。它们在模板实例化时被确定,类似于函数的形参。

  • 类型变量template <typename T>,将类型作为参数传递。
  • 非类型变量 (NTTP)template <int N>,将具体的数值(如整数、指针、甚至 C++20 后的浮点数和某些类对象)作为参数传递。

2. constexpr 变量 —— “常量值变量”

constexpr(以及后来的 consteval)定义了编译期的数值存储

  • 它们解决了传统 宏定义 的类型安全问题。
  • 它们可以在编译期进行复杂的算术运算。
  • 等价关系:在运行时我们写 int a = 10;,在编译期我们写 static constexpr int a = 10;

3. using 别名 —— “类型变量/返回结果”

在处理类型转换和元编程时,using(或旧式的 typedef)充当了类型的占位符

  • 中间变量:在复杂的模板逻辑中,通过 using TemporaryType = ... 来存储中间转换后的类型。
  • 元函数返回:习惯上,C++ 元函数通过定义一个名为 type 的成员别名来“返回”结果(如 std::decay_t<T> 内部就是 using type = ...)。

快速对比表

维度 运行时变量 (int x) 编译期“变量” 备注
输入 函数参数 模板参数 (T, N) 实例化时绑定
数值存储 栈/堆内存 constexpr 变量 存储在符号表或只读区
类型处理 不支持(需反射) using 别名 核心元编程手段
可变性 可修改 不可变 (Immutable) 每次修改都会产生新类型/常量

综合示例:编译期计算

下面是一个简单的元编程片段,展示了这三者的协作:

template <typename T, int Factor> // 1. 模板参数作为输入
struct ScaledType {
    // 2. using 别名作为类型转换“变量”
    using UnderlyingType = T; 

    // 3. constexpr 变量进行编译期数值计算
    static constexpr int Result = sizeof(T) * Factor;
};

// 使用
using MyType = ScaledType<double, 10>; 
// MyType::Result 在编译期即为 80

为什么这种映射很重要?

这种映射允许我们将逻辑从运行阶段提前到编译阶段,带来的直接好处是:

  1. 零开销抽象:所有的逻辑在程序运行前已经计算完毕。
  2. 类型安全:在编译时就能捕获逻辑错误,而不是在用户运行时崩溃。

2.函数

核心映射:从“逻辑运行”到“类型变换”

1. 模板类:传统的“元函数”

在 C++11 之前,如果你想写一个“函数”来处理类型,你必须使用 structclass

  • 输入:模板参数。
  • 计算逻辑:通过模板特化(Specialization)来实现分支逻辑(if-else)。
  • 返回结果:约定俗成地使用内部的 using type(返回类型)或 static constexpr T value(返回值)。

2. 模板别名:现代的“函数调用”

using 模板别名(C++11 引入)极大地简化了元函数的“调用”过程。它通常作为模板类的包装器,省去了繁琐的 typename ...::type


深度对比:函数 vs. 元函数

维度 运行时函数 (double f(int)) 编译期元函数 (Template)
定义方式 ReturnType Name(Args...) template <...> struct Name
调用方式 Name(args) Name<Args>::typeName_t<Args>
执行时机 程序运行阶段 编译器实例化阶段
逻辑实现 if, switch, for 模板特化std::conditional、递归
副作用 可能修改全局变量、IO 无副作用(纯函数),仅产生新类型

语法演进示例

假设我们要实现一个“如果类型是 int 就返回 long,否则返回原类型”的逻辑:

第一步:使用模板类定义“函数体”

这是基础的元函数形态。

template <typename T>
struct UpgradeInt {
    using type = T; // 默认分支
};

// 模板特化:相当于 if (T == int)
template <>
struct UpgradeInt<int> {
    using type = long; // 特化分支
};

第二步:使用模板别名简化“调用”

为了不像 typename UpgradeInt<T>::type 这样写出一长串,我们定义一个别名:

template <typename T>
using UpgradeInt_t = typename UpgradeInt<T>::type;

// 使用时,就像调用函数一样直观:
UpgradeInt_t<int> myVar; // myVar 的类型是 long

为什么这种映射改变了 C++?

  1. 多态性的转变:

    运行时函数通过 virtual 实现动态多态;元函数通过模板特化实现静态多态。编译器在编译时就确定了执行哪个“分支”,彻底消除了运行时的虚函数表查找开销。

  2. 类型计算的力量:

    你可以写出在编译期自动推导最优存储类型的代码。例如,根据数值大小自动选择 uint8_t、uint16_t 或 uint32_t。

3.数据结构

在运行时编程中,我们使用 std::vectorstd::list 在内存中排列数据;而在编译期,由于没有动态内存分配,我们使用变长参数包(Variadic Templates)和封装它们的类型列表(Type Lists)在编译器内部排列“元数据”。


编译期“数据结构”的映射逻辑

1. 变长参数包 (...) —— “原始数据流”

变长参数包是 C++11 引入的语法,它像是一个可以容纳任意数量、任意类型参数的“麻袋”。

  • 特性:它是非第一类实体的,你不能直接存储它,只能通过模板转发(Forwarding)或展开(Expansion)来操作它。
  • 类比:它就像是函数调用时压入栈的原始参数流

2. 类型列表 (Type Lists) —— “编译期容器”

由于参数包不能直接操作,我们通常用一个空的模板类将其“捕获”起来,形成一个持久的结构。

  • 形态template <typename... Ts> struct TypeList {};
  • 作用:它将零散的参数包转化为了一个单一的类型。这个类型可以被作为“变量”传递给其他元函数。
  • 类比:它就是编译期的 std::vector<std::any>

核心映射表

维度 运行时数据结构 (std::vector) 编译期数据结构 (TypeList)
存储内容 对象的实例(Value) 类型的定义(Type/Constant)
大小获取 v.size() sizeof...(Ts)
元素访问 v[i] (下标访问) 递归拆解 或 std::tuple_element
修改操作 push_back, pop (原地修改) 产生新类型 (Immutable)
遍历方式 for, while 循环 递归折叠表达式 (Fold Expr)

如何“操作”这些数据结构?

在编译期,你不能像运行时那样写 list.add(int),你必须通过类型演变来完成操作。

示例:向类型列表末尾添加元素

// 基础容器
template <typename... Ts> struct TypeList {};

// “函数”:PushBack
template <typename List, typename NewElement>
struct PushBack;

// 通过模板特化提取出内部的参数包 Ts,并重新包装
template <typename... Ts, typename NewElement>
struct PushBack<TypeList<Ts...>, NewElement> {
    using type = TypeList<Ts..., NewElement>;
};

// 使用
using MyList = TypeList<int, float>;
using ExtendedList = typename PushBack<MyList, double>::type; 
// 结果为 TypeList<int, float, double>

算法:从递归到折叠表达式

如果你有了数据结构(TypeList),你必然需要算法来处理它:

  1. 递归 (Recursion):这是 C++11/14 处理参数包的标准方式。通过 HeadTail... 不断剥离元素,直到触发基本情况(Base Case)。
  2. 折叠表达式 (Fold Expressions):C++17 的神来之笔。它允许你用极其简洁的语法(如 (... + args)) 对参数包进行批量运算,消灭了大量的递归写法。
  3. 索引序列 (Integer Sequences):利用 std::index_sequence 产生一组编译期数字,像下标一样去访问参数包。

4.循环

1. 递归 (Recursion) —— “逻辑上的循环”

在 C++17 之前,递归是实现循环的唯一手段。它利用模板的部分特化(Partial Specialization)来定义终止条件。

  • 基本模式:将参数包拆分为 Head(当前处理的元素)和 Tail...(剩余的元素)。
  • 终止条件:定义一个处理空包或单个元素的特化模板。

代码示例:计算参数包中类型的总大小

// 基础情况(终止条件):空列表大小为 0
template <typename... Ts>
struct TotalSize {
    static constexpr int value = 0;
};

// 递归情况:当前类型大小 + 剩余类型的大小
template <typename Head, typename... Tail>
struct TotalSize<Head, Tail...> {
    static constexpr int value = sizeof(Head) + TotalSize<Tail...>::value;
};

2. 折叠表达式 (Fold Expressions) —— “算法上的循环”

C++17 引入的折叠表达式是元编程的一次巨大飞跃。它将繁琐的递归代码压缩成了一行极其简洁的语法。

  • 本质:它是对参数包进行规约(Reduce/Accumulate)操作的快捷方式。
  • 语法(args op ...)(... op args)

代码示例:同样的累加逻辑

template <typename... Args>
constexpr auto sum(Args... args) {
    return (... + args); // 一行解决,编译器自动展开为 (arg1 + (arg2 + arg3))
}

3. 包展开 (Pack Expansion) —— “并行循环”

有时候我们不需要累加,而是需要对每个元素执行相同的操作(类似于 std::transformMap)。

  • 模式f(args)...
  • 应用:在构造函数初始化列表、函数参数传递中非常常见。

循环方式对比表

特性 递归 (Recursion) 折叠表达式 (Fold Expr) 包展开 (Expansion)
C++ 版本 C++98/11 C++17 及以上 C++11 及以上
代码量 较多(需定义特化) 极简(一行) 简洁
灵活性 最高(可处理复杂逻辑) 限于二元运算符 限于函数调用/初始化
编译开销 较高(产生大量模板实例) 较低 较低
类比 while 循环 / 递归函数 std::accumulate for_each / map

5.条件判断

1. 模板特化 (Specialization) —— “模式匹配”

if constexpr 出现之前,特化是唯一的选择。它更接近声明式编程

  • 原理:你定义一套规则,编译器根据你提供的“样板”(Pattern)自动选择最匹配的那一个。
  • 类比:这就像是函数的重载(Overloading),或者像 Haskell/Rust 中的模式匹配
// 通用版本:相当于 else
template <typename T>
struct IsPointer { static constexpr bool value = false; };

// 特化版本:针对指针类型的模式匹配
template <typename T>
struct IsPointer<T*> { static constexpr bool value = true; };

2. if constexpr —— “编译期分支”

C++17 引入的 if constexpr 是对元编程的一次“平民化”改造。它允许你用命令式编程的直觉来写编译期逻辑。

  • 原理:编译器在编译阶段计算条件。如果为真,则丢弃 else 分支的代码(不进行实例化);反之亦然。
  • 优势:代码可读性极高,逻辑紧凑,不需要为了一个小分支就去写好几个 struct
template <typename T>
void process(T t) {
    if constexpr (std::is_integral_v<T>) {
        // 只有 T 是整数时这段代码才会被编译
        std::cout << "Integer: " << t << std::endl;
    } else {
        std::cout << "Not an integer." << std::endl;
    }
}

补充:第三种形态 —— std::conditional (三元运算符)

如果说 if constexprif-else 语句,那么 std::conditional 就是编译期的三元运算符 (? :)。它通常用于在两个类型之间做二选一。

// 变量 $\rightarrow$ using 别名
// 函数 $\rightarrow$ std::conditional

using MyType = std::conditional_t<(sizeof(int) > 4), long, int>;

深度对比表

维度 模板特化 (Specialization) if constexpr std::conditional
思维范式 声明式 / 模式匹配 指令式 / 流程控制 函数式 / 表达式
适用范围 类模板、成员函数、全局逻辑 函数体内部 类型选择、别名定义
可读性 较低(逻辑分散在多个定义中) 极高(逻辑集中) 中等
功能边界 可以改变类的结构(增减成员) 仅能改变函数内的逻辑执行 仅能返回类型
引入版本 C++98 C++17 C++11

2.学习路径框架

1.第一阶段:模板基础(打好地基)

1. 函数模板 (Function Templates)

函数模板用于创建可以处理不同数据类型的通用函数。

声明与定义

通常,模板的声明和定义建议放在同一个头文件中,因为编译器在实例化时需要看到完整的定义。

// 声明与定义
template <typename T>
T add(T a, T b) {
    return a + b;
}

实例化 (Instantiation)

实例化是将模板转换为具体类型函数的过程,分为两种:

  1. 隐式实例化:编译器根据传入的实参自动推导类型。

    add(5, 3);       // T 被推导为 int
    add(2.5, 1.5);   // T 被推导为 double
    
  2. 显式实例化:手动指定类型,不依赖编译器推导。

    add<int>(5, 3.2); // 强制将 T 设为 int,3.2 会被截断
    

2. 类模板 (Class Templates)

类模板用于定义可以处理不同类型的类(如容器 std::vector)。

声明与定义

类模板的成员函数如果在类外定义,必须再次声明模板参数。

template <typename T>
class Box {
public:
    Box(T val) : data(val) {}
    T getData();
private:
    T data;
};

// 类外定义成员函数
template <typename T>
T Box<T>::getData() {
    return data;
}

实例化

与函数模板不同,类模板通常需要显式指定类型(除非使用 C++17 的 CTAD):

  1. 显式指定(常用)

    Box<int> intBox(10);
    Box<std::string> strBox("Hello");
    
  2. 类模板参数推导 (CTAD, C++17起):编译器可以根据构造函数推导类型。

    Box b(20); // 自动推导为 Box<int>
    

3. 模板的工作原理:二次编译

模板的实例化是一个按需生成的过程,可以将其理解为:

  1. 检查期(第一阶段):编译器检查模板本身的语法是否有误(如漏掉分号)。
  2. 实例化期(第二阶段):当你在代码中使用 Box<int> 时,编译器会根据模板生成一个真正的类 class Box_int { ... },并对生成的代码进行第二次编译。

注意:如果模板从未被调用,编译器可能不会发现其中的逻辑错误(例如调用了该类型不支持的方法)。


4. 核心区别对比

特性 函数模板 类模板
类型推导 支持(自动推导参数) C++17 前必须显式指定,C++17 后支持 CTAD
特化 支持全特化,不支持偏特化(通常通过重载实现) 支持全特化和偏特化
默认参数 C++11 起支持默认模板参数 一直支持默认模板参数

关键点:为什么模板通常写在 .h 文件中?

普通函数编译后会变成二进制指令存储在 .obj 文件中,链接时能找到。但模板不是函数,它只是生成函数的说明书。如果 main.cpp 看不到模板的定义(只看到声明),编译器就无法在编译时生成具体的 add<int> 代码,导致链接错误。

2.特化

1. 全特化 (Full Specialization)

全特化是指所有的模板参数都被明确指定为一个具体的类型。

  • 适用范围:类模板、函数模板均支持。
  • 语法特征template <>(后面不带参数列表)。

示例:处理字符串逻辑

假设我们有一个通用的 compare 函数,但对于 char* 类型,我们需要用 strcmp 而不是直接用 >

// 1. 基本模板 (Primary Template)
template <typename T>
bool isEqual(T a, T b) {
    return a == b;
}

// 2. 全特化版本:针对 const char*
template <>
bool isEqual<const char*>(const char* a, const char* b) {
    return strcmp(a, b) == 0;
}

2. 偏特化 (Partial Specialization)

偏特化是指只指定部分模板参数,或者对模板参数增加特定的限制条件(如改为指针、引用等)。

  • 适用范围仅限类模板。函数模板不支持偏特化(通常通过重载 Overloading 解决)。
  • 语法特征template <typename T> 后面依然保留部分参数。

示例 A:部分参数特化

// 基本模板:两个参数
template <typename T, typename U>
class Container { /* 通用实现 */ };

// 偏特化:当第二个参数固定为 int 时
template <typename T>
class Container<T, int> { /* 针对 int 的特殊优化 */ };

示例 B:模式匹配(指针/引用)

这是元编程中最强大的部分,可以判断一个类型是否为指针。

template <typename T>
class Analyzer {
    static const bool isPointer = false;
};

// 偏特化:针对所有指针类型 T*
template <typename T>
class Analyzer<T*> {
    static const bool isPointer = true;
};

3. 元编程中的“条件分支”

在元编程中,我们利用特化来模拟 if 逻辑。编译器通过匹配最合适的特化版本来决定执行哪条路径。

经典案例:Type Traits 的实现

我们可以自己实现一个简单的 is_void

// 默认情况:所有的类型都不是 void
template <typename T>
struct my_is_void {
    static const bool value = false;
};

// 全特化:唯独 void 类型是 true
template <>
struct my_is_void<void> {
    static const bool value = true;
};

// 使用
bool b = my_is_void<int>::value;  // false
bool b2 = my_is_void<void>::value; // true (编译器选择了特化版本)

进阶:std::conditional (编译期 if)

这是元编程中常用的工具,根据布尔值选择不同的类型:

// 基本模板:默认选择第二种类型
template <bool Condition, typename TrueType, typename FalseType>
struct my_conditional {
    using type = FalseType;
};

// 偏特化:当第一个参数为 true 时,选择第一种类型
template <typename TrueType, typename FalseType>
struct my_conditional<true, TrueType, FalseType> {
    using type = TrueType;
};

// 使用
using MyType = my_conditional<(sizeof(int) > 2), double, int>::type; 
// 如果 int 大于 2 字节,MyType 就是 double,否则是 int

4. 全特化 vs 偏特化 总结

特性 全特化 (Full) 偏特化 (Partial)
参数状态 所有参数都已确定 部分确定,或改变参数形态(如 TT*
类模板 支持 支持
函数模板 支持 不支持(需改用函数重载)
编译器语法 template <> template <typename T>
元编程角色 处理特定“点”的情况 处理特定“类”的情况(如所有指针、所有数组)

注意事项:函数模板为什么不支持偏特化?

C++ 标准不推荐函数模板偏特化,主要是因为这会与函数重载(Overloading)产生极其复杂的交互,容易导致开发者难以预料的调用结果。通常,如果你需要对函数模板进行“偏特化”,直接写一个新的重载函数即可。

3.类型推导

模板实参推导 (Template Argument Deduction, 简称 TAD) 是 C++ 编译器的一项“黑魔法”。它让我们可以像调用普通函数一样调用模板函数,而不必每次都笨拙地写出 <int><std::string>

编译器通过查看你传递给函数的实参(Arguments),并将其与模板定义的形参(Parameters)进行匹配,从而推断出具体的类型。


1. 函数模板推导的核心逻辑

当你调用一个函数模板时,编译器会将实参类型(A)模板形参类型(P)进行对比。

template <typename T>
void func(T param);

func(42); // 实参是 int,推导 T 为 int

推导的三种场景

根据模板参数的形式,推导规则分为以下三类:

  1. 非引用/非指针 (T param)
    • 发生类型退化 (Decay):忽略 constvolatile 修饰符。
    • 数组和函数会退化为指针。
  2. 引用/指针 (T& paramT\* param)
    • 保留 const 属性。
    • 如果实参是引用,引用会被忽略(例如 int& 匹配 T&T 仍为 int)。
  3. 万能引用 (T&& param)
    • 这是最特殊的情况(用于转发)。如果传入左值,T 会推导为左值引用;如果传入右值,T 推导为原类型。

2. 类型退化 (Type Decay) 的细节

在非引用的模板推导中,编译器为了简化,会剥离一些“外壳”:

传入实参类型 模板参数 T param 的推导结果 说明
const int int const 被忽略
int[5] int* 数组退化为指针
void(int) void(*)(int) 函数退化为函数指针

3. 推导失败与 SFINAE

如果编译器无法找到唯一的、一致的类型,推导就会失败:

  • 矛盾的实参

    template <typename T>
    void add(T a, T b);
    
    add(1, 2.0); // 错误!T 应该是 int 还是 double?编译器拒绝猜测。
    

    解决方法:显式指定 add<double>(1, 2.0) 或使用多个模板参数。

  • SFINAE (匹配失败不是错误):

    编译器在尝试推导时,如果发现某个模板不匹配,它不会报错,而是简单地忽略它,继续寻找下一个候选者。这是泛型编程中进行“类型过滤”的基础。


4. C++17 的重大进步:CTAD

在 C++17 之前,类模板不支持自动推导,你必须写:

std::pair<int, double> p(1, 2.0);

现在有了 CTAD (Class Template Argument Deduction),编译器可以根据构造函数的参数推导类模板的参数:

std::pair p(1, 2.0);          // 自动推导为 std::pair<int, double>
std::vector v = {1, 2, 3};    // 自动推导为 std::vector<int>

5. 推导引导 (Deduction Guides)

有时候,自动推导的结果不是我们想要的。开发者可以手动编写“引导规则”来告诉编译器如何推导。

// 假设有一个包装类
template <typename T>
struct Wrapper {
    T value;
};

// 引导规则:如果传入的是 const char*,请推导为 std::string
Wrapper(const char*) -> Wrapper<std::string>;

Wrapper w{"Hello"}; // w 的类型是 Wrapper<std::string> 而不是 Wrapper<const char*>

总结

  • 函数模板:推导是自动的,依赖实参类型。
  • 类模板 (C++17+):通过构造函数参数自动推导 (CTAD)。
  • 显式指定:如果推导产生歧义,通过 <Type> 手动干预。

4.非类型模板参数

1. 常见的非类型模板参数类型

在 C++20 之前,非类型模板参数的类型限制非常严格,主要包括:

  • 整数类型int, size_t, char, long 等)。
  • 枚举类型 (enum)。
  • 指针或引用(指向对象或函数)。
  • std::nullptr_t

示例:定义固定大小的数组

这是 NTTP 最经典的应用,类似于 std::array

template <typename T, size_t N>
class StaticArray {
    T data[N]; // N 是编译期常量,用于确定数组大小
public:
    size_t size() const { return N; }
};

StaticArray<int, 10> arr1; // N = 10
StaticArray<int, 20> arr2; // N = 20

2. 指针与引用作为参数

当使用指针或引用作为模板参数时,该值必须在编译期就能确定其地址。

  • 限制:通常要求指向的对象具有静态存储期(如全局变量、静态变量)。
  • 用途:常用于回调机制或将特定内存地址绑定到类型中。
void myGlobalFunction() { /* ... */ }

template <void (*FuncPtr)()>
class Executor {
public:
    void run() { FuncPtr(); }
};

// 实例化
Executor<&myGlobalFunction> exec;

3. C++17 的进化:template <auto>

在 C++17 之前,你必须显式指定非类型参数的类型(如 int N)。C++17 引入了 auto,让编译器自动推导值的类型:

template <auto Value>
void printValue() {
    std::cout << Value << std::endl;
}

printValue<42>();      // Value 推导为 int
printValue<'A'>();     // Value 推导为 char

4. C++20 的重大突破:浮点数与类类型

在 C++20 之前,double 和自定义结构体是不允许作为 NTTP 的。C++20 放宽了这一限制:

  1. 浮点数:现在可以直接传递 doublefloat

    template <double Ratio>
    struct Scaler { /* ... */ };
    
    Scaler<3.14> s; // C++20 起合法
    
  2. 字面量类类型 (Literal Class Types):只要类满足特定条件(如所有成员是 public,且有 constexpr 构造函数),就可以作为模板参数。这允许我们将编译期字符串作为参数传递。


5. NTTP 的核心约束

非类型模板参数有一个不可逾越的红线:它必须是编译期常量表达式 (Constant Expression)

  • 错误示例

    int x = 10;
    StaticArray<int, x> errorArr; // 错误!x 是运行期变量,不是常量
    
    const int y = 10;
    StaticArray<int, y> okArr;    // 正确!y 是编译期常量
    

6. 在元编程中的角色

NTTP 是元编程中“数值计算”的基础。例如,在编译期计算阶乘:

template <int N>
struct Factorial {
    static const int value = N * Factorial<N - 1>::value;
};

// 递归终止
template <>
struct Factorial<0> {
    static const int value = 1;
};

int main() {
    // 结果在编译期就已经算好了,等同于 int res = 120;
    int res = Factorial<5>::value; 
}

总结

  • NTTP 是值而非类型
  • 性能优于运行期参数:因为它们在编译期就被硬编码到生成的类或函数中。
  • 类型系统的一部分StaticArray<int, 10>StaticArray<int, 20>两个完全不同的类型,不能互相赋值。

第二阶段:核心技术(元编程的“语法”)

1.SFINAE (Substitution Failure Is Not An Error)

SFINAE (Substitution Failure Is Not An Error) 是 C++ 模板编程中最重要的准则之一。它允许编译器在“尝试”匹配模板时,如果发现某个模板不合适,可以体面地退出并寻找下一个候选者,而不是直接中断编译并报错。

这是 C++ 实现编译期内省(Introspection)——即检测一个类型是否具有某种属性(如是否有某个成员函数、是否可相加等)的核心机制。


1. SFINAE 的工作阶段

要理解 SFINAE,首先要明确编译器处理函数调用的顺序:

  1. 名称查找:找到所有名为 func 的函数或模板。
  2. 模板实参推导与替换(Substitution):将具体的类型(如 int)代入模板参数中。 <-- SFINAE 发生在这里
  3. 重载决议:在剩下的合法函数中寻找“最匹配”的一个。
  4. 实例化:如果选中的是模板,则真正生成代码。<-- 如果这里出错,则是“硬错误”

2. 核心定义:什么是“替换失败”?

如果在“替换”阶段,生成的函数签名是非法的(例如引用了不存在的内嵌类型),编译器会认为这个模板不匹配当前调用,从而将其从候选名单中剔除。

示例:区分整数和浮点数

#include <iostream>
#include <type_traits>

// 只有当 T 是积分类型时,这个模板才有效
template <typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
print_type(T val) {
    std::cout << "Integer: " << val << std::endl;
}

// 只有当 T 是浮点类型时,这个模板才有效
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
print_type(T val) {
    std::cout << "Floating point: " << val << std::endl;
}

int main() {
    print_type(10);   // 匹配第一个,第二个在替换时失败(被剔除)
    print_type(3.14); // 匹配第二个,第一个在替换时失败(被剔除)
}

3. 经典应用:探测成员是否存在

通过 SFINAE,我们可以写出一个“探测器”,在编译期判断一个类是否有 serialize() 成员函数。

template <typename T, typename = void>
struct has_serialize : std::false_type {};

// 偏特化版本:尝试调用 T.serialize()
template <typename T>
struct has_serialize<T, std::void_t<decltype(std::declval<T>().serialize())>> : std::true_type {};

// 使用
struct Person { void serialize() {} };
struct Dog {};

static_assert(has_serialize<Person>::value, "Person should have serialize");
static_assert(!has_serialize<Dog>::value, "Dog should not have serialize");

原理分析:

  • 当传入 Dog 时,decltype(std::declval<Dog>().serialize()) 是非法表达式。
  • 替换失败,编译器放弃这个偏特化版本,回退到默认的 false_type 版本。
  • 注意: 这不会报错,因为这仅仅是“替换失败”。

4. SFINAE 的局限性

虽然 SFINAE 极其强大,但它也存在明显缺点:

  • 语法晦涩std::enable_ifdeclvalvoid_t 的组合对初学者极不友好。
  • 错误信息难懂:一旦出现真正的错误,编译器的报错信息往往长达几百行。
  • 编译速度:大量使用 SFINAE 会显著增加编译器的负担。

5. 现代替代方案:C++20 Concepts

鉴于 SFINAE 的复杂性,C++20 引入了 Concepts(概念)。它将这种“探测”逻辑变成了语言的一等公民。

同样的 print_type 逻辑,在 C++20 中可以这样写:

void print_type(std::integral auto val) {
    std::cout << "Integer: " << val << std::endl;
}

void print_type(std::floating_point auto val) {
    std::cout << "Floating point: " << val << std::endl;
}

这不仅可读性极高,而且当匹配失败时,编译器会给出非常清晰的解释。


总结

  • SFINAE 是一种“试错”机制:只要在函数签名替换阶段出错,就只是“不合适”,不是“错误”。

  • 它是 std::enable_if类型萃取(Type Traits) 的底层基石。

  • 在现代 C++ 中,我们正逐渐从 SFINAE 转向更简洁的 Concepts

2.类型萃取

类型萃取(Type Traits) 是 C++ 模板元编程中的“情报站”。它利用我们之前提到的模板特化技术,在编译期提供查询类型信息(Querying)和修改类型属性(Transforming)的能力。

简单来说,类型萃取就像是给编译器的一套“体检工具”,用来检查某个类型是不是指针、是不是整数,或者强行去掉它的 const 修饰符。


1. 类型萃取的两大分类

<type_traits> 头文件中的工具主要分为两大类:类型判断(返回布尔值)和类型转换(返回新类型)。

A. 类型判断 (Type Categories & Properties)

这类工具通常有一个静态成员 value。自 C++17 起,为了简化书写,标准库提供了 _v 后缀的模板变量。

  • 常见工具std::is_integral<T>, std::is_floating_point<T>, std::is_pointer<T>, std::is_class<T>

  • 代码示例

    #include <type_traits>
    
    template <typename T>
    void checkType(T val) {
        if constexpr (std::is_integral_v<T>) {
            // 如果 T 是整数类型,编译这段代码
        } else if constexpr (std::is_pointer_v<T>) {
            // 如果 T 是指针类型,编译这段代码
        }
    }
    

B. 类型转换 (Type Modifications)

这类工具通常有一个内嵌的类型别名 type。自 C++14 起,提供了 _t 后缀的类型别名。

  • 常见工具std::remove_pointer<T>, std::add_const<T>, std::decay<T> (退化,如数组转指针)。

  • 代码示例

    using Ptr = int*;
    using Raw = std::remove_pointer_t<Ptr>; // Raw 现在是 int
    
    using ConstInt = std::add_const_t<int>; // ConstInt 现在是 const int
    

2. 核心原理:它是如何实现的?

类型萃取本质上就是模板特化的精妙应用。我们可以自己实现一个简易版的 is_pointer

// 1. 基础模板:默认所有类型都不是指针
template <typename T>
struct my_is_pointer {
    static constexpr bool value = false;
};

// 2. 偏特化版本:匹配所有 T* 模式的类型
template <typename T>
struct my_is_pointer<T*> {
    static constexpr bool value = true;
};

// 使用
bool b = my_is_pointer<int*>::value; // true

3. 常用类型萃取工具快速一览

分类 工具示例 (C++17) 作用描述
基础属性 is_void_v<T> 检查是否为 void
is_array_v<T> 检查是否为数组
复合属性 is_signed_v<T> 检查是否有符号
is_const_v<T> 检查是否有 const
类型关系 is_same_v<T, U> 检查两个类型是否完全相同
is_base_of_v<Base, Derived> 检查继承关系
转换工具 remove_reference_t<T> 强行去掉引用(如 int& -> int
underlying_type_t<T> 获取枚举类型的底层整数类型

4. 为什么要用类型萃取?

在泛型编程中,类型萃取通常与 SFINAEif constexpr 配合使用,实现“按需编译”:

  1. 性能优化:例如在 std::copy 中,如果萃取发现元素类型是“平凡可拷贝的(Trivially Copyable)”,它会放弃循环赋值,直接调用 memcpy 以获得极高的性能。
  2. 安全约束:确保模板参数符合特定的要求,如果不符合,在编译期就给出友好的报错。
  3. 消除歧义:在处理指针、引用、值时,通过 decay 统一处理类型。

5. C++20 的终极进化:Concepts(概念)

虽然类型萃取很强大,但它在函数签名中往往显得臃肿(如 std::enable_if 的大段代码)。C++20 的 Concepts 是对类型萃取的语义化包装。

传统的 SFINAE 写法:

template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void func(T val);

C++20 Concepts 写法:

void func(std::integral auto val); 

3.递归与终止

1. 编译时数值计算

这是展示递归最直观的方式。编译器在实例化模板时,会像剥洋葱一样一层层展开,直到遇到终止条件。

示例:编译时求和(Sum 1 to N)

// 1. 基本模板:递归步骤
template <int N>
struct Sum {
    static constexpr int value = N + Sum<N - 1>::value;
};

// 2. 特化模板:终止条件
template <>
struct Sum<0> {
    static constexpr int value = 0;
};

// 使用
int main() {
    // 编译器在编译阶段就计算出了 15
    constexpr int result = Sum<5>::value; 
}

2. 编译时类型处理(可变参数模板)

在处理多个类型(如 std::tuple 的实现原理)时,递归也是核心工具。我们可以利用可变参数模板(Variadic Templates)配合递归来遍历类型列表。

示例:计算类型列表中包含多少个字节

// 1. 基本模板:处理第一个类型 T,然后递归处理剩余类型 Args
template <typename T, typename... Args>
struct TotalSize {
    static constexpr size_t value = sizeof(T) + TotalSize<Args...>::value;
};

// 2. 终止条件:当参数列表为空时
template <typename T>
struct TotalSize<T> {
    static constexpr size_t value = sizeof(T);
};

// 使用
size_t sz = TotalSize<int, double, char>::value; // 4 + 8 + 1 = 13

3. 现代 C++ 的进化:从递归到折叠表达式

虽然递归很强大,但它会生成大量的中间类实例,增加编译器的负担(容易触发递归深度限制)。

A. if constexpr (C++17)

if constexpr 允许我们将递归逻辑写在一个函数内,代码更接近普通逻辑:

template <typename T, typename... Args>
auto sum_all(T first, Args... args) {
    if constexpr (sizeof...(args) > 0) {
        return first + sum_all(args...); // 递归
    } else {
        return first; // 终止
    }
}

B. 折叠表达式 (Fold Expressions, C++17)

对于简单的算术运算或逻辑运算,C++17 引入了折叠表达式,彻底消除了手动写递归的必要:

template <typename... Args>
auto sum_simple(Args... args) {
    return (args + ...); // 一行搞定所有参数的累加
}

4. 递归的限制与风险

  1. 编译深度限制:编译器通常有一个默认的模板递归深度限制(如 256 或 1024)。如果递归太深(如 Sum<2000>),会导致编译错误。
  2. 编译速度:每一次递归都会产生一个新的类型实例化,占用编译器内存。
  3. 调试困难:模板递归错误通常会产生极长的错误堆栈,难以定位。

5. 总结:元编程的“逻辑三板斧”

功能 传统运行期实现 编译期模板实现
条件分支 if / else 模板特化 (SFINAE)
循环迭代 for / while 模板递归
变量存储 int x = 5; static constexpr int x = 5

4.变长参数包

变长参数包 (Variadic Templates) 是 C++11 引入的一项极其强大的特性。它允许模板接受任意数量、任意类型的参数。在它出现之前,如果你想实现类似 printf 的功能,要么使用不安全的 C 风格变长参数(va_list),要么就得为 1 到 N 个参数手动重载 N 个模板版本。

有了它,我们可以优雅地处理“参数集合”。


1. 核心语法:省略号(...)

省略号 ... 在变长模板中有两个核心位置,决定了它的身份:

  1. 模板参数包 (Template Parameter Pack):出现在模板声明中,代表“零个或多个类型”。
  2. 函数参数包 (Function Parameter Pack):出现在函数参数列表中,代表“零个或多个值”。
template <typename... Args>      // Args 是模板参数包 (类型)
void log(Args... args) {         // args 是函数参数包 (值)
    // ...
}

获取包的大小:sizeof...

你可以直接在编译期获取包中参数的个数:

size_t count = sizeof...(Args); // 获取类型个数
size_t count2 = sizeof...(args); // 获取值个数

2. 如何解包(Parameter Pack Expansion)

参数包不能像数组那样通过下标访问。处理它的传统方式有两种:解包模式递归展开

A. 模式展开 (Pattern Expansion)

你可以对包中的每个元素应用某种“模式”。

template <typename... Args>
void print_doubled(Args... args) {
    // 模式:(args * 2)
    // 展开:(arg1 * 2), (arg2 * 2), ...
    pass_to_other_func((args * 2)...); 
}

B. 递归处理(经典解法)

利用我们上一节讨论的“递归与终止”,逐个剥离参数:

// 终止条件:处理最后一个参数
void print() { std::cout << std::endl; }

template <typename T, typename... Args>
void print(T first, Args... args) {
    std::cout << first << " ";
    print(args...); // 递归展开剩下的参数
}

// 调用
print(1, 2.5, "Hello", 'A'); 

3. C++17 的大招:折叠表达式 (Fold Expressions)

递归虽然有效,但写起来比较繁琐且编译开销大。C++17 引入了折叠表达式,允许直接对参数包进行二元运算。

折叠方式 语法 展开结果示例 (参数为 a, b, c)
一元右折叠 (args op ...) (a op (b op c))
一元左折叠 (... op args) ((a op b) op c)
二元右折叠 (args op ... op init) (a op (b op (c op init)))

示例:一行代码实现求和

template <typename... Args>
auto sum(Args... args) {
    return (args + ...); // 一元右折叠
}

// 示例:一行代码实现按顺序打印所有参数
template <typename... Args>
void quick_print(Args... args) {
    ((std::cout << args << " "), ...); // 利用逗号运算符折叠
}

4. 变长模板的实际用途

  1. 完美转发容器:如 std::vector::emplace_back,它可以接受任意构造函数参数并直接在内存中构造对象。
  2. 元组 (Tuples)std::tuple 的底层实现严重依赖变长参数包的递归继承。
  3. 委托调用:编写包装器函数,将所有参数原封不动地转发给另一个函数。

5. 常见坑点

  • 语法位置... 的位置非常讲究。Args... 表示解包,Args ...args 是声明包。放错地方会导致难懂的编译错误。
  • 空包处理:如果调用 sum() 时不传任何参数,一元折叠会报错(除非是 &&, ||, 或 , 运算符,它们有默认值)。

总结

变长参数包将 C++ 模板从“固定维度的泛型”提升到了“无限维度的泛型”。

  • C++11 给了我们“包”和“递归”。
  • C++17 给了我们“折叠表达式”,让操作包变得极其简单。

第三阶段:现代 C++ 的飞跃(简化元编程)

1.constexpr

在 C++11 中,constexpr 的限制非常严苛(函数体基本只能包含一条 return 语句,必须使用递归代替循环)。而 C++14 对 constexpr 规则的极大放松,是 C++ 元编程史上的一个里程碑——它让“编译期编程”变得不再像“写复杂的数学递归”,而更像“写普通的 C++ 代码”。


1. C++11 vs C++14:从“函数式”到“命令式”

C++11 的限制(痛苦的递归)

在 C++11 中,如果你想在编译期计算阶乘,你必须这么写:

constexpr int factorial(int n) {
    return (n <= 1) ? 1 : (n * factorial(n - 1)); // 只能有一条 return
}

C++14 的解放(自然的逻辑)

C++14 允许在 constexpr 函数中使用大部分普通的 C++ 语法:

constexpr int factorial(int n) {
    int res = 1; // 允许局部变量
    for (int i = 1; i <= n; ++i) { // 允许循环 (for, while)
        res *= i;
    }
    if (n < 0) return 0; // 允许分支 (if-else, switch)
    return res;
}

2. C++14 constexpr 函数的新能力

在 C++14 的 constexpr 环境中,你可以使用以下特性:

  • 局部变量声明(但不能是 staticthread_local)。
  • 修改对象状态(你可以修改在函数内创建的局部变量)。
  • 循环语句for, while, do-while)。
  • 分支语句if, switch)。
  • 多条 return 语句

唯一的核心限制依然存在:函数中调用的所有东西也必须是 constexpr 的,且不能有副作用(如输入输出、动态内存分配 new 等,尽管 C++20 甚至放宽了 new)。


3. 对模板元编程(TMP)的降维打击

在 C++14 之前,很多逻辑需要靠“模板偏特化”或“SFINAE”来完成。有了强大的 constexpr,很多类型计算可以转变为简单的数值计算。

示例:查找数组中的最大值

以往这需要递归模板,现在只需要一个简单的 constexpr 函数:

template <typename T, size_t N>
constexpr T find_max(const T (&arr)[N]) {
    T max_val = arr[0];
    for (size_t i = 1; i < N; ++i) {
        if (arr[i] > max_val) max_val = arr[i];
    }
    return max_val;
}

int main() {
    // 数组内容是编译期确定的
    constexpr int data[] = {3, 1, 4, 1, 5, 9};
    // 结果在编译期直接算出
    constexpr int top = find_max(data); 
}

4. 一个关键特性:双重身份

constexpr 函数最迷人的地方在于它的通用性

  1. 编译期:如果参数是常量表达式,且结果被赋值给 constexpr 变量,它就在编译期运行。
  2. 运行期:如果参数是普通变量,它就像普通函数一样运行,没有任何额外开销。

这实现了代码复用:你不需要为编译期和运行期写两套逻辑。


5. 编译期编程的演进路线

特性 编程范式 复杂度
C++98 模板递归 纯声明式,像黑魔法 极高
C++11 constexpr 纯函数式,受限严重 中高
C++14 constexpr 命令式(像普通 C++)
C++17 if constexpr 编译期分支控制 极低

总结

C++14 的 constexpr 让元编程从“精英程序员的奇技淫巧”变成了“普通程序员的日常工具”。它极大地缩短了编译期逻辑的长度,提高了可读性。

2.C++17折叠表达式

折叠表达式 (Fold Expressions) 是 C++17 为处理变长参数包(Variadic Templates)引入的一项“减负”特性。

在 C++17 之前,处理参数包通常需要写递归模板和终止条件(即“剥洋葱”法),代码冗长且编译开销大。折叠表达式允许你用一行代码对包中的所有元素进行二元运算(如 +, *, &&, , 等)。


1. 语法形式

折叠表达式共有四种形式,主要区别在于:是从左边开始折叠还是右边,以及是否有初始值

假设参数包为 args...,二元运算符为 op,初始值为 init

类别 语法 展开结果示例 (假设包为 a,b,c)
一元右折叠 (args op ...) $(a \text{ op } (b \text{ op } c))$
一元左折叠 (... op args) $((a \text{ op } b) \text{ op } c)$
二元右折叠 (args op ... op init) $(a \text{ op } (b \text{ op } (c \text{ op } init)))$
二元左折叠 (init op ... op args) $((init \text{ op } a) \text{ op } b) \text{ op } c$

2. 核心应用场景

A. 算术运算:求和与平均值

这是最直观的用法。

template<typename... Args>
auto sum(Args... args) {
    return (... + args); // 一元左折叠
}

// sum(1, 2, 3, 4) -> (((1 + 2) + 3) + 4) = 10

B. 逻辑运算:全选或任选

在编写类型检查逻辑时非常有用,例如检查是否所有参数都是整数。

template<typename... Args>
bool all_integers(Args... args) {
    return (std::is_integral_v<Args> && ...); // 一元右折叠
}

// all_integers(1, 2, 3.5) -> (true && (true && false)) = false

C. 逗号运算符:顺序执行

这是元编程中最强大的技巧之一。你可以利用逗号运算符对包中的每个元素执行一个函数。

template<typename... Args>
void print_all(Args... args) {
    // 这里的模式是 (std::cout << args << " ")
    // 模式后面紧跟逗号运算符和省略号
    ((std::cout << args << " "), ...); 
    std::cout << std::endl;
}

// print_all(1, "A", 3.14) -> 执行 (cout<<1), (cout<<"A"), (cout<<3.14)

3. 空参数包的处理规则

如果传给折叠表达式的参数包是的,大多数运算符会直接导致编译错误。但有三个运算符有特殊的“默认值”:

  • 逻辑与 (&&): 默认为 true
  • 逻辑或 (||): 默认为 false
  • 逗号 (,): 默认为 void()

对于其他运算符(如 +*),如果可能存在空包,建议使用二元折叠并提供一个初始值(如 01)。


4. 为什么折叠表达式更好?

  1. 可读性:从 5-10 行的递归代码缩减为 1 行。
  2. 编译性能:编译器处理折叠表达式的速度比处理递归模板实例化快得多,因为它不需要生成多个中间函数。
  3. 避免堆栈溢出:对于非常长的参数包,递归可能触及编译器的递归深度限制,而折叠表达式是扁平化处理的。

总结:从递归到折叠

  • C++11: 必须写一个 Base Case 函数和核心递归模板。
  • C++17: 只需要一个括号括起来的表达式。

注意:折叠表达式必须用圆括号包围。例如 ... + args; 是错误的,必须写成 (... + args);

3.if constexpr

在 C++17 之前,如果你想根据模板参数的类型执行不同的逻辑,通常需要动用 SFINAE(通过 std::enable_if)或函数重载。这会导致代码碎片化,逻辑分散在多个函数签名中。

if constexpr 的出现,允许我们将这些逻辑重新整合到一个函数体内,由编译器在编译期进行“分支剪枝”。


1. 核心机制:代码剪枝 (Code Pruning)

if constexpr 与普通 if 的本质区别在于:

  • 普通 if:所有分支都会被编译。如果某个分支的代码对当前类型非法(例如对 int 调用 .length()),即使运行期走不到该分支,编译也会报错。
  • if constexpr:编译器只编译条件为 true 的分支。对于条件为 false 的分支,编译器会将其丢弃(Discard)。只要丢弃的分支语法正确,即使逻辑上对当前类型无效,也不会报错。

2. “进化”对比:SFINAE vs. if constexpr

假设我们要写一个 printValue 函数:如果是指针则解引用打印,如果是普通值则直接打印。

C++11 (SFINAE 方式)

你需要写两个独立的模板函数,通过 std::enable_if 强行让其中一个“失效”。

// 针对指针的版本
template <typename T>
typename std::enable_if<std::is_pointer<T>::value>::type
printValue(T t) {
    std::cout << *t << std::endl;
}

// 针对非指针的版本
template <typename T>
typename std::enable_if<!std::is_pointer<T>::value>::type
printValue(T t) {
    std::cout << t << std::endl;
}

C++17 (if constexpr 方式)

逻辑高度集中,可读性极佳。

template <typename T>
void printValue(T t) {
    if constexpr (std::is_pointer_v<T>) {
        std::cout << *t << std::endl; // 如果 T 不是指针,这段代码直接被编译器删掉
    } else {
        std::cout << t << std::endl;  // 如果 T 是指针,这段代码被删掉
    }
}

3. 为什么 if constexpr 是“降维打击”?

A. 解决“非法调用”问题

在模板中,最头疼的就是某些操作只对部分类型有效。

template <typename T>
auto get_value(T t) {
    if constexpr (std::is_pointer_v<T>) {
        return *t; // 如果 T 是 int,普通 if 会在这里报错,if constexpr 不会
    } else {
        return t;
    }
}

如果 T 是 int,普通 if 会尝试编译 *t,导致 error: invalid type 'int' to unary decompression。而 if constexpr 会直接把这一行抹除。

B. 简化递归终止

结合我们之前聊过的变长参数模板,if constexpr 可以直接在函数内处理终止逻辑,不再需要写多余的空参数函数。

template <typename T, typename... Args>
void log(T first, Args... args) {
    std::cout << first << " ";
    if constexpr (sizeof...(args) > 0) {
        log(args...); // 只有还有参数时才继续递归
    }
}

4. 使用限制

  1. 条件必须是编译期常量:即必须能推导出 bool 值的 constexpr 表达式。
  2. 作用域限制if constexpr 只能用在函数体内部。
  3. 语法正确性:被丢弃的分支虽然不参与实例化,但必须语法正确。例如,你不能在里面写乱码,或者引用完全未定义的模板。

5. 总结:元编程的新面貌

特性 SFINAE (enable_if) if constexpr
可读性 差(代码分散、签名冗长) 极好(逻辑紧凑,像普通代码)
报错信息 极其混乱 清晰
编译速度 较慢(涉及大量重载决议) (编译器直接剪枝)
适用场景 依然用于函数重载和类偏特化 模板函数内部的逻辑分支

4.C++20 concepts

C++20 的 Concepts(概念) 是模板元编程史上的一次“降维打击”。如果说 SFINAE 是程序员通过挖掘编译器漏洞(替换失败并非错误)而发明出的“黑魔法”,那么 Concepts 就是 C++ 官方为模板设计的第一等公民语法

它解决了模板编程中最痛苦的两个问题:晦涩的约束代码由于约束不匹配导致的几千行报错信息


1. 核心概念:什么是 Concept?

Concept 是一组对模板参数的要求(Constraints)。它本质上是一个编译期命题,告诉编译器:“只有满足这些条件的类型,才能进入这个模板。”

语法结构

template <typename T>
concept Incrementable = requires(T x) {
    x++;       // 必须支持自增
    ++x;       // 必须支持前置自增
};

2. 约束的四种写法:从繁到简

Concepts 提供了多种方式来约束模板,让代码变得极其优雅:

A. 完整写法 (requires 子句)

template <typename T>
requires std::integral<T>
T add(T a, T b) { return a + b; }

B. 后置写法 (Trailing requires)

常用于成员函数,或者当约束依赖于函数返回类型时:

template <typename T>
void func(T t) requires std::integral<T> { ... }

C. 简写写法(最推荐)

这让模板代码看起来和普通代码几乎没有区别:

void func(std::integral auto val) { ... } // 只有整数类型能进来

D. 嵌套约束 (requires 表达式)

可以在内部检查类型成员、返回类型甚至嵌套 Concept:

template <typename T>
concept AdvancedContainer = requires(T c) {
    typename T::value_type;         // 必须有内部类型定义
    { c.size() } -> std::same_as<size_t>; // size() 必须返回 size_t
};

3. SFINAE vs. Concepts:天壤之别

报错信息对比

这是 Concepts 最深入人心的地方。

  • SFINAE:当你传入一个不匹配的类型时,编译器会尝试所有重载,最后报错:“找不到匹配的函数”,并附带几百行尝试过程。

  • Concepts:编译器会直接了当地告诉你:

    error: constraints not satisfied for 'func'

    note: because 'double' does not satisfy 'integral'

代码对比:检查是否有 serialize 方法

还记得之前用 SFINAE 写这个有多痛苦吗?现在:

// C++20 方式
template <typename T>
concept HasSerialize = requires(T v) {
    v.serialize();
};

void save(HasSerialize auto& obj) {
    obj.serialize();
}

4. 逻辑组合:与、或、非

Concepts 支持像普通布尔运算一样进行逻辑组合,这在以前的 std::enable_if 中简直是噩梦。

template <typename T>
void process(T val) requires std::integral<T> || std::is_pointer_v<T> {
    // 只有当 T 是整数 OR 指针时才编译
}

template <typename T>
void debug(T val) requires (!std::integral<T>) {
    // 只有当 T 不是整数时才编译
}

5. 标准库提供的现成 Concepts

C++20 在 <concepts> 头文件中内置了大量常用的约束,你不需要重新发明轮子:

  • 基础类型std::integral, std::floating_point, std::signed_integral
  • 关系std::same_as<T, U>, std::derived_from<Derived, Base>
  • 对象模型std::copyable, std::movable, std::default_initializable
  • 可调用项std::invocable, std::predicate

6. 总结:元编程的终局

时代 技术 哲学 体验
C++98 模板递归 模仿函数式编程 痛苦,报错无法阅读
C++11/14 SFINAE / enable_if 利用编译器的“失败”机制 晦涩,逻辑分散
C++17 if constexpr 模板内部的剪枝 进步,但约束依然难写
C++20 Concepts 语义化接口编程 优雅,像写普通代码一样
posted @ 2025-12-21 10:40  belief73  阅读(3)  评论(0)    收藏  举报