Ruby Is an Object-Oriented Language: Understanding Objects, Methods, and Inheritance in Ruby

Ruby Is an Object-Oriented Language

I still remember the exact moment Ruby’s philosophy clicked for me. I typed 3.times { puts "hi" } into an irb session, half expecting an error, because in every other language I’d used up to that point, 3 was just a dumb number — not something you could call methods on. But it worked. 3 was an object. It had methods. That’s when I understood the phrase people kept repeating: “In Ruby, everything is an object.”

In this article, I want to dig into what that actually means in practice, how methods work under the hood, and how inheritance and modules let you build flexible, reusable object hierarchies. This isn’t just theory — I’ll walk through real code, real output, and the mistakes I made before this stuff felt natural.

Everything Really Is an Object

In many languages, there’s a hard split between “primitive types” (integers, booleans) and “objects” (things with methods). Ruby refuses to make that split. Numbers, strings, true, false, nil, arrays, even classes themselves — all objects, all instances of some class, all capable of responding to methods.

puts 5.class
puts true.class
puts nil.class
puts "hello".class
puts [1, 2, 3].class
puts Integer.class

Output:

Integer
TrueClass
NilClass
String
Array
Class

Notice that last line: Integer.class returns Class. Even a class is an object — an instance of the class Class. This is what people mean when they say Ruby’s object model goes “all the way down.” There’s no escape hatch to some non-object primitive layer.

This matters practically, not just philosophically. Because everything is an object, everything can have methods called on it, be passed around, be reopened and extended, and participate fully in the object-oriented features I’ll cover below.

puts 5.even?
puts (-5).abs
puts nil.to_a.inspect
puts "ruby".upcase

Output:

false
5
[]
RUBY

What Exactly Is a Method?

A method is a named, reusable block of behavior attached to a class. When you call obj.method_name, you’re sending a message to obj, asking it to execute the behavior defined by method_name in its class (or an ancestor of its class).

class Greeter
  def hello(name)
    "Hello, #{name}!"
  end
end

g = Greeter.new
puts g.hello("World")

Output:

Hello, World!

Method Visibility: Public, Private, and Protected

Ruby lets you control which methods can be called from outside an object, using three visibility levels.

class Account
  def initialize(balance)
    @balance = balance
  end

  def display_balance
    "Balance: #{formatted_balance}"
  end

  private

  def formatted_balance
    "$#{@balance}"
  end
end

acc = Account.new(500)
puts acc.display_balance
puts acc.formatted_balance

Output:

Balance: $500

The second call raises a NoMethodError because formatted_balance is private — it can only be called from within the object itself, without an explicit receiver. I use private for internal helper methods that support public behavior but shouldn’t be part of the object’s external contract. protected is similar but allows calling the method on other instances of the same class, which is useful for comparison methods:

class Money
  def initialize(amount)
    @amount = amount
  end

  def >(other)
    amount > other.amount
  end

  protected

  attr_reader :amount
end

puts Money.new(100) > Money.new(50)

Output:

true

Class Methods vs Instance Methods

Instance methods operate on individual objects. Class methods operate on the class itself and are defined with self. or inside class << self.

class Product
  @@catalog = []

  def initialize(name, price)
    @name = name
    @price = price
    @@catalog << self
  end

  def self.total_products
    @@catalog.size
  end

  def self.most_expensive
    @@catalog.max_by { |p| p.instance_variable_get(:@price) }
  end
end

Product.new("Laptop", 1200)
Product.new("Mouse", 25)
puts Product.total_products

Output:

2

I reach for class methods when the behavior conceptually belongs to the class as a whole rather than to a single instance — things like factory methods, counters, or finder methods.

How Method Lookup Actually Works

This is where Ruby’s object-oriented design becomes genuinely elegant. When you call a method on an object, Ruby doesn’t search the object — it searches the object’s ancestor chain, a linear sequence of classes and modules that Ruby walks through in order until it finds a matching method.

class Animal
  def speak
    "Some generic sound"
  end
end

class Dog < Animal
end

puts Dog.ancestors.inspect
puts Dog.new.speak

Output:

[Dog, Animal, Object, Kernel, BasicObject]
Some generic sound

Dog doesn’t define speak, so Ruby walks up the chain to Animal, finds it there, and executes it. This lookup process is deterministic and inspectable, which makes debugging “where did this method come from?” questions much easier than in languages with less transparent dispatch mechanisms.

Inheritance: Building on What Already Exists

Inheritance lets you define a general class and then create more specific classes that automatically get all its behavior, while being free to override or extend it.

class Animal
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def speak
    "#{name} makes a sound"
  end
end

class Dog < Animal
  def speak
    "#{name} barks: Woof!"
  end
end

class Cat < Animal
  def speak
    "#{name} meows: Meow!"
  end
end

[Dog.new("Rex"), Cat.new("Whiskers")].each do |animal|
  puts animal.speak
end

Output:

Rex barks: Woof!
Whiskers meows: Meow!

This is polymorphism — different classes responding to the same method call (speak) in ways appropriate to their own type. The calling code doesn’t need to know or care whether it’s dealing with a Dog or a Cat; it just trusts that speak will do the right thing.

Calling the Parent’s Implementation with super

Sometimes you don’t want to fully replace a parent method — you want to extend it. super calls the same-named method in the superclass.

class Animal
  def initialize(name)
    @name = name
  end
end

class Dog < Animal
  def initialize(name, breed)
    super(name)
    @breed = breed
  end

  def info
    "#{@name} is a #{@breed}"
  end
end

puts Dog.new("Rex", "Labrador").info

Output:

Rex is a Labrador

Calling super without parentheses (just super) automatically forwards all the arguments the current method received. Calling super() with empty parentheses calls the parent method with no arguments at all. This distinction has bitten me more than once, so I always write it explicitly when I mean “no arguments.”

Modules and Mixins: Ruby’s Answer to Multiple Inheritance

Ruby classes can only inherit from one superclass — but Ruby gives you a powerful alternative for sharing behavior across unrelated classes: modules, mixed in using include or extend.

module Swimmable
  def swim
    "#{name} is swimming"
  end
end

module Flyable
  def fly
    "#{name} is flying"
  end
end

class Duck
  include Swimmable
  include Flyable

  attr_reader :name

  def initialize(name)
    @name = name
  end
end

duck = Duck.new("Donald")
puts duck.swim
puts duck.fly

Output:

Donald is swimming
Donald is flying

Duck isn’t a subclass of Swimmable or Flyable — it simply mixes their behavior in. This is how Ruby avoids the complexity of true multiple inheritance while still letting you compose behavior from multiple sources. When you include a module, it gets inserted into the ancestor chain right above the class that included it:

puts Duck.ancestors.inspect

Output:

[Duck, Flyable, Swimmable, Object, Kernel, BasicObject]

Notice modules are included in reverse order of declaration — the most recently included module sits closest to the class in the lookup chain. This matters when two mixed-in modules define the same method name, since it determines which one wins.

extend vs include

While include adds module methods as instance methods, extend adds them as methods on the object (or class) itself.

module Describable
  def describe
    "I am #{self}"
  end
end

class Robot
  extend Describable
end

puts Robot.describe

Output:

I am Robot

I use include constantly for shared instance behavior (comparable logic, enumerable behavior, formatting helpers) and extend less often, typically for adding class-level utility methods to a class.

Abstract Base Classes and Duck Typing

Ruby doesn’t have formal abstract class or interface keywords like Java or C#. Instead, Ruby relies on a philosophy called duck typing: “If it walks like a duck and quacks like a duck, treat it like a duck.” What matters is whether an object responds to the methods you need, not what class it officially belongs to.

class PDFExporter
  def export
    "Exporting as PDF"
  end
end

class CSVExporter
  def export
    "Exporting as CSV"
  end
end

def run_export(exporter)
  puts exporter.export
end

run_export(PDFExporter.new)
run_export(CSVExporter.new)

Output:

Exporting as PDF
Exporting as CSV

run_export doesn’t care what class it receives — only that the object responds to .export. You can enforce this loosely with respond_to?:

def run_export(exporter)
  raise ArgumentError, "must respond to #export" unless exporter.respond_to?(:export)
  puts exporter.export
end

If you do want something closer to a formal abstract class, a common convention is to raise NotImplementedError in the base class:

class Exporter
  def export
    raise NotImplementedError, "#{self.class} must implement export"
  end
end

class JSONExporter < Exporter
  def export
    "Exporting as JSON"
  end
end

puts JSONExporter.new.export
Exporter.new.export

Output:

Exporting as JSON

followed by a raised NotImplementedError on the last line, since Exporter itself never provides a real implementation.

Internal Working: Message Passing and send

Under the hood, calling object.method_name(args) is really Ruby sending a message called method_name with args to object. You can do this explicitly with send, which even bypasses private method restrictions — useful for testing or metaprogramming, dangerous if overused.

class Wallet
  def initialize(amount)
    @amount = amount
  end

  private

  def secret_amount
    @amount
  end
end

w = Wallet.new(200)
puts w.send(:secret_amount)

Output:

200

This message-passing model is also why method_missing works the way it does — when Ruby can’t find a matching method anywhere in the ancestor chain, it sends a method_missing message instead of immediately failing, giving you a hook to intercept undefined calls dynamically (this is how many Ruby DSLs and ORMs, including parts of Rails, implement dynamic attribute access).

class DynamicResponder
  def method_missing(name, *args)
    "You called #{name} with #{args.inspect}"
  end
end

puts DynamicResponder.new.anything_goes(1, 2, 3)

Output:

You called anything_goes with [1, 2, 3]

Performance Considerations

Method lookup in Ruby involves walking the ancestor chain, which sounds slow but is heavily optimized internally through method caching (Ruby’s MRI implementation caches lookups so repeated calls to the same method on the same class don’t re-walk the chain every time). Still, a few practical performance notes I’ve picked up:

  • Deep inheritance hierarchies and heavy module chains add lookup overhead; keep hierarchies as shallow as reasonably possible.
  • method_missing is convenient but slower than defined methods, since it’s only reached after the full ancestor chain lookup fails. Use define_method to generate real methods dynamically when performance matters.
  • send and public_send have a small overhead compared to direct calls; avoid them in hot loops.
class Config
  [:host, :port, :timeout].each do |attr|
    define_method(attr) { instance_variable_get("@#{attr}") }
  end

  def initialize
    @host = "localhost"
    @port = 8080
    @timeout = 30
  end
end

c = Config.new
puts c.host
puts c.port

Output:

localhost
8080

This define_method pattern generates real, cacheable methods instead of relying on method_missing, giving you dynamic behavior without the performance penalty.

Real-World Application: A Notification System

Here’s a practical example pulling together inheritance, modules, and duck typing the way you’d actually structure this in a production Ruby application.

module Loggable
  def log(message)
    puts "[LOG] #{Time.now.strftime('%H:%M:%S')} - #{message}"
  end
end

class Notifier
  include Loggable

  def send_notification(message)
    raise NotImplementedError
  end

  def notify(message)
    send_notification(message)
    log("Notification sent via #{self.class}")
  end
end

class EmailNotifier < Notifier
  def send_notification(message)
    puts "Emailing: #{message}"
  end
end

class SMSNotifier < Notifier
  def send_notification(message)
    puts "Texting: #{message}"
  end
end

notifiers = [EmailNotifier.new, SMSNotifier.new]
notifiers.each { |n| n.notify("Server restarted successfully") }

Output:

Emailing: Server restarted successfully
[LOG] 14:02:31 - Notification sent via EmailNotifier
Texting: Server restarted successfully
[LOG] 14:02:31 - Notification sent via SMSNotifier

This is a template method pattern: the base class defines the overall workflow (notify), while subclasses fill in the specific step (send_notification). It’s a pattern I use constantly in real Ruby codebases, and it only works because of the inheritance and polymorphism fundamentals covered above.

Best Practices and Common Mistakes

  • Favor composition (modules) over deep inheritance when behavior doesn’t represent a true “is-a” relationship. A Car is a Vehicle (inheritance); a Car is Trackable (mixin).
  • Don’t overuse method_missing. It’s powerful but makes code harder to trace and debug; prefer define_method for dynamic-but-known method sets.
  • Be explicit with super vs super() — forgetting the difference is a classic source of “why is this argument nil?” bugs.
  • Avoid deep module chains that shadow each other’s methods unexpectedly; always check SomeClass.ancestors when you’re unsure which implementation wins.
  • Don’t reach for send to bypass private methods as a routine practice — if you need to call something from outside the object often, it probably shouldn’t be private.

Summary

Ruby earns its reputation as a purely object-oriented language because there is genuinely no escape hatch from the object model — numbers, strings, booleans, and even classes are all objects that respond to methods. Method calls are message sends resolved through an inspectable, predictable ancestor chain, which is what makes inheritance, super, and mixins behave consistently. Modules give you a clean way to share behavior across unrelated classes without the complexity of true multiple inheritance, and duck typing lets you write flexible code that cares about behavior rather than rigid type hierarchies. Once these pieces click together, you start writing Ruby that feels less like following syntax rules and more like designing a small society of cooperating objects.

References

Total
1
Shares

Leave a Reply

Previous Post
Getting started with Ruby

Getting Started with Ruby: Installation, Syntax Basics, and Writing Your First Ruby Program

Next Post
classes, objects and variables in ruby

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

Related Posts