01 · OOP Deep Dive¶
Level 1 covered classes, attr_accessor, and single inheritance. Ruby only
allows a class to have one superclass, but real programs need to share
behavior across unrelated classes all the time — a Dog and a Duck might
both need to "swim," even though neither is a subclass of the other. Ruby
solves this with modules: namespaces and mixins that let you share code
without forcing an inheritance relationship.
Modules as namespaces¶
A module can group related classes/constants/methods under one name, purely to avoid naming collisions:
module Shapes
class Circle
def initialize(radius)
@radius = radius
end
def area
(Math::PI * @radius**2).round(2)
end
end
end
circle = Shapes::Circle.new(4)
puts circle.area # 50.27
Shapes::Circle uses :: to reach inside the module — this is the same
namespacing Ruby itself uses for things like Math::PI.
Modules as mixins — include¶
A module can also hold plain methods that get mixed into a class as
instance methods, via include:
module Swimmable
def swim
"#{name} is swimming!"
end
end
class Fish
include Swimmable
attr_reader :name
def initialize(name)
@name = name
end
end
class Athlete
include Swimmable
attr_reader :name
def initialize(name)
@name = name
end
end
puts Fish.new("Nemo").swim # Nemo is swimming!
puts Athlete.new("Michael").swim # Michael is swimming!
Fish and Athlete share zero inheritance, but both gained swim for
free. This is Ruby's answer to "multiple inheritance" — a class can
include as many modules as it wants, while only ever having one
superclass.
extend — mixing in as class methods instead¶
include adds instance methods. extend adds the module's methods as
class methods (or as singleton methods on whatever object you extend):
module Describable
def describe
"I am #{self}"
end
end
class Robot
extend Describable
end
puts Robot.describe # I am Robot -- describe became a CLASS method
Where include actually puts the module: the ancestor chain¶
include doesn't copy methods into the class — it inserts the module into
the class's ancestor chain, just above the class itself. Ruby method
lookup walks this chain in order, which is why a method defined directly on
the class overrides one pulled in from an included module:
module Greetable
def greet
"Hi from the module"
end
end
class Person
include Greetable
end
puts Person.ancestors
# [Person, Greetable, Object, Kernel, BasicObject]
puts Person.new.greet # Hi from the module
If Person defined its own greet, that would win, because Person comes
before Greetable in the chain. This lookup order is also why include order
matters when two modules define the same method — the last one included
sits closer to the class and wins.
prepend — inserting before the class¶
prepend is the less common cousin of include: it inserts the module
before the class in the ancestor chain, so the module's method runs
first and can call super to reach the class's own method. This is the
idiomatic way to wrap/decorate an existing method:
module LoudLogger
def greet
"#{super.upcase}!!!"
end
end
class Person
prepend LoudLogger
def greet
"hello"
end
end
puts Person.ancestors.first(2) # [LoudLogger, Person]
puts Person.new.greet # HELLO!!!
Comparable and Enumerable are just modules¶
Ruby's own standard library leans hard on this pattern — Comparable and
Enumerable (covered in depth in
Enumerable & Comparable) are ordinary modules
you include, not special language features.
method_missing — intercepting unknown method calls¶
Every object responds to method_missing whenever a method is called that
isn't defined anywhere in its ancestor chain. By default it raises
NoMethodError, but you can override it to handle calls dynamically:
class DynamicRecord
def initialize(attributes)
@attributes = attributes
end
def method_missing(name, *args)
key = name.to_s.chomp("=").to_sym
if name.to_s.end_with?("=")
@attributes[key] = args.first
elsif @attributes.key?(key)
@attributes[key]
else
super # important! see the footgun below
end
end
def respond_to_missing?(name, include_private = false)
key = name.to_s.chomp("=").to_sym
@attributes.key?(key) || super
end
end
record = DynamicRecord.new(title: "Refactoring", year: 1999)
puts record.title # Refactoring
record.year = 2018
puts record.year # 2018
puts record.respond_to?(:title) # true
The method_missing footguns¶
method_missing is powerful but easy to misuse:
- Always call
superfor names you don't recognize. Skipping thesupercall above would silently returnnilfor typos likerecord.tielinstead of raisingNoMethodError— turning obvious bugs into confusingnilvalues downstream. - Always define
respond_to_missing?alongside it. Without it,respond_to?and.methodslie about what the object can actually do, which breaks duck-typing checks and tools likemethod(:foo).respond_to_missing?is whyrecord.respond_to?(:title)correctly returnstrueabove. - It's slower than a real method, because Ruby has to fail normal
lookup first before falling into
method_missing. Reach fordefine_method(see Metaprogramming Basics) instead when you know the method names up front — it defines a real method once instead of intercepting every call forever.
Monkey-patching: reopening classes (and why it's risky)¶
Ruby classes are never "closed" — you can reopen String, Integer, or any
class, including ones from the standard library, and add or redefine
methods on them:
This is called monkey-patching. It's tempting because it's global and convenient, but it's risky:
- Global mutation — every
Stringin the entire process getsshout, including ones inside gems you didn't write. Two gems patching the same method differently causes silent, hard-to-debug conflicts. - Version fragility — if a future Ruby version adds a real
String#shoutwith different behavior, your patch silently shadows it. - Safer alternatives: wrap the behavior in your own module and
include/extendit only where you need it, or userefine(Ruby's scoped alternative to monkey-patching) if you truly need to change built-in behavior.
How It Actually Works¶
include and extend don't copy methods into your class — they splice a
module into the ancestor chain itself. include Comparable inserts the
module directly above your class in the lookup chain returned by
ancestors, so instance method lookup finds it there; extend Comparable
instead inserts the module into the singleton class of the receiver, which
is why extended methods behave as class (or object-level) methods rather
than instance methods. prepend (Ruby's less common third option) inserts a
module below the class in the chain, ahead of the class's own methods —
which is the only clean way to intercept and call super from inside a
method the class itself defines, since normal include can never come
before the class in lookup order. Modules can't be instantiated directly
because MRI's Module class is a superclass of Class but deliberately
doesn't implement new the way Class does — a module is method
storage and a chain node, not a blueprint for objects.
Cheat sheet¶
| Goal | Tool |
|---|---|
| Share instance methods across unrelated classes | module M; end + include M |
| Add class-level (or singleton) methods | extend M |
| Wrap/override a method and still call the original | prepend M + super |
| Inspect method lookup order | SomeClass.ancestors |
| Handle undefined method calls dynamically | method_missing + respond_to_missing? |
| Add methods to an existing class | reopen it (monkey-patch) — use sparingly |
Exercise¶
Write a module Trackable with a method track_change(field, value) that
appends "#{field} changed to #{value}" to an internal @history array
(initialize it lazily with @history ||= []), plus a history reader.
Mix Trackable into a Product class that calls track_change from its
price= setter whenever the price changes. Then write a tiny
DynamicConfig class using method_missing (with respond_to_missing?)
that lets you write config.timeout = 30 and read it back with
config.timeout.