🔙 to the list of posts

Ruby’s Bitwise Operators

Working in a high-level language like Ruby, it’s not common to find oneself in need of bitwise operators. Knowing how they work might nevertheless be both valuable and interesting.

Bitwise operators work directly on the binary representations of integers in memory.

Being able to inspect these binary representations makes understanding how bitwise operators work much easier. To convert an integer to a string of ones and zeros, use the Fixnum#to_s method passing 2 as the only argument:

13.to_s(2)    #=> "1101"

Ruby has six bitwise operators:

Operator Description
& Bitwise AND
| Bitwise OR
^ Bitwise XOR
~ Bitwise NOT
<< Bitwise left shift
>> Bitwise right shift

Let’s go through them one by one!

Bitwise AND

The bitwise AND operator compares the binary representation of two integers bit by bit; if the same bits in both integers are set to 1 the resulting integer’s binary representation will have the corresponding bit set to 1. If not, the bit will be set to 0.

18.to_s(2)           #=> "10010"
20.to_s(2)           #=> "10100"
(18 & 20).to_s(2)    #=> "10000"

Bitwise OR

The bitwise OR operator works the same way as the bitwise AND operator, but only requires at least one of the corresponding bits in the two binary representations to be set to 1 in order to set the bit in the resulting integer to 1.

18.to_s(2)           #=> "10010"
20.to_s(2)           #=> "10100"
(18 | 20).to_s(2)    #=> "10110"

Bitwise XOR

The bitwise XOR operator performs what’s called an exclusive OR operation on two binary representations.

This means it requires that only one of the corresponding bits in the two binary representations is set to 1 in order to set the bit in the resulting integer to 1:

(a = 18).to_s(2)     #=> "10010"
(b = 20).to_s(2)     #=> "10100"
(a ^ b).to_s(2)      #=>   "110" (leading zeros omitted)

Bitwise NOT

The bitwise NOT (or one’s complement) operator flips the bits inside an integer turning zeros to ones and ones to zeros. This sounds simple but is a bit harder to demonstrate. First, let’s see what Fixnum#to_s has to say about this:

18.to_s(2)       #=> "10010"
~18              #=> -19
(~18).to_s(2)    #=> "-10011"

That doesn’t look very flipped to me! And what is that minus sign doing there?

It turns out Fixnum#to_s doesn’t return the underlying binary representation of numbers; it returns the mathematical representation in a given base. In mathematics, negative numbers are denoted with a minus sign regardless of their base.

This means that for negative numbers, when passing 2 as the only argument, Fixnum#to_s returns the binary representation of the corresponding positive number prepended with a minus sign:

-19.to_s(2)         #=> "-10011"
"-" + 19.to_s(2)    #=> "-10011"

In computer hardware, there are no minus signs; only ones and zeros. To overcome this limitation, signed integers are encoded in memory using a method called two’s complement. This method is designed to make basic arithmetic operations simple to implement and can be summarized in the following three rules:

  1. The number zero is represented by all zeros.

  2. Positive numbers start at zero and count upward towards a maximum value of 2(n-1)-1 where n is the number of bits used to represent the number. This means that the maximum value that can be represented using four bits is 2(4-1)-1 = 7 or 0111.

  3. For negative numbers, the meaning of zeros and ones changes. Instead of starting at zero, negative numbers start at minus oen, which is represented using all ones, and count downward using zeros towards a minimum value of -2(n-1). In the case of a four bit number, that would be -2(4-1) = -8 or 1000.

This means that instead of being able to represent the numbers zero to fifteen, four bits can represent the numbers negative eight to positive seven. Here are some examples of positive and negative numbers and their two’s complement binary representation:

Decimal Binary
0 0000
1 0001
2 0010
7 0111
-1 1111
-2 1110
-3 1101
-8 1000

So, if Fixnum#to_s can’t help us, how do we get hold of the underlying binary representation of negative numbers?

To do this, we’ll have to turn to the Fixnum#[] method. This method returns the bit at a given position in the binary representation of an integer with zero being the rightmost. This means that we can loop over the positions and collect their corresponding bit value:

5.downto(0).map { |n| 18[n] }.join     #=> "010010"
5.downto(0).map { |n| -19[n] }.join    #=> "101101"

At last, we can see the effect of the bitwise NOT operator. Reading the rules above, we can also understand why 101101 in this case means -19 instead of 45.

Bitwise left and right shift

The bitwise left and right shift operators shift the bits of an integer’s binary representation to the left or right by the given number of positions, padding with zeros or truncating bits as necessary:

18.to_s(2)           #=>   "10010"
(18 << 1).to_s(2)    #=>  "100100"
(18 << 2).to_s(2)    #=> "1001000"
(18 >> 1).to_s(2)    #=>    "1001"
(18 >> 2).to_s(2)    #=>     "100"

To learn more about how and when to use these operators, check out my followup post: Flags, Bitmasks, and Unix File System Permissions in Ruby.

About the author

Hey there, I’m Calle! I’m a programmer based in Stockholm, Sweden.

If you liked this post, you might also like one of the other posts I’ve written. Or, if you’re in the mood for watching something, here are a few talks I’ve given. 🍿