Class comparison in Ruby
I recently encountered an interesting issue. It boils down to this: How do you know if an object is an instance of a class?
I had to ask this question because I help maintain ActiveInteraction, a
command pattern library. It provides a way to ensure that an object is a
certain class. For instance, if an interaction needs a User
, it could say:
model :someone,
class: User
During execution, it’s guaranteed that someone
is in fact a User
. Behind
the scenes, ActiveInteraction validates that using a case
statement. In this
instance, it would look like this:
case someone
when User
# It's valid.
else
# It's invalid.
end
Turns out that’s not enough for determining if an object is an instance of a
class. In particular, test mocks pretend to be something they’re not by
overriding the #is_a?
method. Desugaring the case
statement reveals why it
fails.
if User === someone
# It's valid.
else
# It's invalid.
end
The .===
method asks the class if an object is the right class. #is_a?
does
the same thing in the other direction by asking the object if it’s the right
class. Since the test mock doesn’t monkey patch the class it’s mocking, the
only way around this is to ask both questions.
if User === someone
# The class says it's valid.
elsif someone.is_a?(User)
# The object says it's valid.
else
# It's invalid.
end
While developing a fix for ActiveInteraction, I wondered if there were other ways to do this. I did some research and discovered that there are at least 18 different ways to make this comparison.
It would be unreasonable to make all those checks. In fact, if you’re using
anything other than .===
and #is_a?
, you’re doing it wrong. However, I was
interested in creating a class that is indistinguishable from another class. In
other words, the perfect mock.
Creating the Perfect Mock
Before we create the mock, we need to create the class we’ll be mocking.
Cheese = Class.new
gouda = Cheese.new
Next up let’s create the mock. It shouldn’t have anything in common with the class it’s mocking.
FakeCheese = Class.new
american = FakeCheese.new
With those defined, we can move on to faking the comparisons.
.===
#is_a?
#kind_of?
#instance_of?
#class
.==
.eql?
.equal?
.object_id
.__id__
.<=>
.<
.>
.<=
.>=
.ancestors
.to_s
.inspect
.name
#to_s
#inspect
.===
We hit a problem right out of the gate:
Cheese === american
# => false
This is an issue because it means instances of FakeCheese
won’t be able to
pass as Cheese
in case
statements. Unfortunately there’s nothing we can do
about it without monkey patching Cheese
. Let’s stay focused on the
FakeCheese
class.
FakeCheese === gouda
# => false
We can do something about this one. Let’s make FakeCheese
behave like
Cheese
by delegating to it.
class FakeCheese
def self.===(other)
Cheese === other
end
end
After making that change, we can see that the conditional returns true
now.
FakeCheese === gouda
# => true
Note that we broke the default behavior:
FakeCheese === american
# => false
Instances of FakeCheese
aren’t able to pass as FakeCheese
in case
statements anymore. We could fix that by throwing a call to super
somewhere
in .===
, but remember that we’re trying to build the perfect mock. If
Cheese === american
is false
, FakeCheese === american
should be too.
(We’ll see later that falling back to super
doesn’t always make sense.)
#is_a?
Since we can’t make case
statements work without monkey patching, let’s move
on to something we can fix.
american.is_a?(Cheese)
# => false
We want to delegate to Cheese
again, but this is an instance method. We don’t
have an instance of Cheese
in FakeCheese
. We could make one and delegate to
it, but initializing Cheese
could be complicated or expensive. Let’s turn to
#is_a?
’s documentation for inspiration.
Returns
true
if class is the class of obj, or if class is one of the superclasses of obj or modules included in obj.
We need a class method that does the same thing. Looking at the documentation
for .>=
, it seems to fit the bill.
Returns true if mod is an ancestor of other, or the two modules are the same.
Using .>=
we can essentially delegate #is_a?
to Cheese
without having an
instance handy.
class FakeCheese
def is_a?(klass)
Cheese >= klass
end
end
Let’s reevaluate our conditional to make sure it worked.
american.is_a?(Cheese)
# => true
Great! That was a little tricky, but ultimately not too bad.
#kind_of?
Even though #kind_of?
and #is_a?
do the same thing, they aren’t aliases.
american.kind_of?(Cheese)
# => false
class FakeCheese
def kind_of?(klass)
Cheese >= klass
end
end
american.kind_of?(Cheese)
# => true
#instance_of?
Unlike #is_a?
and #kind_of?
, #instance_of?
checks for an exact match.
american.instance_of?(Cheese)
# => false
class FakeCheese
def instance_of?(klass)
Cheese == klass
end
end
american.instance_of?(Cheese)
# => true
#class
Instead of using a predicate method, we can look directly at the object’s class.
american.class
# => FakeCheese
class FakeCheese
def class
Cheese
end
end
american.class
# => Cheese
This is the first method where falling back to super
doesn’t make sense.
.==
We can check the classes themselves for equality.
FakeCheese == Cheese
# => false
class FakeCheese
def self.==(other)
Cheese == other
end
end
FakeCheese == Cheese
# => true
.eql?
Or slightly stricter equality.
FakeCheese.eql?(Cheese)
# => false
class FakeCheese
def self.eql?(other)
Cheese.eql?(other)
end
end
FakeCheese.eql?(Cheese)
# => true
.equal?
Or the strictest equality.
FakeCheese.equal?(Cheese)
# => false
class FakeCheese
def self.equal?(other)
Cheese.equal?(other)
end
end
FakeCheese.equal?(Cheese)
# => true
.object_id
We can also use the object IDs to compare object equality by hand.
FakeCheese.object_id
# => 70241271125600
class FakeCheese
def self.object_id
Cheese.object_id
end
end
FakeCheese.object_id
# => 70241271152880
.__id__
Ruby provides another way to get at the object IDs.
FakeCheese.__id__
# => 70241271125600
class FakeCheese
def self.__id__
Cheese.__id__
end
end
FakeCheese.__id__
# => 70241271152880
.<=>
Now that we’ve faked all of the ways to check equality, let’s move on to inequality. The obvious place to start is with the spaceship operator.
FakeCheese <=> Cheese
# => nil
class FakeCheese
def self.<=>(other)
Cheese <=> other
end
end
FakeCheese <=> Cheese
# => 0
Even though Class
implements .<=>
, it doesn’t include Comparable
. So we
have to manually override all of the associated methods.
.<
FakeCheese < Cheese
# => nil
class FakeCheese
def self.<(other)
Cheese < other
end
end
FakeCheese < Cheese
# => false
.>
FakeCheese > Cheese
# => nil
class FakeCheese
def self.>(other)
Cheese > other
end
end
FakeCheese > Cheese
# => false
.<=
FakeCheese <= Cheese
# => nil
class FakeCheese
def self.<=(other)
Cheese <= other
end
end
FakeCheese <= Cheese
# => true
.>=
FakeCheese >= Cheese
# => nil
class FakeCheese
def self.>=(other)
Cheese >= other
end
end
FakeCheese >= Cheese
# => true
.ancestors
Another way to see if two classes are the same is to see if they have the same
ancestors. Let’s make FakeCheese
pretend like it has the same family tree as
Cheese
.
FakeCheese.ancestors
# => [FakeCheese, Object, PP::ObjectMixin, Kernel, BasicObject]
class FakeCheese
def self.ancestors
Cheese.ancestors
end
end
FakeCheese.ancestors
# => [Cheese, Object, PP::ObjectMixin, Kernel, BasicObject]
.to_s
Having exhausted all of the somewhat reasonable ways to compare classes, let’s move on to comparing their string representations.
FakeCheese.to_s
# => "FakeCheese"
class FakeCheese
def self.to_s
Cheese.to_s
end
end
FakeCheese.to_s
# => "Cheese"
.inspect
By default, .to_s
and .inspect
do the same thing, but they aren’t aliased.
FakeCheese.inspect
# => "FakeCheese"
class FakeCheese
def self.inspect
Cheese.inspect
end
end
FakeCheese.inspect
# => "Cheese"
.name
.name
is just like .to_s
and .inspect
. It’s not aliased either.
FakeCheese.name
# => "FakeCheese"
class FakeCheese
def self.name
Cheese.name
end
end
FakeCheese.name
# => "Cheese"
#to_s
Instances of classes in Ruby don’t use their class’s string representation in their own string representation.
american.to_s
# => "#<FakeCheese:0x007fa3e09ccd00>"
Even though we overrode .to_s
, .inspect
, and .name
, the instance somehow
uses the class’s real name. So we have to provide a custom #to_s
implementation that mimics the default behavior. Other than shifting the
object ID, this is pretty easy.
class FakeCheese
def to_s
"#<#{Cheese}:0x#{'%x' % (object_id << 1)}>"
end
end
american.to_s
# => "#<Cheese:0x7fa3e09ccd00>"
#inspect
Unsurprisingly, this is not an alias.
american.inspect
# => "#<FakeCheese:0x007fa3e09ccd00>"
class FakeCheese
def inspect
"#<#{Cheese}:0x#{'%x' % (object_id << 1)}>"
end
end
american.inspect
# => "#<Cheese:0x7fa3e09ccd00>"
Shorter & More Generic
We created the perfect mock, but it took a lot of code and we repeated ourselves quite a bit. We can make it a lot simpler. Let’s write a function that takes a class and returns a perfect mock of that class.
require 'forwardable'
def fake(klass)
Class.new(BasicObject) do
eigenclass = class << self; self end
eigenclass.extend Forwardable
eigenclass.def_delegators klass, *%i[
<
<=
<=>
==
===
>
>=
__id__
ancestors
eql?
equal?
inspect
name
object_id
to_s
]
define_method :class do
klass
end
define_method :inspect do
"#<#{klass.name}:0x#{'%x' % (__id__ << 1)}>"
end
alias_method :to_s, :inspect
define_method :instance_of? do |other|
klass == other
end
define_method :is_a? do |other|
klass >= other
end
alias_method :kind_of?, :is_a?
end
end
We can replace all our work above with just one function call.
FakeCheese = fake(Cheese)
# => Cheese
american = FakeCheese.new
# => #<Cheese:0x7fd8e1e5ba48>
And it passes all the checks!
[
american.is_a?(Cheese),
american.kind_of?(Cheese),
american.instance_of?(Cheese),
american.class == Cheese,
FakeCheese == Cheese,
FakeCheese.eql?(Cheese),
FakeCheese.equal?(Cheese),
FakeCheese.object_id == Cheese.object_id,
FakeCheese.__id__ == Cheese.__id__,
(FakeCheese <=> Cheese) == 0,
FakeCheese <= Cheese && FakeCheese >= Cheese,
FakeCheese.ancestors == Cheese.ancestors,
FakeCheese.to_s == Cheese.to_s,
FakeCheese.inspect == Cheese.inspect,
FakeCheese.name == Cheese.name,
american.to_s =~ /#{Cheese}/,
american.inspect =~ /#{Cheese}/
].all?
# => true