Classes, Objects, and Variables in Ruby: Object-Oriented Programming Foundations Explained

classes, objects and variables in ruby

classes, objects and variables in ruby

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:

  1. A pointer to its class (which determines what methods it can respond to).
  2. 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

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.

References

Exit mobile version