深入理解 switch/case 语句
1. switch/case 语句概述
switch(或 case)高级控制语句是高级编程语言(HLL)中的一种条件语句。与 if 语句不同,if 语句测试布尔表达式,并根据表达式的结果执行代码中的两条不同路径之一;而 switch/case 语句可以根据序数(整数)表达式的结果分支到代码中的几个不同点。
以下是 C/C++、Pascal 和 HLA 中 switch 和 case 语句的示例:
- C/C++ 的 switch 语句 :
switch( expression )
{
case 0:
<< statements to execute if the
expression evaluates to zero >>
break;
case 1:
<< statements to execute if the
expression evaluates to one >>
break;
case 2:
<< statements to execute if the
expression evaluates to two >>
break;
<<etc>>
default:
<< statements to execute if the expression is
not equal to any of these cases >>
}
- Pascal 的 case 语句 :
case ( expression ) of
0: begin
<< statements to execute if the
expression evaluates to zero >>
end;
1: begin
<< statements to execute if the
expression evaluates to one >>
end;
2: begin
<< statements to execute if the
expression evaluates to two >>
end;
<<etc>>
end; (* case *)
- HLA 的 switch 语句 :
switch( REG32 )
case( 0 )
<< statements to execute if
EAX contains zero >>
case( 1 )
<< statements to execute
REG32 contains one >>
case( 2 )
<< statements to execute if
REG32 contains two >>
<<etc>>
default
<< statements to execute if
REG32 is not equal to any of these cases >>
endswitch;
从这些示例可以看出,这些语句都具有相似的语法。
2. switch/case 语句的语义
在大多数编程入门课程和教材中,通常通过将 switch/case 语句与 if..else..if 语句链进行比较来讲解其语义。例如,下面的 Pascal 代码被认为与前面的 Pascal case 语句等效:
if( expression = 0 ) then begin
<< statements to execute if expression is zero >>
end
else if( expression = 1 ) then begin
<< statements to execute if expression is one >>
end
else if( expression = 2 ) then begin
<< statements to execute if expression is two >>
end;
虽然这个特定的序列将实现与 case 语句相同的结果,但 if..then..elseif 序列和 case 实现之间存在几个根本差异:
- 常量要求 :case 语句中的 case 标签必须都是常量,而在 if..then..elseif 链中,实际上可以将变量和其他非常量值与控制变量进行比较。
- 单一表达式限制 :switch/case 语句只能将单个表达式的值与一组常量进行比较,不能像 if..then..elseif 链那样,对一个 case 将一个表达式与一个常量进行比较,对另一个 case 将另一个表达式与第二个常量进行比较。
因此,if..then..elseif 链在语义上与 switch/case 语句不同,并且功能更强大。
3. 跳转表与链式比较
虽然 switch/case 语句比 if..then..elseif 链更具可读性和便利性,但这种语句最初被添加到高级编程语言中是为了提高效率,而不是为了可读性或便利性。
考虑一个有十个不同表达式要测试的 if..then..elseif 链。如果所有情况都是相互排斥且等可能的,那么平均而言,程序在遇到一个计算结果为 True 的表达式之前将执行五次比较。
在汇编语言中,可以通过使用表查找和间接跳转,在固定的时间内将控制转移到几个不同的位置,而与情况的数量无关。例如,以下是将 switch(i) 转换为汇编语言的简单实现:
// Conversion of
// switch(i)
// { case 0:...case 1:...case 2:...case 3:...}
// into assembly
static
jmpTable: dword[4] :=
{ &label0, &label1, &label2, &label3 };
// jmps to address specified by jmpTable[i]
mov( i, eax );
jmp( jmpTable[ eax*4 ] );
label0:
<< code to execute if i = 0 >>
jmp switchDone;
label1:
<< code to execute if i = 1 >>
jmp switchDone;
label2:
<< code to execute if i = 2 >>
jmp switchDone;
label3:
<< code to execute if i = 3 >>
switchDone:
<< Code that follows the switch statement >>
这个代码的执行过程如下:
1. jmpTable 声明定义了一个包含四个双字指针的数组,每个指针对应 switch 语句模拟中的一个 case。
2. 第一条机器指令将 switch 表达式(变量 i 的值)加载到 EAX 寄存器中。
3. 下一条指令(jmp)根据 EAX 作为索引从 jmpTable 数组中获取地址,并跳转到该地址。
与 if..then..elseif 实现相比,这种跳转表实现有以下优点和缺点:
- 优点 :当情况数量超过三四个时,跳转表实现通常比对应的 if..then..elseif 链更快,并且消耗的内存更少。因为无论情况数量如何,跳转表实现只需要两条机器指令(加上跳转表),而 if..then..elseif 实现的比较和条件分支指令数量会随着情况数量的增加而增加。
- 缺点 :
- 内存访问速度 :跳转表是内存中的一个数组,访问(非缓存)内存可能较慢,访问跳转表数组可能会影响系统性能。
- 连续值要求 :必须为最小 case 值和最大 case 值之间的每个可能值在表中都有一个条目,包括那些实际上没有提供显式 case 的值。例如,对于以下 Pascal case 语句:
case( i ) of
0: begin
<< statements to execute if i = 0 >>
end;
1: begin
<< statements to execute if i = 1 >>
end;
5: begin
<< statements to execute if i = 5 >>
end;
8: begin
<< statements to execute if i = 8 >>
end;
end; (* case *)
如果使用跳转表实现,需要九个条目来处理 0 到 8 的所有可能 case 值:
// Conversion of
// switch(i)
// { case 0:...case 1:...case 5:...case 8:}
// into assembly
static
jmpTable: dword[9] :=
{
&label0, &label1, &switchDone,
&switchDone, &switchDone,
&label5, &switchDone, &switchDone,
&label8
};
// jumps to address specified by jmpTable[i]
mov( i, eax );
jmp( jmpTable[ eax*4 ] );
label0:
<< code to execute if i = 0 >>
jmp switchDone;
label1:
<< code to execute if i = 1 >>
jmp switchDone;
label5:
<< code to execute if i = 5 >>
jmp switchDone;
label8:
<< code to execute if i = 8 >>
switchDone:
<< Code that follows the switch statement >>
为了处理任意范围的 case 值,可以对代码进行修改。例如,对于以下 switch 语句:
switch(i)
{ case 10:...case 11:...case 12:...case 15:...case 16:}
转换为汇编语言如下:
// Conversion of
// switch(i)
// { case 10:...case 11:...case 12:...case 15:...case 16:}
// into assembly, that automatically handles values
// greater than 16 and values less than 10.
static
jmpTable: dword[7] :=
{
&label10, &label11, &label12,
&switchDone, &switchDone,
&label15, &label16
};
// Check to see if the value is outside the
// range 10..16.
mov( i, eax );
cmp( eax, 10 );
jb switchDone;
cmp( eax, 16 );
ja switchDone;
// The "- 10*4" part of the following expression
// adjusts for the fact that EAX starts at 10
// rather than zero, we still need a zero-based
// index into our array.
jmp( jmpTable[ eax*4 - 10*4] );
switchDone:
<< Code that follows the switch statement >>
这里的主要区别在于:
- 比较 EAX 中的值是否在 10 到 16 的范围内,如果不在则跳转到 switchDone 标签。
- 修改了 jmpTable 索引为 [eax 4 – 10 4],以调整 EAX 从 10 开始而不是 0 的情况。
一个完全通用的 switch/case 语句实际上需要六条指令来实现:原来的两条指令加上四条用于测试范围的指令。再加上间接跳转的执行成本略高于条件分支,这就是 switch/case 语句(与 if..then..elseif 链相比)的盈亏平衡点大约在三到四个 case 的原因。
下面是一个简单的 mermaid 流程图,展示了跳转表实现的基本流程:
graph TD;
A[开始] --> B[加载表达式值到 EAX];
B --> C[检查范围];
C -- 在范围内 --> D[根据 EAX 索引跳转表];
C -- 不在范围内 --> E[跳转到默认];
D --> F[执行对应代码];
F --> G[结束];
E --> G;
综上所述,跳转表实现虽然在效率上有优势,但也存在一些局限性,在使用时需要根据具体情况进行权衡。
4. switch/case 语句的其他实现方式
由于跳转表大小的问题,一些高级编程语言的编译器不会使用跳转表来实现 switch/case 语句,而是采用其他方式:
- 转换为 if..then..elseif 链 :一些编译器会简单地将 switch/case 语句转换为相应的 if..then..elseif 链。显然,当使用跳转表更合适时,这种编译器生成的代码在速度方面质量较低。
- 智能选择实现方式 :许多现代编译器在代码生成方面比较智能。它们会确定 switch/case 语句中的 case 数量以及 case 值的分布范围,然后根据一些阈值标准(代码大小与速度)选择跳转表或 if..then..elseif 实现方式,甚至可能结合多种技术。
例如,对于以下 Pascal case 语句:
case( i ) of
0: begin
<< statements to execute if i = 0 >>
end;
1: begin
<< statements to execute if i = 1 >>
end;
2: begin
<< statements to execute if i = 2 >>
end;
3: begin
<< statements to execute if i = 3 >>
end;
4: begin
<< statements to execute if i = 4 >>
end;
1000: begin
<< statements to execute if i = 1000 >>
end;
end; (* case *)
一个好的编译器会认识到大多数 case 在跳转表中表现良好,只有一个(或几个)case 是例外。它会将其转换为 if..then 和跳转表实现的组合指令序列,如下所示:
mov( i, eax );
cmp( eax, 4 );
ja try1000;
jmp( jmpTable[ eax*4 ] );
try1000:
cmp( eax, 1000 );
jne switchDone;
<< code to do if i = 1000 >>
switchDone:
此外,还有一些其他的实现方式:
- 生成二元搜索树 :许多现代优化编译器在有相当数量的 case 且跳转表会太大时,会生成二元搜索树来测试 case。例如,对于以下 C 程序:
#include <stdio.h>
extern void f( void );
int main( int argc, char **argv )
{
int boolResult;
switch( argc )
{
case 1:
f();
break;
case 10:
f();
break;
case 100:
f();
break;
case 1000:
f();
break;
case 10000:
f();
break;
case 100000:
f();
break;
case 1000000:
f();
break;
case 10000000:
f();
break;
case 100000000:
f();
break;
case 1000000000:
f();
break;
}
return 0;
}
MSVC 编译器生成的 MASM 输出会对十个 case 进行二元搜索:
_main PROC NEAR ; COMDAT
; File t.c
; Line 11
;
; Binary search. Is argc less or greater than 100,000?
mov eax, DWORD PTR _argc$[esp-4]
cmp eax, 100000 ; 000186a0H
jg SHORT $L1242
je SHORT $L1228
; Binary search: is argc less than 100 or
; greater than 100 (but less than 100,000)
cmp eax, 100 ; 00000064H
jg SHORT $L1243
je SHORT $L1228
; Is argc == 1?
dec eax
je SHORT $L1228
; is argc == 10? If not, branch to default
; case.
sub eax, 9
jne SHORT $L1225
; argc == 10 at this point.
;
; Line 49
call _f
; Line 53
xor eax, eax
; Line 54
ret 0
; Cases where argc is greater than 100
; but less than 100,000 (1,000 & 10,000)
$L1243:
; Line 11
cmp eax, 1000
je SHORT $L1228
cmp eax, 10000
jne SHORT $L1225 ;Default case
; Line 49
call _f
; Line 53
xor eax, eax
; Line 54
ret 0
;Cases where argc is greater than 100,000
$L1242:
; Line 11
; Above or below 100,000,000?
cmp eax, 100000000
jg SHORT $L1244
je SHORT $L1228 ;100,000,000
; Below 100,000,000 and above 10,000
cmp eax, 1000000 ;1,000,000
je SHORT $L1228
cmp eax, 10000000 ;10,000,000
jne SHORT $L1225 ;Default case
; Line 49
call _f
; Line 53
xor eax, eax
; Line 54
ret 0
; Handle the case where it's 1,000,000,000
$L1244:
; Line 11
cmp eax, 1000000000
jne SHORT $L1225
$L1228:
; Line 49
call _f
; Default case and BREAK come down here:
$L1225:
; Line 53
xor eax, eax
; Line 54
ret 0
_main ENDP
_TEXT ENDS
END
- 生成 2 - 元组表 :一些编译器,特别是用于某些微控制器设备的编译器,会生成 2 - 元组(记录/结构)表,其中元组的一个元素是 case 的值,第二个元素是如果值匹配时要跳转的地址。然后编译器会发出一个循环来扫描这个小表,查找当前 switch/case 表达式的值。如果是线性搜索,这种实现方式甚至比 if..then..elseif 链还慢;如果编译器发出二元搜索,那么代码可能比 if..then..elseif 链快(尽管可能不如跳转表实现快)。
- 代码技巧实现 :有时,编译器会采用一些代码技巧在某些情况下生成稍好的代码。例如,对于以下 switch 语句:
switch( argc )
{
case 1:
f();
break;
case 2:
g();
break;
case 10:
h();
break;
case 11:
f();
break;
}
Microsoft Visual C++ 编译器生成的代码如下:
; File t.c
; Line 13
;
; Use ARGC as an index into the $L1240 table,
; which returns an offset into the $L1241 table:
mov eax, DWORD PTR _argc$[esp-4]
dec eax ;--argc, 1 = 0, 2 = 1, 10 = 9, 11 = 10
cmp eax, 10 ;Out of range of cases?
ja SHORT $L1229
xor ecx, ecx
mov cl, BYTE PTR $L1240[eax]
jmp DWORD PTR $L1241[ecx*4]
npad 3
$L1241:
DD $L1232 ;cases that call f
DD $L1233 ;cases that call g
DD $L1234 ;cases that call h
DD $L1229 ;Default case
$L1240:
DB 0 ;case 1 calls f
DB 1 ;case 2 calls g
DB 3 ;default
DB 3 ;default
DB 3 ;default
DB 3 ;default
DB 3 ;default
DB 3 ;default
DB 3 ;default
DB 2 ;case 10 calls h
DB 0 ;case 11 calls f
; Here is the code for the various cases:
$L1233:
; Line 19
call _g
; Line 31
xor eax, eax
; Line 32
ret 0
$L1234:
; Line 23
call _h
; Line 31
xor eax, eax
; Line 32
ret 0
$L1232:
; Line 27
call _f
$L1229:
; Line 31
xor eax, eax
; Line 32
ret 0
这种代码通过先进行表查找将 argc 值映射到 0 到 3 的范围(对应不同的代码体和默认情况),虽然比跳转表短,但由于需要访问两个不同的内存表,速度稍慢。
5. 优化 switch/case 语句的建议
由于很少有编译器允许你明确指定如何翻译特定的 switch/case 语句,并且不同编译器的实现方式差异较大,因此在编写代码时需要考虑以下优化建议:
- 手动拆分 case :如果编译器对某个 switch 语句生成的跳转表过大,可以手动拆分 case。例如,对于有 case 0、1、2、3、4 和 1000 的 switch 语句,可以这样优化:
- Pascal 代码 :
if( i = 1000 ) then begin
<< statements to execute if i = 1000 >>
end
else begin
case( i ) of
0: begin
<< statements to execute if i = 0 >>
end;
1: begin
<< statements to execute if i = 1 >>
end;
2: begin
<< statements to execute if i = 2 >>
end;
3: begin
<< statements to execute if i = 3 >>
end;
4: begin
<< statements to execute if i = 4 >>
end;
end; (* case *)
end; (* if *)
- **C/C++ 代码**:
switch( i )
{
case 0:
<< statements to execute if i == 0 >>
break;
case 1:
<< statements to execute if i == 1 >>
break;
case 2:
<< statements to execute if i == 2 >>
break;
case 3:
<< statements to execute if i == 3 >>
break;
case 4:
<< statements to execute if i == 4 >>
break;
default:
if( i == 1000 )
{
<< statements to execute if i == 1000 >>
}
else
{
<< Statements to execute if none of the cases match >>
}
}
- 根据概率选择实现方式 :虽然跳转表实现通常在有相当数量的 case 且每个 case 等可能时效率较高,但如果一两个 case 比其他 case 更有可能出现,使用 if..then..elseif 链(或 if..then..elseif 和 switch/case 语句的组合)可能更高效。例如,如果某个变量超过一半的时间值为 15,大约四分之一的时间值为 20,其余 25% 的时间为其他几个不同的值,那么使用 if..then..elseif 链来实现多路测试可能更合适。
下面是一个总结不同实现方式特点的表格:
| 实现方式 | 优点 | 缺点 | 适用场景 |
| ---- | ---- | ---- | ---- |
| 跳转表 | 当 case 数量较多时速度快,内存使用相对稳定 | 内存访问可能慢,需要连续值 | case 数量较多且值连续 |
| if..then..elseif 链 | 实现简单,可比较变量和非常量值 | 随着 case 数量增加速度变慢 | case 数量较少或值不连续 |
| 二元搜索树 | 对于大量分散的 case 查找速度较快 | 实现相对复杂 | case 数量多且分散 |
| 2 - 元组表 | 适用于微控制器设备 | 线性搜索慢 | 微控制器设备 |
| 代码技巧实现 | 代码较短 | 可能需要访问多个表,速度稍慢 | case 分布特殊 |
综上所述,在使用 switch/case 语句时,需要根据 case 数量、值的分布以及不同 case 的概率等因素综合考虑,选择合适的实现方式,以达到最佳的性能和代码可读性。
graph LR;
A[switch/case 语句] --> B{编译器选择};
B -- 跳转表合适 --> C[跳转表实现];
B -- 跳转表不合适 --> D{情况};
D -- case 少 --> E[if..then..elseif 链];
D -- case 多且分散 --> F[二元搜索树];
D -- 微控制器 --> G[2 - 元组表];
D -- 特殊分布 --> H[代码技巧实现];
超级会员免费看

1298

被折叠的 条评论
为什么被折叠?



