content: Add demystifying Ruby 2/3

This commit is contained in:
Wilfried OLLIVIER 2024-11-15 09:29:18 +01:00
parent 341f5c2d91
commit a20166862a

View file

@ -0,0 +1,246 @@
---
title: "Demystifying Ruby ♦️ (2/3): Objects, Objects everywhere"
subtitle: "And a little bit of Metaprogramming"
date: 2024-11-18
draft: false
author: Wilfried
tags: [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 !
```ruby
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)
```ruby
Object.superclass # BasicObject
BasicObject.superclass # nil
```
Even the main “context” of the simpliest Ruby script :
```ruby
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.
```ruby
# 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
```ruby
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
```ruby
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”.
```ruby
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.
```ruby
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 :
```ruby
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!)
```ruby
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
```
```ruby
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!**