Explaining magic behind popular Ruby code
Ruby language allows us to easily create beautiful DSL's and design complex libraries that anybody can easily use, despite programming experience. The code often looks perfect, but sometimes it is not clear how it was achieved under the hood. In this article, I explain a few solutions that are quite popular among many gems, and you can easily use it in your projects once you understand how you can build a similar code.
The config-way of setting variables
You can see this code in almost every Ruby gem that utilizes initializers to set some variables needed for later use:
SomeGem.configure do |config|
config.api_key = 'api_key'
config.app_name = 'My App'
end
This way of setting variables is very readable and easily extendable. It works like any other method that yields something. While the each
method yields elements of the array, in the above example, the configure
method yields the instance of the Configuration
class (or any other). It allows us to set attributes of this class inside the block.
You can build your configurator this way:
module SomeGem
class << self
attr_accessor :configuration
def configure
self.configuration ||= Configuration.new
yield(configuration)
end
end
class Configuration
attr_accessor :api_key, :app_name
end
end
Let's give it a try:
SomeGem.configure do |config|
config.api_key = 'api_key'
config.app_name = 'My App'
end
SomeGem.configuration.api_key # => 'api_key'
SomeGem.configuration.app_name # => 'My App'
You can later access values just like for any other object.
Dynamic methods
If you ever used Ruby on Rails code, you probably saw that many methods refer to the model attributes. Since the code is not aware of the attribute names unless you define them, it dynamically creates a method, and you won't find the standard definition for them.
Let's see some examples assuming that you have the class User
and first_name
and last_name
attributes defined on it:
user = User.new(first_name: "John", last_name: "Doe")
user.first_name_is?('John') # => true
user.last_name_is?('John') # => false
Of course, you can call user.first_name == 'John'
, but I decided to show a straightforward case for the demonstration purposes. Here is the definition of our class:
class User
attr_reader :first_name, :last_name
def initialize(first_name:, last_name:)
@first_name = first_name
@last_name = last_name
end
end
Right now, when calling #first_name_is?
or last_name_is_mike?
we would get the NoMethodError
error because we didn't define those methods. This error is the starting point of the implementation of dynamic methods. Ruby exposes the method_missing
method to allow us to do something with the not existing method and decide if we want to raise the error:
class User
attr_reader :first_name, :last_name
def initialize(first_name:, last_name:)
@first_name = first_name
@last_name = last_name
end
private
def method_missing(method_name, *args, &block)
puts "You are missing #{method_name} method"
super # raise the error anyway
end
end
After rerunning the previous code, you should see the following text printed before the error is raised:
You are missing first_name_is? method
The method_missing
method accepts three arguments:
method_name
- the name of the method that doesn't exist yetargs
- optional parameter; arguments passed to the methodblock
- optional parameter; the block executed on the method
If we want to create a dynamic method, we must first verify that it ends with _is?
and starts with the existing attribute's name. Then we have to raise the error if the method name is invalid or called on a not defined attribute or compare the attribute value with the first argument and return the result:
def method_missing(method_name, *args, &block)
attribute_name = method_name.to_s.match(/(.*)_is\?/)&.captures&.first
if !attribute_name.nil? && instance_variable_defined?("@#{attribute_name}")
instance_variable_get("@#{attribute_name}") == args.first
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
method_name.to_s.end_with?('_is?') || super
end
Now we can play with our class and see that it's working:
user = User.new(first_name: 'John', last_name: 'Doe')
user.first_name_is?('John') # => true
user.last_name_is?('Tom') # => false
user.method(:first_name_is?).call('John') # => true
# call on not existing attribute
user.age_is?('John') # => NoMethodError
Remember to always define the respond_to_missing?
method when overriding method_missing
.
If you want to have more complex methods, you may want to create dynamic definitions.
Duck typing
Some objects behave differently when you call to_s
on them (or any other method from the standard language library). For example, you might saw the following example:
response = Request.get('some_url')
puts response # => "status: 200, body: some page body"
puts response.status # => 200
puts response.body # => "some page body"
Let's create a sample definition of Response
and Request
class:
class Response
attr_reader :status, :body
def initialize(status:, body:)
@status = status
@body = body
end
end
class Request
def self.get(url)
Response.new(status: 200, body: "some page body")
end
end
Fine, but when calling the same code as before, we would not get the same result:
response = Request.get('some_url')
puts response # => #<Response:0x00007f9e3f1a0150>
It happens because each time you use puts
, it calls the to_s
method on the passed thing, and in our case, the default to_s
method defined on an object is called. It returns the string that includes the class name.
If we want to change this behavior, we can define the to_s
method:
class Response
attr_reader :status, :body
def initialize(status:, body:)
@status = status
@body = body
end
def to_s
"status: #{@status}, body: #{@body}"
end
end
Now it works as expected:
response = Request.get('some_url')
puts response # => "status: 200, body: some page body"
If it quacks like a duck, it's a duck. If it implements the to_s
method, you can consider it as a string and use it as a string.