blog/content/post/08-ruby-objects.md

9.3 KiB
Raw Permalink Blame History

title subtitle date draft author tags
Demystifying Ruby ♦️ (2/3): Objects, Objects everywhere And a little bit of Metaprogramming 2024-11-18 false Wilfried
dev
languages
ruby
demystifying-ruby

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 its 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. Lets 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 Rubys 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 dont understand whats 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:

  1. 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.
  2. 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 (like puts, 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 Rubys 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), its possible to encounter situations where a method we expect doesnt 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 (dont @me, its 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, its essential to strike a balance between flexibility and maintainability.

With great power comes great responsibility!