DEV Community

Cover image for Metaprogramming with Ruby
Davide Santangelo
Davide Santangelo

Posted on

Metaprogramming with Ruby

Ruby is an object-oriented programming language known for its simplicity, flexibility, and power. One of the features that sets Ruby apart is its support for metaprogramming, which allows programmers to write code that can modify or generate other code at runtime.

Metaprogramming can be used to achieve a wide range of goals, from creating domain-specific languages to implementing design patterns to simplifying repetitive tasks. In this article, we'll explore the basics of metaprogramming in Ruby and see how it can be used to write concise, expressive code.

Defining Methods Dynamically

class Example
  def self.define_component(name)
    define_method(name) do
      puts "This is the #{name} component"
    end
  end

  define_component :foo
  define_component :bar
end

Example.new.foo # => "This is the foo component"
Example.new.bar # => "This is the bar component"
Enter fullscreen mode Exit fullscreen mode

In this example, we've defined a class called Example with a class method called define_component. This method takes a symbol representing the name of the component and defines a new instance method with that name. We can then create new instances of Example and call the dynamically defined methods, which will output a message indicating the name of the component.

This technique can be useful for building flexible systems where the set of available components is not known in advance.

Evaluating Code at Runtime

Another way to modify a program's behavior at runtime is to use the eval method, which takes a string containing Ruby code and executes it in the current context. For example:

x = 10
eval "puts x" # => 10
eval "x = 20"
puts x # => 20
Enter fullscreen mode Exit fullscreen mode

The eval method can be used to dynamically define methods or classes, create variables, and perform other tasks that would normally be static. However, it's important to use eval with caution, as it can have security implications if used improperly.

Method Missing

Ruby provides a hook called method_missing that is called whenever an undefined method is called on an object. You can use this hook to implement custom behavior for undefined methods, such as looking up a value in a database or calling a remote API.

For example:

class Example
  def method_missing(name, *args)
    puts "Called #{name} with arguments #{args.inspect}"
  end
end

Example.new.foo(1, 2, 3) # => "Called foo with arguments [1, 2, 3]"
Enter fullscreen mode Exit fullscreen mode

In this example, we've defined a class called Example with a method_missing method that takes the name of the undefined method and an array of arguments. When we call the foo method on an instance of Example, method_missing is called and prints a message to the console.

Singleton Methods

Ruby allows you to define methods on a specific object rather than on a class, using the define_singleton_method method. These methods are called singleton methods, and they are only available on the object on which they are defined.

obj = Object.new
def obj.hello
  puts "Hello from #{self}"
end

obj.hello # => "Hello from #{obj}"
Enter fullscreen mode Exit fullscreen mode

In this example, we've defined a singleton method called hello on the obj object. This method is only available on obj, and cannot be called on other instances of Object.

Singleton methods can be useful for adding behavior to specific objects without modifying their class or creating a new subclass.

Advanced use of metaprogramming (DSL)

One advanced use of metaprogramming in Ruby is the creation of domain-specific languages (DSLs). Domain-specific languages (DSLs) are languages that are specialized for a particular domain or problem. They are designed to make it easier for people working in that domain to express themselves and solve problems.

One way to create a DSL in Ruby is through the use of metaprogramming. Metaprogramming allows you to define methods and classes at runtime, rather than writing them out explicitly in your code. This can make it easier to create flexible and expressive DSLs.

Here is an example of using metaprogramming to create a simple DSL for defining tasks in a project management application:

class Task
  attr_accessor :name, :description, :dependencies, :duration

  def initialize(name, &block)
    @name = name
    @description = ""
    @dependencies = []
    @duration = 1
    instance_eval(&block) if block_given?
  end
end

# Create a method for setting the description
class Task
  def description(text)
    @description = text
  end
end

# Create a method for adding dependencies
class Task
  def depends_on(*tasks)
    @dependencies.concat(tasks)
  end
end

# Create a method for setting the duration
class Task
  def duration(days)
    @duration = days
  end
end

# Now we can use our DSL to define tasks like this:
task1 = Task.new "Task 1" do
  description "This is the first task"
  depends_on :task2, :task3
  duration 5
end

task2 = Task.new "Task 2" do
  description "This is the second task"
  depends_on :task3
  duration 2
end

task3 = Task.new "Task 3" do
  description "This is the third task"
  duration 3
end
Enter fullscreen mode Exit fullscreen mode

With this DSL, we can create tasks by calling the Task.new method and passing in a block of code that specifies the task's name, description, dependencies, and duration. This makes it easy to define complex tasks in a clear and concise way, without having to write out the details of each task in a long and repetitive manner.

Here is an example of how you could write some tests for the DSL code that I provided above:

require 'test/unit'

class TaskTest < Test::Unit::TestCase
  def test_task_creation
    task = Task.new "Task 1" do
      description "This is the first task"
      depends_on :task2, :task3
      duration 5
    end

    assert_equal "Task 1", task.name
    assert_equal "This is the first task", task.description
    assert_equal [:task2, :task3], task.dependencies
    assert_equal 5, task.duration
  end
end
Enter fullscreen mode Exit fullscreen mode

This test creates a new task using the DSL and verifies that the task has the correct name, description, dependencies, and duration.

You could also write additional tests to verify that the DSL methods for setting the description, dependencies, and duration are working correctly. For example:

def test_description
  task = Task.new "Task 1"
  task.description "This is a task"
  assert_equal "This is a task", task.description
end

def test_dependencies
  task = Task.new "Task 1"
  task.depends_on :task2, :task3
  assert_equal [:task2, :task3], task.dependencies
end

def test_duration
  task = Task.new "Task 1"
  task.duration 5
  assert_equal 5, task.duration
end
Enter fullscreen mode Exit fullscreen mode

These tests verify that the description, depends_on, and duration methods are working correctly and setting the appropriate values on the Task object.

Conclusion

Metaprogramming is a powerful feature of Ruby that allows you to modify and generate code at runtime. With the techniques described in this article, you can define methods dynamically, evaluate code at runtime, intercept undefined method calls, and define singleton methods.

Metaprogramming can make your code more concise and expressive, but it can also make it more difficult to understand and maintain. As with any powerful tool, it's important to use metaprogramming judiciously and only when it adds real value to your codebase.

Top comments (0)