Classes #
Classes are the foundation of object-oriented programming in Ruby — and Ruby takes OOP more seriously than most languages. In Ruby, everything is an object: integers, strings, nil, and even classes themselves are objects of the Class class. This isn’t just philosophy — it has deep practical implications. You can add methods to existing classes (including built-ins like String and Integer), classes are open to be reopened at any time, and every object can introspect itself. This article covers all aspects of classes in Ruby, from the most basic to the design patterns used in professional codebases.
Defining Classes #
Classes are defined with the class keyword followed by a name in PascalCase. Every class implicitly descends from Object if no inheritance is declared:
class Product
# class body here
end
# Classes are objects of Class
puts Product.class # => Class
puts Product.superclass # => Object
puts Object.superclass # => BasicObject
puts BasicObject.superclass # => nil (top of the hierarchy)
initialize — The Constructor #
initialize is a special method called automatically when a new object is created with new. This is where instance variables are initialized:
class Product
def initialize(name, price, stock = 0)
@name = name
@price = price
@stock = stock
end
def info
"#{@name} — Rp #{@price} (stock: #{@stock})"
end
end
laptop = Product.new("Laptop", 15_000_000, 10)
mouse = Product.new("Mouse", 350_000) # stock defaults to 0
puts laptop.info # => Laptop — Rp 15000000 (stock: 10)
puts mouse.info # => Mouse — Rp 350000 (stock: 0)
Attributes — attr_reader, attr_writer, attr_accessor #
Instance variables can’t be accessed from outside the class directly. Ruby provides three shortcuts for creating getters and setters:
# ANTI-PATTERN: manual getters and setters — verbose and repetitive
class Product
def name
@name
end
def name=(value)
@name = value
end
def price
@price
end
end
# CORRECT: use attr_* to reduce boilerplate
class Product
attr_reader :id # read-only from outside
attr_writer :stock # write-only from outside
attr_accessor :name, :price # readable and writable
def initialize(id, name, price)
@id = id
@name = name
@price = price
@stock = 0
end
def info
"#{@name} — Rp #{@price}"
end
end
p = Product.new(1, "Keyboard", 450_000)
puts p.id # => 1 (reader)
puts p.name # => Keyboard (reader from accessor)
p.name = "Mechanical Keyboard" # (writer from accessor)
p.price = 750_000 # (writer from accessor)
p.stock = 25 # (writer)
puts p.info # => Mechanical Keyboard — Rp 750000
puts p.id # => 1 (can't be changed — reader only)
# p.id = 99 # => NoMethodError: undefined method 'id='
Validation Inside Setters #
One reason not to always use attr_writer directly is that you lose the ability to validate. When you need to validate the value being set, write your own setter:
class Product
attr_reader :name, :price, :stock
def initialize(name, price)
self.name = name # use your own setter so validation runs
self.price = price
@stock = 0
end
def name=(value)
raise ArgumentError, "Name cannot be empty" if value.to_s.strip.empty?
@name = value.strip
end
def price=(value)
raise ArgumentError, "Price must be positive" unless value.is_a?(Numeric) && value > 0
@price = value
end
def stock=(value)
raise ArgumentError, "Stock cannot be negative" if value < 0
@stock = value
end
end
p = Product.new("Laptop", 15_000_000)
p.stock = 10
# p.price = -500 # => ArgumentError: Price must be positive
# p.name = "" # => ArgumentError: Name cannot be empty
Instance Methods vs Class Methods #
Instance methods are called on objects; class methods are called on the class itself:
class User
@@count = 0
attr_reader :name, :email
def initialize(name, email)
@name = name
@email = email
@@count += 1
end
# Instance method — called on an object
def introduction
"Hello, I'm #{@name} (#{@email})"
end
def active?
true # simplified
end
# Class method — called on the User class
def self.registered_count
@@count
end
# Alternative way to define class methods — class << self
class << self
def create_admin(name)
new(name, "#{name.downcase}@admin.com")
end
def create_guest
new("Guest", "[email protected]")
end
end
end
u1 = User.new("Rina", "[email protected]")
u2 = User.new("Budi", "[email protected]")
admin = User.create_admin("SuperAdmin")
puts u1.introduction # => Hello, I'm Rina ([email protected])
puts User.registered_count # => 3
puts admin.email # => [email protected]
flowchart TD
A[User class] --> B[Instance Methods\ncalled on objects]
A --> C[Class Methods\ncalled on the class]
B --> B1["introduction\nactive?\nto_s"]
C --> C1["registered_count\ncreate_admin\ncreate_guest"]
D[Object u1] --> B
E[User] --> CInheritance #
Inheritance lets a child class inherit all methods and attributes of its parent class. Use the < operator:
class Animal
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
def breathe
"#{@name} is breathing..."
end
def description
"#{@name} (#{self.class.name}), #{@age} years old"
end
def sound
raise NotImplementedError, "#{self.class} must implement #sound"
end
end
class Cat < Animal
attr_reader :breed
def initialize(name, age, breed)
super(name, age) # call the parent class's initialize
@breed = breed
end
def sound
"Meow!"
end
def description
"#{super}, breed: #{@breed}" # call the parent's description then extend
end
end
class Dog < Animal
def sound
"Woof!"
end
def fetch(thing)
"#{@name} fetches the #{thing}!"
end
end
cat = Cat.new("Mochi", 3, "Persian")
dog = Dog.new("Rex", 5)
puts cat.breathe # => Mochi is breathing... (inherited)
puts cat.sound # => Meow!
puts cat.description # => Mochi (Cat), 3 years old, breed: Persian
puts dog.sound # => Woof!
puts dog.fetch("ball") # => Rex fetches the ball!
# Checking inheritance relationships
puts cat.is_a?(Cat) # => true
puts cat.is_a?(Animal) # => true
puts cat.is_a?(Dog) # => false
puts Cat.ancestors.inspect
# => [Cat, Animal, Object, Kernel, BasicObject]
super — Calling the Parent Class’s Method #
super calls the method with the same name from the parent class. There are three forms of usage:
class Vehicle
def initialize(brand, year)
@brand = brand
@year = year
end
def info
"#{@brand} (#{@year})"
end
end
class Car < Vehicle
def initialize(brand, year, doors)
super(brand, year) # pass specific arguments to the parent
@doors = doors
end
def info
super + ", #{@doors} doors" # call the parent's info, then extend
end
end
class Motorcycle < Vehicle
def initialize(brand, year, cc)
super # pass ALL arguments to the parent (brand and year)
@cc = cc
end
def info
"#{super} — #{@cc}cc"
end
end
puts Car.new("Toyota", 2022, 4).info # => Toyota (2022), 4 doors
puts Motorcycle.new("Honda", 2023, 150).info # => Honda (2023) — 150cc
Modules and Mixins #
Ruby only allows single inheritance — one class can have only one parent class. But Ruby overcomes this limitation with mixins: the ability to inject methods from a module into a class using include, extend, or prepend.
module Printable
def print
puts "--- #{self.class.name} ---"
instance_variables.each do |var|
puts " #{var}: #{instance_variable_get(var)}"
end
puts "---"
end
end
module Serializable
def to_simple_json
pairs = instance_variables.map do |var|
key = var.to_s.delete("@")
val = instance_variable_get(var)
"\"#{key}\": #{val.inspect}"
end
"{ #{pairs.join(', ')} }"
end
end
module ComparableByValue
def same_value_as?(other)
self.class == other.class &&
instance_variables.all? do |var|
instance_variable_get(var) == other.instance_variable_get(var)
end
end
end
class Product
include Printable
include Serializable
include ComparableByValue
attr_accessor :name, :price
def initialize(name, price)
@name = name
@price = price
end
end
p1 = Product.new("Mouse", 350_000)
p2 = Product.new("Mouse", 350_000)
p3 = Product.new("Keyboard", 450_000)
p1.print
# --- Product ---
# @name: "Mouse"
# @price: 350000
# ---
puts p1.to_simple_json
# { "name": "Mouse", "price": 350000 }
puts p1.same_value_as?(p2) # => true
puts p1.same_value_as?(p3) # => false
include vs extend vs prepend #
module Greeting
def hello
"Hello from #{self}!"
end
end
# include — adds methods as INSTANCE methods
class Class1
include Greeting
end
puts Class1.new.hello # => Hello from #<Class1:...>
# extend — adds methods as CLASS methods
class Class2
extend Greeting
end
puts Class2.hello # => Hello from Class2
# prepend — inserts methods IN FRONT of the class in the method lookup
# module methods are called BEFORE class methods (useful for wrapping/decoration)
module Logger
def save
puts "[LOG] Before save"
result = super
puts "[LOG] After save"
result
end
end
class DataModel
prepend Logger
def save
puts "Saving to database..."
true
end
end
DataModel.new.save
# => [LOG] Before save
# => Saving to database...
# => [LOG] After save
include | extend | prepend | |
|---|---|---|---|
| Method type | Instance | Class | Instance |
| Position in lookup | After the class | — | Before the class |
| Main use | Shared behavior | Class helpers | Wrapping/decoration |
Open Classes — Reopening Existing Classes #
One of Ruby’s most distinctive — and most controversial — features is the ability to open and add methods to existing classes, including Ruby’s built-ins:
# Add methods to Ruby's built-in Integer class
class Integer
def seconds
self
end
def minutes
self * 60
end
def hours
self * 3600
end
def days
self * 86_400
end
def in_rupiah
"Rp #{to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
end
end
puts 5.minutes # => 300
puts 2.hours # => 7200
puts 3.days # => 259200
puts 1_500_000.in_rupiah # => Rp 1.500.000
# Add methods to String
class String
def snake_case
gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
.downcase
end
def pascal_case
split('_').map(&:capitalize).join
end
def blank?
strip.empty?
end
end
puts "NamaLengkapPengguna".snake_case # => nama_lengkap_pengguna
puts "nama_lengkap".pascal_case # => NamaLengkap
puts " ".blank? # => true
Open classes (also called monkey patching) is a double-edged sword. It makes code more expressive, but adding methods to Ruby’s built-in classes can cause name conflicts with other gems or future Ruby versions. As a safer alternative, use Refinements — a Ruby feature that limits class changes to a specific file or module scope rather than globally.
Comparable — Free Comparison Operators #
By including the Comparable module and defining a single <=> method, your class automatically gets all comparison operators (<, >, <=, >=, between?, clamp):
class Temperature
include Comparable
attr_reader :value, :unit
def initialize(value, unit = :celsius)
@value = value
@unit = unit
end
def in_celsius
case @unit
when :celsius then @value
when :fahrenheit then (@value - 32) * 5.0 / 9
when :kelvin then @value - 273.15
end
end
def <=>(other)
in_celsius <=> other.in_celsius
end
def to_s
"#{@value}°#{@unit.to_s[0].upcase}"
end
end
boiling = Temperature.new(100, :celsius)
freezing = Temperature.new(32, :fahrenheit) # = 0°C
room = Temperature.new(300, :kelvin) # = 26.85°C
puts boiling > room # => true
puts freezing < room # => true
puts room.between?(freezing, boiling) # => true
temps = [boiling, room, freezing]
puts temps.sort.map(&:to_s).inspect
# => ["32°F", "300°K", "100°C"]
puts temps.min # => 32°F
puts temps.max # => 100°C
Struct — Simple Data Classes #
Struct is a quick way to create simple data classes without having to write initialize, getters, and setters manually:
# Creating a Struct
Point = Struct.new(:x, :y)
Color = Struct.new(:r, :g, :b)
Address = Struct.new(:street, :city, :postal_code, keyword_init: true)
p1 = Point.new(3, 4)
puts p1.x # => 3
puts p1.y # => 4
puts p1.to_a # => [3, 4]
puts p1 == Point.new(3, 4) # => true (comparison by value!)
# Struct with additional methods
Point = Struct.new(:x, :y) do
def distance_to(other)
Math.sqrt((x - other.x)**2 + (y - other.y)**2)
end
def to_s
"(#{x}, #{y})"
end
end
a = Point.new(0, 0)
b = Point.new(3, 4)
puts a.distance_to(b) # => 5.0
# keyword_init: true — more expressive when creating instances
address = Address.new(street: "Jl. Sudirman No. 1", city: "Jakarta", postal_code: "10220")
puts address.city # => Jakarta
When to use Struct vs a regular class:
Struct:
✓ Simple data containers without complex logic
✓ Need automatic value comparison (== by value)
✓ Want to reduce initialize + attr_accessor boilerplate
✓ Immutable value objects
Regular class:
✓ There's validation in setters/initialize
✓ There's complex business logic
✓ Need detailed visibility control
✓ Inheritance from another class
to_s and inspect — Text Representations of Objects #
The two most useful methods for custom classes are to_s (for user-friendly output) and inspect (for debugging output):
class Order
attr_reader :id, :total, :status
def initialize(id, total, status = :pending)
@id = id
@total = total
@status = status
end
# For puts, string interpolation, user-facing output
def to_s
"Order ##{@id} — Rp #{@total} [#{@status}]"
end
# For debugging, p(), irb
def inspect
"#<Order id=#{@id}, total=#{@total}, status=:#{@status}>"
end
end
order = Order.new(42, 150_000, :processing)
puts order # => Order #42 — Rp 150000 [processing]
puts "#{order}" # => Order #42 — Rp 150000 [processing]
p order # => #<Order id=42, total=150000, status=:processing>
puts order.inspect # => #<Order id=42, total=150000, status=:processing>
Good Class Design Principles #
flowchart TD
A[Good class] --> B["Single Responsibility\nOne reason to change"]
A --> C["Encapsulation\nHide internal details"]
A --> D["Minimal interface\nOnly expose what's needed"]
A --> E["Clear initialize\nInitialize all state"]
B --> B1["Separate Product and ProductRepository"]
C --> C1["Validate in setters\nnot in callers"]
D --> D1["Everything internal is private\nPublic is only the required API"]
E --> E1["Don't leave @var nil\nwithout a clear reason"]# ANTI-PATTERN: a class that does too much
class User
attr_accessor :name, :email
def initialize(name, email)
@name = name
@email = email
end
def save_to_database # ← persistence responsibility
DB.execute("INSERT INTO users ...")
end
def send_welcome_email # ← email responsibility
Mailer.send(...)
end
def generate_report # ← reporting responsibility
# ...
end
end
# CORRECT: separate responsibilities
class User
attr_reader :name, :email
def initialize(name, email)
self.name = name
self.email = email
end
private
def name=(value)
raise ArgumentError, "Name is required" if value.to_s.strip.empty?
@name = value.strip
end
def email=(value)
raise ArgumentError, "Invalid email format" unless value.match?(/\A\S+@\S+\z/)
@email = value.downcase
end
end
class UserRepository
def save(user)
DB.execute("INSERT INTO users (name, email) VALUES (?, ?)", user.name, user.email)
end
end
class UserWelcome
def send(user)
Mailer.send(to: user.email, subject: "Welcome, #{user.name}!")
end
end
Summary #
initializeis the constructor — initialize all instance variables here, using internal setters so validation runs from the start.attr_reader/writer/accessorreduce boilerplate — but write your own setters if you need validation; don’t exposeattr_writeroutward if not needed.- Class methods with
self.orclass << self— for factory methods, class-level utilities, and shared counters.- Single inheritance, many mixins — Ruby allows only one superclass, but modules can be included as many times as needed.
includefor instance methods,extendfor class methods,prependfor wrapping — choose based on need, not habit.- Open classes are powerful but dangerous — consider Refinements as a safer alternative for modifying built-in classes.
Comparablegives you all comparison operators — just define<=>and include Comparable, the rest is free.Structfor simple value objects — far more concise than writing a manual class, and you get==by value automatically.- Define
to_sandinspect—to_sfor user-facing output,inspectfor debugging. Without them, the default output isn’t informative.- Single Responsibility Principle — separate persistence, email, and business logic into different classes. A good class has one reason to change.