When I first started learning Ruby, I remember being confused by how effortlessly everything seemed to “just work.” I’d write a class, create an object, call a method, and get a sensible result without fighting the language. It took me a while to realize that this smoothness isn’t an accident — it’s a direct result of how deliberately Ruby was designed around object-oriented principles. Every single thing you touch in Ruby, from a simple integer to a full-blown class definition, is an object with behavior and state.
In this article, I want to walk you through classes, objects, and variables in Ruby the way I wish someone had explained them to me — starting from the absolute basics and working up to the internal mechanics that make Ruby’s object model so elegant. By the end, you’ll not only know how to write classes, but you’ll understand why they behave the way they do.
Why Object-Oriented Programming Matters in Ruby
Object-oriented programming (OOP) is a paradigm that organizes code around “objects” — bundles of data (state) and behavior (methods) that model real-world or logical entities. Instead of writing a pile of loose functions that operate on raw data, you group related data and the operations on that data into a single unit: a class.
Ruby takes OOP further than most mainstream languages. In Python, JavaScript, or even Java, there are primitive types that sit outside the object system to some degree. In Ruby, there are no primitives. 42 is an object. true is an object. nil is an object. Even classes themselves are objects (instances of the class Class). This consistency is what gives Ruby its reputation for being intuitive once it clicks.
What Is a Class in Ruby?
A class is a blueprint. It defines what data an object of that type will hold and what behavior it will expose. Think of a class like an architectural drawing for a house — the drawing itself isn’t a house you can live in, but it tells you exactly how to build one.
Here’s the simplest class I can show you:
class Dog
end
my_pet = Dog.new
puts my_pet.class
Output:
Dog
That’s it. Dog.new creates a new object — an instance — of the Dog class. Right now this dog doesn’t do anything interesting, so let’s give it some data and behavior.
Creating Objects with initialize
Every Ruby class can define a special method called initialize, which runs automatically whenever you call .new. This is Ruby’s constructor.
class Dog
def initialize(name, breed)
@name = name
@breed = breed
end
def bark
"#{@name} says: Woof!"
end
end
rex = Dog.new("Rex", "German Shepherd")
puts rex.bark
Output:
Rex says: Woof!
Notice the @name and @breed variables. These are instance variables, and they’re the primary way an object stores its own private state. I’ll explain them in more depth in a moment, but the key idea is this: each object you create from a class gets its own independent copy of these variables.
milo = Dog.new("Milo", "Beagle")
puts milo.bark
puts rex.bark
Output:
Milo says: Woof!
Rex says: Woof!
rex and milo are separate objects with separate state, even though they were built from the same class.
Understanding Variables in Ruby
Ruby has several types of variables, and knowing when to use each one is a big part of writing clean object-oriented code.
Local Variables
Local variables live inside the scope where they’re defined — a method, a block, or the top level of a script. They start with a lowercase letter or underscore.
def greet
message = "Hello there"
puts message
end
greet
Once greet finishes executing, message no longer exists. Local variables can’t be accessed outside their scope, which keeps your code predictable and free of accidental interference between unrelated parts of a program.
Instance Variables
Instance variables start with an @ symbol and belong to a specific object. They hold the state of that object and are accessible from any instance method within the class.
class BankAccount
def initialize(owner, balance)
@owner = owner
@balance = balance
end
def deposit(amount)
@balance += amount
end
def summary
"#{@owner}'s balance is $#{@balance}"
end
end
account = BankAccount.new("Sara", 100)
account.deposit(50)
puts account.summary
Output:
Sara's balance is $150
Notice that @balance isn’t visible outside the class unless you explicitly expose it. This is encapsulation in action — the object controls how its internal state can be changed.
Class Variables
Class variables start with @@ and are shared across all instances of a class, as well as any subclasses. I use these sparingly because they can create subtle bugs if you’re not careful, especially in inheritance hierarchies.
class Car
@@total_cars = 0
def initialize(model)
@model = model
@@total_cars += 1
end
def self.total
@@total_cars
end
end
Car.new("Civic")
Car.new("Corolla")
puts Car.total
Output:
2
Here, @@total_cars tracks how many Car objects have been created, no matter which instance triggered the increment.
Global Variables
Global variables start with $ and are accessible from anywhere in your program. I avoid these almost entirely in real projects because they break encapsulation and make code harder to reason about, but it’s worth knowing they exist.
$app_name = "InventoryTracker"
def show_app_name
puts $app_name
end
show_app_name
Constants
Constants start with an uppercase letter and are meant to hold values that shouldn’t change. Ruby won’t stop you from reassigning a constant, but it will warn you.
class Circle
PI = 3.14159
def initialize(radius)
@radius = radius
end
def area
PI * @radius ** 2
end
end
puts Circle.new(4).area
Output:
50.26544
Accessing Instance Variables: Getters and Setters
By default, instance variables are private to the object. If you try to read @balance from outside the BankAccount class, Ruby will complain. To expose data safely, you write accessor methods — or let Ruby generate them for you.
class Person
def name
@name
end
def name=(new_name)
@name = new_name
end
def initialize(name)
@name = name
end
end
person = Person.new("Ahmed")
puts person.name
person.name = "Ahmed Khan"
puts person.name
Writing getters and setters manually gets repetitive fast, so Ruby gives you a shortcut: attr_accessor, attr_reader, and attr_writer.
class Person
attr_accessor :name
attr_reader :id
def initialize(name, id)
@name = name
@id = id
end
end
p = Person.new("Ayesha", 101)
puts p.name
p.name = "Ayesha Malik"
puts p.name
puts p.id
attr_accessor generates both a getter and setter, attr_reader generates only a getter, and attr_writer generates only a setter. I use attr_reader for things like IDs that shouldn’t change after creation, and attr_accessor for anything meant to be freely readable and writable.
The Internal Object Model: How Ruby Actually Stores This
This is the part that made everything click for me. Internally, every Ruby object has:
- A pointer to its class (which determines what methods it can respond to).
- A hash-like table of instance variables, created lazily the first time they’re assigned.
When you call rex.bark, Ruby doesn’t search the object itself for a bark method — objects don’t store methods directly. Instead, Ruby looks up rex‘s class (Dog), and if Dog doesn’t define bark, Ruby walks up what’s called the method lookup chain (or ancestor chain): from the object’s class, to any included modules, to the superclass, all the way up to BasicObject.
You can inspect this chain yourself:
puts Dog.ancestors.inspect
Output:
[Dog, Object, Kernel, BasicObject]
This lookup mechanism is why methods defined in a superclass are automatically available to subclasses, and it’s the backbone of Ruby’s inheritance and mixin system.
Instance variables, on the other hand, are not looked up through this chain. They belong strictly to the object instance, stored in that object’s own variable table. This is why two objects of the same class never accidentally share instance variable values — each object’s @name lives in a completely separate slot in memory.
Memory Management and Object Lifecycle
Ruby uses automatic memory management through garbage collection, so you rarely need to think about freeing memory manually. When you create an object with .new, Ruby allocates memory for it on the heap. As long as something references that object — a variable, an array, another object’s instance variable — it stays alive.
def create_temp_object
temp = Dog.new("Ghost", "Unknown")
temp.bark
end
create_temp_object
Once create_temp_object returns, nothing references temp anymore, and Ruby’s garbage collector (which uses a mark-and-sweep algorithm, generational since Ruby 2.1+) will reclaim that memory during its next collection cycle. You can observe object counts and force garbage collection for debugging purposes:
GC.start
puts ObjectSpace.count_objects[:T_OBJECT]
I rarely need to call GC.start manually in production code, but understanding that it exists helps when you’re debugging memory bloat in a long-running Ruby process, like a Rails server handling thousands of requests.
Object Identity vs Object Equality
A subtlety that trips up a lot of newcomers is the difference between two objects being equal and two objects being the same object in memory.
a = "hello"
b = "hello"
puts a == b
puts a.equal?(b)
puts a.object_id
puts b.object_id
Output:
true
false
123456789
987654321
== checks value equality, while .equal? checks object identity — whether both variables point to the exact same object in memory. Every object has a unique object_id, and understanding this distinction matters a lot when you’re debugging why mutating one variable seems to (or doesn’t) affect another.
c = a
c << " world"
puts a
Output:
hello world
Here, c = a doesn’t copy the string — it copies the reference. Both a and c point to the same object, so mutating c also changes what a sees. This is a common source of bugs for people coming from languages with different assignment semantics, so I’d genuinely recommend spending real time experimenting with object_id and .dup / .clone until this feels natural.
Practical Real-World Example: A Task Manager
Let’s put everything together in something closer to real code you might actually write.
class Task
attr_accessor :title, :done
attr_reader :created_at
def initialize(title)
@title = title
@done = false
@created_at = Time.now
end
def complete!
@done = true
end
def status
@done ? "✅ Done" : "🕒 Pending"
end
def to_s
"#{title} - #{status}"
end
end
class TaskList
def initialize
@tasks = []
end
def add(title)
@tasks << Task.new(title)
end
def complete(index)
@tasks[index].complete! if @tasks[index]
end
def show_all
@tasks.each_with_index do |task, i|
puts "#{i}: #{task}"
end
end
end
list = TaskList.new
list.add("Write blog post")
list.add("Review pull request")
list.complete(0)
list.show_all
Output:
0: Write blog post - ✅ Done
1: Review pull request - 🕒 Pending
This small example demonstrates encapsulation (each Task manages its own state), composition (TaskList holds an array of Task objects), and clean accessor usage — patterns you’ll use constantly in real Ruby applications, from Rails models to command-line tools.
Best Practices I’ve Learned the Hard Way
- Prefer
attr_accessor/attr_readerover manual getters unless you need custom logic in the setter — it keeps classes shorter and more readable. - Avoid class variables (
@@) in inheritance hierarchies. They’re shared across subclasses in ways that often surprise people; class instance variables (a class-level@variablecombined withself.methods) are usually a safer choice. - Keep instance variables private by default. Only expose what callers actually need. Encapsulation isn’t bureaucracy — it’s what lets you change internal implementation later without breaking everyone who uses your class.
- Use
to_sandinspectdeliberately. Overridingto_smakesputsand string interpolation produce readable output, which pays off enormously during debugging. - Watch out for shared mutable state, especially with arrays and hashes assigned as default values in
initialize. Assigning@items = []insideinitialize(not as a class-level default) avoids one array being accidentally shared across every instance.
Common Mistakes to Avoid
One mistake I made early on was defining default values for instance variables at the class level instead of inside initialize, which led to every instance sharing the same array:
# Problematic pattern
class ShoppingCart
@@items = [] # shared across all carts!
def add(item)
@@items << item
end
end
The fix is to always initialize per-instance state inside initialize using @, not @@.
Another common mistake is forgetting that instance variables default to nil if never assigned, which can cause silent bugs rather than loud errors:
class Product
def initialize(name)
@name = name
end
def price
@price * 1.1
end
end
Product.new("Pen").price
This raises a NoMethodError because @price is nil and nil doesn’t understand *. Always initialize every instance variable your object depends on, even if just to a sensible default like 0.
Summary
Classes give you a blueprint for creating objects, and objects are the living instances that carry their own independent state through instance variables. Ruby’s variable system — local, instance, class, global, and constants — gives you precise control over scope and visibility, while accessor methods let you decide exactly what parts of an object’s internal state are exposed to the outside world. Underneath all of this, Ruby’s object model treats everything as an object with a class pointer and its own variable table, using method lookup chains for behavior and independent storage for state. Understanding this foundation makes everything else in Ruby — inheritance, modules, metaprogramming — dramatically easier to reason about.