Written July 13, 2023. Tagged Ruby, Metaprogramming.
I recently discovered the Ruby Module Builder pattern.
It lets you pass in arguments to dynamically generate a module at include
time:
class Greeter < Module
def initialize(name)
define_method(:greet) { puts "Hello #{name}!" }
end
end
class MyClass
include Greeter.new("world")
end
MyClass.new.greet
# => "Hello world!"
I thought I'd share some insights and patterns I've found when using it.
Crucially (and mind-bendingly), you need to set up your module inside the initialize
block. Methods defined on Greeter
won't be available on including classes:
class Greeter < Module
def initialize(name) = # …
def greet_on_module = "Hello!"
end
class MyClass
include Greeter.new
end
# This works:
Greeter.new("world").greet_on_module
# But this raises NoMethodError:
MyClass.new.greet_on_module
This broke my brain initially, but it makes sense when you think through it.
Modules have a dual nature: they are class instances, and they can also be mixed into classes and other modules.
Module
is a class. So are its subclasses, like Greeter
above.
When you do Greeter.new
, you get an instance of Greeter
. This instance is a module that you can mix in.
instance = Greeter.new("world")
class MyClass
include instance
end
When you use module
, you also get an instance, but assigned to a global constant:
module OtherGreeter
end
instance = OtherGreeter
class MyClass
include instance
end
So Greeter
is a class whose instances are modules you can mix in; OtherGreeter
is not a class, but is itself a module you can mix in¹.
After all, the whole point of Greeter
is to create multiple modules – one for "world"
, another for "moon"
– so they can't all be assigned to a single Greeter
constant.
And this explains why greet_on_module
can only be called on instances of Greeter
. It's an instance method and the instances are modules. It's equivalent to
module OtherGreeter
def self.greet_on_module = "Hello!"
end
You would not expect this method to be included when you mix in the module.
Classes have instance methods that you call on instances but not on the class itself.
Modules also have instance methods (we might think of them as "mixin methods") that are mixed in, but are not called on the module itself.
With the module
keyword, we just define those in the module body:
module OtherGreeter
def greet = "Hello!"
end
With Module.new
, we define them in a block:
Module.new do
def greet = "Hello!"
end
With class … < Module
, we define them in the initializer:
class Greeter < Module
def initialize(name)
define_method(:greet) { puts "Hello #{name}!" }
end
end
It makes sense. This is a class that creates modules. We need to create a module before we can define instance methods on it, and it's only in the initializer that we've created it.
define_method
defines instance methods on the receiver, which inside the initializer is the module we created.
We can't use def greet
inside the initializer. As with any class, def
inside the initializer defines instance methods on the class. It would be just like greet_on_module
.
There is still a way to use def
, though.
module_eval
Sometimes define_method
is exactly what we need, if we use the passed-in values to determine method names (as in SecurePassword
), or whether to define a method at all.
But especially in a more complex module, it's nice to be able to use def
for most of it, with define_method
oneliners only to capture passed-in data.
module_eval
to the rescue:
class Greeter < Module
def initialize(name:, time:)
private define_method(:greeter_name) { name }
private define_method(:greeter_time) { time }
module_eval do
def greet = "Good #{greeter_time}, #{greeter_name}!"
end
end
end
We still need define_method
to make the passed-in data available to def
ed methods – I can't think of a sensible² way around that.
Note that I'm defining greeter_name
etc rather than name
, since this method will be mixed into MyClass
, where names could otherwise conflict.
ActiveSupport::Concern
Here's an example of a Module Builder using ActiveSupport::Concern
, since it took me a few attempts to get right.
require "active_support/concern"
class Greeter < Module
def initialize(name)
extend ActiveSupport::Concern
class_methods do
define_method(:greeter_name) { name }
def classy_greet = "Classy hello #{greeter_name}!"
end
module_eval do
def greet = "Hello #{self.class.greeter_name}!"
end
end
end
class MyClass
include Greeter.new("world")
end
puts MyClass.classy_greet
puts MyClass.new.greet
Note that extend ActiveSupport::Concern
goes inside the initializer.
Don't be tempted to replace module_eval
with included
. Both let you use def
, but included
would define methods on the including class, not on the module. This means you can't override them conveniently. included
is still fine for calling class methods, of course.
Max mentioned a variation on this technique, where you don't use the initializer.
Arguably this is a little less confusing; Module.new { … }
is a common pattern.
It also lets you define multiple builders on the same module.
module Greeter
def self.by_name(name)
Module.new do
define_method(:greet) { "Hello #{name}!" }
end
end
def self.loudly_by_name(name)
Module.new do
define_method(:greet) { "HELLO #{name.upcase}!!1" }
end
end
end
class MyClass
include Greeter.by_name("world")
end
class MyLoudClass
include Greeter.loudly_by_name("world")
end
puts MyClass.new.greet
puts MyLoudClass.new.greet
You could also use the Template Method pattern – pick a conventional method name and define that on the class:
module Greeter
def greet = "Hello #{greeter_name}!"
def greeter_name = raise NoMethodError, "Define #{__method__}!"
end
class MyClass
include Greeter
def greeter_name = "world"
end
That's what we had before moving to module builders.
It's simpler to reason about, but also has some downsides:
include
, that groups them nicely, but linters may complain about the class layout if you define a method in between two include
s. If you define the method further away, they are not grouped as nicely.greeter_name
) in the including class rather than shorter, unqualified names (name
or a positional argument). Arguably less elegant.Regular modules let you check if they're mixed in:
MyClass.new.is_a?(Greeter)
MyClass < Greeter
Greeter === MyClass.new
MyClass.ancestors
# => [MyClass, Greeter, …]
That's harder to do with these built modules.
The modules built from an initializer are instances of Greeter
:
MyClass.ancestors
# => [MyClass, #<Greeter:…>, …]
It's easy to see where they come from, but we can't do MyClass.new.is_a?(Greeter)
. We'd need something like MyClass.ancestors.any? { _1.is_a?(Greeter) }
.
If we extend ActiveSupport::Concern
, this modifies Greeter
's singleton class and we no longer see a helpful <#Greeter:…>
:
MyClass.ancestors
# => [MyClass, #<#<Class:…>:…>, …]
And the non-initializer ones are just anonymous modules with no knowledge of whence they came:
MyClass.ancestors
# => [MyClass, #<Module:…>, …]
inspect
We can make things nicer by overriding inspect
:
class Greeter < Module
def initialize(name)
define_singleton_method(:inspect) { "#<Greeter:#{name}>" }
end
end
class MyClass
include Greeter.new("world")
end
MyClass.ancestors
# => [MyClass, <#Greeter:world>, …]
If we wanted to go completely overboard (and we do), we could do something like this:
require "digest"
module Greeter
def self.by_name(name)
module_name = "ByName#{Digest::SHA1.hexdigest(name)}"
return const_get(module_name) if const_defined?(module_name, false)
const_set(module_name, Module.new do
define_method(:greet) { "Hello #{name}!" }
end)
end
end
class MyClass
include Greeter.by_name("world")
end
MyClass.ancestors
# => [MyClass, Greeter::ByName7c211433f02071597741e6ff5a8ea34789abbf43, …]
If we instead build in the initializer, we need to override .new
:
class Greeter < Module
def self.new(name)
module_name = "ByName#{Digest::SHA1.hexdigest(name)}"
return const_get(module_name) if const_defined?(module_name, false)
const_set(module_name, super)
end
def initialize(name)
define_method(:greet) { "Hello #{name}!" }
end
end
And now we can check for module identity in the usual ways:
MyClass < Greeter.new("world") # => true
MyClass < Greeter.new("moon") # => nil
I say "a module you can mix in" because Class
inherits from Module
. All classes are modules, but not ones you can mix in.
Classes are modules with extra stuff. Both hold methods and constants and can have modules mixed into them. Classes add instantiation and state.
If you try to include
or extend
with a class as argument, you get a TypeError
. They're still modules; Ruby just won't let you mix them in.
A non-sensible way with Concern
could be this 🙈:
included { @@greeter_name = name }