Classes in Ruby
A class in Ruby is a blueprint for creating objects that bundle together state (data) and behavior (methods). Almost everything you touch in Ruby — strings, arrays, even the number 5 — is an instance of some class, and understanding how classes work is the key to understanding Ruby itself. Once you can define your own classes, you can model real-world things — a bank account, a shopping cart, a game character — as objects with their own data and their own rules for how that data can be changed.
Overview: How Classes Work in Ruby
In Ruby, everything is an object, and every object is an instance of some class. Run 5.class in irb and you get Integer; run nil.class and you get NilClass; even true and false belong to their own classes. Classes are themselves objects (instances of the class Class), which is part of why Ruby feels so consistent: there is no special syntax carve-out for "primitives" the way some languages have.
You define a class with the class keyword and a constant (capitalized) name. Inside the class body you define methods with def. The most important method is initialize, which Ruby automatically calls whenever you create a new object with SomeClass.new(...). Whatever arguments you pass to .new are forwarded to initialize, and .new itself always returns the newly built object — not whatever initialize happens to return.
Inside instance methods, @name-style variables are instance variables: each object gets its own independent copy, and they default to nil if never assigned (Ruby does not raise an error for reading an unset instance variable, unlike an unset local variable or an undefined method). Instance variables are private to the object by default — there is no way to read @name from outside the object unless you define a method that exposes it, which is exactly what attr_reader, attr_writer, and attr_accessor do: they generate simple getter/setter methods for you so you don’t have to write boilerplate like def name; @name; end by hand.
Variables written with two @@ signs are class variables, shared by the class and every instance of it (and, more subtly, by every subclass too — a frequent source of bugs, covered in Common Mistakes below). Methods defined with def self.method_name are class methods, called on the class itself (like BankAccount.accounts_created) rather than on an instance.
When you call a method on an object, Ruby doesn’t just look in that object’s class — it walks up the ancestor chain: the object’s singleton class, then its class, then any modules mixed in with include (searched in reverse order of inclusion), then the superclass and its included modules, and so on up to Object, Kernel, and finally BasicObject. This is the mechanism that makes inheritance, mixins, and super all work: super simply resumes the method lookup one step further up this same chain.
Syntax
class ClassName < ParentClass
attr_accessor :attribute_name
def initialize(param1, param2 = default_value)
@param1 = param1
@param2 = param2
end
def instance_method
# uses @param1, @param2
end
def self.class_method
# called as ClassName.class_method
end
private
def helper_method
# only callable from inside other instance methods
end
end
- class ClassName – starts the definition; names must start with a capital letter (they are constants).
- < ParentClass – optional; makes
ClassNameinherit methods and behavior fromParentClass. Omit it and Ruby usesObjectautomatically. - attr_accessor / attr_reader / attr_writer – auto-generate getter and/or setter methods for the named instance variables.
- initialize – the constructor, run automatically by
.new; supports positional, default, and keyword arguments like any method. - @instance_variable – per-object state, private by default.
- def self.method_name – defines a class-level method.
- private – everything defined below this line (until
public/protectedor the end of the class) can only be called without an explicit receiver, i.e. from inside other instance methods of the same object.
Examples
Example 1: A Basic Class
class Dog
attr_accessor :name, :breed
def initialize(name, breed)
@name = name
@breed = breed
end
def bark
"#{@name} says Woof!"
end
end
dog = Dog.new("Rex", "Labrador")
puts dog.bark
puts dog.name
dog.name = "Max"
puts dog.name
Output:
Rex says Woof!
Rex
Max
Calling Dog.new("Rex", "Labrador") runs initialize, storing both arguments as instance variables. attr_accessor :name, :breed generated both a name getter and a name= setter (and the same for breed), which is why dog.name = "Max" works even though we never wrote that method ourselves.
Example 2: Inheritance and super
class Animal
attr_reader :name
def initialize(name)
@name = name
end
def speak
"#{@name} makes a sound."
end
def to_s
"#<Animal: #{@name}>"
end
end
class Cat < Animal
def initialize(name, indoor: true)
super(name)
@indoor = indoor
end
def speak
"#{super} Specifically, #{@name} says Meow."
end
end
cat = Cat.new("Whiskers")
puts cat.speak
puts cat
puts cat.is_a?(Animal)
Output:
Whiskers makes a sound. Specifically, Whiskers says Meow.
#<Animal: Whiskers>
true
Cat < Animal makes Cat a subclass. Inside Cat#initialize, super(name) explicitly calls Animal#initialize with name, so @name gets set by the parent’s logic before Cat adds its own @indoor state. Inside Cat#speak, bare super (used inside a string interpolation) calls Animal#speak and returns its string, which Cat#speak then extends. Since Cat never defines its own to_s, puts cat falls back to the one it inherited from Animal — exactly the ancestor-chain lookup described above.
Example 3: Class Variables, Class Methods, and Private Methods
class BankAccount
@@accounts_created = 0
attr_reader :owner, :balance
def initialize(owner, balance: 0)
@owner = owner
@balance = balance
@@accounts_created += 1
end
def deposit(amount)
@balance += amount
self
end
def withdraw(amount)
raise ArgumentError, "Insufficient funds" if amount > @balance
@balance -= amount
self
end
def self.accounts_created
@@accounts_created
end
private
def audit_log
"Audit: #{@owner} has #{@balance}"
end
end
account = BankAccount.new("Priya", balance: 100)
account.deposit(50).withdraw(30)
puts account.balance
puts BankAccount.accounts_created
second = BankAccount.new("Arjun")
puts BankAccount.accounts_created
Output:
120
1
2
Both deposit and withdraw return self, which is what makes the chained call account.deposit(50).withdraw(30) possible: deposit returns the same account object, and withdraw is then called on that result. The class variable @@accounts_created is shared by the class itself, so every call to BankAccount.new — regardless of which object it produces — increments the same counter, and BankAccount.accounts_created (a class method, note the self.) reports the running total. audit_log is private, so it can only ever be called from inside another instance method of BankAccount, never as account.audit_log from the outside.
Example 4: Endless Methods for Computed Attributes
class Circle
def initialize(radius)
@radius = radius
end
def area = Math::PI * @radius**2
end
circle = Circle.new(2)
puts circle.area.round(2)
Output:
12.57
Modern Ruby (3.0+) lets you write a one-line method body with def name(args) = expression — an "endless method". It’s ideal for small, computed, read-only attributes like area here, where writing a full def ... end block would just add noise. The value of the expression becomes the method’s return value, exactly as if it were the last line of a normal method.
How It Works Step by Step
When you write Dog.new("Rex", "Labrador"), several things happen in order:
- Ruby allocates a new, blank object and tags it as an instance of
Dog. - Ruby looks up the
initializemethod by walkingDog‘s ancestor chain (its own definition is found immediately here) and calls it on the new object, passing along"Rex"and"Labrador". - Inside
initialize,@name = nameand@breed = breedattach instance variables directly to that specific object — no otherDoginstance is affected. initializefinishes; its return value is discarded, and.newreturns the object itself.- Later,
dog.barktriggers another ancestor-chain search, this time forbark: Ruby checksdog‘s singleton class, thenDog, finds it there, and runs it withselfbound todog— which is how@nameinsidebarkresolves to"Rex".
For the Cat < Animal example, the same lookup happens for speak, but this time Ruby finds it on Cat first (since Cat is earlier in the chain than Animal); the call to super inside it explicitly resumes the search one link further up, landing on Animal#speak.
Common Mistakes
Mistake 1: Reading an instance variable without an accessor
class Point
def initialize(x, y)
@x = x
@y = y
end
end
point = Point.new(1, 2)
puts point.x
This raises NoMethodError: undefined method 'x' for an instance of Point. Instance variables are not automatically readable from outside the object — only methods are callable on an object, and initialize never defined an x method. Fix it with attr_reader (or attr_accessor if you also need a setter):
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
end
point = Point.new(1, 2)
puts point.x
puts point.y
Output:
1
2
Mistake 2: Confusing == with equal?
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
end
a = Point.new(1, 2)
b = Point.new(1, 2)
puts a == b
puts a.equal?(b)
Output:
false
false
Both print false because the default Object#== (inherited since Point doesn’t override it) checks object identity, exactly like equal? does — a and b are two distinct objects that merely happen to hold equal-looking data. If you want == to mean "same data", you must define it yourself:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def ==(other)
other.is_a?(Point) && x == other.x && y == other.y
end
end
a = Point.new(1, 2)
b = Point.new(1, 2)
puts a == b
puts a.equal?(b)
Output:
true
false
Note that equal? still correctly reports false — it always checks true object identity and, unlike ==, should almost never be overridden.
Mistake 3: Using a class variable where you meant an instance variable
class ShoppingCart
@@items = []
def add(item)
@@items << item
end
def items
@@items
end
end
cart1 = ShoppingCart.new
cart2 = ShoppingCart.new
cart1.add("Book")
puts cart1.items.inspect
puts cart2.items.inspect
Output:
["Book"]
["Book"]
Adding an item through cart1 also shows up in cart2! Because @@items is a class variable, there is only ever one array, shared by every ShoppingCart instance (and any subclass). What was intended as per-object state should instead be an instance variable, initialized fresh inside initialize:
class ShoppingCart
def initialize
@items = []
end
def add(item)
@items << item
end
def items
@items
end
end
cart1 = ShoppingCart.new
cart2 = ShoppingCart.new
cart1.add("Book")
puts cart1.items.inspect
puts cart2.items.inspect
Output:
["Book"]
[]
Best Practices
- Use
attr_readerby default; only reach forattr_accessorwhen external code genuinely needs to reassign the attribute, andattr_writeris rare outside special cases. - Keep
initializefocused on assigning state — avoid heavy computation or I/O in a constructor. - Prefer keyword arguments (
def initialize(name:, age: 0)) once a class has more than one or two constructor parameters; it makes call sites self-documenting. - Reach for
privateto mark implementation details that callers shouldn’t depend on — it keeps the public interface small and lets you refactor internals freely. - Remember that only
nilandfalseare falsy in Ruby — a brand-new object, an empty array attribute, or a balance of0are all truthy, soif @balanceis not a safe way to check "is the balance zero?". - Use endless methods (
def area = ...) for small, single-expression computed attributes; use fulldef...endfor anything with multiple statements or branching. - Override
==(and, if needed,hashandeql?) whenever "equal" should mean "same data" rather than "same object".
Practice Exercises
- Write a
Rectangleclass withwidthandheightpassed toinitialize. Add instance methodsareaandperimeter. Expected: for a 3 by 4 rectangle,areais12andperimeteris14. - Write a
Vehicleclass with aspeakmethod, then aCar < Vehiclesubclass that overridesspeakbut callssuperinside it to reuse the parent’s message. Print the combined result. - Add a class-level counter to your
Rectangleclass (similar toBankAccount.accounts_created) that tracks how manyRectangleobjects have been created so far, exposed through a class method.
Summary
- A class is defined with
class Name ... end;initializeis the constructor, automatically called by.new. @instance_variableshold per-object state and are private unless exposed viaattr_reader/attr_writer/attr_accessor.@@class_variablesare shared across the class and all its instances (and subclasses) — a common source of surprising bugs.- Methods defined with
def self.nameare class methods, called directly on the class. - Inheritance uses
class Child < Parent;supercalls the same-named method further up the ancestor chain. - Method calls are resolved by walking the ancestor chain: singleton class, class, included modules, then superclass, up to
ObjectandBasicObject. - The default
==checks object identity, same asequal?; override==to compare by data instead. - Only
nilandfalseare falsy in Ruby — everything else, including0and empty collections, is truthy.
