Search code examples
ruby

How do I implement a get method of instance of a class, and still access to object methods in Ruby?


I need to write an instance of a class to a variable and be able to access it both to get its value and to access its methods. Can I implement this somehow?

For example:

class A
  def initialize(value)
    @value = value
  end

  def preview
    puts "Class preview: #{@value}"
  end

  def something(param)
    puts "Something method: #{@value * param}"
  end
end

class B
  attr_reader :obj

  def set_object(obj)
    @obj = obj
  end
end

b = B.new
b.set_object(A.new(5))

b.obj # ==> 5
10 + b.obj # ==> 15

b.obj.preview # ==> "Class preview: 5"
b.obj.something(3) # ==> "Something method: 15"

Solution

  • The fundamental problem with your code is that you are implementing an object that behaves like a number, but without following Ruby's protocol for number-like types, as laid out by the Numeric class.

    In particular, you are missing the arithmetic coercion protocol provided by the Numeric#coerce method.

    In Ruby, whenever an arithmetic operator does not know what to do with its operand, it will send the operand the coerce message and tell it to respond with a pair of operands it does know how to deal with.

    For example, when send the + message to 10 and pass an instance of A as an argument, the method Integer#+ will be invoked. So, in this line:

    10 + b.obj
    

    Here, you are sending the message + to the (result of evaluating the) Integer literal 10, and passing the result of evaluating the expression b.obj (i.e., an instance of A) as an argument.

    So, what we have here is essentially:

    some_integer + some_a
    

    Now, the problem is, of course, that Integer#+ doesn't know how to add an instance of A to itself. However, every arithmetic operation observes the arithmetic coercion protocol, i.e., the implementation of Integer#+ looks a little bit like this:

    class Integer
      def +(other)
        if other.is_a?(Integer)
          # I know what to do!
          # Do whatever internal magic computes the sum of two `Integer`s
        else
          coerced_self, coerced_other = other.coerce(self)
          coerced_self + coerced_other
        end
      end
    end
    

    Do you see the trick? Integer#+ does not know how to add Integers and As, which is not surprising since you just wrote A today whereas Integer#+ was written almost 30 years ago at this point. However, since Integer is a standard Ruby class, it assumes that A knows how to deal with Integers!

    So, what Integer#+ does here, is that it calls A's coerce method and says "Hey, I don't know what you are, but here I am passing myself as the argument, and I hope that you know what I am, so please convert myself and yourself to something that does know how to add the two together".

    Which means, we need to implement a coerce method for A. The protocol for coerce is as follows:

    • coerce gets sent to the right operand of the arithmetic operation with one argument, which is the left operand of the arithmetic operation.
    • coerce needs to return a pair of objects [coerced_left_operand, coerced_right_operand].
    • coerce needs to ensure that this process eventually terminates, i.e., at least one of the two coerced return values should be one step closer to a builtin type.

    So, let's implement coerce:

    class A
      def coerce(other) = [other, @value]
    end
    

    Now, if we run the code in the question, we get:

    #<A:0x0000000101021cd0 @value=5>
    15
    Class preview: 5
    Something method: 15
    

    So, as you can see, the expression 10 + b.obj was correctly evaluated to 15.

    The next problem with your code is that you are not overriding some of the standard methods that should always be overridden by objects. I am talking about methods like BasicObject#==, Object#eql?, Object#hash, Object#to_s, etc.

    In particular, the message that is sent for displaying a human-readable debugging representation of an object, is inspect. We have not overridden Object#inspect, so we get the default implementation, which contains information about the class, an implementation-defined identifier, and a list of instance variables with their values.

    We need to override inspect to work more like this:

    class A
      def inspect = @value.inspect
    end
    

    Now, running the code in the question, produces the desired result:

    5
    15
    Class preview: 5
    Something method: 15
    

    There are a couple of other things that could be improved in your code.

    • Since A is intended to be number-like, it should inherit from Numeric.
    • Since A is integer-like, it should respond to to_int.
    • Anything that responds to to_int, should logically also respond to to_i.
    • Since A is number-like, it should implement the arithmetic operations.
    • Almost always, a class should override to_s to provide a more sensible string representation.
    • Almost always, a class should override == to provide more sensible equality semantics.
    • Almost always, a class should override eql? and hash to provide more sensible set membership and hash semantics.
    • In Ruby, setters (called attribute writers in Ruby) should not be prefixed with set_, instead they should be named foo=.
    • Trivial attribute readers and writers should not be manually written, they should be defined using Module#attr_reader, Module#attr_writer, or Module#attr_accessor.
    • Objects should be fully valid after being created, there shouldn't be a need to call any setters or change any state to make them valid.

    If we put this all together, we get something like this:

    class A < Numeric
      include Comparable
    
      def initialize(value)
        super()
        @value = value
      end
    
      def to_int = @value
      alias to_i to_int
    
      def to_s = @value.to_s
      alias inspect to_s
    
      def +(other)
        case other
        when A
          self.class.new(@value + other.value)
        when Integer, Float, BigDecimal, Rational, Complex
          self.class.new(@value + other)
        else
          raise(TypeError, "Don't know how to add #{other.inspect} of class #{other.class}") unless other.respond_to?(:coerce)
    
          coerced_self, coerced_other = other.coerce(self)
          coerced_self + coerced_other
        end
      end
    
      def -(other)
        case other
        when A
          self.class.new(@value - other.value)
        when Integer, Float, BigDecimal, Rational, Complex
          self.class.new(@value - other)
        else
          raise(TypeError, "Don't know how to add #{other.inspect} of class #{other.class}") unless other.respond_to?(:coerce)
    
          coerced_self, coerced_other = other.coerce(self)
          coerced_self - coerced_other
        end
      end
    
      def *(other)
        case other
        when A
          self.class.new(@value * other.value)
        when Integer, Float, BigDecimal, Rational, Complex
          self.class.new(@value * other)
        else
          raise(TypeError, "Don't know how to add #{other.inspect} of class #{other.class}") unless other.respond_to?(:coerce)
    
          coerced_self, coerced_other = other.coerce(self)
          coerced_self * coerced_other
        end
      end
    
      def /(other)
        case other
        when A
          self.class.new(@value / other.value)
        when Integer, Float, BigDecimal, Rational, Complex
          self.class.new(@value / other)
        else
          raise(TypeError, "Don't know how to add #{other.inspect} of class #{other.class}") unless other.respond_to?(:coerce)
    
          coerced_self, coerced_other = other.coerce(self)
          coerced_self / coerced_other
        end
      end
    
      def coerce(other)
        case other
        when Integer, Float, BigDecimal, Rational, Complex
          [self.class.new(other), self]
        else
          [other, to_int]
        end
      end
    
      def <=>(other) = to_i <=> other.to_i
    
      def preview
        puts("Class preview: #{self}")
      end
    
      def something(param)
        puts("Something method: #{self * param}")
      end
    
      protected
    
      attr_reader(:value)
    end
    
    class B
      attr_reader :obj
    
      def initialize(obj)
        @obj = obj
      end
    end