Skip to content

Instantly share code, notes, and snippets.

@ByteDecoder
Forked from samholst/ghost_methods.md
Created March 29, 2022 17:47
Show Gist options
  • Save ByteDecoder/c73e2fe50d29f1908a15b11afa2f9a17 to your computer and use it in GitHub Desktop.
Save ByteDecoder/c73e2fe50d29f1908a15b11afa2f9a17 to your computer and use it in GitHub Desktop.
ruby 'ghost' methods

Ruby 'Ghost' Methods

Ghosts are spooooky!

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.

TL:DR;

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.

Basics ( no ghosts yet, this section != 'spooky' )

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.

Enhancing our User ( still no ghosts, this gist is a lie )

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.

Meta Enhancing our User (this is the part where the ghosts are)

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.

Better Metaprogramming ( still has ghosts but they are less spooky )

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.

Maybe Don't Do This ( back to ghost free zone! )

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment