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!
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"
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"
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)
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:
The number zero is represented by all zeros.
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
.
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
.
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.