- Part 1 - A Quirk With Implicit vs Explicit Interfaces
- Part 2 - What is an OpCode?
- Part 3 - Conditionals and Loops (this post)
Last time we explored IL (well, CIL, but most people know it as just IL) we were introduced to OpCodes and their meaning by going through a really simple method. Today, I want to look at two common statements we use in programming, conditional statements (
switch) and loops (
foreach, etc.). We'll also look at something that we skipped from the last post, the difference between
Release builds (well, building with or without compiler optimisations).
Let's take the following F# function:
Console.WriteLine rather than the F#
Core.Printf module and
printfn as it makes less verbose code. Normally in F# I'd use
This will generate the following IL:
There are a few OpCodes which are familiar but we're also meeting a some new ones. The first two in our output are responsible for loading the value of the argument (
a in our code) onto the stack and then pushing the
1 onto the stack. Now we're getting to a new OpCode and we're also going to need to understand a bit more about the piece that is to the left of the
: (that we ignored last time), which is called the instruction prefix.
An instruction in IL can be made up of two pieces of information in the format of
prefix: instruction. Let's take the following line:
Here we have a prefix of
IL_0002 and the instruction for that line is
bne.un.s IL_0006 which is define here. To quote the documentation for the instruction for
Transfers control to a target instruction (short form) when two unsigned integer values or unordered float values are not equal.
That's interesting, it's a not equal operation whereas our code was
if a = 1 then, so the IL represents the inverse of what our code represented. To understand why we need to look at the rest of the instruction, and the rest of the IL, since there's another bit of information passed to
IL_0006, which represents the target instruction to transfer control to. Effectively what this says is “if the two values don't match the next line to execute is
Here's where that sits in the IL:
You'll notice that
IL_0006 is after
IL_0004, so we're skipping
IL_0004, essentially using a
Just an aside, F# doesn't have a
goto statement, C# does!
br.s which again transfers control to another instruction:
Both of these blocks are pretty similar,
IL_0008 is the start of the truthy branch of our
if statement, loading a string onto the stack then calling
Console.WriteLine before issuing a
ret to end the method.
IL_0013 is the falsey branch and using the control transfer we skip over the truthy block.
So if we break it down, an
if statement is a series of GOTO calls to jump over blocks we don't want to execute.
If Statements in
Out of curiosity, I decided to create the same example in C#:
And what I found is that it generates different IL!
What's interesting here is that in the C# version it uses
ceq and combines it with
ceq does an equality test on two values and if they are equal it pushes
1 onto the stack, otherwise
0, and then
brfalse.s transfers control to an instruction,
IL_0018 in our case, if the value on the stack is false,
null or 0.
You'll also notice the use of
br.s when the truthy block finishes skipping over the falsey block and land on
ret, whereas F# just inlined the
This makes the C# version a little more verbose than the F# version when achieving the same thing.
Debug vs Release
Both of the above examples are compiled using “Debug mode”, so there are no compiler optimisations enabled. But that's not what you're going to deploy to production (right? RIGHT!) so again I was interested to see the differences there. Here's the F# one compiled with optimisations:
And here's C#:
Well, they look very similar don't they, in fact, they are identical (I had to check a few times that yes, I was copying different ones!). They both resemble the unoptimised F# output, but with the minor difference of a few less
br.s instructions around the place.
When it comes to loops I tend to use the
for loop most commonly, so we'll use that for our exploration today. Starting with a simple F# function:
Which results in the following IL:
I've left some indentations and comments that IL Spy generated for me to help visualise the IL a bit better.
We start by pushing the initial value of
i onto the stack with
ldc.i4.0 and then ensure the stack is at the right location with
stloc.0 before using
br.s to move down to
IL_000e. This is the start of our equality test to ensure that we're still in the loop range:
Location 0 is loaded on the stack (which is our
i value) then the values of
99 are pushed onto the stack with
ldc.i4.s respectively (
ldc.i4.1 is shorthand for
ldc.i4.s 1, and likely optimised by your runtime) before
add is called which pushes the value of
100 onto the stack. Finally
blt.s is called and if the value in stack position
0 is less than the result of
add we'll transfer control higher up in the instruction list, specifically to
IL_0004. Now we've ended up with this block:
We grab the value from stack position
0, hand it to
Console.WriteLine, get it again, add
1 to it and fall through to
IL_000e since there's no control transfer.
I find this order quite interesting, the output IL is in reverse order to what I expected it to be, the conditional test is at the end of the instruction list, but upon dissection it makes a lot of sense. If the conditional test was at the top you'd always have to use a
br.s to transfer control back up to the top when the loop body finishes and then have a control transfer test (a
brtrue.s) if the range was exceeded. This would be inefficient as you'd always execute a control transfer and then have a second potental control transfer, whereas having it in reverse you only have 1 control transfer and it's conditional.
For Loops in
Again we'll look at a C# implementation:
Resulting in the following IL:
It's pretty similar to the F# version (ignoring the
nop instructions) except how the equality test is done. In our F# version, like with the
if statement, the equality test was combined with the control transfer using
blt.s whereas C# uses
brtrue.s to compare the values and then transfer control.
The other major difference is that the C# version loads 100 onto the stack for the
clt test but F# pushes 1 and 99, then adds them together, meaning it's equality test is more akin to
i < 1 + 99 rather than C#‘s
i < 100.
Debug vs Release
It's time to compare what happens when we enable compiler optimisations, starting with F#:
And now C#:
This time we've got identical IL (F# even uses 100 rather than doing an addition!), good to know, but also interesting to see just how different the output can be when you enable compiler optimisations.
Now we've seen some of the nity gritty parts of IL and that everything is just a GOTO statement at the end of the day! 🤣
Understanding how control is transfered around within our IL helps us understand how the compiler makes optimisations and why we should write code in a particular way.
I'll leave you with the output of a
switch statement, see if you can work out the C# that it was generated from: