Arguments for Included Modules in Ruby
--
When modules are included (or extended or prepended) into an class the traditional approach doesn’t allow arguments to be provided. Why would you want arguments on an include? Let’s work with a concrete example. Below is a simple “slug” implementation for ActiveRecord objects:
module Slug
def to_param
name.downcase.gsub /\W+/, '-'
end
end
The problem with this module is that it is brittle. It assumes the value we want to slugify is always `name`. We could make this a bit more flexible with this implementation:
module Slug
def to_param
slug_field.downcase.gsub /\W+/, '-'
end private def slug_field
name
end
end
This sort of callback approach works. For example:
class BlogPost < ApplicationRecord
include Slug private def slug_field
title
end
end
The problem is that the callback seems disconnected from the include as you add more to the file. Especially when the callback and include are not so obviously connected (here they share the keyword slug). It would be better if we could pass arguments during the include to configure it. My ideal syntax would be:
class BlogPost < ApplicationRecord
include Slug, field: :title
end
Unfortunately Ruby doesn’t support arguments with includes, but by using anonymous modules we can emulate the desired behavior. Although we don’t get exactly the above syntax we get something close:
class BlogPost < ApplicationRecord
include Slug.new field: title
end
So what does a arg-enabled `Slug` module now look like?
class Slug < Module
def initialize field: :name
super() do
define_method :to_param do
public_send(field).downcase.gsub /\W+/, '-'
end
end
end
end
Obviously our Slug module has gotten a bit more complicated, but to the advantage of the code using our module, a trade-off often worth making. Let’s break this down to understand how this works.
Syntactical Sugar
Modules are just instances of the class Module assigned to a constant. The following are the same:
module Foo
endFoo = Module.new
Anonymous/Local Modules
Modules don’t need to be assigned to a constant. They can remain anonymous or assigned to a local variable:
def foo
bar = Module.new
end
Above we create a new module but instead of assigning it to a global constant we assign it to a local variable so it only exists inside the method `foo`. After executing `foo` it is available for garbage collection just like any other object instance.
Defining Mixin Methods for Modules
`Module#new` accepts a block and the methods defined in that block become the methods for that module to be mixed into the class including that module. The following are the same:
module Foo
def bar
puts 'baz'
end
endFoo = Module.new do
def bar
puts 'baz'
end
end
We can also use `define_method` if we want our method definition to be a block.
Foo = Module.new do
define_method :bar do
puts 'baz'
end
end
This is logically the same as the previous examples. While more verbose it does give us an advantage. Blocks create closures to capture local variables. We will see how to use that later.
Subclassing Modules
Since modules are just instances of the class Module, you can subclass it to provide specializations. The below is logically equivalent to the previous examples:
class ModuleWithBar < Module
def initialize
super do
def bar
puts 'baz'
end
end
end
endFoo = ModuleWithBar.new
Rather than define `bar` in a block given to `Module#new`, we are subclassing Module so that the block with `bar` is automatically provided anytime a `ModuleWithBar` is created.
Passing Arguments
You can pass arguments to your subclass `initialize` method just like any `initialize` method. Since the superclass doesn’t accept arguments you must explicitly call `super` without arguments to avoid the arguments you added from moving up the chain. Arguments can be positional, have defaults, be keyword arguments, etc.
class ModuleWithArgs < Module
def initialize arg='default'
super()
end
end
In our above example we didn’t actually use the argument. Here is where our previous discussion of `define_method` comes in handy.
If we just use `def` when defining our methods they just execute in the scope of the included object. But, if we use `define_method` then our block is executed in the scope of the included object but also has access to the local variables in the closure created. This allows our method to use the arguments. So:
class ModuleWithArgs < Module
def initialize arg='default'
super() do
define_method :bar do
puts arg
end
end
end
end
If we include an anonymous instance of our module and supply no arguments we get the following:
class Foo
include ModuleWithArgs.new
endFoo.new.bar # prints 'default'
But instead if we use the arg we get to configure the behavior of our include:
class Cat
include ModuleWithArgs.new 'hello'
endCat.new.bar # prints 'hello'
Wrap Up
Let’s use all this info to circle back to our implementation of Slug:
class Slug < Module
def initialize field: :name
super() do
define_method :to_param do
public_send(field).downcase.gsub /\W+/, '-'
end
end
end
end
They key points here are:
- We are creating a subclass of Module that defines our methods for us anytime an instance is created.
- Our subclass accepts a keyword argument called `field` with a default value of `:name`
- The methods defined is just one called `to_param`.
- This method is defined using `define_method` so the block can capture the keyword argument.
- The implementation of that method just uses that argument to get access to the value to do it’s transformation.