But really this is just a goofy name for one of many options of dynamically creating methods in ruby which is part of that fancy business known as Metaprogramming. Which is a fancy name for the concept of writing code that 'writes' code. It's bananas and great and the best thing you will ever see in your career also it is terrible, horrible, no good and the worst thing you will ever see in your career. We are just going to go over what has been dubbed 'ghost' methods by someone who likes to have cool names for things, there is a lot more to metaprogramming in general than just this technique but it is a pretty good entry point.
They are called ghost methods because you can call them but they won't show up in lists of methods
available or be explicitly defined. They are there, but invisible. You can make them by hijacking
and writing your own method_missing
.
Lets start with a very simple class definition
class User
attr_accessor :name, :role
ROLES = {
0 => "viewer",
1 => "admin",
}
def initialize(name: "Jane Doe", role: 0)
@name = name
@role = role
end
end
Lets take a look at what methods are defined on an instance of User
. We can look at just the
methods we have defined in the class definition by passing false to the instance_methods method.
User.instance_methods(false).sort
# => [:name, :name=, :role, :role=]
user = User.new
user.respond_to?(:name)
# => true
This result shows us all the methods that we did something to explicitly define in our class
definition above. They exist because we wrote stuff that made them so. When we ask Ruby about the
object it is aware of these methods and tells us about them. They are not ghosts
that are
'invisible' in any way even though we used attr_accessor
to set some of them up and didn't
explicitly use def
. The User
instance is also aware that it can respond to the name
method.
We've previously decided to store role
as an integer and have those values mapped to a name key
in ROLES
. When we interact with an instance of User
, we really want to have a nice way to check
if the User
is an admin
or a viewer
or any other arbitrary role. We can easily define those
methods in an explicit way that will be visible at all times to Ruby.
class EnhancedUser
attr_accessor :name, :role
ROLES = {
0 => "viewer",
1 => "admin",
2 => "master_admin",
}
def initialize(name: "Jane Doe", role: 0)
@name = name
@role = role
end
def admin?
ROLES[role] == :admin
end
def viewer?
ROLES[role] == :viewer
end
end
Now if we look at the methods available that we defined for EnhancedUser
the list has grown with
the new specific additions and an instance of the object is aware that it responds to the new
methods. What a smart and not spooky instance of an object.
EnhancedUser.instance_methods(false).sort
# => [:admin?, :name, :name=, :role, :role=, :viewer?]
user = EnhancedUser.new
user.respond_to?(:admin?)
# => true
user.admin?
#=> false
user.respond_to?(:master_admin?)
# => false
user.master_admin?
# => NoMethodError!!!!
But what about master_admin?
which I snuck into the ROLES
hash when we created EnhancedUser
.
You might not have noticed it because I did it so quickly but it is there. An explicitly defined
method for master_admin?
was not created and now we have roles that have diverged from the role
checks that are available. Oh no!!! This is one of the problems that arise when code must be
changed in more than one place. There are problems that come from solving these issues with
metaprogramming as well, because programming in general is an evil occupation that was never meant
to be. Just be aware that everything sucks and is also awesome.
If we were to call the method master_admin?
on an instance of EnhancedUser
it would fail and
throw a no method error. This would blow up our code and that is never great. Before it decided to
throw that error it would check up the inheritance chain looking for a method defined that could
respond. Hoping to prevent the error by asking everyone along the way to please respond to
master_admin?
. Ultimately it would run out of places to ask and would admit defeat by calling
method_missing
. To accomplish our next goal we will defile all that is good and holy by writing
our own method_missing
method in order to intercept that failed attempt and try some other hijinx
instead.
Now that we've added methods to check the roles we now have a requirement to add new roles. We've
already added the master_admin
role but these roles grow on trees and they'll just keep coming.
We screwed up when we added master_admin
by not adding a corresponding role check and I
personally really like to make the same mistake many many times, this won't end well. We would have
to remember to add a corresponding predicate method to check every role we add, and all these
methods are basically the exact same method. Instead lets utilize some meta programming to reduce
the code needed to accomplish the role checks and to limit the different places that need to
change. Currently to add new roles and checks we would have to modify ROLES
as well as add a
unique check for each new role. Lets make it so that we just have to modify ROLES
and the rest
is dynamically handled.
class MetaEnhancedUser
attr_accessor :name, :role
ROLES = {
0 => "viewer",
1 => "admin",
2 => "master_admin",
}
def initialize(name: "Jane Doe", role: 0)
@name = name
@role = role
end
private
def method_missing(method_name, *args, &block)
ROLES[role] == method_name.to_s.chomp("?")
end
end
Well, that all seems fancy, but we've also done great harm by having a poorly implemented approach
to method_missing
. First let's expose our ghosts.
MetaEnhancedUser.instance_methods(false).sort
# => [:name, :name=, :role, :role=]
user = MetaEnhancedUser.new
user.respond_to?(:master_admin?)
# => false
user.master_admin?
# => false
user.viewer?
# => true
Awesome, so the master_admin?
and viewer?
methods exist, it didn't blow up when we called them
and it returned false and true respectively which we expect for a viewer
user. However, these
methods are not listed in the available instance methods. They are.... GHOSTS. They are both
available to call but invisible when we ask about them. We can improve the result of respond_to?
by being a better metaprogrammer and adding some additional context to the class definition. But
with this approach we won't really be able to change the fact that this method is invisible in the
instance methods list. That is the nature of ghosts. We also have another problem, we just broke
the normal behavior method_missing
by intercepting every single method call that triggers
method_missing
. Look at this example.
user = MetaEnhancedUser.new
user.some_totally_other_missing_method
# => false
What happened here is that method was caught by our missing method and was treated as if it was a
role check. The current user doesn't have the role named "some_totally_other_missing_method" so we
returned false instead of the expected result of a NoMethodError
. Ideally what we want is for an
instance of this object to properly handle respond_to?
and for methods that are not role checks
to properly bubble up and result in no method errors. Lets solve that in the next section with
some better metaprogramming.
class BetterMetaEnhancedUser
attr_accessor :name, :role
ROLES = {
0 => "viewer",
1 => "admin",
2 => "master_admin",
}
def initialize(name: "Jane Doe", role: 0)
@name = name
@role = role
end
private
def method_missing(method_name, *args, &block)
if valid_role_check?(method_name.to_s)
role?(method_name.to_s.chomp("?"))
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
valid_role_check?(method_name.to_s) || super
end
def role?(role_name)
ROLES[role] == role_name
end
def valid_role_check?(method_name)
method_name.chars.last == "?" && ROLES.value?(method_name.chomp("?"))
end
end
So lets start off with doing our method checks.
BetterMetaEnhancedUser.instance_methods(false).sort
# => [:name, :name=, :role, :role=]
user = BetterMetaEnhancedUser.new
user.respond_to?(:master_admin?)
# => true
user.respond_to?(:some_totally_other_missing_method)
# => false
user.master_admin?
# => false
user.viewer?
# => true
user.some_totally_other_missing_method
# => NoMethodError!!!
So our role check methods are still ghosts, they don't show up in the instance methods list.
However, they are slightly less secret spooky ghosts, because instances of our objects do know that
they respond to those methods. We accomplished this by writing the respond_to_missing
method and
checking to see if the method that was called would result in a valid role check method. We now use
that same test to better handle the method_missing
code. Instead of treating all missing methods
as if they were role check attempts, we only check roles when the method would be a valid role
check. It has to be in the format of existing_role_name?
otherwise we drop it off to super
to
handle method_missing
from there. This means we get the exception for arbitrary missing_methods
but we catch the role checks and process them. It is beautiful.
So it is beautiful, but it's also dangerous and problematic and often not the best solution. This particular scenario is a decent use case for metaprogramming, but that is far from the only way to solve the original issue of growing roles while maintaining the ability to check those roles. Below is an example with an explicit method, the same method we used as part of the metaprogramming checks, but placed on the public interface. We can use this to solve our original problem in just a slightly more verbose way at the callsite but without all the fancy stuff.
class AlternateEnhancedUser
attr_accessor :name, :role
ROLES = {
0 => "viewer",
1 => "admin",
2 => "master_admin",
3 => "manager",
}
def initialize(name: "Jane Doe", role: 0)
@name = name
@role = role
end
def role?(role_name)
ROLES[role] == role_name
end
end
Here we can see the list of methods and how we would accomplish the role checks with this version.
AlternateEnhancedUser.instance_methods(false).sort
# => [:name, :name=, :role, :role=, :role?]
user = AlternateEnhancedUser.new(role: 3)
user.role?("manager")
# => true
user.role?("admin")
# => false
The individual checks are more verbose here but potentially more obvious. There is less surface area
and the code only needs to change in one spot. We actually needed to write the role?
code for both
solutions but now we don't have to carry the weight of the additional method_missing?
and
respond_to_missing?
code.