Introduction
Triple Equals (===
) in Ruby is one of the most powerful features in the entire language, but also one you may not know you're using either. In fact, it's one of the best kept secrets!
We're going to learn a bit about those secrets today and explore ===
, how it's defined, what it does, and where it's hiding in your Ruby code today.
Note: This is a rewrite and expansion on an older post of mine from 2017, Triple Equals Black Magic, and includes updated syntax including Pattern Matching to examples.
Difficulty
Foundational
No prerequisite knowledge needed. This post focuses on foundational and fundamental knowledge for Ruby programmers.
Triple Equals
So what is it? How is it defined?
Some coming from Javascript might have the notion that ===
is a stricter equality operator than ==
, but in Ruby it does something quite different. Defaultly it's an alias for ==
, but some classes do something much more interesting.
It goes by a few names: Case equality operator, membership operator, triple equals.
Its function is very much akin to checking to see if the value on the right is a member of whatever is on the left.
How it's Implemented in Ruby
What do I mean by that? Well let's take a look at a few classes real quick to see how it works.
WARNING: Do not use
===
explicitly like this in code, prefer methods with clearer names. We'll see soon when it's acutally used, and often times in an implicit manner rather than explicitly using it.
Ranges
A range in Ruby has a starting point and an ending point, like 1..10
is a range from 1
to 10
. For it ===
works like include?
which checks if the value on the right happens to be included within the range, or a member of the range:
(1..10) === 1
# => true
(1..10).include?(1)
# => true
Ranges are interesting in that they're not limited to numbers. Strings also work, and that makes range inclusion even more interesting:
SUPPORTS_PATTERN_MATCH_VERSIONS = '2.7.0'..'3.0.0'
SUPPORTS_PATTERN_MATCH_VERSIONS === '2.7.5'
# => true
Granted that example breaks once Ruby goes beyond 3.0.0
, but the point is range also recognizes types beyond Integer, and sometimes in interesting ways, but that's the subject for another post.
Regular Expressions
Regular Expressions are a language for matching against patterns in text, and for ===
it happens to work very much like match?
:
/abc/ === 'abcdef'
# => true
/abc/.match?('abcdef')
# => true
===
in this case is saying that there's a match, or that our string is a member of the set of matches this Regex refers to. Noticing a pattern?
Classes
Ruby has classes like Integer
, String
, and others. Normally you can check to see if something happens to be of a certain type using is_a?
. Not surprisingly ===
works much the same way here:
String === 'foo'
# => true
'foo'.is_a?(String)
# => true
===
here is saying that 'foo'
is a member of the String
class, or it's included in what we'd call Strings.
It should be noted that this works for about every Ruby core class in the standard library, but as it requires custom implementation there may be some more exotic cases which don't.
Functions (Proc and Lambda)
Ruby has a few ways to express anonymous functions, procs and lambdas. There's also block, but we'll focus on those two for the moment. They can be expressed as such:
add_one_lambda = -> x { x + 1 }
add_one_proc = proc { |x| x + 1 }
We won't get into differences between all of them in this round, but note that I tend to prefer lambdas in general over procs.
To use these functions you'd need to use .call
(or []
or .()
), which you might not be surprised to find out is ===
as well:
add_one_lambda = -> x { x + 1 }
add_one_lambda === 1
# => 2
add_one_lambda.call(1)
# => 2
add_one_lambda.(1)
# => 2
add_one_lambda[1]
# => 2
This one is a head scratcher. How is 1
a member of a function? That doesn't make much sense, it's not really any type of collection or set is it? Well in Mathematics it's called the domain of a function, or the set of which all valid inputs fall into, so that sounds a lot like membership too!
IP Addresses
Ruby also has this lovely feature for all of us Operations and Networking types, IPAddr
:
require 'ipaddr'
IPAddr.new('10.0.0.1')
You can even express subnets using it:
local_network = IPAddr.new('192.168.1.0/24')
local_network.include?('192.168.1.1')
# => true
Is your intuition dinging a bit? Because we have another case for ===
here:
local_network === '192.168.1.1'
# => true
For this one we're checking if an IP Address is a member of a given subnet, further rounding out this interesting pattern. Ruby loves patterns, and math and programming in general have a certain affinity for them.
A Case for ===
Now this is all well and good, but the above warning said not to use ===
explicitly, so why spend all that time describing how it works? Because we're about to see the implicit through case
statements.
You see, every when
branch in a case
statement compares via ===
:
case 1990
when ..1899 then :too_early
when 1900..1924 then :gi
when 1925..1945 then :silent
when 1946..1964 then :baby_boomers
when 1965..1979 then :generation_x
when 1980..2000 then :millenials
when 2000..2010 then :generation_z
when 2010.. then :generation_alpha
else
:who_knows
end
# => :millenials
You can even use commas to check against multiple possibilities:
case 'foobar'
when String, Integer then :one
when Float, NilClass then :two
else
:three
end
There's a lot of potential to check against whether a value is within an expected set, and with functions that gets even more interesting:
divisible_by = -> divisor { -> n { n % divisor == 0 } }
(1..15).map do |n|
case n
when divisible_by[15] then :fizzbuzz
when divisible_by[5] then :buzz
when divisible_by[3] then :fizz
else
n
end
end
# => [
# 1, 2, :fizz, 4, :buzz, :fizz, 7, 8, :fizz, :buzz,
# 11, :fizz, 13, 14, :fizzbuzz
# ]
Interesting no?
There is one trick in there called a closure, which is a function which returns another function. That returned function remembers what the value of divisor
was, allowing us to check if somthing is divisible by it. It's a really useful trick from functional programming, and really shows the power of functions in Ruby, especially with things like case
statements.
Enumerating All the Fun We Can Have
Enumerable
also has a number of methods which take values that respond to ===
. Let's take a look at some examples.
Predicate Methods
The predicate methods (any?
, all?
, none?
, one?
) all play well with ===
:
['1', 2, :a].any?(Integer)
# => true
The Search is On
Searching methods like grep
(find all that match pattern) and grep_v
(find all that do not match pattern) also implement a ===
interface:
%w(The rain in spain falls mainly on the plain).grep(/the/i)
# => ["The", "the"]
Slice of Life
There are also Slice methods that allow us to group elements by a pattern like slice_before
and slice_after
:
array = [7, 9, 4, 1, 14, 5, 13, 8, 2, 6, 3, 12, 15, 11, 10]
array.slice_before(10..).to_a
# => [[7, 9, 4, 1], [14, 5], [13, 8, 2, 6, 3], [12], [15], [11], [10]]
array.slice_after(..5).to_a
# => [[7, 9, 4], [1], [14, 5], [13, 8, 2], [6, 3], [12, 15, 11, 10]]
Pattern Matching
Now if you were excited about case
statements let me tell you Pattern Matching is like a case
statement with a lot of extra fun built in. We won't get into all the nuances of it, but every value is matched against using ===
. It uses in
rather than when
, which unlocks some additional features.
Array-Like
It supports two types of syntaxes, an Array-like match and a Hash-like match. Let's start with Array-like:
case [0, 1]
in [..10, ..10] then :close_to_base
in [..100, ..100] then :venturing_out
in [..1000, ..1000] then :pretty_far_out
else :way_out_there
end
# => :close_to_base
If we want we could even capture those values by name or a few other fun items, but we'll save that for another post.
Hash-like
The next is Hash-like, and this is where things get interesting. Let's say we have a JSON API with some data and we got back that data and that data was in a variable called raw_json
:
raw_json = <<~JSON
[{
"age": 22,
"eyeColor": "blue",
"name": { "first": "Trina", "last": "Chang" },
"friends": ["Browning Marsh", "Keisha Abbott", "Shawn Callahan"]
}, {
"age": 32,
"eyeColor": "brown",
"name": { "first": "Irma", "last": "Petersen" },
"friends": ["Koch Ballard", "Chandra Rodriquez", "Carmen Avery"]
}, {
"age": 27,
"eyeColor": "hazel",
"name": { "first": "Madeleine", "last": "Blake" },
"friends": ["Tina Massey", "Annette Yates", "Zelma Brennan"]
}, {
"age": 20,
"eyeColor": "green",
"name": { "first": "Horton", "last": "Haynes" },
"friends": ["Sophia Oconnor", "Sheila Wilkins", "Mia Molina"]
}, {
"age": 12,
"eyeColor": "brown",
"name": { "first": "Hull", "last": "Benson" },
"friends": ["Teresa Mack", "Mcfadden Conley", "Juanita Rollins"]
}]
JSON
We'd start by parsing it, but we want to ensure those keys are Symbols
using this syntax for JSON.parse
:
require 'json'
json_data = JSON.parse(raw_json, symbolize_names: true)
Now we can do something really interesting with Pattern Matching:
selected_people = json_data.select do |person|
case person
in age: 20.., eyeColor: /^b/, name: { first: /^[TI]/ }
person
else
false
end
end
selected_people.map do |person|
person => { name: { first:, last: } }
"#{first} #{last}"
end
# => ["Trina Chang", "Irma Petersen"]
That's a lot. What's it doing exactly?
First we want to select all people older than 20
with an eye color that starts with the letter b
, and a first name that starts with either T
or I
. How's that for expressive?
Next we're using something called right-hand-assignment (=>
) to pull the first and last names out of the person to just return back their names. In Pattern Matching if a key doesn't have a value it gets put into a local variable, hence first
and last
being accessible in the line right below it.
Again, we won't get into all the nuance of Pattern Matching in this post, but you can see how it gets very interesting very quickly. I intend to write a more thorough introduction to Pattern Matching concepts fairly soon, so stay tuned for that one.
Wrapping Up
So that was a lot. ===
is hiding everywhere in Ruby, and once you build up an intuition to it you'll notice it pretty frequently. Even better, that intuition means you now know how to create your own ===
if you should find yourself in such a need one day.
Spoilers though, it's an operator defined as a method:
# Example implementation
class String
def self.===(other)
other.is_a?(self)
end
end
Ruby has a lot of interesting facets, and with them a substantial amount of power. This series will continue to cover some of the foundations of Ruby and some of its most useful tools and features.
Until then enjoy your newfound knowledge of ===
!
Want to keep up to date on what I'm writing and working on? Take a look at my new newsletter: The Lapidary Lemur
Top comments (4)
Range#===
changed in 2.7. Before 2.7 it behaves like #include?, since 2.7 it behaves like #cover?I should probably remember this as I was part of that discussion to get that changed over 😅
I am always nervous when I see
symbolize_names: true
, as it won't convert nested hashes which were used in this example.Great article as always, thanks for sharing this!
What a magic language 🤯