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
def
anddefine_method
- Related bug on ruby issue tracker
Comments
There are no existing comments
Got your own view or feedback? Share it with us below …