Polymorphism on a single object in Ruby
Consider the following code snippet:
|
The semantics is clear: The read do ... end block defines a method MyData#read that populates the instance variables from binary data, where another method MyData#read_raw, defined by a read :raw do ... end block, is called to read some processed binary data. Similarly, the write do ... end block defines a method MyData#write that serializes the object into binary data, where another method MyData#write_raw, defined by a write :raw do ... end block, is called to write some processed binary data.
In those blocks, methods like u8, u32, bytes, and string are called. What is interesting is that they should be instance methods of MyData (because they are called in the same scope where the instance variables can be accessed directly), but they behave differently in the read and write blocks. For example, u8 in the read block accepts no arguments and returns an integer read from data, while u8 in the write block accepts an integer argument to write into data.
What we have here, is what I call the polymorphism on a single object: the same object has different behaviors in different contexts. In this case, it is the same MyData object that behaves differently in the read and write blocks. How can we achieve this?
One natural idea is to extend the object with a module that defines those polymorphic methods at the start of the block and to un-extend at the end of the block. The extended module should be different for the read and write blocks, so that the methods with the same name can have different implementations. The problem with this approach is that, if you look up the core library documentation of Ruby, you will find that there is no method to un-extend a module in pure Ruby. It is, however, possible to do this with C extensions, and there is a gem that has been developed for this exact purpose, but the gem is ancient and is not compatible with Ruby 1.9 and later.
The next idea, then, is to modify the extended module so that the methods are defined at the start of the block and removed at the end of the block. To create a module, to extend it, and to define all methods from another module mod in it, we can do the following:
|
It is just as easy as that. Now, if mod is a module contains the definitions of the methods u8, bytes, etc., then they will now be available to self. After we are done, we can remove those methods from the extended module:
|
It is also very easy. Now, although we still cannot un-extend ext, the methods are no longer available, which effectively makes the object behave as if it un-extended ext.
A problem with this approach is that, if a method like read or write is called many times on the same object, then its singleton_class.ancestors will contain many garbage extended modules. Therefore, we should reuse the same extended module for the same object. At the start of the block in which the polymorphic methods are available, we should back up the original instance methods of the extended module, and at the end of the block, we should restore them. Another problem is that manipulating ancestors and methods is not thread-safe, so we need to lock the object while we are doing this. After considering all these problems, we can implement a TempExtend module like this:
|
There is a notable interesting pattern here when defining meth (though it is not closely related to the topic of this article) as it finds an unused singleton method name to define a singleton method with the given block to circumvent the limitation of instance_exec that it cannot pass a block; I opened a feature request for anonymous methods hoping that the same workaround is not needed in the future.
We can now finally define our version of Binary module, extended by MyData, that enables it to use read and write methods to define blocks for polymorphic methods.
|
For Ruby 4.1 and later, there is a new method Proc#refined that can be used to modify the methods available in a block. Our TempExtend module is of no use anymore, and we can simplify the Binary module as follows:
|
Not only is this approach cleaner, it is also effectively better: the extended module is now invisible in the ancestors of the object, and the methods are now truly local to the block protected by the lexical scope of the block. The latter advantage is, however, also a disadvantage in some use cases: if you want to define a method in MyData outside of Binary::Reader or Binary::Writer that calls methods like u8, it will not work because the method definition is outside of the lexical scope of the block.