UnboundMethod and Sinatra-like DSL
- 05 March 2017
- Ruby
- UnboundMethod and Sinatra-like DSL
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:
- Convert block into instance method
- Make
UnboundMethodfrom that method - Store it into
actionsfor 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
UnboundMethodfor 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.
