rubyoop

Ruby class level variable, private setter and initialize


In my learning of Ruby, here is a simple code I'm trying to make it work. It is an implementation of instance_counter using class level variables (@instance_count).

Please note, I'm not using class variable (@@), because I want every layers of class hierarchy (Foo and Bar) to have its own counter.

An important point is that I don't want the counters to be accessible in write from the outside of the class hierarchy. This is the purpose of defining the setter private.

class Foo
  @instance_count = 0
  class << self
    attr_accessor :instance_count
  end
  private_class_method :instance_count=

  def initialize
    Foo.instance_count += 1
  end
end

class Bar < Foo
  @instance_count = 0
  def initialize
    super
    Bar.instance_count += 1
  end
end

p "Foo count #{Foo.instance_count}, Bar count #{Bar.instance_count}"
# => Foo count 0, Bar count 0

f = Foo.new  # ERROR  'Foo#initialize': private method 'instance_count=' called for class Foo (NoMethodError)
b = Bar.new

p "Foo count #{Foo.instance_count}, Bar count #{Bar.instance_count}"
# => Foo count 2, Bar count 1 (that i would have expected without the error)

The only way I can make it "working" was to by-pass the privacy in initialize and use Foo.send(:instance_count=, Foo.instance_count + 1). But, this looks more as a patch than a solution from my point of view.

My goal here is more to understand what I'm missing about the Ruby concepts around Class Level Variable and privacy definition.


Solution

  • Attempting to call a private method with an explicit receiver (other than self) results in a NoMethodError (see the docs on visibility). Class method are no exception to this rule and instances don't have special privileges for accessing private class methods, even for their very own class.

    You could move the whole logic into the class, e.g. by overriding new. This way the counting is handled entirely by the class and doesn't depend on the instances.

    class Foo
      class << self
        def new(...)
          self.instance_count += 1
          super
        end
    
        def instance_count
          @instance_count ||= 0
        end
    
        private
    
        def instance_count=(count)
          @instance_count = count
        end
      end
    
      def initialize
        # nothing to do here
      end
    end
    
    class Bar < Foo
      # will work just fine
    end
    

    This gives:

    Foo.new
    Foo.new
    Bar.new
    
    Foo.instance_count #=> 2
    Bar.instance_count #=> 1
    

    As well as:

    Foo.instance_count = 3 # NoMethodError: private method 'instance_count=' called for class Foo
    

    Note that I'm lazily initializing the class instance variable inside the getter. That's because modules and classed don't have the typical initialize mechanic. (you can implement that method but it won't be called)