DEV Community

Bhumi
Bhumi

Posted on

How Method Lookup and method_missing Works

This post is part of a series on understanding Object Oriented Programming using Ruby.

Subscribe to my free newsletter for more.


Have you ever wondered why we can write Rails.env.production? instead of having to check with equals like Rails.env == "production"?

We'll take a little tour of ActiveSupport code to answer this question about method calls and method_missing. Specifically the StringInquirer class, which is behind this little feature on Rails.env.

On this tour, we'll also see other examples of metaprogramming concepts in Ruby. Such as instance_variable_set for dynamically setting instance variables, class_eval for executing a block in the context of an existing class, etc.

Let's get started!

Let's open up the StringInquirer Class. It's pretty short and there we see method_missing on line 27.

class StringInquirer < String
    private
      def respond_to_missing?(method_name, include_private = false)
        method_name.end_with?("?") || super
      end

      def method_missing(method_name, *arguments)
        if method_name.end_with?("?")
          self == method_name[0..-2]
        else
          super
        end
      end
end
Enter fullscreen mode Exit fullscreen mode

What is method_missing?

Let's review how method calls work in Ruby. When we call a method on an object, Ruby looks for the method definition in the object's class. If it doesn't find it there, it goes up the class's ancestor chain, all the way to a class called BasicObject.

>> String.ancestors
=> [String, Comparable, Object, PP::ObjectMixin, Kernel, BasicObject] 
Enter fullscreen mode Exit fullscreen mode

BasicObject is at the top of ruby's class hierarchy. It has an instance method called method_missing, that all objects in ruby inherit. So if ruby can't find the method we're calling anywhere else in the ancestor chain, it admits defeat by calling method_missing.

This implies that we can override method_missing in a new class to 'catch' calls to methods that don't actually exist. These are sometimes called Ghost Methods. Why is this useful?

Let's play with StringInquirer to see.

>> fruit = ActiveSupport::StringInquirer.new("apple")
=> "apple"
>> fruit.apple?
=> true
>> fruit.orange?
=> false
>> fruit.sldjlsd?
=> false
Enter fullscreen mode Exit fullscreen mode

We can call arbitrary methods on fruit and it will return true for apple and false for everything else. Note that these are all predicate methods, ending in a ?

That's all the StringInquirer class does. But how does it reply correctly to arbitrary method names?

StringInquirer Explained

When we call a method apple? on fruit it eventually gets to method_missing of StringInquirer. In method_missing, it checks whether the name of the method ends with a ?. If it does, we want to 'catch' it. If not, we pass it to super.

When we 'catch' the method, we return the value of this comparison self == method_name[0..-2]. The right hand side is just the method name without the ?. The value of self is fruit.

Remember we are in a method call. The value of self during a method call is the receiver (the object that the method was called on). In this case, self is an ActiveSupport::StringInquirer object, which is a subclass of String. (We will cover how the value of self changes in Ruby, in a later post)

If the method_name does not end with a ?, we don't want method_missing to catch that call. It passes to super which will throw 'NoMethodError' error. This is important because we don't want to catch all possible method calls.

>> fruit.grow
/Users/bhumi/.rbenv/versions/3.0.3/lib/ruby/gems/3.0.0/gems/activesupport-7.0.0/lib/active_support/string_inquirer.rb:29:in `method_missing': undefined method `grow' for "apple":ActiveSupport::StringInquirer (NoMethodError)
Enter fullscreen mode Exit fullscreen mode

We get our NoMethodError if we call grow on fruit.

One more thing, there is also this respond_to_missing in StringInquirer, we'll come back to that one.

But now we're ready to look at the Rails sourcecode that makes calls like Rails.env.development? possible.

    def env
      @_env ||= ActiveSupport::EnvironmentInquirer.new(ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development")
    end

    # Sets the \Rails environment.
    #
    #   Rails.env = "staging" # => "staging"
    def env=(environment)
      @_env = ActiveSupport::EnvironmentInquirer.new(environment)
    end
Enter fullscreen mode Exit fullscreen mode

Here are the methods for reading and writing env. It wraps the env name string in a class called EnvironmentInquirer. We haven't seen that class yet. But I bet that's a subclass of our StringInquirer.

Yup it sure is class EnvironmentInquirer < StringInquirer.

The comment tells us that this class is doing some optimization for the three default environments, so it doesn't need to rely on the slower delegation through method_missing that StringInquirer uses.

This class doesn't use method_missing directly but it has two nice examples of dynamic programming concepts for us to explore. instance_variable_set and class_eval.

    def initialize(env)
      raise(ArgumentError, "'local' is a reserved environment name") if env == "local"

      super(env)

      DEFAULT_ENVIRONMENTS.each do |default|
        instance_variable_set :"@#{default}", env == default
      end

      @local = in? LOCAL_ENVIRONMENTS
    end

    DEFAULT_ENVIRONMENTS.each do |env|
      class_eval <<~RUBY, __FILE__, __LINE__ + 1
        def #{env}?
          @#{env}
        end
      RUBY
    end
Enter fullscreen mode Exit fullscreen mode

Here's what the code is doing:

  • instance_variable_set will create 3 instance variables called @development, @test, @production. And set them to either true or false.

  • Under the class_eval, we define 3 methods. development?test?production?. Those methods simply return the value of the instance variable with a matching name we set up earlier.

Both of these are examples of metaprogramming. We are dynamically setting instance variables and defining methods at runtime.

An aside: the __FILE__, __LINE__ + 1 stuff in the heredoc makes it so if something goes wrong in this dynamically defined methods, the debugger can point us to a file name and line number in the code. I wasn't sure, I had to look it up, but that's what I think this stuff is for.

That's all. We've gotten to the bottom of this code exploration. We can see exactly how Rails.env.production? works now. It's a method that is dynamically defined on the EnvironmentInquirer class (which is a subclass of StringInquirer and has method_missing for all calls that end in a ?).

What is respond_to_missing?

Before we wrap up, I said I'd come back to respond_to_missing. Why is that needed?

Remember that Ruby has duck typing, and we can ask any object if it responds to a given method.

>> fruit.respond_to?("apple?")
=> true
>> fruit.respond_to?("grow")
=> false
Enter fullscreen mode Exit fullscreen mode

It's answering correctly because we are overriding respond_to_missing in StringInquirer. If we didn't, ruby will have no way of knowing that we're catching methods that end in ?. With this line method_name.end_with?("?") || super in respond_to_missing we are telling ruby that we respond to all methods that end in ?.

In general, overriding respond_to_missing whenever you override method_missing is a good thing to do, so our objects don't lie to us.

To wrap up, We confirm that class of Rails.env is ActiveSupport::EnvironmentInquirer which is a subclass of ActiveSupport::StringInquirer and that is why we can call those handy predicate methods.

>> Rails.env.development?
=> true
>> Rails.env.class
=> ActiveSupport::EnvironmentInquirer
Enter fullscreen mode Exit fullscreen mode

Beautiful. It's just a tiny little feature. But in this tiny feature of ActiveSupport, we saw many metaprogramming concepts in action. We used method_missing and respond_to_missing. We talked about method lookup and ancestor chain and the value of self. And we saw instance_varialbe_set and class_eval.

That's all I got. Hope you enjoyed this guided tour of some ActiveSupport code.

Until next time,

Bhumi

Top comments (0)