NOTE: This article is part of the series of posts about coding in assembly. Please check out the entire series for more fun!
Captain's log day 2
-- You've been burned from the previous day, but your motivation stays intact.
Last time we saw how to use the conditional if
statement in assembly
and what the bytecode did behind the curtains, but you may have seen that when we use an if
statement in assembly
there is a downside: We don't have an else
statement
But don't worry, Assembly
comes to the rescue with the switch
statement!
In this article we will be discussing how to make use of the switch
statement to cover multiple conditional cases that determine the flow of the code to be executed.
Episode VI β Return of the Code
Let's use the example from day 1 and slightly change its desired functionality. If we wanted a different non-zero value in the output when the input is higher than 26 (0x1a), we could make use of the if
-else
statement in Solidity combined with the less than operator <
:
function solidityIfElse(uint256 input) public pure returns (uint256 output) {
if (input < 0x1a) {
output = 0x1;
} else {
output = 0x2;
}
}
But remember that we don't have the else
statement in assembly, so instead we can use the switch
statement:
function assemblySwitch(uint256 input) public pure returns (uint256 output) {
assembly {
switch lt(input, 0x1a)
case 0x1 {
output := 0x1
}
default {
output := 0x2
}
}
}
As you can see, we compare the input
against the 0x1a value using the lt(x,y)
instruction. This instruction outputs a one when x
is strictly less than y
and a zero in all other cases. We have split the results into two cases: when input is a one and when it's not. This last case would be the default
.
Analogously, there is a gt(x,y)
instruction, which is the reciprocal instruction that outputs a one when x
is strictly greater than y
and a zero in all other cases.
The switch
statement reads the input and classifies it among the defined options. Although in this situation we only have a binary problem, the beauty of the switch
statement is that we can define multiple cases above the default one - which handles all the situations that don't match with the other conditions - and perform operations based on each case.
For our purposes, the possible options are a one and a zero - the output from the lt
instruction, but, for example, itβs possible that an integer variable goes from 0 to 9, and when the variable is a 2, 6, or 7 the code emits an event but stays quiet otherwise.
One thing to notice - contrary to the gas consumption of the assembly
code that we've seen in the past - is that the switch
function consumes more gas in the assembly
case than when we used the Solidity version. So you shouldn't extrapolate the idea that writing the code in assembly
will always be cheaper. I'll repeat it again: Focus on the code readability and not in the gas optimization.
If you didn't drink any coffee yet, now is the time. I'll wait...
Episode V β The Bytecode Strikes Back
Ready? Caffeinated? Great, because we are going to compare what the opcodes do in each scenario. This was extracted from the Remix debug tool, and as you've learned from Ale's posts, the most important instructions of the Solidity solidityIfElse
function are:
# | Opcode | Value | Case |
---|---|---|---|
182 | PUSH1 | 00 | |
184 | PUSH1 | 1A | |
186 | DUP3 | ||
187 | LT | ||
188 | ISZERO | ||
189 | PUSH1 | C7 | |
191 | JUMPI | D---> | |
192 | PUSH1 | 01 | |
194 | SWAP1 | ||
195 | POP | ||
196 | PUSH1 | CC | |
198 | JUMP | 1---> | |
199 | JUMPDEST | <---D | |
200 | PUSH1 | 02 | |
202 | SWAP1 | ||
203 | POP | ||
204 | JUMPDEST | <---1 | |
205 | SWAP2 | ||
206 | SWAP1 | ||
207 | POP | ||
208 | JUMP | R---> |
If you are sweating, believe in yourself. YOU CAN DO IT!
Again, some magic has happened and we start with our input
right on top of the stack.
We start PUSH
ing two values to the stack, a 0x0 and a 0x1A, and we duplicate the input
value on top of the stack. After that, we use our known LT
instruction with the first two elements in the stack which would be the input
and the 0x1A. For those that are new here and don't know how the LT
works, I recommend you go to my previous post first =)
LT
would decide if the first element is less than the second one, and leave the result on top of the stack. After that, the ISZERO
instruction in 188 would mirror the result (0x0 -> 0x1 or 0x1 -> 0x0), and from here, we have another crossroads. If the input
is less than 0x1A, LT
would output a 0x1 in line 187, resulting in a 0x0 after line 188; however, if the input
is greater than or equal to 0x1A, we would have a 0x0 after 187 but a 0x1 after 188.
β Why the heck are you trying to make me dizzy? - You may ask, but all of this is the evil intention of the compiler, not mine!
Fasten your seatbelts, because after the PUSH
in line 189, which defines the destination line 199 (0xC7), we are ready to JUMP
through space and time!
... or not...
In line 191, we have a JUMPI
instruction, and as you may recall, it's a conditional JUMP
that will happen only if the second element in the stack is a one. So, now we REALLY REALLY have to divide the problem into two paths.
Let's assume that the input
is less than 0x1A. In this case, we would have a zero as a second parameter for the JUMPI
, so the JUMP
wouldn't be made =(
Who wants to JUMP
anyway? That's too mainstream...
Downstream, we are pushing a one into the stack, we POP
an old zero, and we JUMP
weeee =D to the line 204 (0xCC). There, we SWAP
a couple of times in line 205 and 206 - to POP
the input
that we don't need anymore - and we JUMP
again to the line 96 (0x60).
β What happens there? We find a place in memory that's free, we save the output
there, and we RETURN
it. And when I say "we", it's actually the code, but it's too shy to go alone.
Easy peasy, right? Now, let's get into the other scenario.
We are back into line 191 just before the JUMPI
, but this time, we have a one as a secondary parameter so... yes, you guessed it: hyperspace!
Nevertheless, this trip is short because it leaves us at line 199 (0xC7) where we PUSH
a 2 onto the stack (200) and POP
a zero from it. But after that, we continue with the same path as before: We JUMP
to line 96, and we RETURN
the output
.
β What's the difference then? The output
. In the first run, we were inside the body of the if
, but in the second run, we went into the else
body, the default one :o
β Wooow, so what happens then with the assemblySwitch
function? Eeeasy now. We are getting into that. The instructions that are relevant for the assemblySwitch
function are the following:
# | Opcode | Value | Case |
---|---|---|---|
210 | PUSH1 | 00 | |
212 | PUSH1 | 1A | |
214 | DUP3 | ||
215 | LT | ||
216 | PUSH1 | 01 | |
218 | DUP2 | ||
219 | EQ | ||
220 | PUSH1 | E6 | |
222 | JUMPI | 1---> | |
223 | PUSH1 | 02 | |
225 | SWAP2 | ||
226 | POP | ||
227 | PUSH1 | EB | |
229 | JUMP | D---> | |
230 | JUMPDEST | <---1 | |
231 | PUSH1 | 01 | |
233 | SWAP2 | ||
234 | POP | ||
235 | JUMPDEST | <---D | |
236 | POP | ||
237 | SWAP2 | ||
238 | SWAP1 | ||
239 | POP | ||
240 | JUMP | R---> |
Alrighty then, I reckon you have enough strength to continue with this last step, so no more coffee breaks.
As always, we start with the input
in the stack and we PUSH
a 0x0 and a 0x1A in lines 210-212 to compare our input
with the 0x1A in line 215 using an LT
instruction. That would give us a 0x1 or a 0x0 depending on the input
(as before).
After that, a 0x1 is PUSH
ed to compare with the result from the LT
(216). Why? Because this is our first case in the switch
statement. It checks if the value - in this case the result from the LT
- corresponds with one of the defined cases. Amazing, right?
Let's imagine that we have a value that is less than 0x1A. In this case, LT
would return a one and after the EQ
in line 219 we would have a 0x1 as a second parameter for the JUMPI
that is on line 222.
What does it mean? We are jumpiiiinng.
Where? To the line 230 (0xE6).
There, we start PUSH
ing a one into the stack (231), and we POP
away some garbage in 234, 236, and 239.
After that, we have a final JUMP
to the part of the code that RETURN
s the output
.
For the other case, when the input
is at least 0x1A, we go down another road.
The result from the LT
in line 215 is going to be 0x0 so when it's compared to 0x1 with the EQ
(219), the result will be 0x0, too, which means no JUMP
in line 222.
But because we completed the comparisons of all the possible cases - a list with only one case - we end up executing the default
one. Here we PUSH
a 0x2 as output
, JUMP
to the common garbage removal, and RETURN
the value 0x2.
Episode IV β A New Conclusion
This example is trivial because we only had 2 possibilities, but the switch
statement would allow me to list all the possible outcomes - each one of them with their respective execution code - and define a default
case that covers all other situations.
We've learned that this statement sequentially checks all the cases, so some gas will be wasted iterating all possibilities. When none of the cases matched with the condition, we also saw that the default body execution code is the one that comes first. Compared with the if
-else
alternative in Solidity, the gas consumption is greater this time. This means that you shouldn't go to full assembly
mode just to save some gas; use it only when it's needed =)
On a more global view, during these last 3 days, we've learned how certain instructions like eq
, not
, iszero
, lt
, and gt
are used and also how we can make conditional statements in assembly
using if
s and switch
es.
You should be proud of yourself, but remember that there are plenty more opcodes to have fun with, so stay tuned for the next chapter, or even better, why don't you write one? We'll be very happy to include it in the index