Metaprogramming in Ruby
Metaprogramming, as per Wikipedia, is defined as
A programming technique in which computer programs have the ability to treat other programs as their data. It means that a program can be designed to read, generate, analyze or transform other programs, and even modify itself while running. In some cases, this allows programmers to minimize the number of lines of code to express a solution, in turn reducing development time.
Metaprogramming is a powerful technique but can also be complex and challenging to master. Programmers often use it to write more efficient, flexible, and adaptable code.
A few examples where metaprogramming gets used are:
- Compiler
- Debugger
- Unit Testing framework
What is Metaprogramming in Ruby?
Ruby, being an object-oriented and dynamic programming language, has built-in support for metaprogramming techniques. Metaprogramming in Ruby can be used in a variety of ways:
- Dynamically defining methods and classes
- Modifying existing classes and methods
- Inspecting and manipulating code objects
Metaprogramming makes Ruby code more powerful, flexible, and expressive. It can also create new features and functionality for the Ruby language itself.
Metaprogramming in Ruby can help DRY up code and help developers write reusable code or create a library that can be extracted as a gem. Rails relies heavily on metaprogramming, which is why it is often described as magical.
Metaprogramming methods
Let's explore the methods send
,
define_method
,
and method_missing
with real life scenarios
and
how metaprogramming makes Ruby code more powerful,
flexible,
and expressive.
send method
The send method in Ruby is used to dynamically call methods on objects. It takes two arguments: the name of the method to call and any arguments that should be passed to the method. The method name can be passed as a string or a symbol.
Let's take a Rails application where it tracks the order shipment status. The application has a function where it calls the respective action on the order shipment.
def update_order_shipment_status(order_shipment, action)
if action == "initiated"
order_shipment.initiated
elsif action == "dispatched"
order_shipment.dispatched
elsif action == "in_transit"
order_shipment.in_transit
elsif action == "out_for_delivery"
order_shipment.out_for_delivery
elsif action == "delivered"
order_shipment.delivered
elsif action == "cancelled"
order_shipment.cancelled
end
end
The method would need a change whenever a new order shipment status is added.
The multiple if..else
clause can be changed by replacing the code
using send
.
def update_order_shipment_status(order_shipment, action)
return if !order_shipment.respond_to?(action)
order_shipment.send(action)
end
Note:
The send
method can call both the private
and
the public methods.
Developers must carefully add it to avoid exposing the class's private methods.
You can use the public_send
method to call only the public methods.
define_method
Let's consider the order shipment Rails application.
If you want to check the order shipment status,
you might implement the methods below in the OrderShipment
model.
class OrderShipment < ApplicationRecord
def is_initiated?
self.status == "initiated"
end
def is_dispatched?
self.status == "dispatched"
end
def is_in_transit?
self.status == "in_transit"
end
def is_out_for_delivery?
self.status = "out_for_delivery"
end
end
Instead of repeating the code,
you can use the metaprogramming function provided by Ruby,
define_method
.
Using the define_method
,
you can create all the functions dynamically,
as shown below:
class OrderShipment < ApplicationRecord
STATUSES = ["initiated", "dispatched", "in_transit", "out_for_delivery", "delivered", "cancelled"].freeze
STATUSES.each do |status|
define_method("is_#{status}?") do
self.status == status
end
end
end
The define_method
will create the methods
for all the status values defined in the STATUSES
constant.
The methods will be
is_initiated?
,
is_dispatched?
,
is_in_transit?
,
etc
and
you need to call them on the OrderShipment
object.
method_missing
One of the best examples of using method_missing
in Rails is
implementation of dynamic named scopes.
Named scopes allow you to define reusable queries on your models,
which can be used in your controllers
and
views.
Let's consider the OrderShipment
example,
where you add the below scopes:
class OrderShipment < ApplicationRecord
scope :fetch_all_initiated_shipments, -> { where(status: "initiated") }
scope :fetch_all_dispatched_shipments, -> { where(status: "dispatched") }
...
end
You might need to add a new scope whenever a new status gets added.
You can avoid this by implementing a method_missing
function.
class OrderShipment < ApplicationRecord
STATUSES = ["initiated", "dispatched", "in_transit", "out_for_delivery", "delivered", "cancelled"].freeze
def method_missing(method_name, *args)
matched_expression = method_name.to_s.match(/^fetch_all_(?<status>.*)_shipments$/)
status = matched_expression[:status]
if STATUSES.include?(status)
where(status: status)
end
end
end
The fetch_all_initiated_shipments
is not added in the
OrderShipment
class.
This will call the method_missing
function.
The function checks the method_name
with a Regex /^fetch_all_(?<status>.*)_shipments$/
.
If the method name starts with fetch_all_
and
ends with _shipments
,
the regular expression will extract the text between these words.
It considers the match expression as the status
.
You need to verify whether the status value matched
in Regex is present in the STATUSES
constant.
If present,
you can then query the OrderShipment
model based on the status value.
Note
You can eliminate the metaprogramming code in the
send,
define_method
and
method_missing
sections with Rails ActiveRecord::Enum
.
Monkey Patching
Monkey patching in Ruby is a metaprogramming technique that allows you to modify the behaviour of existing classes and modules at runtime. It can be done by reopening the class or module and adding or overriding methods.
Here is an example of using monkey patching:
class String
def reverse
super.split("").reverse.join("")
end
end
puts "hello, world".reverse
=> dlrow ,olleH
In the above example,
the String class #reverse
method is overridden.
The new reverse
method splits the string into characters,
reverses the order of the characters,
and
then joins the characters back into a string.
How metaprogramming in Ruby is helpful
Metaprogramming can offer several benefits for Ruby developers, including:
-
More dynamic and flexible code: Metaprogramming allows developers to write code that can adapt to changing requirements. This can be useful for developing complex applications that need to be able to handle a wide range of scenarios.
-
Reduced duplication of code: Metaprogramming can reduce code duplication by allowing developers to define generic code that can be reused for different purposes.
-
Testing and Debugging: Metaprogramming can also simplify testing and debugging by creating mock objects or stubs that simulate the behaviour of real objects. This can be useful for isolating a particular object or service's behaviour and testing its interactions with other objects.
Metaprogramming in Rails
Metaprogramming is widely used in Rails to implement a variety of features, including:
Dynamic routing: Rails uses metaprogramming to generate routes based on the controllers and models in your application. This allows you to create dynamic and flexible URLs for your application.
Scaffolding: Rails uses metaprogramming to generate scaffolding code for your models. This scaffolding code includes basic controllers, views, and tests, which can save you a lot of time and effort when developing new applications.
Model validation: Rails uses metaprogramming to implement model validation. This allows you to easily define validation rules for your models and ensure that your data is valid before it is saved to the database.
Active Record: Active Record, the Rails ORM, uses metaprogramming extensively to implement its features. For example, Active Record uses metaprogramming to generate SQL queries, manage database connections, and implement object-relational mapping.
Associations:
Rails associations,
such as has_many
and
belongs_to
,
are implemented using metaprogramming.
This allows you to easily define relationships between your models
and
generate the necessary SQL code to manage those relationships.
Callbacks:
Rails callbacks,
such as before_save
and
after_create
,
are implemented using metaprogramming.
This lets you quickly hook into the model lifecycle
and
execute custom code at specific points.
In addition to these specific features, Rails also uses metaprogramming in several other ways, such as implementing its asset pipeline, testing framework, and configuration system.
Here are some specific examples of how metaprogramming is used in Rails:
-
When you create a new model, Rails uses metaprogramming to generate a table in your database and to define methods for interacting with that table.
-
When you create a new route, Rails uses metaprogramming to generate a regular expression that matches that route and defines a controller action that will be executed when the route is matched.
-
When you use Active Record to query the database, Rails uses metaprogramming to generate SQL code based on your query criteria.
-
When you use Rails associations, Rails uses metaprogramming to generate the necessary SQL code to manage those relationships.
Drawbacks of Metaprogramming in Ruby
Metaprogramming in Ruby is powerful, but it is essential to be aware of its potential drawbacks. Some of the caveats of metaprogramming include:
Reduced readability: Metaprogramming can make code more difficult to read and understand. This is because metaprogramming code often uses complex and abstract concepts.
Increased complexity: Metaprogramming can also increase the complexity of code. This is because metaprogramming code often adds additional layers of abstraction to the codebase.
Performance overhead: Metaprogramming can also introduce a performance overhead. This is because metaprogramming code often requires additional runtime processing.
Potential for bugs: Metaprogramming code can be more difficult to debug than regular code. Metaprogramming code often interacts with the Ruby language in complex ways.
In addition to these general caveats,
there are some specific caveats to be aware of
when using metaprogramming in Ruby.
For example,
it is essential to be careful when using the method_missing
method,
as it can be used to implement unexpected behaviour.
Here are some tips for using metaprogramming effectively in Ruby:
-
Use metaprogramming sparingly. Only use metaprogramming when it is necessary to implement a specific feature or to solve a specific problem.
-
Document your metaprogramming code carefully. Explain what your code is doing and why it is necessary.
-
Test your metaprogramming code thoroughly. Ensure your metaprogramming code does not introduce any new bugs into your application.
-
Avoid using metaprogramming to implement complex logic. If you need to implement complex logic, consider using a more traditional approach, such as object-oriented programming.
By following these tips, you can minimize the drawbacks of metaprogramming and maximize its benefits.