Take a look at a case expression in Ruby:
case x
when 1, 2
"It is either 1 or 2"
when 3..10
"It is a number from 3 to 10"
when BigDecimal
"It is a BigDecimal instance"
when /bot/
"It is a string that matches the /bot/ regular expression"
end
Compared to many languages that have switch statements, case expressions in Ruby are much more flexible, matching not just on strict equality but also some sort of membership or match: 2 is a member of the range 1..3; 5 is a member of the class Integer; "thoughtbot" matches the regular expression /bot/.
This power comes from the === operator (or case equality operator, threequals, triple equals, you name it). Each argument passed to when is compared to x using this operator. Ruby will interpret the case expression above as if it was:
if 1 === x || 2 === x
"It is either 1 or 2"
elsif 3..10 === x
"It is a number from 3 to 10"
elsif BigDecimal === x
"It is a BigDecimal instance"
elsif /bot/ === x
"It is a string that matches the /bot/ regular expression"
end
The Object class, which is inherited by most Ruby classes, implements the === method as an alias to ==. The documentation for Object describes it as:
Case Equality – For class
Object, effectively the same as calling#==, but typically overridden by descendants to provide meaningful semantics incaseexpressions.
How do descendants override it?
Module
The Module class, inherited by the Class class itself, implements === as an is_a? check on the object provided. It will return true if the class or module is in the ancestor tree of the given object.
Enumerable === [1, 2, 3] # => true
Array === "A string" # => false
String === "A string" # => true
Range
Ranges implement === with the same behavior as cover?.
(1..10) === 3.5 # => true
(5..20) === 2 # => false
Set
A set will respond to === by checking whether the provided object is a member of that set. Effectively the same as include?.
Set[1, 2, 3] === 2 # => true
Set[1, 2, 3] === "😀" # => false
Regexp
Regexp objects will return true on === when, given a string, the regular expression matches the string. It’s close to the implementation of match?.
/bot/ === "thoughtbot" # => true
URI::MailTo::EMAIL_REGEXP === "https://thoughtbot.com" # => false
IPAddr
IPAddr objects respond to === as an alias for include?. They will return true if the given IP is equal to or is in the range.
subnet = IPAddr.new("192.168.2.0/24")
ip = IPAddr.new("192.168.2.100")
subnet.include?(ip) # => true
subnet === ip # => true
Proc
Proc objects, including lambdas, have too many ways to be called, and === is yet another one.
Prime = ->(n) { n >= 2 && (2..Math.sqrt(n)).none? { |i| n % i == 0 } }
Prime === 3 # => true
Prime === 4 # => false # 4 is not a prime number
Any class you write
Now that you know how case expressions work under the hood, you can implement the case equality operator in your own classes and match their instances in a meaningful way, such as whether a GPS point is within an area:
class Map::Area
# ...
def ===(other)
if other.respond_to?(:latitude) && other.respond_to?(:longitude)
# Is the given coordinate within this area?
else
other == self
end
end
end
state = Map::Area.new(list_of_gps_coordinates_to_draw_the_area)
city = Map::Point.new(-9.648139, -35.717239)
state === city # => true
Beyond case expressions
You may be wondering that, as case expressions are a code smell that your code has too much knowledge, the === method isn’t so useful. But there’s more use of the case equality operator beyond its initial purpose.
The Enumerable module implements many methods that use it. One interesting example is #grep. It will match the enumerable elements based on the given pattern using the === method, so:
[1, "a", 3].grep(Integer) # => [1, 3]
["foo", "bar", "baz"].grep(/a/) # => ["bar", "baz"]
(1..10).grep(3..8) # => [3, 4, 5, 6, 7, 8]
(1..15).grep(Prime) # => [2, 3, 5, 7, 11, 13]
venues.grep(map_area) # => [venue_1, venue_3, ...]
Other Enumerable methods that use the case equality operator are: #all?, #none?, #one?, #any?, #grep_v, #slice_after, and #slice_before.