If you have seen some Ruby on Rails code, have you ever wondered how certain keywords are meant to work? As an example, take a look at the code below:
class User < ApplicationRecord
has_many :posts, dependent: :destroy
end
By writing this one has_many
line, Rails automatically queries out all the posts of a certain user when we write user.posts
. How does it work? Is it a built-in feature of Ruby (it isn’t)? And this is not the only example. Many gems also have certain ‘keywords’ that add more functionality to your Ruby/Rails projects. For example, if you have used Devise gem for authentication of a user, writing devise :database_authenticatable, :registerable,...
and including other modules for the user model automatically configures authentication and other features for users. If you have used ancestry gem, declaring has_ancestry
in your model configures it to have a tree-like structure and you can query that, while you have barely written any code. And the list of such examples is non-exhaustive.
So, how are these DSL-like features meant to work? How can we create our own certain keywords like these? The answer lies in Ruby’s powerful Metaprogramming.
Metaprogramming!
Metaprogramming is a technique in which computer programs can write and manipulate other programs. Ruby is a super flexible programming language and its ecosystem of metaprogramming is extremely powerful. If it wasn’t for Ruby’s metaprogramming, we would never have had Ruby on Rails.
Ruby is a pure OOP-based language. Everything in Ruby is an Object (even Class
itself is an Object! You can even create a class with Class.new
!). Furthermore, Ruby makes it easy for you to write singleton methods for your objects as well. Here is an example:
cool_str = 'hello'
def cool_str.cool_method
'I have a cool method'
end
not_cool_str = 'world'
puts cool_str.cool_method # Prints 'I have a cool method'
puts not_cool_str.cool_method # Shows an 'undefined method' error.
In the code above, we declared a singleton method for our cool_str
and it works. We can attach such singleton methods to our classes and other certain objects. For our classes, these singleton methods are also called Class Methods (Note: We cannot define such methods for all types of objects. As an exercise, try attaching such a singleton method to an integer like we did to our cool_str
). Furthermore, Ruby’s code blocks are also a powerful feature.
Features like defining class methods, usage of blocks, symbols etc., showcase Ruby’s expressiveness and its ability to write dynamic, concise and metaprogrammed code. Below is a little taste of Ruby’s metaprogramming.
Let’s Get Started
Let’s declare a base class called Animal
. This class is going to have some class methods that define instance methods on runtime. These class methods can then be utilized by the child classes that are inherited from Animal
.
Here is our Animal
class:
class Animal
def self.can_bark
define_method(:woof) { 'Woof Woof' }
end
def self.can_meow
define_method(:meow) { 'Meow' }
end
def self.can_speak(speak_text:)
define_method(:speak) { speak_text }
end
end
Our parent Animal
class has three class methods. Each of these class methods can dynamically create a new instance method for the class which is inherited from Animal
. The define_method
here is key. It is used to define methods in runtime. The provided block becomes the body of the method. Now, for example, if the inheriting class has the keyword can_bark
, it’s instance will have a method named woof
defined for it. Let’s try it out:
class Dog < Animal
can_bark
end
class Cat < Animal
can_meow
end
class UniqueAnimal < Animal
can_bark
can_meow
can_speak speak_text: 'Abrakadabra'
end
dog = Dog.new
cat = Cat.new
unique_animal = UniqueAnimal.new
puts dog.woof # Prints 'Woof Woof'
puts cat.meow # Prints 'Meow'
puts unique_animal.speak # Prints 'Abrakadabra'
puts unique_animal.woof # Prints 'Woof Woof'
puts unique_animal.meow # Prints 'Meow'
puts dogs.meow # Will render an 'undefined method' error.
Here, we declared three child classes inherited from Animal
which utilize the class methods from their parent — Animal
. For instance, Dog
class has can_bark
which would define an instance method woof
for it — and all of this is done in our class method self.can_bark
of Animal
. We have all three ‘keywords’ (class methods) for our UniqueAnimal
— can_bark, can_meow, can_speak
and these class methods have declared the respective instance methods for our UniqueAnimal
class, and our unique_animal
expresses that behavior! You can see how these ‘magic words’ can mask the logic and define custom methods for our classes, keeping the code expressive and concise.
Lets take a look at another example.
class Attributer
def initialize(**attribute_inputs)
set(**attribute_inputs)
end
def has_attribute?(attribute)
self.class.has_attribute?(attribute)
end
def attributes
self.class.attributes
end
def set(**attribute_inputs)
attribute_inputs.each do |key, value|
next unless attributes.include?(key)
instance_variable_set("@#{key}", value)
end
end
@@all_attributes = []
def self.has_attribute(attribute_name)
raise 'Attribute can be only be String or a Symbol!' unless [Symbol, String].include?(attribute_name.class)
attribute_name = attribute_name.to_sym
attr_accessor attribute_name
@@all_attributes = (@@all_attributes << attribute_name).uniq
end
def self.has_attributes(*attributes)
attributes.each { |attribute| has_attribute attribute }
end
def self.has_attribute?(attribute)
@@all_attributes.include?(attribute.to_s.to_sym)
end
def self.attributes
@@all_attributes
end
end
This is going to be our parent class Attributer
. Let’s dissect it:
- The instance methods
has_attribute?
andattributes
call the respective class methods:self.has_attribute?
andself.attributes
. These class methods check for existence of an attribute or enlist all the attributes respectively. As you can see, it is possible to call class methods in our instance methods withself.class
giving us access to our class’s blueprint. - The class methods are also simple. The
has_attribute
class method takes the incoming argument (attribute) and converts it into anattr_accessor
for our class, and appends that attribute to our class variable@@all_attributes
. Thehas_attributes
class method executeshas_attribute
on each of its argument. - The instance method
set
loops over the arguments hash and sets the values of attributes with the help ofinstance_variable_set
— a method provided by the parent of all Objects in Ruby —Object
. Thisset
method can also be used during initialization of the object as well.
Now, let’s utilize this class:
class GameCharacter < Attributer
has_attributes :first_name, :last_name, :age, :game
def full_name
"#{first_name} #{last_name}"
end
end
character = GameCharacter.new(first_name: 'Shermie', age: 21, game: 'King of Fighters \'97')
puts "Full name: #{character.full_name}" # Prints 'Shermie'
puts "Has date_of_birth? #{character.has_attribute?(:date_of_birth)}" # Prints false
puts "Has first_name? #{character.has_attribute?(:first_name)}" # Prints true
puts "Available Attributes: #{character.attributes}" # Prints all the available attributes
character.first_name = 'Blaze'
character.last_name = 'Fielding'
character.set(age: 33, game: 'Streets of Rage 4')
puts "Character's full name: #{character.full_name}" # Prints 'Blaze Fielding'
puts "Character's game: #{character.game}" # Prints 'Streets of Rage 4'
Our GameCharacter
class inherits from Attributer
class and has access to all of its class methods. By just one line — has_attributes
— we made all of the mentioned attributes as the attr_accessor
and all the instance methods work as expected too! And this is just a basic example of metaprogramming. With all such features that Ruby provides, you can go absolutely bonkers and take it to the next level. But be careful, while all of this is super powerful, making your code secure and maintainable is also important and a skill of its own.
In Conclusion, Ruby is one of the best programming languages when it comes to metaprogramming. It’s expressiveness and flexibility is unmatched, and allow you to take your implementation of Classes to next level. And we have barely scratched the surface of how awesome the Ruby’s metaprogramming ecosystem is!