The compiler aims to produce the same code in each of the two cases, when compiling in release mode. However, the match
approach is easier for the compiler to deal with, and in fact there are some examples where the compiler is unable to produce optimal code for the if
/else
chain.
How this sort of code is supposed to be compiled
The Rust compiler is compiled of two halves. One half, written by the Rust developers, checks that your source code is valid (e.g. by enforcing the borrow checker rules), and then compiles it into LLVM IR; the second half is LLVM, which is a compiler backend shared between multiple different languages, that takes the LLVM IR and produces an executable.
In the case of the code you've written, a) it doesn't matter which order the tests for the various sorts of Expr
are written in, and b) it's guaranteed that at least one of them will match. Those are pieces of information that give LLVM a lot of flexibility as to how to express the match in an executable (and at least on the processor I tested, LLVM's choice is to use an array of possible jump locations, and index into them using the discriminant of your enum, i.e. it is actually performing all the tests in parallel).
When you write the match
example, the Rust to LLVM IR part of the compilation understands all that information itself. It writes LLVM IR that says "please jump to one of these four locations based on the value of the enum discriminant", using LLVM IR's switch
statement which is comparable to Rust's match
, and the IR looks something like this (note the "unreachable" in the first line, specifying that the default case of the match is unreachable – the compiler has determined that no options other than these four are possible):
switch i64 %0, label %default.unreachable [
i64 0, label %bb3
i64 1, label %bb4
i64 2, label %bb5
i64 3, label %bb2
]
With the if
/else
example, then in earlier stages of compilation, the compiler doesn't understand that doing this with a single test is possible, and it writes out the equivalent of four if
statements, each with its own test (even though, technically, only three of the tests are actually necessary because if the first three fail the fourth will always pass). If you compile in debug mode, then the resulting executable actually does each of those tests separately.
What about if you compile in release mode? Well, the compiler has a number of optimizations (running only in release mode) that are designed to detect less efficient code and replace it with more efficient code. In this case, the optimizations notice that the entire chain is simply a check on a single integer (the enum discriminant). So the optimizations end up rewriting the if/else chain into a single switch block, producing almost the same IR as you would get with the match
case. In many cases, this means that you get identical code either way.
A case where it does make a difference
Unfortunately, there are some cases where the compiler does fail to handle this situation and produces slightly suboptimal code. Let's say we have an enum
with seven options, each of which has no fields, and try the match
and if
/else
chain on it:
pub enum ExampleEnum {
A, B, C, D, E, F, G
}
pub fn match1(e: ExampleEnum) {
match e {
ExampleEnum::A => panic!("a"),
ExampleEnum::B => panic!("b"),
ExampleEnum::C => panic!("c"),
ExampleEnum::D => panic!("d"),
ExampleEnum::E => panic!("e"),
ExampleEnum::F => panic!("f"),
ExampleEnum::G => panic!("g"),
}
}
pub fn match2(e: ExampleEnum) {
if let ExampleEnum::A = e {
panic!("a")
} else if let ExampleEnum::B = e {
panic!("b")
} else if let ExampleEnum::C = e {
panic!("c")
} else if let ExampleEnum::D = e {
panic!("d")
} else if let ExampleEnum::E = e {
panic!("e")
} else if let ExampleEnum::F = e {
panic!("f")
} else if let ExampleEnum::G = e {
panic!("g")
} else {
unreachable!()
}
}
Playground link
In release mode, we'd expect these to produce the same code. Unfortunately, with this many options, the compiler loses track of the fact that the final else
block is unreachable while it's collapsing the whole if
/else
chain into a single switch
, so the LLVM IR looks slightly different in the two cases:
match1 (using match ) |
match2 (using if /else ) |
switch i8 %0, label %bb1 [
i8 0, label %bb3
i8 1, label %bb4
i8 2, label %bb5
i8 3, label %bb6
i8 4, label %bb7
i8 5, label %bb8
i8 6, label %bb2
]
bb1:
unreachable
|
switch i8 %0, label %bb14 [
i8 0, label %bb1
i8 1, label %bb3
i8 2, label %bb5
i8 3, label %bb7
i8 4, label %bb9
i8 5, label %bb11
i8 6, label %bb13
]
bb14:
; call core::panicking::panic
tail call void @_ZN4core9panicking5panic… |
The compiler has produced a switch
block in both cases, but the match
specifies the default case as a branch to the LLVM intrinsic unreachable
(the equivalent of Rust's unreachable_unchecked!()
), whereas with the if
/else
, the unreachable!()
at the end hasn't been proven to be necessarily unreachable, so the default case branches to Rust's unreachable!()
macro (which translates into a call to panic()
), and because that's all the information that LLVM has, it doesn't optimize the unreachable!()
call out of the final program.
Still, having dead code in an executable mostly doesn't matter – if it doesn't run, it isn't taking up much time – but in this seven-element enum
case, the resulting executable is going to be marginally slower in the if
/else
case. Compiling for x86_64 (as a commonly used example), most of the generated assembly code is the same except for the names of line labels, but one small portion near the start of the function differs (I manually changed the whitespace to show the difference):
match1 (using match ) |
match2 (using if /else ) |
match1:
subq $56, %rsp
movzbl %dil, %eax
leaq .LJTI0_0(%rip), %rcx
movslq (%rcx,%rax,4), %rax
addq %rcx, %rax
jmpq *%rax |
match2:
subq $56, %rsp
cmpb $6, %dil
ja .LBB1_9
movzbl %dil, %eax
leaq .LJTI1_0(%rip), %rcx
movslq (%rcx,%rax,4), %rax
addq %rcx, %rax
jmpq *%rax |
The code is testing to see whether the enum discriminant has a value above 6 (cmpb $6, %dil; ja
, i.e. "compare, then jump if above"), and if so, is performing a jump. Of course, as a 7-element enum (whose discrminants are internally numbered 0 to 6 inclusive) it can't have a discriminant above 6, so this test will never pass – but the test is still going to be made whenever the function is called, and so it will run very slightly slower, because the test itself will take some amount of time. (Note that Rust knows that a discriminant of this sort of enum can never go above 6 – but LLVM doesn't, so it can't safely declare the block of code to be unreachable.)
Conclusions
In most cases it isn't going to matter, but the compiler has to do a lot more work to handle the if
/else
chain – it's something of an unnatural construct for the compiler to handle (e.g. it can't syntactically determine that the final else
block is unreachable, and has to do a static analysis to work that out). The compiler would prefer to work with the match
block – that allows it to jump directly to the correct line of code with a single test if that's fast on the platform, or to perform the tests in a sequence of its choice otherwise – and in fact it will internally attempt to rewrite the if
/else
chain into a match
block. But that's done quite late in the compile, by which point it's possible that the compiler has lost track of exactly what is known about the set of values that the discriminant can have.
Simply writing the match
block directly is going to make life a lot easier – it expresses your intent more clearly both to human programmers reading the code, and to the compiiler, which will need to do less work to understand that this is an exhaustive match on all options of the enum
. Because the compiler is doing more work to handle the if
/else
chain, it increases the chance that something will go wrong which causes the code to be optimized imperfectly. (I suspect that the version with match
will probably also give you marginally faster compile times.)
match
andif let
chains deploy powerful optimizations to generate the fastest possible code. Nothing is guaranteed, but for the simple cases outlined in the question, I would expect the same (optimal) code to be generated by both. – Addison