DEV Community

Cover image for Understanding Ruby - Triple Equals
Brandon Weaver
Brandon Weaver

Posted on

Understanding Ruby - Triple Equals

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

=== 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
Enter fullscreen mode Exit fullscreen mode

=== 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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

You can even express subnets using it:

local_network = IPAddr.new('192.168.1.0/24')
local_network.include?('192.168.1.1')
# => true
Enter fullscreen mode Exit fullscreen mode

Is your intuition dinging a bit? Because we have another case for === here:

local_network === '192.168.1.1'
# => true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
# ]
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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]]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
3limin4t0r profile image
3limin4t0r • Edited

Range#=== changed in 2.7. Before 2.7 it behaves like #include?, since 2.7 it behaves like #cover?

('1.2.3'..'11.22.33').include?('5.6.7') #=> true
('1.2.3'..'11.22.33').cover?('5.6.7')   #=> false

# pre 2.7
('1.2.3'..'11.22.33') === '5.6.7' #=> true
# 2.7 and later
('1.2.3'..'11.22.33') === '5.6.7' #=> false
Enter fullscreen mode Exit fullscreen mode
Collapse
 
baweaver profile image
Brandon Weaver

I should probably remember this as I was part of that discussion to get that changed over 😅

Collapse
 
swilgosz profile image
Seb Wilgosz

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!

Collapse
 
defman profile image
Sergey Kislyakov

What a magic language 🤯