Metaprogramming in ruby

Cherry Ramatis - Oct 30 '23 - - Dev Community

The ruby programming language is known for two major facts: One is its core philosophy with object-oriented programming where "Everything is an object", the other important one is its incredible flexibility to define DSLs and "magic" classes. This magic is on purpose and a quite special feature of ruby called metaprogramming, in this article we'll see more about the deep nested details of ruby and how to create magic APIs with metaprogramming!

Table of contents

What is metaprogramming anyway?

Metaprogramming is a concept where you write a program to write a program, confusing, huh? But trust me it's quite simple, imagine the following scenario:

You create a function that write another file with a specific functionality, or even append content on the same file with the whole implementation for a class or an object or whatever, this is code creating more code got it? This is metaprogramming!

A cool example of metaprogramming are DSLs (Domain specific languages) that define a specific syntax within the same language to express far more complex concepts, such as querying a database or defining routes. It's not new that the ruby ecosystem uses a lot of metaprogramming, so we can keep writing elegant and clean code with that "magic" feel to it.

In Ruby everything is an object, what does that mean?

It's possible that you saw the famous phrase "In ruby, everything is an object", but what does that even mean and how it's related to metaprogramming at all?

In ruby all the things are objects under the hood, even the core data structures like numbers, strings, etc. as you can see on the example below:

irb(main):009> 5.class
=> Integer
irb(main):010> 5.class.class
=> Class
irb(main):011> ''.class
=> String
irb(main):012> ''.class.class
=> Class
irb(main):013> true.class
=> TrueClass
irb(main):014> true.class.class
=> Class
Enter fullscreen mode Exit fullscreen mode

At the end of the day, even simple structures are defined from a common class object and can have all the things objects are known for such as methods, inheritance, etc...

This allows crazy manipulation possibilities, and it's on that specific field where metaprogramming lives and thrives, since all things are objects we gain the power of changing that object dynamically. Allowing us to create DSLs within the language easily.

Let's try to understand this metaprogramming thingy together without using any modern syntax provided by ruby, shall we?

Disclaimer: The following example are not how to actually implement metaprogramming, it's just an abstraction so you can understand the general idea around the concept.

Imagine we have a hash with a function inside, simulating a class object with method, we can even call this function by accessing the "property" and calling the "method".

my_obj = {
  fn: ->() { puts 'original method' }
}

my_object[:fn].() # => original method
Enter fullscreen mode Exit fullscreen mode

After defining this initial object, it's possible to write a function that receives this object and append another function to it as follows:

my_obj = {
  fn: ->() { puts 'original method' }
}

def add_new_method(obj, cb)
  {another_fn: cb}.merge(obj)
end
Enter fullscreen mode Exit fullscreen mode

And with this function working, we can append new functionalities to it by calling the newly created function:

my_obj = {
  fn: ->() { puts 'original method' }
}

def add_new_method(obj, key, cb)
  {key => cb}.merge(obj)
end

my_obj[:fn].() # => original method

my_obj = add_new_method(my_obj, :another_fn, ->() { puts 'another method' })
my_obj[:another_fn].() # => another method

my_obj = add_new_method(my_obj, :hello, ->(target) { puts "hello #{target}" })
my_obj[:hello].("world") # => hello world
Enter fullscreen mode Exit fullscreen mode

This is the core concept of metaprogramming, of course that modern functions provided by ruby itself lets you do far more things such as listen to specific events etc. But the core mindset around it is manipulating the underlying objects and adding new functions to it.

But what about rails? How this framework applies that concept for maximum developer experience

The Ruby on Rails framework is the most known and powerful ruby gem for a long time, and its core philosophy evolves around providing the smallest bit of elegant code to achieve a lot of features on your application. To provide that level of abstraction and elegant syntax, rails rely a lot on metaprogramming, so we can write less and achieve more on our codebase.

Methods like validates, has_many, scope, before_save are just a couple that comes with the ORM part that provide various benefit for declarative model code as shown on the examples below:

class User < ApplicationRecord
  has_many :posts
  validates :username, presence: true
  before_save :hash_password

  scope :recent, -> { where('created_at > ?', 1.week.ago)}

  private

  def hash_password
    self.password = hash(self.password)
  end
end
Enter fullscreen mode Exit fullscreen mode
  • has_many enable us to express a database relationship of users and posts within the model itself, in this particular example the Post model should use another method called belongs_to.
  • validates allow validation when we try to perform any creation method using this particular model
  • before_save act as a hook informing a method as a symbol, this particular method will be called before the record is saved on the database.
  • scope provide a way to create a new method on this model with the particular query, that way we can call something like User.recent and get the result of the function.

And there is much more metaprogramming patterns on different parts of the framework such as controllers, views, helpers, etc. It's truly an important part of what makes rails a beloved framework.

How to define methods dynamically

Now for the more practical parts, creating methods dynamically is as easy and as elegant as anything in ruby! Hopefully we're using a well though language, right? 👀 /j

The most basic action is defining a method dynamic, and we can use the built-in syntax provided by ruby called define_method, below we'll see a practical example:

class FirstExample
  define_method(:example_method) do
    puts 'example method'
  end
end

FirstExample.new.example_method # => example method

class SecondExample
  def self.define_new_method(method_name, &body)
    define_method(method_name, &body)
  end
end

SecondExample.define_new_method(:hello) { |target| puts "Hello #{target}" }
SecondExample.new.hello("world") # => Hello world
Enter fullscreen mode Exit fullscreen mode

The FirstExample class show how a simple usage of the define_method looks like, it accepts a symbol as the first parameter and a block that can optionally receive an argument and perform any actions (it'll be replaced as the class method once it's instantiated), at the end of the example we can simply instantiate the class with .new and call the dynamically defined method.

The SecondExample is more involved and define a method to define methods(finally some meta-language references), although it's simply an abstraction on top of the define_method function itself we can see the power that this can lead to a real codebase right? An important detail is that we accept the second parameter as &body because this tells ruby to accept as a block that we'll latter use on the example with the hello method.

Using hooks to detect moments on the instantiation of the class

Ruby not only provide a way to create new methods dynamically, but also provide hooks, so we can run arbitrary code when a specific action happen (around the object cycle of course).

Things such as running some code when a class is inherited or included and even when a particular method does not exist within a class, pretty cool and powerful, huh?

We'll see in detail about some possibilities with different hooks below:

method_missing

This hook function allow the run of arbitrary code once an unknown method is called within a class, below it's an example of a class implementing it:

class MethodMissingExample
  def method_missing(name, *args)
    puts "An unknown method was called using the name ->  #{name} and the arguments -> #{args.inspect}"
  end
end

MethodMissingExample.new.test_method(1, 2, 3) # => An unknown method was called using the name ->  test_method and the arguments -> [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

As you can see, by simply creating a method with the name method_missing we already enable this functionality and can act on it with the name and the args of the method at hand. This can be quite useful if you're working with public code that it's used by other developers and want to provide custom experience when a method is misspelled or indeed not implemented.

included

This method works only for module and will run a particular block when the module is included into another class or module as shown below:

module IncludedExample
  def self.included(name)
    puts "The #{name} was included!!"
  end
end

module Test
  include IncludedExample # => The Test was included!!
end
Enter fullscreen mode Exit fullscreen mode

Similarly, we have a method for the extend and prepend action within modules, it works the same as the includes, and it's shown below with an example:

module ExtendedExample
  def self.extended(name)
    puts "The #{name} was extended!!"
  end
end

module PrependedExample
  def self.prepended(name)
    puts "The #{name} was prepended!!"
  end
end

module Test
  extend ExtendedExample # => The Test was extended!!
  prepend PrependedExample # => The Test was prepended!!
end
Enter fullscreen mode Exit fullscreen mode

inherited

Back to the classes with a similar functionality, we can create a hook that will be run every time a class is inherited, as shown below:

class InheritedExample
  def self.inherited(name)
    puts "The #{name} class was inherited!!"
  end
end

class Test < InheritedExample # => The Test class was inherited!!
end
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hope this was useful for anyone reading it! metaprogramming is a very fun subject to learn and use, it can create very messy code but with good architecture it's possible to create amazing experience such as with Ruby on Rails. Just reach out if I can help with anything and may the force be with you 🍒

. . . . . . . . . . . . . . . . . . . . . .