Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mimic another Ruby class so the object passes the === type check

I want to create an object that acts as a specific class, such as Fixnum, but isn't an instance of that class nor its subclasses.

There are various use-cases for this. In the Fixnum case, I want to define a more specific integer type that is essentially a Fixnum but implements some extra logic, too. I can't subclass Fixnum itself because immediate types such as Fixnum and Symbol can't be subclassed.

Another use-case is mocking in automated tests: sometimes you want to create an object that acts like a certain class (usually a model instance) but is for technical reasons not an instance of that exact class.

Here's how to create a specific integer type that delegates all methods to an internally stored fixnum:

require 'delegate'
require 'forwardable'

# integer representing a page number
class PageNumber < DelegateClass(Integer)
  extend Forwardable

  def initialize(value, name)
    @name = name
    super(value)
  end

  def inspect
    "#{@name} #{to_i}"
  end

  alias_method :to_i, :__getobj__
  def_delegators :to_i, :instance_of?, :kind_of?, :is_a?
end

This object can pass is_a? and similar checks:

page = PageNumber.new(1, "page")
page.is_a? Fixnum  #=> true

But nothing I do can make it pass the Module#=== type check:

# my problem:
Fixnum === page    #=> false

The fact that my object fails this check is very unfortunate, since the === method is used internally in case statements:

case page
when Fixnum
  # it will never get here
when String
  # ...
else
  # ...
end

My question is how can I create a mock type that passes the === check without augmenting the === methods on built-in classes?

like image 827
mislav Avatar asked Aug 07 '11 13:08

mislav


2 Answers

If we are speaking about the MRI1, the answer is simple: you cannot.

The Module#=== method is effectively an alias of rb_obj_is_kind_of C API method. The implementation of latter is so short that I will paste it here:

VALUE
rb_obj_is_kind_of(VALUE obj, VALUE c)
{
    VALUE cl = CLASS_OF(obj);

    /* Type checking of `c' omitted */

    while (cl) {
    if (cl == c || RCLASS_M_TBL(cl) == RCLASS_M_TBL(c))
        return Qtrue;
    cl = RCLASS_SUPER(cl);
    }
    return Qfalse;
}

As you can see, this method traverses the ancestors of the object being examined, and compares them in two ways: first, it checks if the ancestor is the same as the module which was passed, and then, it checks if they have same method table.

The latter check is required because included modules in Ruby are seemingly inserted in the inheritance chain, but as one module may be included in several other ones, it's not the real module which is inserted into the chain, but a proxy object, which has its constant and method tables pointing to the original module.

For example, let's look at Object's ancestors:

ruby-1.9.2-p136 :001 > Object.ancestors
 => [Object, Kernel, BasicObject] 
ruby-1.9.2-p136 :002 > Object.ancestors.map { |mod| Object.new.is_a? mod }
 => [true, true, true] 

Here, the Object and BasicObject will be successfully compared by the first check, and Kernel by the second one.

Even if you'll try to make (with a C extension) a proxy object which will try to trick the rb_obj_is_kind_of method, it will need to have the same method table as a real Fixnum, which would effectively include all Fixnum's methods.


1 I've investigated the internals for Ruby 1.9, but they behave exactly same way in 1.8.
like image 197
whitequark Avatar answered Sep 29 '22 20:09

whitequark


This is a hackish solution that I warned against in my question:

Fixnum === page  #=> false

Numeric.extend Module.new {
  def ===(obj)
    obj.instance_of?(PageNumber) or super
  end
}

Fixnum === page  #=> true

It solves the problem but raises a question is it safe to do? I can't think of any drawbacks of this method from the top of my mind but since we're messing with a very important method here it might not be something we'd want to do.

like image 28
mislav Avatar answered Sep 29 '22 21:09

mislav