9.3 KiB
title | subtitle | date | draft | author | tags | ||||
---|---|---|---|---|---|---|---|---|---|
Demystifying Ruby ♦️ (2/3): Objects, Objects everywhere | And a little bit of Metaprogramming | 2024-11-18 | false | Wilfried |
|
One of the most fundamental principles of Ruby is that everything in the language is an object. This object-oriented allows developers to think in terms of objects and their interactions rather than just functions and procedures.
In Ruby, every piece of data, whether it’s a number, string, array, or even a class itself, is treated as an object. This means that all these entities can have properties and behaviors, encapsulated within methods and attributes. Let’s explore this !
1.is_a?(Object) # true
"Hello".is_a?(Object) # true
Even Object
, is an Object
(but a very Basic one, with no dependency from Kernel module)
Object.superclass # BasicObject
BasicObject.superclass # nil
Even the main “context” of the simpliest Ruby script :
self.class # Object
self.to_s # main
Convinced ?
Object ID
Every object in Ruby has a unique identifier known as the object ID. This ID is a representation of the memory address where the object is stored. The object ID is an important aspect of Ruby’s object model, as it allows you to differentiate between object instances, even if they contain the same value.
# Some of them are hardcoded inside the Ruby VM
nil.object_id # 4
true.object_id # 20
false.object_id # 0
Class.object_id # 34900
# And others are dynamic
a = "hello"
b = "hello"
a.object_id # Unique object ID
b.object_id # Different object ID (strings are different objects)
equal?
for example implement the most basic check between two objects by comparing their object id.
Knowing this can sometime help you debug situation where you don’t understand what’s happening, maybe you are not manipulating the object you wanted at first…
Variables & Methods
Accessing Variables
class Car
def initialize(make, model)
@make = make
@model = model
end
end
car = Car.new("Toyota", "Camry")
puts car.instance_variable_get(:@make) # "Toyota"
This structure includes:
- Instance Variables: Each object has its own set of instance variables, typically stored in a hash-like structure, where the keys are the variable names (with an
@
prefix) and the values are the associated data. This allows Ruby to maintain a separate state for each object. - Class Variables: Class variables (denoted with
@@
) are shared among all instances of the class. They are also stored in a similar structure but are scoped to the class itself, meaning that every instance of the class can access and modify the same class variable.
Every Ruby object has some low level methods like instance_variable_get
, instance_variable_set
, to explore data in a class.
When an object is created, Ruby allocates a contiguous block of memory for it. This memory contains various fields that are used to store data about the object, including:
- A pointer to the object's class (which defines the methods available to the object).
- A hash-like structure (called
CStruct
, if you want to dig more) for storing instance variables (with the variable names as keys and their corresponding values). Due to Ruby dynamic nature, this struct is used when you add or remove instance variables at runtime! - Additional fields that may contain metadata (like the object's type, garbage collection flags, etc.)
Calling Methods
Now lets dig what happens when you call a method on an object. In Ruby this concept is known as the method lookup chain.
- The Singleton Class (aka Eigenclass) of the Object: Ruby first checks for any methods defined specifically on the object (singleton methods). This is what you have with
Object Properties
in JavaScript. - The Class of the Object: Next, it checks the class of the object for any methods defined there.
- Included Modules (in reverse order): Ruby then checks the included modules in the reverse order of their inclusion.
- Superclass Chain: If the method is not found, Ruby checks the superclass of the object's class, continuing up the inheritance hierarchy. (Ruby only support single inheritance so you can build a chain and compose behavior but you can not inherit from two classes at the same time)
- Kernel Module: After checking all superclasses, Ruby checks the
Kernel
module, which is included in all classes and provides many useful methods (likeputs
,print
, etc.). - Object: Finally, Ruby checks the
Object
class itself. - BasicObject: At the very end, if the method is still not found, Ruby checks
BasicObject
, which is the parent of all classes in Ruby.
Here is an example
module Flyable
def fly
"I'm flying!"
end
end
class Bird
def chirp
"Chirp chirp!"
end
end
class Duck < Bird
include Flyable
def quack
"Quack quack!"
end
end
duck = Duck.new
# Calling instance methods from Duck and included module
puts duck.quack # Quack quack!
puts duck.chirp # Chirp chirp!
puts duck.fly # I'm flying!
# Defining a singleton method for the duck instance
def duck.swim
"I'm swimming!"
end
# Calling the singleton method
puts duck.swim # I'm swimming!
# Accessing the singleton class (eigenclass) of the duck instance
eigenclass = duck.singleton_class
puts eigenclass # #<Class:#<Duck:0x00007fffdc8a6780>>
Open Classes and Metaprogramming
In Ruby, you can pretty much do what you want to any class, from the one you have created to the ones included in the core lang ! The “private” aspect of methods is also “open”.
class String
def shout
self.upcase + "!"
end
def shuffle
chars.shuffle.join
end
private
def answer
"42"
end
end
"hello".shout # "HELLO!"
puts "hello".shuffle # "lleho"
"hello".send(:answer) # Private method is called 👁️👄👁️
Open classes in Ruby make the language highly flexible, supporting a range of use cases from adding helper methods to modifying library behavior. However, this flexibility comes with risks, so it's essential to use open classes thoughtfully (or not use them at all, library code does this for us).
ActiveSupport
is a part of Rails and a well-known example that heavily utilizes open classes to extend core Ruby classes with utility methods.
require 'active_support/core_ext/string'
"my_class_name".camelize # => "MyClassName"
"MyClassName".underscore # => "my_class_name"
This behavior open the door to Metaprogramming a technique where code can write or modify code dynamically at runtime. In Ruby, this is mostly used for
- defining methods
- call interception (
method_missing
👀)
When we look at the details, we have all the tooling to do it pretty easily, lets look at this code :
class Person
def initialize(name, age)
@name = name
@age = age
[:name, :age].each do |attr|
self.class.define_method(attr) do
instance_variable_get("@#{attr}")
end
end
end
end
This example may look familiar! it's a re-implementation of attr_accessor
, the Ruby method that magically creates getter method for an attribute. While this might not be the exact code used internally, attr_accessor
definitely relies on Ruby’s powerful metaprogramming features to dynamically create methods at runtime. This example demonstrates how deeply Ruby incorporates metaprogramming into its core.
The flexibility that Ruby offers through open classes and metaprogramming does introduce some unique challenges—particularly around safely handling undefined methods. Since Ruby allows us to reopen classes and dynamically define methods (as attr_accessor
does), it’s possible to encounter situations where a method we expect doesn’t exist. To address this, Ruby provides a safety mechanism in the form of method_missing
.
For example, if we have a class with dynamically generated attributes using attr_accessor
but call an undefined method by mistake, Ruby would usually raise a NoMethodError
. However, by defining method_missing
, we can handle the error or even dynamically respond to the method call.
This allows for such hideous thing like this one (don’t @me, it’s for the example!)
class DynamicHash
def initialize
@attributes = {}
end
def method_missing(method_name, *args, &block)
attribute = method_name.to_s.chomp('=').to_sym
if method_name.to_s.end_with?('=')
@attributes[attribute] = args.first # Setter
else
@attributes[attribute] # Getter
end
end
def respond_to_missing?(method_name, include_private = false)
true
end
end
data = DynamicHash.new
data.name = "Alice"
puts data.name # Outputs: "Alice"
data.age = 30
puts data.age # Outputs: 30
Please don't do this
Metaprogramming gives you the power to shape Ruby to your will, leveraging the fact that in Ruby, everything is an object and classes are open by design. This dynamic magic can streamline code, make it highly expressive, and support advanced customization—but it also requires disciplined control. With the freedom to redefine and extend core behaviors, it’s essential to strike a balance between flexibility and maintainability.
With great power comes great responsibility!