C++的核心:硬件暴力美学和零成本抽象!


有一个专门算浮点乘加的函数:std::fma,简写来自 Fused Multiply-Add;你输入 x, y, z,然后它给你返回 x * y + z。有人可能会问:“哎?那这和直接写 x * y + z 有什么区别呢?”这个函数真正关键的其实是那个 Fused。

普通写法 x * y + z 会分两步走:先算 temp = x * y。在这个过程中,结果会被舍入到浮点数的可表示精度(例如 double 的 53 位有效数字)。这里丢失了一次精度。再算 result = temp + z,而这里又进行了一次舍入;总共有2 次舍入误差。

而 std::fma(x, y, z) 根据 IEEE 754 标准,必须像拥有无限精度一样计算 x * y + z 的确切值,然后只在最后将结果存回浮点数格式时,进行唯一的一次舍入。这意味着 std::fma 通常比普通乘加更精确。特别是在 x * y 的结果和 z 大小相近但符号相反发生灾难性抵消时,std::fma 能保留更多的有效位。

现在的现代 CPU(如 Intel 的 Haswell 架构以后,ARM Cortex-A 系列等)通常都在 ISA 层面直接支持 FMA 指令(如 x86 的 FMA3 或 FMA4 指令集)。std::fma 通常会被编译成一条汇编指令(例如 vfmadd213sd)。这意味着它不仅精度高,而且速度极快(通常只需 4-5 个时钟周期,吞吐量很高)。

但如果硬件不支持,编译器会调用软件库来模拟无限中间精度。这时候它会非常慢;标准定义了三个可选的宏:FP_FAST_FMA, FP_FAST_FMAF, FP_FAST_FMAL 来表明地面实况。


想象一下你写了一个std::list,你传进去的分配器是:std::allocator;但是,链表内部并不直接存储 int,它存的是节点Node。分配器只会分配 int 大小的内存,那 Node 的指针域(next/prev)放哪儿呢?那么这里要进行分配器变性:这是通过一个 rebind 的结构体实现的。

在 C++11 之前(比如 C++98),强制要求每个 allocator 都要手写一个 rebind 结构体;但是到了 C++11 及以后,会通过 std::allocator_traits 的 SFINAE 技巧来检测用户有没有手写 rebind,如果有就用用户自定义,如果没有就自动替换模板参数。

什么是 SFINAE 呢?Substitution Failure Is Not An Error:如果在替换模板参数的过程中产生了无效的代码(比如访问不存在的类型),编译器不会立刻报错,而是认为这个重载不匹配,继续寻找下一个;但是SFINAE 只保护函数签名部分的替换失败,包括返回值类型、函数参数类型、模板参数默认值,但是不包括: 函数体内部的代码。

第一代,最经典的SFINAE 工具便是 std::enable_if。它的原理是利用偏特化:如果条件为真,它就有一个 type 成员;如果为假,它就没有 type 成员(从而触发 SFINAE,让函数消失)。请看 Gemini 为我生成的伪代码:

1
2
[#]include <type_traits>
[#]include <iostream>
1
2
#include <type_traits>
#include <iostream>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 版本 1:只在这个 T 是浮点数时才存在
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
process(T t) {
std::cout << "Processing floating point: " << t << std::endl;
}
// 版本 1:只在这个 T 是浮点数时才存在
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
process(T t) {
std::cout << "处理浮点数: " << t << std::endl;
}

// 版本 2:只在这个 T 是整数时才存在
template <typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
process(T t) {
std::cout << "Processing integer: " << t << std::endl;
}
// 版本 2:仅当 T 是整数时存在
template <typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
process(T t) {
std::cout << "Processing integer: " << t << std::endl;
}

int main() {
process(3.14); // 匹配版本 1,版本 2 被 SFINAE 丢弃
process(42); // 匹配版本 2,版本 1 被 SFINAE 丢弃
// process("Hello"); // 两个都失败 -> 此时才是真正的 Compile Error
}

里面的那个 typename 单纯是C++语法问题,因为在模板实例化之前编译器默认作用域解析运算符后面那玩意是一个变量或者值。那么为什么有的时候有有些时候没有呢?核心是 Dependent Names,即编译器不能消除歧义。比如 std::vector::iterator:这不是依赖名称。因为 std::vector 是完全确定的,编译器去查一下就知道 iterator 是个类型,所以不需要写 typename。而面对 T::iterator:这是 Dependent Name。因为 iterator 的含义依赖于 T 是什么。对于依赖名称,默认是值,必须用 typename 标明是类型。还有一个跟它很像的语法坑,叫做 .template (或者 ->template):

1
2
3
4
5
6
7
template <typename T>
void call_foo(T& t) {
// 错误!编译器以为是:(t[.]foo < 3) > (5)
// t[.]foo<3>(5);
// 正确!告诉编译器 < 是模板参数列表的开始
t.template foo<3>(5);
}

(t[.]foo是因为会被识别成链接)

到了 C++17,我们有了更优雅的技巧,专门用来探测“这个类有没有某个成员”。

std::void_t<…> 的作用是:不管你往里面塞什么类型,只要它是有效的,结果就是 void。如果无效,就触发 SFINAE。Gemini 例子时间(检测一个类有没有 reserve() 函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[#]include <type_traits>
[#]include <vector>
[#]include <iostream>
// 主模板:默认并没有 reserve

template <typename T, typename = void>
struct has_reserve : std::false_type {};
// 特化版本:利用 SFINAE 探测
模板 <typename T, typename = void>
struct has_reserve : std::false_type {};
// 特化版本:利用 SFINAE 探测

// 如果 T.reserve(size_t) 合法,std::void_t<> 变成 void,匹配这个特化!
// 如果不合法,这行代码无效,SFINAE 踢掉这个特化,回退到主模板。
template <typename T>
struct has_reserve<T, std::void_t<decltype(std::declval<T>().reserve(1U))>>
: std::true_type {};
int main() {

std::cout << has_reserve<std::vector<int>>::value << std::endl; // 1 (True)
std::cout << has_reserve<int>::value << std::endl; // 0 (False)
}

std::declval() 这个非常有意思:

1
2
3
template<typename T>
typename std::add_rvalue_reference<T>::type declval() noexcept;
// 也就是返回 T&&

在 C++11 引入 decltype 之后,我们经常需要做这样的询问:“嘿,编译器,如果我有两个变量 x 和 y,让它们相加 x + y,得到的结果类型是什么?” 如果你有实例,这很好办;但是在模板元编程中,我们通常只有类型 T,没有实例。这个时候 decltype(std::declval().foo()),编译器看到这个表达式,会进行语义分析;而因为这个时候是在类型参数位(一个直接上下文),所以就会命中 SFINA 的要求,如果推导失败这个选项就会被排除,不过std::declval 只能用在不求值语境中。

幸运的是,后来多态内存资源(PMR, Polymorphic Memory Resources)和 Concept 的引入终结了晦涩难懂的模板天书。

在 PMR 之前,Allocator 是容器类型的一部分。std::vector<int, AllocA> 和std::vector<int, AllocB> 是两个完全不同的类型!这意味着你不能把它们传给同一个函数,除非把函数也写成模板,导致代码膨胀严重,接口极度不灵活。std::pmr 利用虚函数和类型擦除的机制,把具体的内存分配策略藏在了运行时。现在 std::pmr::vector 就是一种类型;无论底层用什么资源: 无论是 new_delete_resource,还是手搓的 monotonic_buffer_resource,容器类型永远不变。本质是用虚函数调用的开销换取了代码体积减小和接口简洁性;这是工程上的权衡。

以前用 enable_if 配合 SFINAE,写出来的代码满屏尖括号。一旦报错,编译器会吐出几千行的“模板实例化失败”堆栈,根本看不懂是哪里缺了条件。Concepts (编译期约束) 让我们直接用“自然语言”描述类型要求:

黑暗时代:

1
2
3
template <typename T,
typename = typename std::enable_if<std::is_integral<T>::value>::type>
void foo(T t) { /*...*/ }

光明时代:

1
2
3
模板<typename T,
typename = typename std::enable_if<std::is_integral<T>::value>::type>
void foo(T t) { /*...*/ }

光明时代:

1
void foo(std::integral auto t) { /*...*/ }

总的来说,PMR 终结了 Allocator 导致的类型碎片化。它把复杂性从编译期类型系统转移到了运行时对象状态,让代码更像传统的 OOP。Concepts 终结了 SFINAE 的晦涩语法。它把隐晦的替换失败变成了显式的约束检查,让模板编程从黑魔法变成了常规工程。