Hands-on engineering leader. Expertise in backend scalability during hypergrowth. Interested in solving complex technical problems. Passionate about mentorship and organizational culture. Runs on a combination of optimism and pragmatism.

Ruby's 'unless X' is processed differently than 'if !X'!

During a code review at work today, I suggested the usage of if a.exclude?(b) over unless a.include?(b) in a Rails project. I said that both of those expressions would be interpreted as if !a.include?(b) .

I was wrong.

While unless X is resultantly equal to if !X , the actual instructions generated behind the scenes are not the same.

I discovered this while trying to learn the difference in implementation of the both. Unfortunately for me, the source code did not make the definitions transparent and it would have been quite time consuming for me to dive into Ruby’s source.

After some looking around, I decided to use RubyVM::InstructionSequence to see what instructions were generated by different commands.

For those who are unfamiliar with it, RubyVM::InstructionSequence is helpful in determining what instructions are sent to the Ruby Virtual Machine when it is executing code. While those instructions are not exactly what our CPU receives, they are an excellent breakdown of the precise overall (or ‘big-picture’) steps that are carried out. (You can read more about Ruby’s code execution over here.)

The basic commands I tested were:

if true ; 'iztroo'; else; 'izfalz' ; end

if false; 'izfalz'; else; 'iztroo'; end

if !false; 'iztroo'; else; 'izfalz'; end

unless false; 'iztroo'; else; 'izfalz'; end

unless !true; 'iztroo'; else; 'izfalz'; end

Let’s go over the results as simply as I can:

1. “if true ; ‘iztroo’; else; ‘izfalz’ ; end”

The first command was a simple “do this if true”.

# Result of 1 cmd = "if true; 'iztroo' else; 'izfalz'; end" puts RubyVM::InstructionSequence.compile(cmd).disassemble #=> 0000 trace 1 ( 1) #=> 0002 putstring "iztroo" #=> 0004 leave #=> 0005 pop #=> 0006 putstring "izfalz" #=> 0008 leave

The instructions were very straightforward. No branching was done. No jumps. The condition evaluated to true and “iztroo” was printed.

2. “if false; ‘izfalz’; else; ‘iztroo’; end”

This command was essentially “do the-other-thing if false”.

# Result of 2 cmd = "if false; 'izfalz'; else; 'iztroo'; end" puts RubyVM::InstructionSequence.compile(cmd).disassemble #=> 0000 trace 1 ( 1) #=> 0002 jump 8 #=> 0004 putstring "izfalz" #=> 0006 leave #=> 0007 pop #=> 0008 putstring "iztroo" #=> 0010 leave

The instructions were as expected. There was a jump in the code which pointed to our “the-other-thing”.

3. “if !false; ‘iztroo’; else; ‘izfalz’; end”

This command was an “if” condition but with an added negation. At this point I was obviously expecting there to be a negation instruction.

# Result of 3 cmd = "if !false; 'iztroo'; else; 'izfalz'; end" puts RubyVM::InstructionSequence.compile(cmd).disassemble #=> 0000 trace 1 ( 1) #=> 0002 putobject false #=> 0004 opt_not <callinfo!mid:!, argc:0, ARGS_SIMPLE> #=> 0006 branchunless 12 #=> 0008 putstring "iztroo" #=> 0010 leave #=> 0011 pop #=> 0012 putstring "izfalz" #=> 0014 leave

So far, so good. The “branchunless” instruction was a little surprise. Apparently Ruby uses “branchunless” instead of something like “branchif”.

You can think of “branchunless” as a conditional-jump; the condition is the output of the previous instruction. If the previous instruction is true, there is no jump; if the previous instruction is false, there is a jump; hence the “unless” in “branchunless”.

4. “unless false; ‘iztroo’; else; ‘izfalz’; end”

This is the command which I expected to be nearly identical to #3.

# Result of 4 cmd = "unless false; 'iztroo'; else; 'izfalz'; end" puts RubyVM::InstructionSequence.compile(cmd).disassemble #=> 0000 trace 1 ( 1) #=> 0002 jump 8 #=> 0004 putstring "izfalz" #=> 0006 leave #=> 0007 pop #=> 0008 putstring "iztroo" #=> 0010 leave

As you can see, I was wrong - the instructions were identical to #2 rather than #3. Eventually, the output is the same but it was interesting to see that “unless X then a else b” translated to “if X then b else a” rather than “if not X then a else b”. Pretty much like a “reverse ternary operator”, if I were to badly name it.

I found this behavaiour interesting because Ruby’s implementation skips the negation step thereby saving time which would otherwise have been spent on using a “branchunless”. That is excellent. And it makes me feel slightly silly when I look back on what I thought was going on.

5. “unless !true; ‘iztroo’; else; ‘izfalz’; end”

Why? Because I had to. I had to confirm a new prediction. If was right, the instructions would reverse the order of “blocks” i.e. “iztroo” and “izfalz”, then use a “branchunless” instruction which depended on an “opt_not” instruction.

# Result of 5 cmd = "unless !true; 'iztroo'; else; 'izfalz'; end" puts RubyVM::InstructionSequence.compile(cmd).disassemble #=> 0000 trace 1 ( 1) #=> 0002 putobject true #=> 0004 opt_not <callinfo!mid:!, argc:0, ARGS_SIMPLE> #=> 0006 branchunless 12 #=> 0008 putstring "izfalz" #=> 0010 leave #=> 0011 pop #=> 0012 putstring "iztroo" #=> 0014 leave

I was glad after being right about something in the end. :)

It was then time to revisit the topic that started it all: unless a.include?(b) or if a.exclude?(b) .

First, I should explain that exclude? is implemented as !include? . Therefore the decision boils down to unless a.include?(b) or if !a.include?(b) .

My instructions-research clearly shows that the first one is better in terms of speed. No negation steps will be added therefore the result will be yielded faster.

But practically, the cost of that additional instruction is negligible in modern CPUs for most scenarios. Furthermore, in my opinion, the latter is reader-friendlier i.e. if a.exclude?(b) . To compromise readability over such an insignificant speed improvement would be ill advised.