DEV Community

Cover image for An Introduction to Metaprogramming in Ruby
Daniel for AppSignal

Posted on • Originally published at blog.appsignal.com

An Introduction to Metaprogramming in Ruby

You've heard of metaprogramming: code you write that generates other code dynamically. Without it, Rails as we know it wouldn't (and couldn't) exist. But there's a good chance you've never done it yourself, and it's not hard to see why; even a brief excursion into the realm of metaprogramming can leave you beset with strange and foreign methods, unfamiliar syntax, and downright mystifying blocks of code.

It's true: metaprogramming is a relatively advanced topic. If you want to really dig in and leverage it on a deep level, it will take time, effort, and a different way of thinking than you're used to. But there's good news: You don't need to wade too deeply into the metaprogramming waters to discover useful methods and techniques to make your life and workflow a little easier.

In this post, we'll take a look at metaprogramming methods like send, define_method, and method_missing and show how they can solve problems we sometimes run into, even in normal Rails applications. But first, let's briefly define metaprogramming and explore when it might come in handy.

When Is Metaprogramming Useful?

Maybe you've got a couple of models in your app that, while being very similar, aren't identical — they have different attributes and methods. But both need to be acted on in a job, and you don't want to write what is essentially the same code more than once.

Maybe once (or twice), you hacked together a kludge masterpiece for a tight deadline on a new feature that got the job done, but you modified an existing model rather than creating a new one the way you would have if you'd had more time. Now that feature needs to be expanded, requiring you to do it the "right" way. This can be intimidating, especially if your codebase references your current implementation all over your app.

Or maybe you rely on a third-party gem that isn't well-maintained, and you discover far too late that it's got a bug in it — right in the middle of a line of code full of metaprogramming.

I've had all these things happen, and I solved them by leveraging some light metaprogramming, all without having to be an expert, and without too much effort. You can, too.

What Is Metaprogramming in Ruby?

Metaprogramming is a set of techniques that Ruby offers us to write code that dynamically writes other code for us. Rather than working on data, metaprogramming works on other code. This is great because it means we can write more dynamic, flexible, and adaptable code if the situation calls for it.

It can help DRY up portions of our code, dynamically define methods we might need, and write useful and reusable pieces of software (like gems!) to make our lives easier.

In fact, Rails leverages metaprogramming on a large scale and to great effect. Any time anyone talks about Rails' magic, they're really talking about its use of metaprogramming.

Caveats

Despite how useful metaprogramming is, it isn't without its drawbacks.

One issue is readability: a little metaprogramming here and there can go a long way and likely won't cause many headaches, but lots of it will hurt the readability of your code.
Many Rails engineers probably are not very familiar with it, so relying on it too heavily can prove frustrating both to your future self and to others who need to work on your code.

Maintainability is also a concern; heavy use of metaprogramming can be mind-bending for even experienced Ruby developers. This means you should document your code. Ruby is an incredibly expressive, intuitive, and readable language, but if you're doing things others might find confusing, you should leave comments. You should also use access modifiers - e.g., private, protected - the way you would with other methods.

Another potential source of trouble: monkeypatching. While monkeypatching (dynamic modification of a class — typically, a Rails core class) can be useful, it should also be used sparingly and carefully. If we're not mindful, we can modify behavior in ways that, while perhaps solving one of our problems, can easily create dozens of others. For example, rather than fixing a small bug, adding a new, uniquely-named method, or simply extending the capabilities of a class, we might modify the behavior of existing methods used not just by our own application but by our gems (and even Rails itself), with potentially disastrous consequences. Check out our post Responsible Monkeypatching in Ruby for more on monkeypatching.

Lastly, metaprogramming can make it hard to find things you're looking for, especially if you didn't write the code in the first place. If you find yourself trying to figure out how it's possible CompanySpecificClass#our_special_method exists on an object but isn't defined anywhere, metaprogramming could be the culprit (in this case, probably the use of define_method).

Basic Ruby Metaprogramming Techniques

Let's explore some of the methods we mentioned earlier: send, define_method, and method_missing.

First, we'll take a look at send, what it does, and how it can help us DRY up our code.

Using send

Remember the example we mentioned earlier (under the header 'When Is Metaprogramming Useful?') of a couple of similar models with different attributes and methods? In this case, both models need to be acted on in a job in a more or less identical manner.

Essentially, invoking .send allows us to dynamically pass methods (along with arguments) to an object, without necessarily knowing what that object (or method) might be at the time we write the code.

First, a word of caution: send can actually call both public and private methods. If you want to be very explicit (and careful), you can use public_send to ensure it's clear you're calling a public method. I've never done this, but I've also never used it to call any private or protected methods; if you do, you might want to use public_send and send for each use case.

So how does it work? Pretty simply. You just pass the method's name to the object you want to call like this: my_object.send(:method_name). If you need to pass parameters/arguments, you can do so: my_object.send(:method_name, argument1, argument2...). send accepts parameters the same way other methods do, so you can use named arguments, too.

So rather than doing something verbose like this:

def sell_or_refund_or_return_item(item, action)
  if action == "sell"
    item.sell
  elsif action == "refund"
    item.refund
  elsif action == "void"
    item.void
  elsif action == "return"
    item.return
  end
end
Enter fullscreen mode Exit fullscreen mode

We can simply pass our method as an argument and call it with send:

def process_item(item, action)
  return unless item.respond_to?(action)

  item.send(action)
end
Enter fullscreen mode Exit fullscreen mode

Note: For the sake of simplicity and brevity, we're not raising an error if our object doesn't respond_to? the action passed into our above methods. But in a real app, we'd probably want to raise an error to bring our attention to the fact something in our codebase is calling a non-existent method/attribute on the object.

send: An Example Scenario

Let's consider the following example. We've got a Purchase model, representing a given purchase from an online store. That purchase could be anything from a single item of one type to many different items.

In the instance below, we're interested in people who bought a particular subscription, which may contain tickets to events (purchase_events), and/or vouchers for products (purchase_products).

Products and events are different things, but there's a lot of overlap, like price, status (sold, returned), etc. In this example, we will refund all instances of a particular item from a subscription, because customers were unable to redeem their purchase (an event was rained off, a company we source from ran out of a collectible, etc.).

class RefundAllInstancesOfItemJob
  def perform(item)
    purchases = Purchase.joins(:subscriptions)
                        .includes(:purchase_events, :purchase_products)
                        .where(subscriptions: { id: item.subscription_id, status: "active" })

    association, foreign_key, foreign_key_value = item.is_a?(PurchaseEvent) ? [:purchase_events, :event_id, item.event_id] : [:purchase_products, :product_id, item.product_id]

    purchases.find_each do |purchase|
      purchase.send(association)
              .where(purchase_id: purchase.id)
              .where(foreign_key => foreign_key_value)
              .each { |item| item.update(status: "refunded") }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

What have we done here? Ordinarily, we might have ended up writing some if/else logic and duplicating most of this code or even two separate jobs. Instead, we've dynamically sent the appropriate association and foreign key to objects within our query, keeping our code DRY.

We've laid out the keys and associations here explicitly, but they could be passed in as arguments if we set up our call to the job differently. Our customers have now been refunded for the particular item(s) in this subscription cycle! (Let's assume there's a callback on those models that refunds the user when the item is marked "refunded").

Using define_method

Now, let's look at define_method and how it can help us with our earlier example where we moved over from storing data on one model where it didn't really belong to a new dedicated model.

Let's say we currently have a Presentation model. When we first added video capabilities to our app, we did it because our biggest customer had to play a single, long video about their business to their shareholders. We had a tight deadline, so rather than create a new, dedicated model, we just tacked on a few columns to our Presentation table, and it got the job done.

Now, though, because we've advertised our app's recording and streaming capabilities, other clients are interested — and they need more than one video per Presentation. Unfortunately, we're still in a bit of a time crunch and have to roll this out fast.

We decide the quickest way to accomplish this without changing tons of code is to:

  • Create a new VideoAsset model
  • Move existing columns over from Presentation
  • Set a boolean current_asset which will update when our clients select the video they want
class Presentation
  has_many :video_assets

  def current_video_asset
    video_assets.find_by(current_asset: true)
  end

  # Replace our dropped columns as new instance methods on Presentation
  # We've used the safety operator (&) on send the way we would any other method with a potential nil return value
  ["status", "playback_id", "asset_id"].each do |column_suffix|
    define_method(column_suffix) do
      current_video_asset&.send(column_suffix)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

What's going on here? We're dynamically defining methods on our Presentation model to replace the columns we dropped and moved over to VideoAsset. The existing calls in our codebase to @presentation objects will now first find the current VideoAsset associated with our Presentation and then call the corresponding column. We don't have to go around updating controllers and views everywhere!

It's worth noting that the methods defined above could also have been accomplished using ActiveSupport's delegate helper. delegate allows you to use this pattern more often, abstracting away the metaprogramming implementation:

class Presentation
  has_many :video_assets
  delegate :status, :playback_id, :asset_id, to: :current_video_asset

  def current_video_asset
    video_assets.find_by(current_asset: true)
  end
end
Enter fullscreen mode Exit fullscreen mode

Using method_missing

Finally, let's look at method_missing. It does pretty much what you'd expect, given its name — it allows you to account for methods that don't exist but are called on an object or class. Let's take a look at turning methods we've placed in our classes into methods that test for truthiness.

class User
  def method_missing(method_name, *args)
    if method_name.ends_with?("?")
      regular_method = method_name.to_s.sub("?", "")

      result = self.send(regular_method, *args)

      result.present? && result != 0
    else
      super
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

So, what's happening here?

Well, we've basically recreated an ActiveRecord feature for our User model. Any column that exists on a table in Rails has an identically-named method ending in a ? added to instances of its corresponding class. We've done something similar with our User class — any undefined instance method called on a user object ending in a ? will be tried against a method of the same name without its question mark, and turn the result into a boolean.

So, for example, @user.purchase_totals? will (rather than return a decimal representing the total amount of money a user has spent in our app) simply return true if the number is nonzero — otherwise, false.

Note the use of present? here. It accounts for things like empty strings and arrays, which otherwise would return true if we'd used a double-bang to assess if it was truthy or not.

If there's no match, we default to calling super, resulting in the expected behavior (a NoMethodError being thrown).

More Advanced Metaprogramming Techniques in Ruby: An Overview

We've covered some useful methods that can be used sparingly to help us out in everyday situations. But what about more advanced uses of metaprogramming? What else is it good for?

  • DSLs: As mentioned, ActiveRecord, the ORM (and DSL) that ships with Rails heavily leverages metaprogramming. That's where all those automatic methods on our objects come from. Every column we add to a table becomes two methods on our instances — column and column= (and column? for those who were paying attention!). It isn't magic that's making this happen, it's metaprogramming — in fact, the use of the same method_missing method we talked about earlier! Metaprogramming is also responsible for Rails' ability to automatically create methods like find_by_first_name and find_by(first_name: "name").
  • Gems: If you're building a gem, chances are you'll want it to be flexible, adaptable, and to work with more than just one specific Rails stack. Most of the gems you regularly use have at least some degree of metaprogramming, and many use a lot to achieve the level of flexibility they offer.
  • Dynamic APIs: Metaprogramming can help us design dynamic APIs by allowing us to define methods on the fly based on runtime information, rather than hardcoding every possible method. For example, a RESTful API might expose resources with dynamic paths and attributes based on the data model of the underlying application (this may sound familiar — Rails' routing system does this). We can generate methods for each resource at runtime based on the database schema and dynamically respond to HTTP requests.
  • Frameworks: As mentioned, Rails wouldn't be Rails without metaprogramming. While it's a particularly magical framework, almost any framework you build will need plenty of metaprogramming. If you're going to build your own (presumably far more lightweight) framework, you'll need to leverage metaprogramming.

Wrapping Up

We've covered some real ground here! We learned about dynamic method definition with define_method and method_missing, and about dynamic method invocation with send and public_send.

We also talked about metaprogramming's pitfalls, including its issues with readability, maintainability, and searchability. Finally, we touched on more advanced use cases like writing gems, DSLs, and frameworks.

Further Learning

There are a lot of resources out there for taking Ruby metaprogramming further.

RubyMonk offers a short but free course.

A couple of other paid courses include:

An oft-recommended book is Metaprogramming Ruby 2: Facets of Ruby.

I hope you found this post useful. Thanks for reading!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)