The Pug Automatic

Ruby Module Builder patterns

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.

The instance-mixin duality

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.

Modules are class instances

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.

Modules can be mixed in

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.

Using 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 defed 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.

Using 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.

Non-initializer builders

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

Template methods

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:

  • If you define the method next to the include, that groups them nicely, but linters may complain about the class layout if you define a method in between two includes. If you define the method further away, they are not grouped as nicely.
  • We expose the longer, qualified names (greeter_name) in the including class rather than shorter, unqualified names (name or a positional argument). Arguably less elegant.
  • We define an extra method instead of just passing in an argument. Arguably less elegant.
  • We can't dynamically decide method names or whether to define a method.
  • We don't get multiple modules with different identity.

Module identity

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:…>, …]

Overriding 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>, …]

Assigning a constant

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

Footnote 1 ^

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.


Footnote 2 ^

A non-sensible way with Concern could be this 🙈:

included { @@greeter_name = name }