Search code examples
rubyoopclass-method

How to Best Factor Out Common Class Methods


I am building an in-memory instance model in Ruby. There are a bunch of classes that each get instantiated and managed by class methods on that class. There are a bunch of those class methods, e.g. list all instances, retrieve all instances, etc.

The code for these methods is common across all classes and does not need to take any account of any particularities of those classes. Hence, I would like that code to live in a common place. See the list method below. My question: How to best achieve this.

class A
    attr_reader :value
    @@instances = []

    def initialize(value:)
        @value = value; @@instances << self
    end

    def self.list
        @@instances.each { |i| puts "#{i.value}"}
    end
end

class B
    attr_reader :value
    @@instances = []

    def initialize(value:)
        @value = value; @@instances << self
    end

    def self.list
        @@instances.each { |i| puts "#{i.value}"}
    end
end

A.new(value: '100')
A.new(value: '101')
B.new(value: '200')
B.new(value: '201')

A.list
B.list

Ideally, I define the list method only once. I have also tried moving that to a super-class:

class Entity
    def self.list
        @@instances.each { |i| puts "AB: #{i.value}"}
    end
end

class A < Entity
    attr_reader :value
    @@instances = []

    def initialize(value:)
        @value = value; @@instances << self
    end
end

class B < Entity
    attr_reader :value
    @@instances = []

    def initialize(value:)
        @value = value; @@instances << self
    end
end

...but as one would expect the super-class cannot access the @@instances array of its sub-classes. Moving the @@instances array to the super-class results in the array being common to all classes, which is not what I need.


Solution

  • The main change you need to make is to use class instance variables rather than class variables. For reasons explained here class variables should be used sparingly; class instance variables are generally a better choice, as is illustrated nicely by this question.

    class Entity
      attr_reader :value
    
      class << self
        attr_reader :ins
      end
    
      def self.inherited(klass)
        klass.instance_variable_set(:@ins, [])
      end       
    
      def initialize(value:)
        @value = value
        self.class.ins << self
      end
    
      def self.list
        @ins.each { |i| puts "#{i.value}"}
      end
    end
    

    class A < Entity; end
    class B < Entity; end
    

    A.new(value: '100')
      #=> #<A:0x00005754a59dc640 @value="100"> 
    A.new(value: '101')
      #=> #<A:0x00005754a59e4818 @value="101"> 
    A.list
      # 100
      # 101
    

    B.new(value: '200')
      #=> #<B:0x00005754a59f0910 @value="200"> 
    B.new(value: '201')
      #=> #<B:0x00005754a59f8b88 @value="201"> 
    B.list
      # 200
      # 201
    

    I defined a getter for the class instance variable @ins in Entity's singleton class1:

    class << self
      attr_reader :ins
    end
    

    When subclasses of Entity are created the callback method Class::inherited is executed on Entity, passing as an argument the class that has been created. inherited creates and initializes (to an empty array) the class instance variable @ins for the class created.

    Another way of doing that, without using a callback method, is as follows.

    class Entity
      attr_reader :value
    
      class << self
        attr_accessor :ins
      end
    
      def initialize(value:)
        @value = value
        (self.class.ins ||= []) << self
      end
    
      def self.list
        @ins.each { |i| puts "#{i.value}"}
      end
    end
    

    The fragment:

    (self.class.ins ||= [])
    

    sets @ins to an empty array if @ins equals nil. If @ins is referenced before it is created, nil is returned, so either way, @ins is set equal to []. In order to execute this statement I needed to change attr_reader :ins to attr_accessor :ins in order to perform the assignment @ins = [] (though I could have used instance_variable_set instead).

    Note that if I were to add the line @ins = [] to Entity (as th first line, say), the instance variable @ins would be created for every subclass when the subclass is created, but that instance variable would not be initialized to an empty array, so that line would serve no purpose.

    1. Alternatively, one could write, singleton_class.public_send(:attr_reader, :ins).