Let's talk about modules in Ruby, how they differ to classes, why are they useful and how does the Ruby method lookup works.
Modules in Ruby are similar to classes, they hold multiple methods. However, the modules cannot be instantiated as classes do, the modules do not have the method new
.
We can think of modules as mix-ins, they allow you to inject some code into a class. The mix-in exports the desired functionality to a child class, without creating an "is a" relationship. In Ruby, the main difference between inheriting from a class and mixing a module is that you can mix in more than one module.
The Well-Grounded Rubyist book has a really good example about when a module may be needed.
💡 When you're designing a program and you identify a behavior or set of behaviors that may be exhibited by more than one kind of entity or object, you've found a good candidate for a module.
Let's review the basics of module creation. We will use "stack-likeness" as our main example. We want to create a module that introduces stack-like behavior to any class.
ℹ️ A stack is a one dimensional data structure that follows a particular order for the operations to be performed on it: LIFO (Last In First Out), the last element to be added into the stack will be the first element to be queried on the stack.
Module Creation
Writing a module follows almost the same syntax that we use to create classes, the only difference is that we need to start the definition using the module
keyword instead of the class
keyword:
module Stacklike
def stack
@items ||= []
end
def push(obj)
items.push(obj)
end
def pop
items.pop
end
end
What we did here is pretty simple. We're creating an instance variable called @stack that is an array representing our stack (1D data structure we talked about above) and we then abstract the push and pop behaviors of the stack into the push and pop methods of the module.
Mixing the Module Into a Class
In order to mix the module into a class we are going to make use of one of three different keywords: include
, prepend
and extend
.
⚠️ For this example, I'm going to show how to use the
include
keyword to add Stack-likeness into our class, to understand how the other keywords work we'll need to go through how the method lookup works in Ruby and we'll do that later in this article.
class Stack
include Stacklike
end
That's it. That's all that is needed in order to mix our Stacklike module into our Stack class, and now it can behave like a stack.
stack = Stack.new
stack.push("First item")
stack.push("Second item")
stack.push("Third item")
puts "Objects currently in the stack:"
puts stack.items
last_item = stack.pop
puts "Removed from the stack:"
puts last_item
puts "Objects currently in the stack:"
puts stack.items
If we execute this code, we'll see that the stack behavior is currently being applied to our Stack class and the Stack-likeness behavior can be introduced to any other class that we want in our codebase, it is easily reusable.
💡 Rubyists often use adjectives for module names in order to reinforce the notion that the module defines a behavior.
Method Lookup Path
The method lookup path is a mechanism in which Ruby starts "bubbling up" in the "object chain" in order to find what object or module is the one that contains the method that we're currently calling. This is what's being used when we call object.pop
or object.items
in our past example, those methods are not explicitly defined in Stack, but they're found in the method lookup path of the Class by including the Stacklike module.
Consider the following code:
module M
def x
puts "Hey! I'm the module M"
end
end
class B
def x
puts "Hey! I'm the class B"
end
end
class A < B
includes M
end
We are creating a class (A) that inherits from another class (B) and also mixes the behavior that's provided by a module (M). Now, we can instantiate the class A and call the method X, by following the Method Lookup Path, we'll get "Hey I'm the module M" as the result.
a = A.new
a.x # Hey I'm the module M
The Class A object will try to find a method to execute based on the message that has been received (x). If Ruby has looked up all the way in the object chain until it reaches out Kernel or BasicObject and still hasn't found it, then it won't be found.
ℹ️ The point of BasicObject is to have as few instance methods as possible, so it doesn't provide much functionality. Most of the functionality or Ruby's fundamental methods is actually found in the Kernel module, that are included in all objects that descend from Object.
By looking at the diagram above, you'll note that the way the method x is found in the example is by following the path: [A, M, B, Object, Kernel, BasicObject]
and as the method x is found in M, it will use that instead of using the one found in B. You can use the ancestors
method to find this path in any object, it is provided by the Kernel module.
ℹ️ If two modules are included in the same class and they both contain a method with the same name, they're going to be searched in reverse order of inclusion. The last mixed-in module is searched first.
Prepend and Extend Keywords
When mixing-in the first module into the class, I told that the prepend
and extend
keywords could be used to that as well. Let's see the differences between them.
Prepend
It works almost the same as include
, the difference is that when you prepend a module to the class, the object will look for the method in the module first, before looking for it in the class.
Consider this change in the code above:
class A < B
prepend M
def x
puts "Hey! I'm the class A"
end
end
The method lookup path will change to [M, A, B, Object, Kernel, BasicObject]
.
Extend
Both include
and prepend
will make the methods of the modules available as instance methods of the class. extend
works a bit different by making module's methods available as class methods instead. Extending an object doesn't add the module into the ancestor chain.
Super Method
There's this keyword called super
that we can use inside the body of a method definition. What this keyword does is that it jumps to the next-highest definition of the current method in the method lookup path.
Consider the following change in the example code:
module M
def x
puts "Hey! I'm the module"
puts "But I'm going to call the next higher-up method..."
super
puts "Back in the module"
end
end
Calling the method x will result in something like:
a = A.new
a.x
# Hey I'm the module
# But I'm going to call the next higher-up method
# Hey I'm the Class B
# Back in the module
Because the super
keyword is going to call the method x that is found in the Class B which is the next object that is available in the method lookup path in this example.
It's also important to note that the super
keyword handles arguments in a different way as methods would do:
- When called with no argument list, it will automatically forward the arguments that were passed to the method from which it's called.
- When called with an empty argument list (
super()
), it sends no arguments to the higher up method, even if there were arguments passed to the current method. - When called with specific arguments (
super(1, 2, 3)
), it sends exactly those arguments to the higher up method.
When to Use Mix-Ins vs Inheritance
Having both inheritance and modules means that you have a lot of choice, but having a lot of choice also means that you also must be very careful about the considerations both this approaches introduce.
- As noted at the beginning of this post, modules don't have instances. Entities or things are better modeled using classes, while behaviors or properties are better encapsulated using modules.
- Classes can have a single superclass, but can mix in as many modules as needed.
You may like to break everything into separate modules, because you think something that you write for one entity may be useful in another entities in the future. But the overmodularization also exists. You've got the tools, and is up to you to consider how to balance them.
Hope you've liked this post, I learned a lot while studying modules and I tried to summarize it as much and as easy I could in this post.
If you've got any feedback or question about this post, please add it as a comment. I'm sure we all could learn about this.
Thanks for your time ❤️👋
Top comments (2)
Nice introduction.
Modules can be used to create real singletons in ruby.
from github.com/ib-ruby/ib-symbols/blob... :
Then
IB::Symbols.allocate_m :c
creates the module
IB::Symbols::C
and the defined methods.No class is involved in the hole approach .
IB::Symbols::C.yml_file
is a real singleton.Good article. What is the difference with 'Concerns' (available on RoR)? 🤔