Metaprogramming Ruby 2

software_writer

Akshay Khot

Posted on December 30, 2021

Metaprogramming Ruby 2

Metaprogramming enables you to produce elegant, clean, and beautiful programs as well as their exact opposite programs. This book will teach you the powerful metaprogramming concepts in Ruby, and how to use them judiciously.

Metaprogramming Ruby

In most programming languages, language constructs like variables, classes, methods, etc. are present while you are programming, but disappear before the program runs. They get transformed into byte-code (Java) or CIL (C#), or plain machine-code (C). You can't modify these representations once the program has been compiled.

In Ruby, however, most language constructs are still there. You can talk to them, query them, manipulate them. This is called introspection. For example, the example below creates an instance of a class and asks for its class and instance variables.

class Language
  def initialize(name, creator)
    @name = name
    @creator = creator
  end
end

ruby = Language.new("Ruby", "Matz")

pp ruby.class
pp ruby.instance_variables
Enter fullscreen mode Exit fullscreen mode

The Object Model

Objects are first-class citizens in Ruby. You'll see them everywhere. However, objects are part of a larger world that also includes other language constructs, such as classes, modules, and instance variables. All these constructs live together in a system called the object model.

Metaprogramming is writing code that writes code. Specifically, Metaprogramming is writing code that manipulates language constructs at runtime, in turn manipulating the Ruby object model.

Open Classes

You can open any class and add new methods on it. For example, let's open String class and add a new method log on it.

class String
  def log
    puts ">> #{self}"
  end
end

"Hello World".log       # >> Hello World
Enter fullscreen mode Exit fullscreen mode

This is called monkeypatching, and it can cause problems if you re-define existing methods unintentionally. However, if you know what you are doing, monkeypatching can be very powerful. For example, the ActiveSupport gem in Rails makes heavy use of monkeypatching to open Ruby core classes and define new functionality on them.

Instance Variables vs. Instance Methods

An object’s instance variables live in the object itself, and an object’s methods live in the object’s class.

Instance Variables vs. Instance Methods

That’s why objects of the same class share methods but don’t share instance variables.

Classes are Objects

Everything that applies to objects also applies to classes. Each class is also a module with three additional instance methods: new, allocate, and superclass.

class MyClass
  def my_method
    @v = 1
  end
end

puts MyClass.class  # Class
puts MyClass.superclass  # Object
puts Class.superclass  # Module
puts Object.class  # Class
puts Object.superclass  # BasicObject
pp BasicObject.superclass  # nil
Enter fullscreen mode Exit fullscreen mode

What happens when you call a method?

  1. Finds the method using method lookup. For this, Ruby interpreter looks into the receiver's class, including the ancestor chain.
  2. Execute the method using self.

The receiver is the object that you call a method on, e.g. in the statement myObj.perform(), myObj is the receiver.

The ancestor chain is the path of classes from a class to its superclass, until you reach the root, i.e. BasicObject.

Inheritance Chain

The Kernel

The Object class includes Kernel module. Hence the methods defined in Kernel are available to every object. In addition, each line in Ruby is executed inside a main object. Hence you can call the Kernel methods such as puts from everywhere.

If you add a method to Kernel, it will be available to all objects, and you can call that method from anywhere.

module Kernel
  def log(input)
    puts "Logging `#{input}` from #{self.inspect}"
  end
end

# Logging `hello` from main
log "hello" 

# Logging `a` from "hello"
"hello".log("a") 

# Logging `temp` from String
String.log("temp") 
Enter fullscreen mode Exit fullscreen mode

The self Keyword

The Ruby interpreter executes each and every line inside an object - the self object. Here are some important rules regarding self.

  • self is constantly changing as a program executes.
  • Only one object can be self at a given time.
  • When you call a method, the receiver becomes self.
  • All instance variables are instance variables of self, and all methods without an explicit receiver are called on self.
  • As soon as you call a method on another object, that other object (receiver) becomes self.

At the top level, self is main, which is an Object. As soon as a Ruby program starts, the Ruby interpreter creates an object called main and all subsequent code is executed in the context of this object. This context is also called top-level context.

puts self  # main
puts self.class  # class
Enter fullscreen mode Exit fullscreen mode

In a class or module definition, the role of self is taken by the class or module itself.

puts self  # main

class Language
  puts self  # Language

  def compile
    puts self  # #<Language:0x00007fc7c191c9f0>
  end
end

ruby = Language.new
ruby.compile
Enter fullscreen mode Exit fullscreen mode

Defining Classes and Methods Dynamically

Language = Class.new do
  define_method :interpret do
    puts "Interpreting the code"
  end
end

# Interpreting the code
Language.new.interpret
Enter fullscreen mode Exit fullscreen mode

Calling Methods Dynamically

When you call a method, you're actually sending a message to an object.

my_obj.my_method(arg)
Enter fullscreen mode Exit fullscreen mode

Ruby provides an alternate syntax to call a method dynamically, using the send method. This is called dynamic dispatch, and it's a powerful technique as you can wait until the last moment to decide which method to call, while the code is running.

my_obj.send(:my_method, arg)
Enter fullscreen mode Exit fullscreen mode

Missing Methods

When you call a method on an object, the Ruby interpreter goes into the object's class and looks for the instance method. If it can't find the method there, it searches up the ancestor chain of that class, until it reaches BasicObject. If it doesn't find the method anywhere, it calls a method named method_missing on the original receiver, i.e. the object.

The method_missing method is originally defined on the BasicObject class. However, you can override it in your class to intercept and handle the unknown methods.

class Language
  def interpret
    puts "Interpreting"
  end

  def method_missing(name, *args)
    puts "Method #{name} doesn't exist on #{self.class}"
  end
end

ruby = Language.new
ruby.interpret # Interpreting
ruby.compile # Method compile doesn't exist on Language
Enter fullscreen mode Exit fullscreen mode

instance_eval

This BasicObject#instance_eval method evaluates a block in the context of an object.

class Language
  def initialize(name)
    @name = name
  end

  def interpret
    puts "Interpreting the code"
  end
end

puts "***instance_eval with object***"

ruby = Language.new "Ruby"

ruby.instance_eval do
  puts "self: #{self}"
  puts "instance variable @name: #{@name}"
  interpret
end

puts "\n***instance_eval with class***"

Language.instance_eval do
  puts "self: #{self}"

  def compile
    puts "Compiling the code"
  end

  compile
end

Language.compile
Enter fullscreen mode Exit fullscreen mode

The above program produces the following output

***instance_eval with object***
self: #<Language:0x00007fc6bb107730>
instance variable @name: Ruby
Interpreting the code

***instance_eval with class***
self: Language
Compiling the code
Compiling the code
Enter fullscreen mode Exit fullscreen mode

Class Definitions

A Ruby class definition is just regular code that runs. When you use the class keyword to create a class, you aren’t just dictating how objects will behave in the future. You are actually running code.

class MyClass
  puts "Hello from MyClass"
  puts self
end

# Output
# Hello from MyClass
# MyClass
Enter fullscreen mode Exit fullscreen mode

class_eval()

Evaluates a block in the context of an existing class. This allows you to reopen the class and define additional behavior on it.

class MyClass
end

MyClass.class_eval do
  def my_method
    puts "#{self}"
  end
end

MyClass.new.my_method  # #<MyClass:0x00007f945e110b80>
Enter fullscreen mode Exit fullscreen mode

A benefit of class_eval is that it will fail if the class doesn't already exist. This prevents you from creating new classes accidentally.

Singleton Methods and Classes

You can define methods on individual objects, instead of defining them in the object's class.

animal = "cat"

def animal.speak
  puts self
end

animal.speak  # cat
Enter fullscreen mode Exit fullscreen mode

When you define the singleton method on the animal object, Ruby does the following:

  1. Create a new anonymous class, also called a singleton/eigenclass.
  2. Define the speak method on that class.
  3. Make this new class the class of the animal object.
  4. Make the original class of the object (String), the superclass of the singleton class.

animal -> Singleton -> String -> Object

Classes are objects, and class names are just constants. Calling a method on a class is the same as calling a method on an object.

The superclass of the singleton class of an object is the object’s class. The superclass of the singleton class of a class is the singleton class of the class’s superclass.

You can define attributes on a class as follows:

class Foo
  class << self
    attr_accessor :bar
  end
end

Foo.bar = "It works"
puts Foo.bar
Enter fullscreen mode Exit fullscreen mode

Remember that an attribute is just a pair of methods. If you define an attribute on the singleton class, they become class methods.


Takeaway

Though metaprogramming in Ruby looks like magic, it's still just programming. It is so deeply ingrained in Ruby that you can barely write idiomatic Ruby without using a few metaprogramming techniques.

Ruby expects that you will change the object model, reopen classes, define methods dynamically, and create/execute code on-the-fly.

Writing perfect metaprogramming code up-front can be hard, so it’s generally easy to evolve your code as you go.

Keep your code as simple as possible, and add complexity as you need it.

When you start, strive to make your code correct in the general cases, and simple enough that you can add more special cases later.


This post was originally published on my blog, where you can find my notes for other books I read. Hope you found it useful.

Let me know in the comments if you found any mistakes. I look forward to your feedback.

💖 💪 🙅 🚩
software_writer
Akshay Khot

Posted on December 30, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related