Learn Ruby Metaprogramming for Great Good

Ruby Metaprogramming can be a good thing.
Recently, I was reviewing one of my student’s code. He had a program with many methods that printed out various messages, for example:
class MyClass
def calculate(a)
result = a ** 2
puts "The result is #{result}"
end
end
class MyOtherClass
def some_action(a, b)
puts "The first value is #{a}, the second is #{b}"
end
def greet
puts "Welcome!"
end
end
I suggested that he store these messages in a separate file to simplify the process working with them. Then, I remembered how I18n translations are stored in Rails and got an idea. Why don’t we create a YAML file (a so-called dictionary) with all the messages and a helper method to fetch them properly, all while supporting additional features like interpolation? This is totally possible with Ruby’s metaprogramming!
That’s how the messages_dictionary gem (created mostly for studying purposes) was born – I even used it in couple of my other projects. So, in this article, we will see Ruby’s metaprogramming in action while writing this gem from scratch.
Basic Gem Structure
Let me quickly cover the files and folders for the gem:
- Gemfile
- A gemspec that contains system information. You can check out the gem’s source code on GitHub to see how they look.
- The Rakefile contains instructions to boot tests written in RSpec.
- .rspec contains options for RSpec. In this case, for example, I want the tests to run in a random order, the spec_helper.rb file should be required by default, and the output should be colored verbose. Of course, these options can be set when running RSpec from the terminal, as well.
- .travis.yml contains configuration for the Travis CI service that automatically runs tests for each commit or pull request. This is a really great service, so give it a try if you have never seen it before.
- README.md contains the gem’s documentation.
- spec/ contains all the tests written in RSpec. I won’t cover tests in this article, but you may study them on your own.
- lib/ contains the gem’s main code.
Let’s start work in the lib directory. First of all, create a messages_dictionary.rb file and a messages_dictionary folder. messages_dictionary.rb will require all third-party gems, as well as some other files, and define our module. Sometimes configuration is also placed inside this file, but we won’t do this.
lib/messages_dictionary.rb
require 'yaml'
require 'hashie'
module MessagesDictionary
end
Pretty minimalistic. Note that this gem has two dependencies: YAML and Hashie. YAML will be used for parsing .yml files whereas Hashie provides a bunch of really cool extensions for the basic Array and Hash classes. Open this RubyGems page and note that Hashie is placed under the Dependencies section. This is because inside the gemspec we have the following line:
spec.add_dependency 'hashie', '~> 3.4'
YAML parser is a part of Ruby core, but Hashie is a custom solution, therefore we have to specify it as a dependency.
Now inside the lib/messages_dictionary create a version.rb file:
lib/messages_dictionary/version.rb
module MessagesDictionary
VERSION = 'GEM_VERSION_HERE'
end
It’s a common practice to define the gem’s version as a constant. Next, inside the gemspec file reference this constant:
spec.version = MessagesDictionary::VERSION
Also note that all the gem’s code is namespaced under the module MessagesDictionary
. Namespacing is very important because otherwise, you may introduce naming collisions. Suppose someone wishes to use this gem in their own project, but there is already a VERSION
constant defined somewhere (after all, this is a very common name). Placing the gem’s version outside of a module may re-write this constant and introduce bugs that are hard to detect. Therefore, think of a name for your gem and make sure that this name is not yet in use by Googling a bit, then namespace all your code under this name.
Okay, so preparations are done and we can start coding!
Dynamically Defining a Method
First of all, let’s discuss how we want this gem to be used. Of course, before doing anything else it has to be required:
require 'messages_dictionary'
Next, our module has to be included:
class MyClass
include MessagesDictionary
end
Then there should be a method to tell MessagesDictionary
to do its job. Let’s call this method has_messages_dictionary
, inspired by Rails’ hassecurepassword:
class MyClass
include MessagesDictionary
has_messages_dictionary
end
The next step for the user is to create a .yml file containing messages:
hi: "Hello there!"
Finally, in order to display this message, a special method has to be called:
class MyClass
include MessagesDictionary
has_messages_dictionary
def greet
pretty_output(:hi) # Prints "Hello there!" in the terminal
end
end
This is somewhat basic functionality, but we will extend it later.
Create a new file called injector.rb inside the lib/messages_dictionary directory. The big question is how to equip a class with an additional method has_messages_dictionary
on the fly? Luckily for us, Ruby presents a special hook method called included
that runs once a module is included into a class.
lib/messages_dictionary/injector.rb
module MessagesDictionary
def self.included(klass)
end
end
included
is a class method, so I need to prefix it with self
. This method accepts an object representing the class which has included this module. Note that I intentionally called this local variable klass
, because class
is a reserved word in Ruby.
What do we want to do next? Obviously, define a new method called has_messages_dictionary
. However, we can’t use def
for that – this has to be done dynamically at runtime. Also, note that the has_messages_dictionary
has to be a class method, therefore we have to use the definesingletonmethod. If you wish to learn more about singleton methods, watch my screencast about them. To put it simply, class methods are singleton methods.
There is a small gotcha, however. If I use define_singleton_method
like this
module MessagesDictionary
def self.included(klass)
define_singleton_method :has_messages_dictionary do |opts = {}|
end
end
end
Then this method will be defined inside the MessagesDictionary
module but not inside the class! Therefore we have to use yet another method called class_exec that, as you’ve probably guessed, evaluates some code in the context of some class:
lib/messages_dictionary/injector.rb
module MessagesDictionary
def self.included(klass)
klass.class_exec do
define_singleton_method :has_messages_dictionary do |opts = {}|
end
end
end
end
Note that define_singleton_method
has a local variable called opts
set to an empty hash by default. If we did not have to define this method dynamically, the corresponding code would look more familiar:
def self.has_messages_dictionary(opts = {})
end
This concept of using the included
hook and defining some method in the context of another class is pretty common and, for example, is used in Devise.
Opening a File
Next, our code should open a file with messages. Why don’t we expect this file to be named after the class name? For example, if the class if called MyClass
then the file should be my_class.yml. The only thing we need to do is convert the class name from camel to snake case. Whereas Rails does have such method, Ruby does not provide it, so let’s just define a separate class for that. The code for the snake_case
case method was taken from the Rails ActiveSupport module:
lib/messages_dictionary/utils/snake_case.rb
module MessagesDictionary
class SpecialString
attr_accessor :string
def initialize(string)
@string = string
end
def snake_case
string.gsub(/::/, '/').
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
gsub(/([a-z\d])([A-Z])/,'\1_\2').
tr("-", "_").
downcase
end
end
end
Of course, we might have reopened an existing String
class, but a user’s application may already have such method defined and we don’t want to redefine it.
Use this new helper class:
injector.rb
# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
file = "#{SpecialString.new(klass.name).snake_case}.yml"
end
The next step is to load a file and halt the program’s execution if it was not found:
injector.rb
# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
file = "#{SpecialString.new(klass.name).snake_case}.yml"
begin
messages = YAML.load_file(file)
rescue Errno::ENOENT
abort "File #{file} does not exist..." # you may raise some custom error instead
end
end
messages
will contain a hash based on the file’s contents but I’d like to offer some flexibility for the users, allowing them to access a message by either providing a symbol or a string as the key. This is called indifferent access and Hashie does support it.
In order to add this feature, define a special class called Dict
and add the proper modules there:
lib/messages_dictionary/utils/dict.rb
module MessagesDictionary
class Dict < Hash
include Hashie::Extensions::MergeInitializer
include Hashie::Extensions::IndifferentAccess
end
end
Now tweak the main file a bit:
injector.rb
# ...
messages = Dict.new(YAML.load_file(file))
# ...
The last step in this section is storing these messages somewhere. Let’s use a class constant for that. Here is the resulting code:
injector.rb
module MessagesDictionary
def self.included(klass)
klass.class_exec do
define_singleton_method :has_messages_dictionary do |opts = {}|
file = "#{SpecialString.new(klass.name).snake_case}.yml"
begin
messages = Dict.new(YAML.load_file(file))
rescue Errno::ENOENT
abort "File #{file} does not exist..."
end
klass.const_set(:DICTIONARY_CONF, {msgs: messages})
end
end
end
end
const_set
dynamically creates a constant named DICTIONARY_CONF
for the class. This constant contains a hash with our messages. Later we will store additional options in this constant.
Support for Options
Our script is starting to gain some shape, but it is way too rigid. Here are some obvious enhancements that need to be introduced:
- It should be possible to provide a custom messages file
- Currently, the messages file has to placed into the same directory as the script, which it is not very convenient. Therefore, it should be possible to define a custom path to the file.
- In some cases, it might be more convenient to pass a hash with messages directly to the script instead of creating a file.
- It should be possible to store nested messages, just like in Rails’ locale files.
- By default all messages will be printed out into
STDOUT
using theputs
method, but users may want to change that.
Some of these features might seem complex but, in reality, they are somewhat simple to implement. Let’s start with the custom path and file names.
Providing a Custom Path
We are going to instruct users to provide the :dir
and :file
options if they want to redefine the default path name. Here is the new version of the script:
injector.rb
# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
file = "#{SpecialString.new(klass.name).snake_case}.yml"
begin
file = opts[:file] || "#{SpecialString.new(klass.name).snake_case}.yml"
file = File.expand_path(file, opts[:dir]) if opts[:dir]
rescue Errno::ENOENT
abort "File #{file} does not exist..."
end
klass.const_set(:DICTIONARY_CONF, {msgs: messages})
end
Basically we’ve changed only two lines of code. We either fetch user-provided file name or generate it based on the class name, then use the expand_path method if they supply a directory.
Now the user can provide options like this:
has_messages_dictionary file: 'test.yml', dir: 'my_dir/nested_dir'
Passing a Hash of Messages
This is a simple feature to implement. Just provide support for the messages
option:
injector.rb
# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
if opts[:messages]
messages = Dict.new(opts[:messages])
else
file = opts[:file] || "#{SpecialString.new(klass.name).snake_case}.yml"
file = File.expand_path(file, opts[:dir]) if opts[:dir]
begin
# ...
end
end
end
Now messages can be provided in the form of a hash:
has_messages_dictionary messages: {hi: 'hello!'}
Support Nesting and Displaying the Messages
This feature is a bit trickier, but totally feasible with the help of Hashie’s deep_fetch method. It takes one or more arguments representing the hash’s keys (or array’s indexes) and does its best to find the corresponding value. For example, if we have this hash:
user = {
name: { first: 'Bob', last: 'Smith' }
}
You can say:
user.deep_fetch :name, :first
to fetch “Bob”. This method also accepts a block that will be run when the requested value cannot be found:
user.deep_fetch(:name, :middle) { |key| 'default' }
Before employing this method, however, we need to extend our object with new functionality:
injector.rb
# ...
klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch)})
# ...
Let’s decide how the users should provide a path to the nested value. Why don’t we use Rails’ I18n approach where keys are delimited with .
:
pretty_output('some_key.nested_key')
It’s high time to add the actual pretty_output
method:
injector.rb
# ...
define_method :pretty_output do |key|
msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do
raise KeyError, "#{key} cannot be found in the provided file..."
end
end
Take the key and split it by .
resulting in an array. The array’s elements should then be converted to the method’s arguments, which is why we prefix this command with *
. Next, fetch the requested value or raise an error if nothing was found.
Outputting the Message
Lastly we’ll display the message while allowing the redefinition of the output location and the method to be used. Let’s call these two new options output
(default is STDOUT
) and method
(default is :puts
):
injector.rb
# ...
klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch),
output: opts[:output] || STDOUT,
method: opts[:method] || :puts})
# ...
Next just user these options. As long as we are calling a method dynamically, without knowing its name, use send:
injector.rb
# ...
define_method :pretty_output do |key|
msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do
raise KeyError, "#{key} cannot be found in the provided file..."
end
klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end
The gem is nearly finished, there are just a few more items.
Interpolation
Interpolating values into a string is a very common practice, so, of course, our program should support it. We only need to pick some special symbols to mark an interpolation placeholder. I’ll go for Handlebars-style {{
and }}
but of course you may choose anything else:
show_result: "The result is {{result}}. Another value is {{value}}"
The values will then be passed as a hash:
pretty_output(:show_result, result: 2, value: 50)
This means that the pretty_output
method should accept one more argument:
injector.rb
# ...
define_method :pretty_output do |key, values = {}|
end
In order to replace a placeholder with an actual value we will stick with the gsub!
:
injector.rb
# ...
define_method :pretty_output do |key, values = {}|
msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do
raise KeyError, "#{key} cannot be found in the provided file..."
end
values.each do |k, v|
msg.gsub!(Regexp.new('\{\{' + k.to_s + '\}\}'), v.to_s)
end
klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end
Custom Transformations and Finalizing the Gem
The last feature to implement is the ability to provide custom transformations for the messages. By that, I mean user-defined operations that should be applied to the fetched string. For example, sometimes you might want to just fetch the string without displaying it anywhere. Other times you may wish to capitalize it or strip out some symbols, etc.
Therefore I would like our pretty_output
method to accept an optional block with some custom code:
injector.rb
# ...
define_method :pretty_output do |key, values = {}, &block|
end
Simply run the code if the block is provided, otherwise, perform the default operation:
injector.rb
# ...
define_method :pretty_output do |key, values = {}, &block|
# ...
block ?
block.call(msg) :
klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end
By removing the &
symbol from the block’s name we turn it into a procedure. Next, simply use the call method to run this procedure and pass our message to it.
The transformations can now be provided like this:
pretty_output(:welcome) do |msg|
msg.upcase!
msg # => Returns "WELCOME", does not print anything
end
Sometimes it might be better to provide transformation logic for the whole class, instead of passing a block individually:
injector.rb
# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
# ...
klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch),
output: opts[:output] || STDOUT,
method: opts[:method] || :puts,
transform: opts[:transform]})
end
define_method :pretty_output do |key, values = {}, &block|
# ...
transform = klass::DICTIONARY_CONF[:transform] || block
transform ?
transform.call(msg) :
klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end
You may say block || klass::DICTIONARY_CONF[:transform]
instead to make the block passed for an individual method more prioritized.
We are done with the gem’s features, so let’s finalize it now. pretty_output
is an instance method, but we probably don’t want it to be called from outside of the class. Therefore, let’s make it private:
injector.rb
# ...
define_method :pretty_output do |key, values = {}, &block|
# ...
end
private :pretty_output
The name pretty_output
is nice, but a bit too long, so let’s provide an alias for it:
injector.rb
# ...
private :pretty_output
alias_method :pou, :pretty_output
Now displaying a message is as simple as saying
pou(:welcome)
The very last step is to require all the files in the proper order:
lib/messages_dictionary.rb
require 'yaml'
require 'hashie'
require_relative 'messages_dictionary/utils/snake_case'
require_relative 'messages_dictionary/utils/dict'
require_relative 'messages_dictionary/injector'
module MessagesDictionary
end
Conclusion
In this article we’ve seen how Ruby’s metaprogramming can be used in the real world and wrote a MessagesDictionary
gem that allows us to easily fetch and work with strings. Hopefully, you now feel a bit more confident about using methods like included
, define_singleton_method
, send
, as well as working with blocks.
The final result is available on GitHub along with thorough documentation. You can also find RSpec tests there. Feel free to suggest your own enhancement for this gem, after all, it was created for studying purposes. As always, I thank you for staying with me and see you soon!