Ruby Module is an easy and great way to encapsulate related methods/constants and extend a class, but sometimes I find myself really want to tell it something about the current class.
A pseudo code of my "wish" looks like this: of course it's invalid, Ruby doesn’t allow us to do so.
class Post < ApplicationRecord
include Publishable(column_name: :published_at)
end
Recently I read a blog post about how the author learned from reading some ruby gems' implementation to allow including a module with argument. Sounds fun so I gave it a try.
When you may want to include a module with arguments?
Let's first see an example, this may not be practical but at the moment I couldn't think of other great ones so please bear with me.
module Publishable
def publish
update(published_at: Time.now)
end
end
class Post < ApplicationRecord
include Publishable
end
There's nothing wrong with this code but some may argue that the Publishable module depends on the published_at column name, if someone renames the column to published_on then it'll be broken, it's coupled to the Post class.
One way to solve it may look like this:
module Publishable
def publish
update(publishable_column_name => Time.now)
end
private
def publishable_column_name
raise NotImplementedError
end
end
class Post < ApplicationRecord
include Publishable
private
def publishable_column_name
:published_at
end
end
Now the Publishable is relying on its "host"(Post) to define the method publishable_column_name to explicitly tell it the column name, otherwise it will raise the not implemented error. But the downside is the module's requirement leaks to Post, and that's arguably break the purpose to use a module, which is to group all methods of a concept in one place.
I'm thinking this could be a use case for the technique I'm gonna show you in this blog post.
Trick: A Class Inherits From Module
How the trick work is already introduced in the article above, let's fast forward to the end result now.
class Publishable < Module
def initialize(column_name)
@column_name = column_name
end
def included(base)
column_name = @column_name
base.class_eval do # self is Post class
define_method(:publish) do
update(column_name => Time.now) # self is Post object
end
end
end
end
class Post < ApplicationRecord
include Publishable.new(:published_at)
end
The notable things are
class Publishable < Module, we're not declaring a module, instead we're making a class that inherits from module
- When we include this module, we can now initialize it with any arguments
- The arguments will be available inside the
Publishable "module", hence no the knowledge is decoupled
- Use
define_method(:publish) { } instead of def publish, latter will make you lose the context and you can't access column_name variable anymore
Why It Works?
I'm gonna borrow the code from the article, this is how Rubinius handles module and is identical to MRI Ruby:
def include?(mod)
if !mod.kind_of?(Module) or mod.kind_of?(Class)
raise TypeError, "wrong argument type #{mod.class} (expected Module)"
end
# snip
end
When we do include SomeModule, this code will be executed to check if the target is a valid module. The trick is how we could manage to pass that check, that is "something is kind of Module and not kind of Class".
class Publishable < Module
end
Publishable.kind_of?(Module) # => true
Publishable.kind_of?(Class) # => true
Publishable.new(:published_at).kind_of?(Module) # => true
Publishable.new(:published_at).kind_of?(Class) # => false
With this trick you can pass whatever dependencies from the host to the module. Hope you find it useful, and let me know if you find a good use case with it in real world 😁Not sure if it's yet another case of abusing Ruby's flexibility to driver it too far, I do concern about its possible confusion when bring to teams in which people are most likely not aware of this kind of hack.
Further Reading
These 2 gems are using the same technique, and their full implementations are really easy to read and follow.