Ruby Include module with arguments
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 ofdef publish
, latter will make you lose the context and you can't accesscolumn_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.