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
- GitHub repo with source code presented in this post
- Official Rails docs for
ActiveSupport::Concern - Makandra card detailing differences between
defanddefine_method - Related bug on ruby issue tracker
Comments
There are no existing comments
Got your own view or feedback? Share it with us below …