Closed Classes in Ruby
Don’t you just hate it when someone reopens your perfect class? Well let’s do something about that.
So let’s say we have a class:
class Monkey
def see
'banana'
end
end
And then we try to reopen it:
class Monkey
def see
'banana'
end
end
class Monkey
def see
'dirty patch'
end
end
And now our class behaves differently.
Monkey.new.see # => 'dirty_patch'
But what if we could do something like this:
class Monkey
extend NotMonkeyPatchable
end
And when we look at the same scenario again:
class Monkey
extend NotMonkeyPatchable
def see
'banana'
end
end
class Monkey
def see
'dirty patches'
end
end
Monkey.new.see # => 'banana'
Cool, our class resisted being reopened. But how? The module we extend our class with looks like this:
module NotMonkeyPatchable
def method_added(name)
@methods ||= {}
if @methods[name]
unpatch_method(name)
else
@methods[name] = instance_method(name)
end
end
def unpatch_method(name)
return if @unpatching
@unpatching = true
method = @methods[name]
define_method(name) { |*args, &block|
method.bind(self).call(*args, &block)
}
@unpatching = false
end
end
We utilize the method_added
hook Ruby gives us.
For each method you define on a class, it’s method_added
method is called with the name of the method that was defined.
def method_added(name)
@methods ||= {}
if @methods[name]
unpatch_method(name)
else
@methods[name] = instance_method(name)
end
end
We store the first implementation for a name. If it already exists then it means someone is trying to redefine the method. And we don’t want that, so we unpatch it back to what it was.
def unpatch_method(name)
return if @unpatching
@unpatching = true
method = @methods[name]
define_method(name) { |*args, &block|
method.bind(self).call(*args, &block)
}
@unpatching = false
end
The method we got through instance_method
is unbound. In order to call it, it must first be bound to an object.
So that’s what we do, we redefine the method again after it was patched, and inside we bind the original method object to self and call it.
Since we are defining a method the method_added
hook will be invoked again, and to avoid dropping into an infinite loop we
guard ourselves with the @unpatching
flag.
We probably need to add a mutex on this whole process of redefinition to be threadsafe, but I omitted it.
In fact, this even works as expected for subclasses.
class Gorilla < Monkey
def see
'jungle'
end
end
Gorilla.new.see # => 'jungle'
class Gorilla
def see
'ketchup'
end
end
Gorilla.new.see # => 'jungle'
And there you have it, no more monkey patches are going to mess up your precious code. :)