yuqi-zheng

编译器优化:编译器如何优化 switch 语句


人们常常以为 switch 语句会被编译成跳转表:一个按 case 值索引的代码地址数组。有时的确如此,但这只是现代编译器可能采取的若干策略之一。实际的选择取决于 case 值的分布、返回或计算的值,以及目标架构上不同指令序列的相对代价。

本文是 Matt Godbolt 的《Advent of Compiler Optimisations 2025》系列中的一篇。

策略 1:算术化简

当 case 值形成一个等差序列、返回值也遵循一个可预测的模式时,编译器可能完全消除 switch,用一个公式代替它。

int f(int x) {
    switch(x) {
        case 0: return 100;
        case 1: return 200;
        case 2: return 300;
        case 3: return 400;
        case 4: return 500;
        default: return 0;
    }
}

编译器观察到返回值对 0 到 4 的输入是 (x + 1) * 100。它生成:

; 若 x < 5:return (x + 1) * 100
; 否则:return 0

没有表,也没有基于 case 值的跳转 —— 只有一次边界检查和一次乘法。switch 被完全代数消除了。

策略 2:查找表

当返回值是任意的、不能用公式表达,但 case 值很稠密(覆盖连续或接近连续的范围)时,编译器会生成一个静态数据表:

switch(x) {
    case 0: return 99;
    case 1: return 42;
    case 2: return 17;
    // ...
}

编译器创建一个只读数组(汇编中常常有类似 CSWTCH.1 的标签),按顺序存放返回值,对 x 做一次边界检查,然后用 x 作为索引去查这个数组。这是数据表,不是跳转表 —— 一次内存加载、一次边界检查,对各个 case 没有分支。

策略 3:位掩码

对返回布尔值的 switch —— 比如成员判断 —— 编译器可能构造一个位掩码,每个 case 值对应一位。

bool is_whitespace(char c) {
    switch(c) {
        case ' ':
        case '\t':
        case '\n':
        case '\r':
        case '\v':
        case '\f':
            return true;
        default:
            return false;
    }
}

空白字符从 '\t'(ASCII 9)到 ' '(ASCII 32),跨越 24 个位置。编译器把”这 24 个位置中哪些是空白”编码为一个 24 位常量,然后使用 bt(bit test)指令:

sub edi, 9           ; 把范围起点移到 0
mov eax, 8388631     ; 位掩码:哪些位置是空白
bt  rax, rdi         ; 测试位于位置 (c - '\t') 的位
setc al              ; 位置 1 则 al = 1
cmp dil, 24          ; 边界检查:c 是否落在范围内?
cmovnb eax, edx      ; 超出范围则返回 0

一次位测试取代了六次比较,没有任何条件分支。结果完全通过标志位操作得到。

注意:在生产代码中通常应优先使用标准库的 isspace();这里纯粹是用来演示优化技术。

策略 4:跳转表

当 case 值稠密、各 case 行为不同(不只是返回值不同)、编译器又不能简化为上面几种方式时,跳转表登场。表中存放的是代码地址,switch 通过按索引 x 加载地址并跳转来派发:

jmp [table + rax*8]

这是大多数人最常想到的实现。它提供 O(1) 的派发,不管有多少 case,但它要求 case 值紧凑(或者编译器为缺失值填充表)。它还引入了间接跳转,这对分支预测和安全(投机执行攻击)都有影响。

策略 5:二叉搜索树

当 case 值稀疏 —— 分散在一个很大的范围、彼此之间有大空隙 —— 跳转表会浪费大量内存。这时编译器会生成一组比较,等价于一次二叉搜索:

switch(x) {
    case 100: ...
    case 2511: ...
    case 4865: ...
    case 5284: ...
}

编译器先与中位数比较,再递归地处理各一半。这把平均比较次数从 O(n)(一条线性的 if-else 链)降到 O(log n) —— 与你手写的二叉搜索相当。

编译器如何选择

编译器评估所有适用的策略,根据估计的代码体积和执行代价做选择。影响因素包括:

  • case 值的密度:稠密 → 查找表或跳转表;稀疏 → 算术或二叉搜索。
  • 返回值的模式:算术序列 → 公式;不规则 → 表。
  • case 数量:极少 → 比较链;稠密且多 → 跳转表;稀疏且多 → 二叉搜索。
  • 目标架构:内存访问代价、分支预测失败惩罚、可用指令,都会影响决策。

不同编译器(GCC、Clang、MSVC)对同一输入可能得出不同结论。Compiler Explorer 是验证”某个具体编译器和版本会生成什么”的正确工具。

实用建议

switch 语句要清晰表达意图。不要为了强迫编译器走某种特定策略而重构它 —— 这更可能让代码更难读,而不是提升性能。编译器的分析是全面的,顾及了很多难以手工推理的因素。

如果你观察到一个热点 switch 的编译效果不如预期,先做 profile 确认它确实是瓶颈,然后在 Compiler Explorer 里看编译器生成了什么、为什么。对 case 值或返回表达式做细微改动,有时能让编译器从一种策略切换到另一种。