UnboundMethod and Sinatra-like DSL

Subscribe to receive new articles. No spam. Quality content.

Hi. Today we're going to learn about UnboundMethod in Ruby and create Sinatra-like DSL. Ruby is so powerful that allows us to unbound method from class or module and bind it to existing object. I know that might sound a bit complicated, but it allows to create incredibly powerful features.

Let me show you what's UnboundMethod by example:

class Man
  def hi
    puts 'hi'
  end
end

m = Man.new
m.hi # => hi

Here we have simple class Man and instance method hi.

class Man
  def hi
    puts 'hi'
  end
end

hi_method = Man.instance_method(:hi)
hi_method.inspect # => #<UnboundMethod: Man#hi>

Using Man.instance_method(:hi) we managed to unbound method hi from class Man and store it in hi_method variable. We can bind and call this method for any instance of class Man, for example:

hi_method = Man.instance_method(:hi)
hi_method.inspect # => #<UnboundMethod: Man#hi>

m = Man.new

hi_method.bind(m).call # => hi 

Pretty neat, right? Having method :hi as an object hi_method, we can bind it to instance of class Man and call it using call method.

Let's repeat it one more time: when we have UnboundMethod of class Man we can bind it to any instance of class Man using bind and call it using call method.

If we try to call UnboundMethod without binding it to any object - we will get an error:

hi_method = Man.instance_method(:hi)
hi_method.inspect # => #<UnboundMethod: Man#hi>

hi_method.call # => undefined method `call' for #<UnboundMethod: Man#hi> 

Because UnboundMethod doesn't know for which object you want to call it.

As I mentioned earlier - we can call unbound method of class Man only for instances of class Man. If we try to bind UnboundMethod to instance of any other class we will get an error:

class Man
  def hi
    puts 'hi'
  end
end

class Woman
end

hi_method = Man.instance_method(:hi)

w = Woman.new
hi_method.bind(w).call # bind argument must be an instance of Man (TypeError)

We can bind method from class Man only to object of class Man. Otherwise we're getting an error.

What if we actually want to have method which we can bind to any instance of class? We can do that if we add method to module:

module Greetable
  def hi
    puts 'hi'
  end
end

class Man
end

class Woman
end

hi_method = Greetable.instance_method(:hi)

w = Woman.new
hi_method.bind(w).call # => hi

m = Man.new
hi_method.bind(m).call # => hi

As we see from example - we can add method hi to any object.

If you're still reading this article - you're hero :) Because I think that question "Why do I need it?" appeared many times. But actually knowledge we have now and we got from this article will allow us to create Sinatra-like framework! Let's do that.

Sinatra-like DSL

Let's try to implement something like that:

class UsersController
  get '/' do
    puts handler
  end

  private

  def handler
    'handling request...'
  end
end

If we run this code we will get an error:

`<class:UsersController>': undefined method `get' for UsersController:Class (NoMethodError)

It makes sense because Ruby executes code inside class immediately and tries to find method get.

Let's create base class BaseController and add class method get.

In article about clsas macros we agreed that we will use class instance variables for this purpose.

If you haven't read this article yet - please do that to understand how this code works:

class BaseController 
  def self.actions
    @actions ||= { get: {} }
  end

  def self.get(path, &block)
    actions[:get][path] = block
  end
end

We have base controller with two class methods: get and actions. Main idea here to store path which we want to process (for example /) and block of code which we want to run for this action. In case of UsersController it's going to be this code:

  get '/' do
    handler
  end

Method get of BaseController will store in instance-variable actions of class UsersController the following hash:

actions[:get]['/'] => { handler }

We're going to process all requests like this:

UsersController.new.call('/')

So we need a call method for BaseController which we want to call by passing a path:

class BaseController 
  def self.actions
    @actions ||= { get: {} }
  end

  def self.get(path, &block)
    actions[:get][path] = block
  end

  def call(path)
    self.class.actions[:get][path].call
  end
end

Method call should execute block of code for the path we passed. In case of UsersController and / path it should be handler method which returns handling request string.

Method call goes to hash actions and tries to find block of code for passed path and call it. At this moment in actions hash we have something like this:

{ '/' => { handler } }

Let's check all code we have now:

class BaseController 
  def self.actions
    @actions ||= { get: {} }
  end

  def self.get(path, &block)
    actions[:get][path] = block
  end

  def call(path)
    self.class.actions[:get][path].call
  end
end

class UsersController < BaseController
  get '/' do
    puts handler
  end

  private

  def handler
    'handling request...'
  end
end

UsersController.new.call('/')

Seems like we have all we need to run our code and process request. But this code fails:

undefined local variable or method `handler' for UsersController:Class

We've properly stored block of code from UsersController action and called it. But during execution we're having an error. If we check where block tries to find method handler we will see that it tries to do that in scope of controller class, but not an object.

Let's try to understand why that happens. We define block for class method get in scope of class. Scope of this block - it's a scope of class. It doesn't know about instance method handler. That's why we have an error. We work with two different scopes.

It means that we need to run block in scope of instance, even if it was defined in scope of class method. That's how block can get an access to instance methods, i.e. handler.

That's where UnboundMethod will help us to do that.

Let's recap problem we have: block was defined in class method, but we need to run it in scope of instance.

To do so we will:

  1. Convert block into instance method
  2. Make UnboundMethod from that method
  3. Store it into actions for future use.

It sounds incredibly hard, but let me show you code:

class BaseController
  # ...
  def self.get(path, &block)
    actions[:get][path] = block_to_unbound_method(block)
  end
  # ...
  private

  def self.block_to_unbound_method(block)
    define_method :temp, block
    method = instance_method(:temp)
    remove_method(:temp)

    method
  end

OK, OK! I know, looks like a magic, but let me tell you what we have here.

If we have block, we can define method and use that block as a body of method. Since we don't want new method to be a UsersController method, we defined it as :temp, then extracted it as UnboundMethod and removed it from UsersController.

Now we have UnboundMethod with block as a body of method. So we can store that unbound method in actions hash.

Let's rewrite method call and take advantage on UnboundMethod:

  def call(path)
    action = self.class.actions[:get][path]

    action.bind(self).call
  end

We look for UnboundMethod for path in hash of actions. Then we bind UnboundMethod to self (which is UsersController and that moment) and call it. Main benefit from using UnboundMethod here that we can run it in scope of instance. Now it has an access to instance methods like handler.

Let's recap all code we have:

class BaseController 
  def self.actions
    @actions ||= { get: {} }
  end

  def self.get(path, &block)
    actions[:get][path] = block_to_unbound_method(block)
  end

  def call(path)
    action = self.class.actions[:get][path]

    action.bind(self).call
  end

  private

  def self.block_to_unbound_method(block)
    define_method :temp, block
    method = instance_method(:temp)
    remove_method(:temp)

    method
  end
end

class UsersController < BaseController
  get '/' do
    handler
  end

  private

  def handler
    'handling request...'
  end
end

UsersController.new.call('/') # => handling request...

There are two complicated parts:

  • class instance variables which we used for storing hash with paths and methods.
  • transforming block to UnboundMethod for later use

It definitely requires understanding of object model of Ruby but still allows us to do really interesting features.

No more stuff like this, please?! :D

If you think that such topics are too complicated - let me know, please. I really appreciate feedback from you guys. If you're interested in other topics - let me know as well. I have many ideas for future posts and they're going to be less complicated.

Also, let me know if you have better way to implement this functionality!

Thanks.

Subscribe to receive new articles. No spam. Quality content.

Comments