How Ruby Works: Interpreted and Object-Oriented
Ruby is both an interpreted language and a thoroughly object-oriented one, and those two facts explain almost everything about how it feels to write Ruby code. Instead of compiling your program into a standalone executable ahead of time, the ruby command reads your source, translates it into instructions, and starts running it right away — which is why you can drop into irb and get instant feedback on a single line. And because literally every value you touch, from an integer to nil to a class itself, is an object that responds to messages (methods), the same core mechanic — sending a message to a receiver and letting Ruby find the matching method — is how you build everything else in the language. Once these two ideas click, the rest of Ruby’s syntax stops looking like a pile of special cases and starts looking like one consistent system.
Overview: How Ruby Works
“Interpreted” used to mean a program was read and executed line by line, with no separate build step producing machine code. Ruby’s default implementation, CRuby (also called MRI, Matz’s Ruby Interpreter), is a bit more sophisticated than that: when you run ruby script.rb, it first parses your source into an abstract syntax tree, then compiles that tree into an internal bytecode format called YARV instructions, and finally executes those instructions on a virtual machine. All of that happens automatically and invisibly the moment you run the file — there is no separate compile-then-link workflow exposed to you, no .exe or .class file left behind. That is what people mean when they call Ruby an interpreted language: you write source, you run source, and Ruby handles translation internally on every run.
Execution then proceeds top to bottom through the file. Method and class definitions are themselves executable statements — def and class run when Ruby reaches them, registering the method or class so it can be looked up later. This is why a method must be defined before it is called, but the call itself can be anywhere textually, including inside another method defined earlier, because Ruby resolves method calls at the moment they run, not while parsing.
The other half of the story is Ruby’s object model. Every piece of data is an instance of some class, including things other languages treat as primitives:
| Expression | Value it produces |
|---|---|
42.class |
Integer |
nil.class |
NilClass |
true.class |
TrueClass |
Integer.class |
Class |
Because everything is an object, calling a method is really sending a message to a receiver. Ruby looks for a matching method by walking the receiver’s ancestor chain: first the object’s own singleton class (methods defined on that one object), then its class, then any modules mixed in with include (searched in reverse order of inclusion, most recently included first), then the superclass and its included modules, and so on up to Object, Kernel, and finally BasicObject. The first matching method found wins. If the search reaches the top with nothing found, Ruby does not simply give up — it calls method_missing on the original receiver, which by default raises NoMethodError but which you can override to handle arbitrary method names dynamically.
Truthiness follows the same “everything is an object” logic taken to its natural conclusion: in an if, unless, or &&/|| expression, only nil and false are falsy. Every other object — including 0, an empty string "", and an empty array [] — is truthy. This trips up people coming from languages where 0 or "" is falsy, and it is one of the most common sources of subtle Ruby bugs.
Syntax
There is no single “how Ruby works” syntax, but the general shape of defining and using an object looks like this:
class ClassName
def method_name(param1, param2 = "default", *rest, keyword:, keyword_opt: nil)
# method body
# the value of the LAST evaluated expression is returned automatically
end
end
object = ClassName.new
object.method_name(value1, value2, keyword: value3)
| Part | Meaning |
|---|---|
class ClassName ... end |
Defines a new class; runs once, registering the class and its methods. |
def method_name ... end |
Defines an instance method; the body executes each time the method is called, not when it is defined. |
param2 = "default" |
An optional parameter with a default value. |
*rest |
Collects any extra positional arguments into an array. |
keyword: / keyword_opt: nil |
Required and optional keyword arguments — preferred for methods with several parameters, since call sites stay self-documenting. |
ClassName.new |
Sends the new message to the class object, which allocates an instance and calls initialize on it. |
object.method_name(...) |
Sends a message (method call) to object; Ruby searches the ancestor chain to find the method to run. |
Examples
Example 1: Everything really is an object
puts 42.class
puts 42.class.class
puts nil.class
puts true.class
puts "hello".class
range = (1..5)
puts range.class
if 1
puts "1 is truthy"
end
if ""
puts "empty string is truthy"
end
if []
puts "empty array is truthy"
end
if nil
puts "nil is truthy"
else
puts "nil is falsy"
end
Output:
Integer
Class
NilClass
TrueClass
String
Range
1 is truthy
empty string is truthy
empty array is truthy
nil is falsy
Every value responds to .class, including a class itself — 42.class is Integer, and Integer is in turn an instance of Class. The four if checks show Ruby’s truthiness rule in action: 1, "", and [] all count as truthy because they are not nil or false; only the final check, on literal nil, takes the else branch.
Example 2: Method lookup and the ancestor chain
module Greetable
def greet
"Hello, I am #{name}"
end
end
class Person
include Greetable
attr_reader :name
def initialize(name)
@name = name
end
end
person = Person.new("Ada")
puts person.greet
puts Person.ancestors.inspect
Output:
Hello, I am Ada
[Person, Greetable, Object, Kernel, BasicObject]
Person does not define greet itself — it comes from the Greetable module mixed in with include. When person.greet is called, Ruby walks the ancestor chain shown by Person.ancestors: it checks Person first (no greet there), then Greetable (found), and stops. Inside greet, calling name works because it is looked up on self (the Person instance), which does have it via attr_reader.
Example 3: method_missing and dynamic dispatch
class GhostResponder
def method_missing(name, *args)
if name.to_s.start_with?("ask_")
topic = name.to_s.sub("ask_", "")
"You asked about #{topic} with #{args.inspect}"
else
super
end
end
def respond_to_missing?(name, include_private = false)
name.to_s.start_with?("ask_") || super
end
end
ghost = GhostResponder.new
puts ghost.ask_weather("today")
puts ghost.respond_to?(:ask_weather)
Output:
You asked about weather with ["today"]
true
Calling ghost.ask_weather("today") sends a message Ruby cannot find anywhere in the ancestor chain, so it falls back to method_missing, which inspects the requested method name and args and builds a response. Overriding respond_to_missing? alongside method_missing keeps introspection methods like respond_to? honest about what the object can actually handle — skipping it is a common mistake when writing dynamic Ruby code.
How It Works Step by Step
When you run a Ruby file, roughly this happens:
- Ruby parses the source text into an abstract syntax tree, checking for syntax errors.
- CRuby compiles that tree into internal YARV bytecode instructions.
- The virtual machine executes those instructions from top to bottom; top-level statements,
classbodies, anddefstatements all run in the order Ruby encounters them. - Whenever a method call is reached, Ruby evaluates the receiver and arguments, then searches the receiver’s singleton class, its class, included modules (most recently included first), and superclasses in order until it finds a matching method name.
- If a match is found, its body executes; the value of the last expression evaluated in that body becomes the method’s return value automatically, with no
returnkeyword required. - If no match is found anywhere in the chain, Ruby calls
method_missingon the receiver; the default implementation (inherited fromBasicObject) raisesNoMethodError.
Common Mistakes
Mistake 1: Treating 0 or an empty collection as falsy
inventory_count = 0
if inventory_count
puts "Item is in stock"
else
puts "Item is out of stock"
end
This prints Item is in stock, which is wrong for an inventory of zero. In Ruby only nil and false are falsy, so 0 passes the if check just like any other object. The fix is to test the actual condition you mean, not the object’s truthiness:
inventory_count = 0
if inventory_count.zero?
puts "Item is out of stock"
else
puts "Item is in stock"
end
Output:
Item is out of stock
Mistake 2: Leaking a mutable reference to internal state
class Cart
def initialize
@items = []
end
def items
@items
end
end
cart = Cart.new
cart.items << "apple"
receipt_preview = cart.items
receipt_preview << "gift wrap"
puts cart.items.inspect
This prints [“apple”, “gift wrap”]. The items getter hands back the exact same Array object stored in @items, not a copy, so appending to receipt_preview silently mutates the cart too. Returning a duplicate (and adding a proper writer method) fixes it:
class Cart
def initialize
@items = []
end
def items
@items.dup
end
def add_item(item)
@items << item
end
end
cart = Cart.new
cart.add_item("apple")
receipt_preview = cart.items
receipt_preview << "gift wrap"
puts cart.items.inspect
Output:
["apple"]
Mistake 3: Chaining onto a bang method that can return nil
words = ["ruby", "gem", "rails"]
result = words.uniq!.length
puts result
Several bang methods, including uniq!, compact!, and reject!, return nil (instead of self) whenever they made no change. Here there were no duplicates to remove, so words.uniq! returns nil, and calling .length on nil raises NoMethodError: undefined method 'length' for nil. Use the non-mutating version when you just want a result, and keep the two steps separate:
words = ["ruby", "gem", "rails"]
unique_words = words.uniq
puts unique_words.length
Output:
3
Best Practices
- Test conditions explicitly (
count.zero?,array.empty?,value.nil?) instead of relying on an object’s bare truthiness when you mean something more specific than “notnil/false.” - Know which bang methods return
nilon no change (uniq!,compact!,reject!,select!, and similar) and avoid chaining directly off them. - Return a
duporcloneof internal arrays and hashes from getters when callers should not be able to mutate your object’s state. - Pair
method_missingwith a matchingrespond_to_missing?so introspection (respond_to?,method) stays accurate. - When debugging “which method actually ran,” check
SomeClass.ancestorsorobject.method(:name).ownerrather than guessing. - Prefer keyword arguments for methods that take more than one or two parameters so call sites stay self-explanatory.
Practice Exercises
- Write a script that prints the
.classof five different values (an integer, a float, a string, a symbol, andnil) and confirms that each result is itself an object by calling.classon the class. - Define a class
Walletwith abalancegetter backed by an internal integer. Write a small script that shows whyif wallet.balancealone is not a safe way to check for “no money left,” even when the balance is0. - Create two modules,
FlyableandSwimmable, each defining a differently named method, and mix both into aDuckclass. PrintDuck.ancestorsand confirm the module you included last appears closer to the front of the chain than the one included first.
Summary
- Ruby is interpreted: CRuby parses your source, compiles it internally to YARV bytecode, and executes it immediately — there is no separate build step you manage.
- Execution runs top to bottom;
defandclassare themselves statements that register methods and classes when Ruby reaches them. - Everything is an object, including integers,
nil,true/false, and classes themselves — every one of them responds to.class. - Only
nilandfalseare falsy;0,"", and[]are all truthy, a frequent source of bugs for newcomers. - Calling a method sends a message that Ruby resolves by walking the ancestor chain: singleton class, class, included modules, superclasses, up to
BasicObject. - When no method matches, Ruby calls
method_missing, which by default raisesNoMethodErrorbut can be overridden for dynamic dispatch. - A method’s return value is whatever its last expression evaluates to — watch for that when a bang method can return
nilor when a getter leaks a mutable reference.
