A recent ruby upgrade exposed a flawed attempt to set some dynamically defined methods as private. In this post I will present a bare-bones example to recreate the problem, and demonstrate how the issue can be resolved.

Introduction

During a recent upgrade on an existing project to ruby 2.7, I was presented with an unfamiliar warning:

warning: calling private without arguments inside a method may not have the intended effect
Digging into the warning it quickly became clear that I had, indeed, unsuccessfully attempted to define some private methods within another method definition. Let's take a look at the offending code, and see how it can be rehabilitated. The code examined in this post can be found on GitHub, [2].

Mixin Module

Using modules to define shared functionality between classes is a common practice. There are a number of different ways we can choose to mix-in the functionality from a module into a 'host' class. The original code in question actually related to an ActiveSupport::Concern, but we shall avoid this unneeded complication in the example that follows.

We will define a HasContent module which can be extended into a host class. This module defines a has_content method that takes the name by which we intend to identify the content attribute, which we will default (shockingly) to content. Within this has_content method we will define two new instance methods for getting and setting an appropriately named instance variable. As well as simply getting and setting the instance variable, these methods will log the action using the log function.

Note that the intention is that the host class will extend this module rather than include it. This means that the methods exposed by the module will be added as class methods. By contrast, if we include a module in a class, the methods defined in the module will become instance methods of the host class.

You can see that, within the has_content method, we use the private access modifier with the intention of making our log method private.

    
module HasContent
  def has_content(field_name=:content)
    define_method "get_#{field_name}" do
      log("Getting #{field_name}")
      instance_variable_get("@#{field_name}")
    end

    define_method "set_#{field_name}" do |content|
      log("Setting #{field_name}=#{content}")
      instance_variable_set("@#{field_name}", content)
    end

    private

    define_method "log" do |msg|
      puts "Logging: #{msg}"
    end
  end
end
    
  

We will use this mixin module inside a simple Box class defined as follows:

    
class Box
  extend HasContent
  has_content(:stuff)
end

box = Box.new
box.set_stuff("Jack")
puts box.get_stuff # Prints "Jack"
puts "!!WARNING!! Box#log should be private" if Box.instance_methods.include?(:log)
    
  

We create a Box instance and attempt to access some of the methods which have been added by the HasContent module. We can successfully call the #set_stuff and #get_stuff methods. We should not be able to invoke the private method log, but as our warning message shows, we have been unsuccessful in preventing access to this method:

    
> ruby main.rb
/home/domhnall/code/ruby-private-method-inside-class-method/has_content.rb:20: warning: calling private without arguments inside a method may not have the intended effect
Logging: Setting stuff=Jack
Logging: Getting stuff
Jack
!!WARNING!! Box#log should be private
    
  

Properly restricting access to private methods

As the warning message explains, the private modifier is not behaving as expected when called from within a method. There may be other ways to fix this problem, but here are the two which I came across.

Using class_eval

My first reaction was to assume that I had misunderstood the context in which the method definitions were taking place, and reached for class_eval:

    
module HasContent
  def has_content(field_name=:content)

    …

    class_eval do
      private

      define_method "log" do |msg|
        puts "Logging: #{msg}"
      end
    end
  end
end
    
  

Within the Box class definition we invoke has_content(:stuff). The has_content method is executed on the Box class, so the value of self within this method invocation is Box. This means that when we hit the class_eval invocation within this method, it is the same as Box.class_eval. Therefore, we can consider the block of code passed to class_eval to be evaluated in the same way as it would, should we lift that block and place it into the Box class definition.

This made sense to me. If I were to place that block inside the Box class definition I would expect the :log method to be private. However, I still wasn't quite sure what the problem was with the first way it had been written …

Pass an argument to private (or, read the error message)

Taking another read of the error message I decided to just alter how I declared the method as private, in particular, invoking the private method with an explicit argument. Lo and behold it worked:

    
module HasContent
  def has_content(field_name=:content)

    …

    define_method "log" do |msg|
      puts "Logging: #{msg}"
    end
    private :log
    end
  end
end
    
  

With the desired output

    
> ruby main.rb      
Logging: Setting stuff=Jack
Logging: Getting stuff
Jack
    
  

Indeed, the following variations will do the same job:

    
    mlog = define_method "log" do |msg|
      puts "Logging: #{msg}"
    end
    private mlog        # The `define_method` returns the symbol identifier for the method defined, in this case :log
       
  

In the example above, the call to define_method will return the symbol identifier for the method defined, in this case :log. We can then pass this as an argument into the private invocation. Or to avoid the mlog variable, we can just execute the define_method call inline within the private invocation:

    
private(define_method "log" do |msg|
  puts "Logging: #{msg}"
end)
    
  

Any of these approaches will solve the problem, and will define the log method as a private method on the Box class. However, whilst I have offered ways to solve the problem I have not really explained why we need to invoke private in this way?

In truth I stopped digging at this point. From my own internalisation, I believe it is linked to the fact that private, like public and protected, are actually methods, rather than keywords. I presume that invoking the method in a class definition must manage some state which is picked up by subsequent method definitions (like a flag indicating that subsequent method definitons should be declared as private). However, if we try to make this same method call outside of the class definition it just fails to work as intended; like a bit of ruby syntactic sugar that we can only rely on in the class context. Nonetheless, it seems like we can still achieve what we want by invoking the private method, passing a symbol representing the method that we are trying to restrict.

If you are interested in learning more, it seems that the actual change in ruby which brought about the error message is related to this bug on the issue tracker.

Summary

Using the private modifier does not work within a class method. If you are defining methods inside a class method that you wish to keep private, you can use a class_eval block or you should invoke the private method with the corresponding symbol for your method. Both of these techniques have been demonstrated.

References

  1. Blog post on the hidden cost of metaprogramming in ruby
  2. GitHub repo with source code presented in this post
  3. Official Rails docs for ActiveSupport::Concern
  4. Makandra card detailing differences between def and define_method
  5. Related bug on ruby issue tracker

Comments

There are no existing comments

Got your own view or feedback? Share it with us below …

×

Subscribe

Join our mailing list to hear when new content is published to the VectorLogic blog.
We promise not to spam you, and you can unsubscribe at any time.