Module Builder Pattern
Did you know you a class can inherit from Module
?
class Foo < Module
end
Foo # => Foo (a Foo class)
Foo.new # => <Foo:0x0000563adb5c4ab0> (an instance of Foo class)
This works because Module
is a class, Module.class # => Class
, so it can be
used in classical inheritence.
Foo
is a class and Foo.new
returns an instance of that class which includes
Module
in its ancestors.
Anything which inherits from Module
can be included in another class.
class Bar
include Foo.new
end
We have to call #new
to get an object which as Module
in its ancestors.
Yeah, but why would you want to do this?
One practical use is when you want a module to be stateful, that is be configurable.
Firstly let’s look at a regular, and very much contrived, stateless module.
module Foo
def hello
'hi'
end
end
class Bar
include Foo
end
Bar.new.hello # => 'hi'
Now let’s say we want the method, #hello
, to be configurable. We want the user of the module to configure the name of the method the
module is going to define. We might reach
for define_method
to create our method dynamically but but how do we pass the desired method name in to the module as configuration?
Because we are defining a class we have an initialize
method to which we
can pass arguments.
class Foo < Module
def initialize(method_name)
define_method method_name do
'hi'
end
super()
end
end
class Bar
include Foo.new(:bonjour)
end
Bar.new.bonjour # => 'hi'
Because the block passed to define_method
is a closure, the scope in which it
is created is remembered and accessible inside the block when it is called.
Therefore we have access to the any arguments passed to #initialize
.
Our module has state, which we could expose if desired.
class Foo < Module
attr_reader :method_name
def initialize(method_name)
@method_name = method_name
end
end
foo = Foo.new('bonjour)
foo.method_name # => 'bonjour'
When creating a module by inheriting from Module
a few other things need to be adjusted compared to using
module
to define a module.
Methods defined in our class are only avalible to instances of the class and
are not available to the class in which the module is included as would be the
case with a module defined using the module
keyword.
module Foo
def welcome
'welcome'
end
end
class Bar
include Foo
end
Bar.new.welcome # => welcome
But with a class inhertied module:
class Foo < Module
def welcome
'welcome'
end
end
class Bar
include Foo.new
end
Bar.new.welcome # => MethodMissingError
Instead we need to do the following:
class Foo < Module
def included(descendant)
super
descendant.send(:include, Methods)
end
module Methods
def welcome
'welcome'
end
end
end
class Bar
include Foo.new
end
Bar.new.welcome # => 'welcome'
So can we get rid the need for .new
when including the module?
Kind of, here are a few options.
Uppercase method on parent module
module MyLibrary
def self.Foo(*args)
Foo.new(*args)
end
class Foo < Module
def initialize(options = {})
super()
puts options
end
end
end
class Pub
include MyLibrary::Foo(a: :b)
include MyLibrary::Foo()
include MyLibrary::Foo # <- does not work as Foo class, of type Class, is returned, the MyLibrary#Foo method is not called
end
Bar.new
This works for two reasons, class methods can be called via .
or ::
and
methods can be capitalized making them look like references to constants.
However the final example does not work because the constant Foo
has
presedence over the method Foo
.
Square brackets
class Foo < Module
def self.[](options = {})
new(options)
end
def initialize(options = {})
super()
puts options
end
end
class Bar
include Foo[a: :b]
include Foo[]
include Foo # does not work, Foo is a class of type Class, the `[]` method is not called
end
Bar.new
Lowercase method on parent module
This is my prefered method, no pun intended.
module MyLibrary
def self.foo(options = {})
Foo.new(options)
end
class Foo < Module
def initialize(options = {})
super()
puts options
end
end
end
class Bar
include MyLibrary.foo
include MyLibrary.foo(a: :b)
end
Bar.new
Finally, here is a template to use
module MyLibrary
def self.foo(*args)
Foo.new(*args)
end
class Foo < Module
attr_reader :strict
def initialize(options = {})
options = parse_options(options)
@strict = options.fetch(:strict, true)
define_method :on_event do |name, payload|
if respond_to?(name)
public_send(name, payload)
else
raise(NoMethodError) if strict
end
end
end
def included(descendant)
super
descendant.send(:include, Methods)
end
module Methods
# this will be a method on a target class
def welcome
'welcome'
end
end
private
# this is in scope to initialize
def parse_options(options)
# ...
end
end
end
class Bar
include MyLibrary.foo
end