The compiled difference between switch-case and if-else
Introduction
The C language provides two methods of chaining together conditional statements: an if-else tree; and a switch-case statement. On the surface, these may seem logically identical, and it is indeed possible to create logically equivalent code using both methods. However, the compiler treats the syntax differently, resulting in (marginally) faster code in some circumstances using switch-case statements.
A very basic test bench
To investigate this I created two functions,
switch_case_func:
int switch_case_func(int in) {
int out;
switch (in) {
case 0:
out = 1;
break;
case 1:
out = 5;
break;
case 2:
out = -4;
break;
case 3:
out = 10;
break;
case 4:
out = -93;
break;
default:
out = 0;
break;
}
return out;
}and if_else_func:
int if_else_func(int in) {
int out;
if (in == 0) {
out = 1;
} else if (in == 1) {
out = 5;
} else if (in == 2) {
out = -4;
} else if (in == 3) {
out = 10;
} else if (in == 4) {
out = -93;
} else {
out = 0;
}
return out;
}These functions were then used to show how the different syntax affects the compiled binary in a variety of different ways.
The machine code
Looking at the decompilation of these functions in Ghidra it is clear
that the compiler has done something different for each function. The
if_else_func function has decompiled as expected:
undefined4 if_else_func(int param_1)
{
undefined4 local_c;
if (param_1 == 0) {
local_c = 1;
}
else if (param_1 == 1) {
local_c = 5;
}
else if (param_1 == 2) {
local_c = 0xfffffffc;
}
else if (param_1 == 3) {
local_c = 10;
}
else if (param_1 == 4) {
local_c = 0xffffffa3;
}
else {
local_c = 0;
}
return local_c;
}This is pretty much identical to the source input with the variable
names stripped out. However, switch_case_func looks like
this:
undefined4 switch_case_func(int param_1)
{
if (param_1 == 4) {
return 0xffffffa3;
}
if (param_1 < 5) {
if (param_1 == 3) {
return 10;
}
if (param_1 < 4) {
if (param_1 == 2) {
return 0xfffffffc;
}
if (param_1 < 3) {
if (param_1 == 0) {
return 1;
}
if (param_1 == 1) {
return 5;
}
}
}
}
return 0;
}Instead of waiting to the end of the function to return, the return statements are done within the if-else tree, meaning the program doesn’t need to jump to the end of the function. The code also makes use of comparison operators other than equals. Lets take a look at these functions in their assembly form:
switch_case_func:
.globl switch_case_func
.type switch_case_func, @function
switch_case_func:
.LFB7:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -20(%rbp)
cmpl $4, -20(%rbp)
je .L3
cmpl $4, -20(%rbp)
jg .L4
cmpl $3, -20(%rbp)
je .L5
cmpl $3, -20(%rbp)
jg .L4
cmpl $2, -20(%rbp)
je .L6
cmpl $2, -20(%rbp)
jg .L4
cmpl $0, -20(%rbp)
je .L7
cmpl $1, -20(%rbp)
je .L8
jmp .L4
.L7:
movl $1, -4(%rbp)
jmp .L9
.L8:
movl $5, -4(%rbp)
jmp .L9
.L6:
movl $-4, -4(%rbp)
jmp .L9
.L5:
movl $10, -4(%rbp)
jmp .L9
.L3:
movl $-93, -4(%rbp)
jmp .L9
.L4:
movl $0, -4(%rbp)
nop
.L9:
movl -4(%rbp), %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE7:
.size switch_case_func, .-switch_case_func
`if_else_func:
.globl if_else_func
.type if_else_func, @function
if_else_func:
.LFB8:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -20(%rbp)
cmpl $0, -20(%rbp)
jne .L12
movl $1, -4(%rbp)
jmp .L13
.L12:
cmpl $1, -20(%rbp)
jne .L14
movl $5, -4(%rbp)
jmp .L13
.L14:
cmpl $2, -20(%rbp)
jne .L15
movl $-4, -4(%rbp)
jmp .L13
.L15:
cmpl $3, -20(%rbp)
jne .L16
movl $10, -4(%rbp)
jmp .L13
.L16:
cmpl $4, -20(%rbp)
jne .L17
movl $-93, -4(%rbp)
jmp .L13
.L17:
movl $0, -4(%rbp)
.L13:
movl -4(%rbp), %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE8:
.size if_else_func, .-if_else_func
The structure of these functions ion assembly is very different. The
if-else method is structures much more like the code, with each part of
the tree handled in its own section, and comparisons being done using
the jne, or jump-if-not-equal, instruction. In comparison,
the compiled switch-case statement has a structure quite different to
the code which generated it. Instead of doing the comparison in-line, it
is all done at the top of the method, with the logic for each case being
handled by a label below the comparison section. We again see the use of
comparison operators other than equal, with the use of the
jg, or jump-if-greater-than, instruction.
Number of instructions per comparison
So, which is better? One way to determine this is by quantifying which method can do the comparison faster. The table below shows the number of instructions the function takes to execute for each input:
| Input | If-Else | Switch-Case |
|---|---|---|
| 0 | 22 | 10 |
| 1 | 24 | 12 |
| 2 | 18 | 14 |
| 3 | 14 | 16 |
| 4 | 10 | 18 |
| >4 | 12 | 17 |
This detracts from the normal wisdom, which states that switch-case statements are faster than if-else trees.
Adding compiler optimizations
Adding compiler optimizations completely changes the output. Using
O1 or O2 optimizations results in the code for
the if-else and switch-case functions being almost identical. Both end
up using look-up table logic which dramatically reduces the amount of
comparisons required. The reason this optimization is made in the (very
basic) case shown above can potentially be explained by the definition
of a switch statement in K&R, the seminal book on the C
language:
The switch statement is a multi-way decision that tests whether an expression matches one of a number of constant integer values, and branches accordingly.
As the example used is only comparing to constant integer values, the compiler might well be optimizing it to the equivalent of a switch-case statement, hence the production of equivalent machine code.