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
UnboundMethod
from that method - 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.