When working in a high-level language like Ruby, it’s quite rare seeing bitwise operators used. Nevertheless, knowing how they work might still be very helpful at times.

So what does an operator being bitwise actually mean? It means that instead of treating integers as whole numbers, it treats them as a sequence of bits.

Because of this, being able to inspect the binary representations of integers 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 argument:

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

Ruby has 6 bitwise operators:

operator description
& bitwise and
| bitwise or
^ bitwise exclusive or
~ bitwise not (complement)
<< bitwise left shift
>> bitwise right shift

Bellow follows explanations of each of them.

The bitwise and operator

The bitwise and operator walks through the binary representation of two integers bit by bit. If the bits at the same position in both integers are 1 the resulting integer will have the corresponding bit set to 1. If not, the bit will be set to 0.

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

The bitwise or operator

The bitwise or operator works the same as the bitwise and operator with the exception that it will set the bit in the resulting integer to 1 as long as at least one of the corresponding bits in the given integers is 1:

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

The bitwise exclusive or operator

The bitwise exclusive or operator will set the bit to 1 in the output if only one of the corresponding bits in the inputs is set to 1:

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

The bitwise not operator

The bitwise not, or 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 show and explain. First, lets see what Fixnum#to_s can show us about this:

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

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

It turns out that Fixnum#to_s won’t return the underlying binary representation of negative numbers. Instead it returns the binary representation of the corresponding positive number prepended with a minus sign:

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

This is because in mathematics, negative numbers are denoted with a minus sign prefix regardless of their base. However, in computer hardware where everything is represented using ones and zeros this is not possible. Instead, other methods has to be used.

In modern computers, negative integers are stored in memory using a method called two’s complement. The two’s complement method is designed to make basic arithmetic operations simple to implement and can be summarized in three rules:

This means that instead of being able to represent the numbers 0 to 15, 4 bits can represent the numbers -8 to 7. Here are some examples of positive and negative integers and their binary representation:

binary decimal
0000 0
0001 1
0010 2
0111 7
1111 -1
1110 -2
1101 -3
1000 -8

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 value of the bit at the given position in the integer with 0 being the rightmost. This means that we can loop through the interesting positions in the integer and collect their bit values:

a = 18
b = ~a     #=> -19

5.downto(0).map { |n| a[n] }.join    #=> "010010"
5.downto(0).map { |n| b[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 rather than 45

The bitwise left and right shift operators

The bitwise left and right shift operators shifts an integer’s bits to the left or right by the given number of positions, truncating bits or padding with zeros where needed.

a = 18

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

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