Table of Contents
- Ruby: A Purely Object-Oriented Language
- Core OOP Principles in Ruby
- Classes and Objects: The Building Blocks
- Modules and Mixins: Reusing Code Without Inheritance
- Method Lookup and the Ancestor Chain
- The
selfKeyword: Understanding Context - Singleton Methods and Eigenclasses
- Advanced OOP Concepts: Metaprogramming Basics
- Design Patterns in Ruby OOP
- Best Practices for Ruby OOP Design
- Conclusion
- References
Ruby: A Purely Object-Oriented Language
At its core, Ruby adheres to the philosophy that everything is an object. Unlike languages like Java or Python (which mix OOP with primitive types), Ruby treats even basic values—integers, strings, booleans, and nil—as objects. This means every value has a class, can respond to methods, and can be manipulated using OOP principles.
Example: Everything is an Object
# Numbers are objects
5.class # => Integer
5.even? # => false (method call on an integer object)
5 + 3 # => 8 (equivalent to 5.+(3) — the + operator is a method!)
# Strings are objects
"hello".class # => String
"hello".upcase # => "HELLO" (method call on a string object)
# Even nil is an object
nil.class # => NilClass
nil.nil? # => true (method call on nil)
# Classes are objects too!
Integer.class # => Class
Class.class # => Class (meta-circularity FTW)
This uniformity simplifies Ruby code: you interact with all values using the same object-oriented paradigm. Whether you’re working with a user-defined Person class or a built-in Array, the rules for method calls, inheritance, and state management remain consistent.
Core OOP Principles in Ruby
Ruby’s OOP design is built on three foundational principles: encapsulation, inheritance, and polymorphism. Let’s break down each and see how Ruby implements them.
Encapsulation: Protecting State and Behavior
Encapsulation is the practice of bundling data (state) and methods (behavior) into a single unit (a class) and restricting access to some of the object’s components. The goal is to hide internal implementation details and expose only what’s necessary.
In Ruby, encapsulation is enforced through:
- Instance variables: Preceded by
@(e.g.,@name), these store an object’s state and are only accessible inside the object by default. - Accessor methods: Explicitly define how state is read or modified (e.g.,
attr_reader,attr_writer,attr_accessor). - Visibility modifiers:
public,private, andprotectedcontrol which methods are accessible from outside the class.
Example: Encapsulation in Action
class BankAccount
# Public reader for account_number (read-only)
attr_reader :account_number
# Public reader/writer for owner (read-write)
attr_accessor :owner
def initialize(owner, balance)
@owner = owner # Instance variable (state)
@balance = balance # Instance variable (state, private by default)
@account_number = generate_account_number # Private method call
end
# Public method: deposits money (behavior)
def deposit(amount)
@balance += amount if amount > 0
end
# Public method: checks balance (behavior)
def check_balance
"Current balance: $#{@balance}"
end
private
# Private method: generates account number (implementation detail)
def generate_account_number
rand(1000000000..9999999999).to_s
end
# Private method: internal balance check (not exposed publicly)
def negative_balance?
@balance < 0
end
end
# Usage
account = BankAccount.new("Alice", 1000)
account.owner = "Alice Smith" # Allowed (attr_accessor)
account.account_number # Allowed (attr_reader)
account.deposit(500) # Allowed (public method)
account.check_balance # => "Current balance: $1500"
account.generate_account_number # Error: private method called for BankAccount (encapsulation!)
account.@balance # Error: direct access to instance variable not allowed
Here, @balance and generate_account_number are hidden (private), ensuring the account’s internal state can’t be modified arbitrarily. Only public methods like deposit and check_balance expose controlled interactions.
Inheritance: Reusing and Extending Code
Inheritance allows a class (child/subclass) to reuse and extend the functionality of another class (parent/superclass). Ruby supports single inheritance (a class can inherit from only one parent), but we’ll see later how mixins弥补 (compensate for) this limitation.
Key features of Ruby inheritance:
super: Calls the parent class’s method of the same name.- Method overriding: Subclasses can redefine methods from the parent to customize behavior.
class << selfordef self.method: Defines class methods (inherited by subclasses).
Example: Inheritance with Method Overriding
# Parent class
class Animal
def initialize(name)
@name = name
end
def speak
"Animal sound" # Default behavior
end
def name
@name
end
end
# Subclass inheriting from Animal
class Dog < Animal
# Override speak to customize behavior
def speak
"#{@name} says woof!"
end
# Add new behavior specific to Dog
def fetch
"#{@name} fetches the ball!"
end
end
# Subclass inheriting from Animal
class Cat < Animal
# Override speak
def speak
"#{@name} says meow!"
end
end
# Usage
dog = Dog.new("Buddy")
dog.speak # => "Buddy says woof!" (overridden)
dog.fetch # => "Buddy fetches the ball!" (new method)
cat = Cat.new("Whiskers")
cat.speak # => "Whiskers says meow!" (overridden)
Here, Dog and Cat inherit name and the base speak method from Animal, then override speak to add species-specific behavior.
Polymorphism: Flexibility Through Common Interfaces
Polymorphism (Greek for “many forms”) allows objects of different classes to respond to the same method name, enabling flexible and generic code. In Ruby, polymorphism is achieved through duck typing: “If it walks like a duck and quacks like a duck, it’s a duck.” In other words, Ruby cares about whether an object responds to a method (not its class).
Example: Polymorphism with to_s
Every Ruby object responds to to_s (short for “to string”), but the method is implemented differently across classes:
5.to_s # => "5" (Integer's to_s)
[1, 2, 3].to_s # => "[1, 2, 3]" (Array's to_s)
Time.now.to_s # => "2024-05-20 14:30:00 +0000" (Time's to_s)
Example: Polymorphic Method Calls
You can write a single method that works with any object that responds to a specific message:
def greet(entity)
# Duck typing:只要entity responds to #speak, it works!
"#{entity.speak} Hello there!"
end
greet(Dog.new("Buddy")) # => "Buddy says woof! Hello there!"
greet(Cat.new("Whiskers")) # => "Whiskers says meow! Hello there!"
greet(Animal.new("Generic")) # => "Animal sound Hello there!" (falls back to Animal's speak)
Here, greet works with Dog, Cat, or Animal because they all implement speak—no need for explicit type checks!
Classes and Objects: The Building Blocks
In Ruby, classes are blueprints for creating objects (instances). A class defines the attributes (state) and methods (behavior) that all its instances will share.
Defining Classes and Creating Instances
To define a class, use the class keyword followed by the class name (conventionally CamelCase). Instances are created by calling new on the class, which triggers the initialize method (Ruby’s constructor).
Example: A Person Class
class Person
# Initialize: called when a new instance is created (constructor)
def initialize(name, age)
@name = name # Instance variable: unique to each instance
@age = age # Instance variable
end
# Instance method: behavior of a Person instance
def introduce
"Hi, I'm #{@name} and I'm #{@age} years old."
end
# Getter method for @name (manual)
def name
@name
end
# Setter method for @age (manual)
def age=(new_age)
@age = new_age
end
end
# Create an instance of Person
alice = Person.new("Alice", 30)
alice.introduce # => "Hi, I'm Alice and I'm 30 years old."
alice.name # => "Alice" (via getter)
alice.age = 31 # => 31 (via setter)
alice.introduce # => "Hi, I'm Alice and I'm 31 years old."
Ruby provides shortcuts for getters/setters with attr_* macros:
attr_reader :name: Defines anamegetter method.attr_writer :age: Defines anage=setter method.attr_accessor :email: Defines bothemail(getter) andemail=(setter).
Rewriting the Person class with attr_*:
class Person
attr_reader :name # Equivalent to def name; @name; end
attr_writer :age # Equivalent to def age=(new_age); @age = new_age; end
attr_accessor :email # Getter + setter for email
def initialize(name, age)
@name = name
@age = age
@email = nil # Default value
end
# ... rest of the class ...
end
Instance Variables, Class Variables, and Constants
Ruby classes support three types of variables:
| Type | Syntax | Scope | Use Case |
|---|---|---|---|
| Instance variable | @name | Unique to each instance | Object-specific state (e.g., a person’s age). |
| Class variable | @@count | Shared across all instances of the class | Class-level state (e.g., total instances created). |
| Constant | MAX_AGE | Class-level, uppercase by convention | Fixed values (e.g., MAX_AGE = 120). |
Example: Class Variables and Constants
class Person
MAX_AGE = 120 # Constant (uppercase)
@@count = 0 # Class variable (shared)
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age.clamp(0, MAX_AGE) # Use constant to validate age
@@count += 1 # Increment class variable on new instance
end
# Class method (uses self) to access @@count
def self.total_people
@@count
end
end
Person::MAX_AGE # => 120 (access constant via ::)
alice = Person.new("Alice", 30)
bob = Person.new("Bob", 150) # Age clamped to 120
bob.age # => 120
Person.total_people # => 2 (@@count is shared across instances)
Modules and Mixins: Reusing Code Without Inheritance
Ruby intentionally avoids multiple inheritance (where a class inherits from two+ parents) to prevent complexity. Instead, it uses mixins (via modules) to reuse code across unrelated classes.
Modules as Namespaces
A module is a container for methods, constants, and other modules. One common use is namespacing—grouping related code to avoid naming collisions.
Example: Namespacing with Modules
module MathUtils
PI = 3.14159
def self.circle_area(radius) # Module method (self refers to MathUtils)
PI * radius**2
end
end
MathUtils::PI # => 3.14159 (access constant)
MathUtils.circle_area(2) # => 12.56636 (call module method)
Built-in Ruby modules like Math (for math operations) and JSON (for JSON parsing) use namespacing extensively.
Mixins: “Including” Behavior
A mixin is a module that is included in a class, adding its methods to the class’s instances. Mixins enable “horizontal code reuse”—sharing behavior across classes that don’t share a common ancestor.
Example: Mixin for Reusable Behavior
# Define a mixin module
module Swimmable
def swim
"#{self.name} is swimming!" # self refers to the instance of the including class
end
end
# Class that includes the mixin
class Fish
include Swimmable # "Mix in" Swimmable behavior
attr_reader :name
def initialize(name)
@name = name
end
end
# Another class that includes the mixin (unrelated to Fish)
class Duck
include Swimmable # Reuse the same swim method
attr_reader :name
def initialize(name)
@name = name
end
end
# Usage
nemo = Fish.new("Nemo")
nemo.swim # => "Nemo is swimming!"
donald = Duck.new("Donald")
donald.swim # => "Donald is swimming!"
Here, Swimmable is a mixin that adds swim behavior to both Fish and Duck—two classes with no inheritance relationship.
Include vs. Extend
include Swimmable: Adds the module’s methods as instance methods of the class.extend Swimmable: Adds the module’s methods as class methods of the class.
Example: Extend for Class Methods
module Loggable
def log(message)
"[LOG] #{Time.now}: #{message}"
end
end
class Product
extend Loggable # Add log as a class method
def initialize(name)
@name = name
Product.log("Created product: #{name}") # Call class method
end
end
Product.log("Starting app...") # => "[LOG] 2024-05-20 15:00:00 +0000: Starting app..."
Product.new("Laptop") # => "[LOG] 2024-05-20 15:00:01 +0000: Created product: Laptop"
Method Lookup and the Ancestor Chain
When you call a method on an object (e.g., fish.swim), Ruby must find the method’s definition. It does this by traversing the ancestor chain—a list of classes and modules that the object’s class inherits from or includes.
To see the ancestor chain, use Module#ancestors:
Example: Ancestor Chain with Inheritance and Mixins
class Animal; end
module Swimmable; end
class Fish < Animal
include Swimmable
end
Fish.ancestors # => [Fish, Swimmable, Animal, Object, Kernel, BasicObject]
Ruby searches for methods in this order:
Fish(the class itself).Swimmable(included mixins, in reverse order of inclusion).Animal(parent class).Object(all classes inherit fromObject).Kernel(mixed intoObject, provides methods likeputs).BasicObject(the root of the class hierarchy).
If the method isn’t found, Ruby raises a NoMethodError.
The self Keyword: Understanding Context
The self keyword refers to the “current object”—the object that is executing the current method. Its value changes depending on the context:
Self in Instance Methods
Inside an instance method, self is the instance of the class.
class Person
attr_accessor :name
def initialize(name)
@name = name
end
def greet
"Hello, I'm #{self.name}!" # self is the Person instance (e.g., alice)
# Equivalent to "Hello, I'm #{name}!" (self is implicit)
end
end
alice = Person.new("Alice")
alice.greet # => "Hello, I'm Alice!"
Self in Class Methods
Inside a class method, self is the class itself.
class Person
@@count = 0
def initialize
@@count += 1
end
# Class method (def self.method_name)
def self.total_people
self # => Person (the class itself)
@@count
end
# Alternative syntax for class methods (class << self)
class << self
def max_age
120
end
end
end
Person.total_people # => 0 (self is Person)
Person.max_age # => 120 (class method defined via class << self)
Self at the Top-Level
At the top-level (outside any class/module), self is a special object called main (of class Object).
self # => main
self.class # => Object
def greet
"Hello from #{self}!" # self is main
end
greet # => "Hello from main!"
Singleton Methods and Eigenclasses
A singleton method is a method defined for a single object, not for all instances of its class.
Example: Singleton Method
# Create a generic object
obj = Object.new
# Define a singleton method for obj (only obj has this method)
def obj.hello
"Hello from obj!"
end
obj.hello # => "Hello from obj!"
another_obj = Object.new
another_obj.hello # => NoMethodError (another_obj has no hello method)
Singleton methods are stored in the object’s eigenclass (or “singleton class”)—an anonymous class that exists solely to hold methods for that specific object). You can access the eigenclass with class << obj.
Example: Eigenclass
obj = Object.new
class << obj # Open the eigenclass of obj
def hello # Define a method in the eigenclass (singleton method)
"Hello from eigenclass!"
end
end
obj.hello # => "Hello from eigenclass!"
Class methods are just singleton methods of the class object! For example, Person.total_people is a singleton method of the Person class (which is an instance of Class).
Advanced OOP Concepts: Metaprogramming Basics
Metaprogramming is the art of writing code that writes code. Ruby’s flexibility makes it a metaprogramming powerhouse, and many of its OOP features (like attr_accessor) are implemented via metaprogramming.
Example: Custom attr_accessor
The built-in attr_accessor :name dynamically defines name and name= methods. We can replicate this with define_method:
class MyClass
# Custom attr_accessor-like method
def self.my_attr_accessor(*names)
names.each do |name|
# Define getter method
define_method(name) do
instance_variable_get("@#{name}")
end
# Define setter method
define_method("#{name}=") do |value|
instance_variable_set("@#{name}", value)
end
end
end
my_attr_accessor :name, :age # Use our custom method
end
obj = MyClass.new
obj.name = "Alice"
obj.age = 30
obj.name # => "Alice"
obj.age # => 30
Metaprogramming is powerful but should be used sparingly—overuse can make code hard to debug!
Design Patterns in Ruby OOP
Ruby’s OOP model aligns naturally with common design patterns. Here are two examples:
Singleton Pattern
Ensures a class has only one instance. Ruby’s standard library includes a Singleton mixin:
require 'singleton'
class AppConfig
include Singleton
attr_accessor :api_key
def initialize
@api_key = "default_key" # Runs once (only one instance)
end
end
config1 = AppConfig.instance
config2 = AppConfig.instance
config1.object_id == config2.object_id # => true (same instance)
config1.api_key = "secret"
config2.api_key # => "secret" (shared state)
Factory Pattern
Creates objects without exposing the instantiation logic.
class AnimalFactory
def self.create_animal(type, name)
case type.downcase
when "dog" then Dog.new(name)
when "cat" then Cat.new(name)
else raise "Unknown animal type: #{type}"
end
end
end
AnimalFactory.create_animal("dog", "Buddy").speak # => "Buddy says woof!"
AnimalFactory.create_animal("cat", "Whiskers").speak # => "Whiskers says meow!"
Best Practices for Ruby OOP Design
To write clean, maintainable Ruby OOP code:
- Favor composition over inheritance: Use mixins or delegate to other objects instead of deep inheritance hierarchies.
- Keep classes small and focused: Follow the Single Responsibility Principle (a class should do one thing).
- Use meaningful names: Classes (CamelCase), methods (snake_case), variables (snake_case), constants (UPPER_CASE).
- Minimize state: Prefer immutable objects where possible; avoid global state.
- Document behavior, not implementation: Use comments to explain why, not how.
- Test rigorously: Use tools like RSpec to test object interactions and edge cases.
Conclusion
Ruby’s object-oriented design is both elegant and powerful, built on the principle that “everything is an object.” By mastering classes, objects, encapsulation, inheritance, polymorphism, mixins, and metaprogramming, you’ll unlock Ruby’s full potential to write expressive, maintainable code.
Whether you’re building a small script or a large Rails application, Ruby’s OOP foundations will guide you to design systems that are flexible, reusable, and a joy to work with.
References
- Ruby Documentation: Official docs for classes, modules, and methods.
- Programming Ruby: The Pragmatic Programmers’ Guide (The Pickaxe Book): Comprehensive guide to Ruby.
- Eloquent Ruby by Russ Olsen: Best practices for writing idiomatic Ruby.
- Design Patterns in Ruby by Russ Olsen: OOP design patterns tailored for Ruby.
- Ruby Monk: Interactive tutorials on Ruby OOP.