Unveiling the Magic of Object-Oriented Programming with Ruby
Welcome, aspiring code wizards and seasoned developers alike! Today, we embark on an exciting journey into the heart of Ruby, a language renowned for its elegance, developer-friendliness, and its pure embrace of Object-Oriented Programming (OOP). If you've ever wondered how to build robust, maintainable, and intuitive software, OOP with Ruby is a fantastic place to start or deepen your understanding. What's OOP, Anyway? And Why Ruby? At its core, Object-Oriented Programming is a paradigm based on the concept of "objects". These objects can contain data, in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods). The main idea is to bundle data and the methods that operate on that data into a single unit. Why is this cool? Modularity: OOP helps you break down complex problems into smaller, manageable, and self-contained objects. Reusability: Write a class once, create many objects from it. Use inheritance to reuse code from existing classes. Maintainability: Changes in one part of the system are less likely to break other parts. Real-world Mapping: OOP allows you to model real-world entities and their interactions more intuitively. And why Ruby? Ruby is not just an OOP language; it's purely object-oriented. In Ruby, everything is an object, from numbers and strings to classes themselves. This consistent model makes OOP concepts feel natural and deeply integrated. Let's dive in! The Building Blocks: Classes & Objects The fundamental concepts in OOP are classes and objects. A Class is a blueprint or template for creating objects. It defines a set of attributes and methods that the objects created from the class will have. An Object is an instance of a class. It's a concrete entity that has its own state (values for its attributes) and can perform actions (through its methods). Anatomy of a Class In Ruby, you define a class using the class keyword, followed by the class name (which should start with a capital letter), and an end keyword. # class_definition.rb # This is a blueprint for creating "Dog" objects. class Dog # We'll add more here soon! end Simple, right? We've just defined a class named Dog. It doesn't do much yet, but it's a valid class. Crafting Objects (Instantiation) To create an object (an instance) from a class, you call the .new method on the class. # object_instantiation.rb class Dog # ... end # Create two Dog objects fido = Dog.new buddy = Dog.new puts fido # Output: # (your object ID will vary) puts buddy # Output: # fido and buddy are now two distinct objects, both instances of the Dog class. Instance Variables: An Object's Memory Objects need to store their own data. This data is held in instance variables. In Ruby, instance variables are prefixed with an @ symbol. They belong to a specific instance of a class. # instance_variables.rb class Dog def set_name(name) @name = name # @name is an instance variable end def get_name @name end end fido = Dog.new fido.set_name("Fido") buddy = Dog.new buddy.set_name("Buddy") puts fido.get_name # Output: Fido puts buddy.get_name # Output: Buddy Here, fido's @name is "Fido", and buddy's @name is "Buddy". They each have their own copy of the @name instance variable. If you try to access @name before it's set, it will return nil. The initialize Method: A Grand Welcome Often, you want to set up an object's initial state when it's created. Ruby provides a special method called initialize for this purpose. It's like a constructor in other languages. The initialize method is called automatically when you use Class.new. # initialize_method.rb class Dog def initialize(name, breed) @name = name # Instance variable @breed = breed # Instance variable puts "#{@name} the #{@breed} says: Woof! I'm alive!" end def get_name @name end def get_breed @breed end end # Now we pass arguments when creating objects fido = Dog.new("Fido", "Golden Retriever") # Output: Fido the Golden Retriever says: Woof! I'm alive! sparky = Dog.new("Sparky", "Poodle") # Output: Sparky the Poodle says: Woof! I'm alive! puts "#{fido.get_name} is a #{fido.get_breed}." # Output: Fido is a Golden Retriever. puts "#{sparky.get_name} is a #{sparky.get_breed}." # Output: Sparky is a Poodle. Instance Methods: What Objects Can Do Methods defined within a class are called instance methods. They define the behavior of the objects created from that class. They can access and modify the object's instance variables. # instance_methods.rb class Dog def initialize(name) @name = name @tricks_learned = 0 end def bark puts "#{@name} says: Woof woof!" end def learn_trick(trick_name) @tricks_learned += 1 puts "#{@name} learned to #{trick_name}!" end def show_off puts "#{@name} knows #{@tricks_learned} trick(s)

Welcome, aspiring code wizards and seasoned developers alike! Today, we embark on an exciting journey into the heart of Ruby, a language renowned for its elegance, developer-friendliness, and its pure embrace of Object-Oriented Programming (OOP). If you've ever wondered how to build robust, maintainable, and intuitive software, OOP with Ruby is a fantastic place to start or deepen your understanding.
What's OOP, Anyway? And Why Ruby?
At its core, Object-Oriented Programming is a paradigm based on the concept of "objects". These objects can contain data, in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods). The main idea is to bundle data and the methods that operate on that data into a single unit.
Why is this cool?
- Modularity: OOP helps you break down complex problems into smaller, manageable, and self-contained objects.
- Reusability: Write a class once, create many objects from it. Use inheritance to reuse code from existing classes.
- Maintainability: Changes in one part of the system are less likely to break other parts.
- Real-world Mapping: OOP allows you to model real-world entities and their interactions more intuitively.
And why Ruby? Ruby is not just an OOP language; it's purely object-oriented. In Ruby, everything is an object, from numbers and strings to classes themselves. This consistent model makes OOP concepts feel natural and deeply integrated.
Let's dive in!
The Building Blocks: Classes & Objects
The fundamental concepts in OOP are classes and objects.
- A Class is a blueprint or template for creating objects. It defines a set of attributes and methods that the objects created from the class will have.
- An Object is an instance of a class. It's a concrete entity that has its own state (values for its attributes) and can perform actions (through its methods).
Anatomy of a Class
In Ruby, you define a class using the class
keyword, followed by the class name (which should start with a capital letter), and an end
keyword.
# class_definition.rb
# This is a blueprint for creating "Dog" objects.
class Dog
# We'll add more here soon!
end
Simple, right? We've just defined a class named Dog
. It doesn't do much yet, but it's a valid class.
Crafting Objects (Instantiation)
To create an object (an instance) from a class, you call the .new
method on the class.
# object_instantiation.rb
class Dog
# ...
end
# Create two Dog objects
fido = Dog.new
buddy = Dog.new
puts fido # Output: # (your object ID will vary)
puts buddy # Output: #
fido
and buddy
are now two distinct objects, both instances of the Dog
class.
Instance Variables: An Object's Memory
Objects need to store their own data. This data is held in instance variables. In Ruby, instance variables are prefixed with an @
symbol. They belong to a specific instance of a class.
# instance_variables.rb
class Dog
def set_name(name)
@name = name # @name is an instance variable
end
def get_name
@name
end
end
fido = Dog.new
fido.set_name("Fido")
buddy = Dog.new
buddy.set_name("Buddy")
puts fido.get_name # Output: Fido
puts buddy.get_name # Output: Buddy
Here, fido
's @name
is "Fido", and buddy
's @name
is "Buddy". They each have their own copy of the @name
instance variable. If you try to access @name
before it's set, it will return nil
.
The initialize Method: A Grand Welcome
Often, you want to set up an object's initial state when it's created. Ruby provides a special method called initialize
for this purpose. It's like a constructor in other languages. The initialize
method is called automatically when you use Class.new
.
# initialize_method.rb
class Dog
def initialize(name, breed)
@name = name # Instance variable
@breed = breed # Instance variable
puts "#{@name} the #{@breed} says: Woof! I'm alive!"
end
def get_name
@name
end
def get_breed
@breed
end
end
# Now we pass arguments when creating objects
fido = Dog.new("Fido", "Golden Retriever")
# Output: Fido the Golden Retriever says: Woof! I'm alive!
sparky = Dog.new("Sparky", "Poodle")
# Output: Sparky the Poodle says: Woof! I'm alive!
puts "#{fido.get_name} is a #{fido.get_breed}." # Output: Fido is a Golden Retriever.
puts "#{sparky.get_name} is a #{sparky.get_breed}." # Output: Sparky is a Poodle.
Instance Methods: What Objects Can Do
Methods defined within a class are called instance methods. They define the behavior of the objects created from that class. They can access and modify the object's instance variables.
# instance_methods.rb
class Dog
def initialize(name)
@name = name
@tricks_learned = 0
end
def bark
puts "#{@name} says: Woof woof!"
end
def learn_trick(trick_name)
@tricks_learned += 1
puts "#{@name} learned to #{trick_name}!"
end
def show_off
puts "#{@name} knows #{@tricks_learned} trick(s)."
end
end
fido = Dog.new("Fido")
fido.bark # Output: Fido says: Woof woof!
fido.learn_trick("sit") # Output: Fido learned to sit!
fido.learn_trick("roll over") # Output: Fido learned to roll over!
fido.show_off # Output: Fido knows 2 trick(s).
Accessors: Controlled Gates to Data
Directly accessing instance variables from outside the class is generally not good practice (it breaks encapsulation, which we'll discuss soon). Instead, we use accessor methods.
Ruby provides convenient shortcuts for creating these:
-
attr_reader :variable_name
: Creates a getter method. -
attr_writer :variable_name
: Creates a setter method. -
attr_accessor :variable_name
: Creates both a getter and a setter method.
These take symbols as arguments, representing the instance variable names (without the @
).
# accessor_methods.rb
class Cat
# Creates getter for @name and getter/setter for @age
attr_reader :name
attr_accessor :age
def initialize(name, age)
@name = name # Can't be changed after initialization due to attr_reader
@age = age
end
def birthday
@age += 1
puts "Happy Birthday! #{@name} is now #{@age}."
end
end
whiskers = Cat.new("Whiskers", 3)
puts whiskers.name # Output: Whiskers (using getter)
puts whiskers.age # Output: 3 (using getter)
whiskers.age = 4 # Using setter
puts whiskers.age # Output: 4
# whiskers.name = "Mittens" # This would cause an error: NoMethodError (undefined method 'name='...)
# because :name is only an attr_reader
whiskers.birthday # Output: Happy Birthday! Whiskers is now 5.
The Four Pillars of OOP in Ruby
OOP is often described as standing on four main pillars: Encapsulation, Inheritance, Polymorphism, and Abstraction (though Abstraction is often seen as a result of the other three, especially Encapsulation). Let's explore them in the context of Ruby.
1. Encapsulation: The Art of Hiding
Encapsulation is about bundling the data (attributes) and the methods that operate on that data within a single unit (the object). It also involves restricting direct access to some of an object's components, which is known as information hiding.
Why?
- Control: You control how the object's data is accessed and modified, preventing accidental or unwanted changes.
- Flexibility: You can change the internal implementation of a class without affecting the code that uses it, as long as the public interface remains the same.
- Simplicity: Users of your class only need to know about its public interface, not its internal complexities.
Ruby provides three levels of access control for methods:
-
public: Methods are public by default (except
initialize
, which is effectively private). Public methods can be called by anyone. -
private: Private methods can only be called from within the defining class, and importantly, only without an explicit receiver. This means you can't do
self.private_method
(unless it's a setter defined byattr_writer
). They are typically helper methods for the internal workings of the class. -
protected: Protected methods can be called by any instance of the defining class or its subclasses. Unlike private methods, they can be called with an explicit receiver (e.g.,
other_object.protected_method
) as long asother_object
is an instance of the same class or a subclass.
Here's an example demonstrating encapsulation with a BankAccount
class:
# encapsulation_access_control.rb
class BankAccount
attr_reader :account_number, :holder_name
def initialize(account_number, holder_name, initial_balance)
@account_number = account_number
@holder_name = holder_name
@balance = initial_balance
end
def deposit(amount)
if amount > 0
@balance += amount
log_transaction("Deposited #{amount}")
puts "Deposited #{amount}. New balance: #{@balance}"
else
puts "Deposit amount must be positive."
end
end
def withdraw(amount)
if can_withdraw?(amount)
@balance -= amount
log_transaction("Withdrew #{amount}")
puts "Withdrew #{amount}. New balance: #{@balance}"
else
puts "Insufficient funds."
end
end
def display_balance
puts "Current balance for #{@holder_name}: #{@balance}"
end
def transfer_to(other_account, amount)
if amount > 0 && can_withdraw?(amount)
puts "Attempting to transfer #{amount} to #{other_account.holder_name}"
self.withdraw_for_transfer(amount)
other_account.deposit_from_transfer(amount)
log_transaction("Transferred #{amount} to account #{other_account.account_number}")
puts "Transfer successful."
else
puts "Transfer failed."
end
end
protected
def deposit_from_transfer(amount)
@balance += amount
log_transaction("Received transfer: #{amount}")
end
def withdraw_for_transfer(amount)
@balance -= amount
log_transaction("Initiated transfer withdrawal: #{amount}")
end
private
def can_withdraw?(amount)
@balance >= amount
end
def log_transaction(message)
puts "[LOG] Account #{@account_number}: #{message}"
end
end
# --- Usage ---
acc1 = BankAccount.new("12345", "Alice", 1000)
acc2 = BankAccount.new("67890", "Bob", 500)
acc1.display_balance # Output: Current balance for Alice: 1000
acc1.deposit(200) # Output: Deposited 200. New balance: 1200
acc1.withdraw(50) # Output: Withdrew 50. New balance: 1150
# acc1.log_transaction("Oops") # Error: private method 'log_transaction' called
# acc1.can_withdraw?(50) # Error: private method 'can_withdraw?' called
acc1.transfer_to(acc2, 100)
# Output:
# Attempting to transfer 100 to Bob
# [LOG] Account 12345: Initiated transfer withdrawal: 100
# [LOG] Account 67890: Received transfer: 100
# [LOG] Account 12345: Transferred 100 to account 67890
# Transfer successful.
acc1.display_balance # Output: Current balance for Alice: 1050
acc2.display_balance # Output: Current balance for Bob: 600
2. Inheritance: Standing on the Shoulders of Giants
Inheritance allows a class (the subclass or derived class) to inherit attributes and methods from another class (the superclass or base class). This promotes code reuse and establishes an "is-a" relationship (e.g., a Dog
is an Animal
).
In Ruby, you denote inheritance using the <
symbol.
# inheritance.rb
class Animal
attr_reader :name
def initialize(name)
@name = name
end
def speak
raise NotImplementedError, "Subclasses must implement the 'speak' method."
end
def eat(food)
puts "#{@name} is eating #{food}."
end
end
class Dog < Animal # Dog inherits from Animal
attr_reader :breed
def initialize(name, breed)
super(name) # Calls the 'initialize' method of the superclass (Animal)
@breed = breed
end
# Overriding the 'speak' method from Animal
def speak
puts "#{@name} the #{@breed} says: Woof!"
end
def fetch(item)
puts "#{@name} fetches the #{item}."
end
end
class Cat < Animal # Cat inherits from Animal
def initialize(name, fur_color)
super(name)
@fur_color = fur_color
end
# Overriding the 'speak' method
def speak
puts "#{@name} the cat with #{@fur_color} fur says: Meow!"
end
def purr
puts "#{@name} purrs contentedly."
end
end
# --- Usage ---
generic_animal = Animal.new("Creature")
# generic_animal.speak # This would raise NotImplementedError
fido = Dog.new("Fido", "Labrador")
fido.eat("kibble") # Inherited from Animal. Output: Fido is eating kibble.
fido.speak # Overridden in Dog. Output: Fido the Labrador says: Woof!
fido.fetch("ball") # Defined in Dog. Output: Fido fetches the ball.
mittens = Cat.new("Mittens", "tabby")
mittens.eat("fish") # Inherited. Output: Mittens is eating fish.
mittens.speak # Overridden. Output: Mittens the cat with tabby fur says: Meow!
mittens.purr # Defined in Cat. Output: Mittens purrs contentedly.
puts "#{fido.name} is a Dog."
puts "#{mittens.name} is a Cat."
puts "Is fido an Animal? #{fido.is_a?(Animal)}" # Output: true
puts "Is fido a Cat? #{fido.is_a?(Cat)}" # Output: false
puts "Is fido a Dog? #{fido.is_a?(Dog)}" # Output: true
Key points about inheritance:
-
super: The
super
keyword calls the method with the same name in the superclass.-
super
(with no arguments): Passes all arguments received by the current method to the superclass method. -
super()
(with empty parentheses): Calls the superclass method with no arguments. -
super(arg1, arg2)
: Calls the superclass method with specific arguments.
-
- Method Overriding: Subclasses can provide a specific implementation for a method that is already defined in its superclass.
- Liskov Substitution Principle (LSP): An important principle related to inheritance. It states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. This means subclasses should extend, not fundamentally alter, the behavior of their superclasses.
3. Polymorphism: Many Forms, One Interface
Polymorphism, from Greek meaning "many forms," allows objects of different classes to respond to the same message (method call) in different ways.
Duck Typing in Ruby
Ruby is famous for "duck typing." The idea is: "If it walks like a duck and quacks like a duck, then it must be a duck." In other words, Ruby doesn't care so much about an object's class, but rather about what methods it can respond to.
# polymorphism_duck_typing.rb
class Journalist
def write_article
puts "Journalist: Writing a compelling news story..."
end
end
class Blogger
def write_article
puts "Blogger: Crafting an engaging blog post..."
end
end
class Novelist
def write_masterpiece
puts "Novelist: Weaving an epic tale..."
end
def write_article
puts "Novelist: Penning a thoughtful essay for a magazine."
end
end
def publish_content(writers)
writers.each do |writer|
# We don't care about the class, only if it can 'write_article'
if writer.respond_to?(:write_article)
writer.write_article
else
puts "#{writer.class} cannot write an article in the conventional sense."
end
end
end
writers_list = [Journalist.new, Blogger.new, Novelist.new]
publish_content(writers_list)
# Output:
# Journalist: Writing a compelling news story...
# Blogger: Crafting an engaging blog post...
# Novelist: Penning a thoughtful essay for a magazine.
Polymorphism via Inheritance
This is what we saw with the Animal
, Dog
, and Cat
example. Each animal speaks differently.
# polymorphism_inheritance.rb
class Animal
attr_reader :name
def initialize(name)
@name = name
end
def speak
raise NotImplementedError, "Subclasses must implement the 'speak' method."
end
end
class Dog < Animal
def speak
puts "#{@name} says: Woof!"
end
end
class Cat < Animal
def speak
puts "#{@name} says: Meow!"
end
end
class Cow < Animal
def speak
puts "#{@name} says: Mooo!"
end
end
animals = [Dog.new("Buddy"), Cat.new("Whiskers"), Cow.new("Bessie")]
animals.each do |animal|
animal.speak # Each animal responds to 'speak' in its own way
end
# Output:
# Buddy says: Woof!
# Whiskers says: Meow!
# Bessie says: Mooo!
Here, we can treat Dog
, Cat
, and Cow
objects uniformly as Animal
s and call speak
on them, and the correct version of speak
is executed.
Beyond the Pillars: Advanced Ruby OOP
Ruby's OOP capabilities extend further, offering powerful tools for flexible and expressive code.
Modules: Ruby's Swiss Army Knife
Modules in Ruby serve two primary purposes:
- Namespacing: Grouping related classes, methods, and constants to prevent name collisions.
- Mixins: Providing a collection of methods that can be "mixed into" classes, adding behavior without using inheritance. This is Ruby's way of achieving multiple inheritance-like features.
Mixins: Adding Behavior (include)
When a module is included in a class, its methods become instance methods of that class.
# modules_mixins.rb
module Swimmable
def swim
puts "#{name_for_action} is swimming!"
end
end
module Walkable
def walk
puts "#{name_for_action} is walking!"
end
end
# A helper method that classes using these modules should implement
module ActionNameable
def name_for_action
# Default implementation, can be overridden by the class
self.respond_to?(:name) ? self.name : self.class.to_s
end
end
class Fish
include Swimmable
include ActionNameable
# Provides name_for_action
attr_reader :name
def initialize(name)
@name = name
end
end
class Dog
include Swimmable
include Walkable
include ActionNameable
attr_reader :name
def initialize(name)
@name = name
end
end
class Robot
include Walkable # Robots can walk, but not swim (usually!)
include ActionNameable
def name_for_action # Overriding for specific robot naming
"Unit 734"
end
end
nemo = Fish.new("Nemo")
nemo.swim # Output: Nemo is swimming!
# nemo.walk # Error: NoMethodError
buddy = Dog.new("Buddy")
buddy.swim # Output: Buddy is swimming!
buddy.walk # Output: Buddy is walking!
bot = Robot.new
bot.walk # Output: Unit 734 is walking!
The Enumerable
module is a classic example of a mixin in Ruby's standard library. If your class implements an each
method and includes Enumerable
, you get a wealth of iteration methods (map
, select
, reject
, sort_by
, etc.) for free!
Namespacing: Keeping Things Tidy
Modules can also be used to organize your code and prevent naming conflicts.
# modules_namespacing.rb
module SportsAPI
class Player
def initialize(name)
@name = name
puts "SportsAPI Player #{@name} created."
end
end
module Football
class Player # This is SportsAPI::Football::Player
def initialize(name, team)
@name = name
@team = team
puts "Football Player #{@name} of #{@team} created."
end
end
end
end
module MusicApp
class Player # This is MusicApp::Player
def initialize(song)
@song = song
puts "MusicApp Player playing #{@song}."
end
end
end
player1 = SportsAPI::Player.new("John Doe")
# Output: SportsAPI Player John Doe created.
player2 = SportsAPI::Football::Player.new("Leo Messi", "Inter Miami")
# Output: Football Player Leo Messi of Inter Miami created.
player3 = MusicApp::Player.new("Bohemian Rhapsody")
# Output: MusicApp Player playing Bohemian Rhapsody.
Blocks, Procs, and Lambdas: Objects of Behavior
In Ruby, blocks are chunks of code that can be passed to methods. Procs and lambdas are objects that encapsulate these blocks of code, allowing them to be stored in variables, passed around, and executed later. They are a key part of Ruby's functional programming flavor and interact deeply with OOP.
- Blocks: Not objects themselves, but can be converted to Proc objects. Often used for iteration or customizing method behavior.
-
Procs (
Proc.new
orproc {}
): Objects that represent a block of code. They have "lenient arity" (don't strictly check the number of arguments) and return from the context where they are defined. -
Lambdas (
lambda {}
or-> {}
): Also Proc objects, but with "strict arity" (raise an error if the wrong number of arguments is passed) and return from the lambda itself, not the enclosing method.
# blocks_procs_lambdas.rb
# Method that accepts a block
def custom_iterator(items)
puts "Starting iteration..."
items.each do |item|
yield item # 'yield' executes the block passed to the method
end
puts "Iteration finished."
end
my_array = [1, 2, 3]
custom_iterator(my_array) do |number|
puts "Processing item: #{number * 10}"
end
# Output:
# Starting iteration...
# Processing item: 10
# Processing item: 20
# Processing item: 30
# Iteration finished.
# --- Procs ---
my_proc = Proc.new { |name| puts "Hello from Proc, #{name}!" }
my_proc.call("Alice") # Output: Hello from Proc, Alice!
my_proc.call # Output: Hello from Proc, ! (lenient arity, name is nil)
# Proc with return
def proc_test
p = Proc.new { return "Returned from Proc inside proc_test" }
p.call
return "Returned from proc_test method" # This line is never reached
end
puts proc_test # Output: Returned from Proc inside proc_test
# --- Lambdas ---
my_lambda = lambda { |name| puts "Hello from Lambda, #{name}!" }
# Alternative syntax: my_lambda = ->(name) { puts "Hello from Lambda, #{name}!" }
my_lambda.call("Bob") # Output: Hello from Lambda, Bob!
# my_lambda.call # ArgumentError: wrong number of arguments (given 0, expected 1) (strict arity)
# Lambda with return
def lambda_test
l = lambda { return "Returned from Lambda" }
result = l.call
puts "Lambda call result: #{result}"
return "Returned from lambda_test method" # This line IS reached
end
puts lambda_test
# Output:
# Lambda call result: Returned from Lambda
# Returned from lambda_test method
Blocks, Procs, and Lambdas allow you to pass behavior as arguments, which is incredibly powerful for creating flexible and reusable methods and classes.
Metaprogramming: Ruby Talking to Itself (A Glimpse)
Metaprogramming is writing code that writes code, or code that modifies itself or other code at runtime. Ruby's dynamic nature makes it exceptionally well-suited for metaprogramming. This is an advanced topic, but here's a tiny taste:
- send: Allows you to call a method by its name (as a string or symbol).
- define_method: Allows you to create methods dynamically.
# metaprogramming_glimpse.rb
class Greeter
def say_hello
puts "Hello!"
end
end
g = Greeter.new
g.send(:say_hello) # Output: Hello! (Same as g.say_hello)
class DynamicHelper
# Dynamically define methods for each attribute
['name', 'email', 'city'].each do |attribute|
define_method("get_#{attribute}") do
instance_variable_get("@#{attribute}")
end
define_method("set_#{attribute}") do |value|
instance_variable_set("@#{attribute}", value)
puts "Set #{attribute} to #{value}"
end
end
def initialize(name, email, city)
@name = name
@email = email
@city = city
end
end
helper = DynamicHelper.new("Jane Doe", "jane@example.com", "New York")
helper.set_email("jane.d@example.com") # Output: Set email to jane.d@example.com
puts helper.get_email # Output: jane.d@example.com
puts helper.get_name # Output: Jane Doe
Metaprogramming is powerful but can make code harder to understand and debug if overused. Use it judiciously!
Common Design Patterns in Ruby
Design patterns are reusable solutions to commonly occurring problems within a given context in software design. Ruby's features often provide elegant ways to implement these patterns.
Singleton
Ensures a class only has one instance and provides a global point of access to it. Ruby has a Singleton
module.
# design_pattern_singleton.rb
require 'singleton'
class ConfigurationManager
include Singleton # Makes this class a Singleton
attr_accessor :setting
def initialize
# Load configuration (e.g., from a file)
@setting = "Default Value"
puts "ConfigurationManager initialized."
end
end
config1 = ConfigurationManager.instance
config2 = ConfigurationManager.instance
puts "config1 object_id: #{config1.object_id}"
puts "config2 object_id: #{config2.object_id}" # Same as config1
# Output: ConfigurationManager initialized. (only once)
# Output: config1 object_id:...
# Output: config2 object_id:... (same as above)
config1.setting = "New Value"
puts config2.setting # Output: New Value
Decorator
Adds new responsibilities to an object dynamically. Ruby's modules and SimpleDelegator
can be used.
# design_pattern_decorator.rb
require 'delegate' # For SimpleDelegator
class SimpleCoffee
def cost
10
end
def description
"Simple coffee"
end
end
# Decorator base class (optional, but good practice)
class CoffeeDecorator < SimpleDelegator
def initialize(coffee)
super(coffee) # Delegates methods to the wrapped coffee object
@component = coffee
end
def cost
@component.cost
end
def description
@component.description
end
end
class MilkDecorator < CoffeeDecorator
def cost
super + 2 # Add cost of milk
end
def description
super + ", milk"
end
end
class SugarDecorator < CoffeeDecorator
def cost
super + 1 # Add cost of sugar
end
def description
super + ", sugar"
end
end
my_coffee = SimpleCoffee.new
puts "#{my_coffee.description} costs #{my_coffee.cost}"
# Output: Simple coffee costs 10
milk_coffee = MilkDecorator.new(my_coffee)
puts "#{milk_coffee.description} costs #{milk_coffee.cost}"
# Output: Simple coffee, milk costs 12
sweet_milk_coffee = SugarDecorator.new(milk_coffee)
puts "#{sweet_milk_coffee.description} costs #{sweet_milk_coffee.cost}"
# Output: Simple coffee, milk, sugar costs 13
# You can also wrap directly
super_coffee = SugarDecorator.new(MilkDecorator.new(SimpleCoffee.new))
puts "#{super_coffee.description} costs #{super_coffee.cost}"
# Output: Simple coffee, milk, sugar costs 13
Strategy
Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
# design_pattern_strategy.rb
class Report
attr_reader :title, :text
attr_accessor :formatter
def initialize(title, text, formatter)
@title = title
@text = text
@formatter = formatter
end
def output_report
@formatter.output_report(self) # Delegate to the strategy
end
end
class HTMLFormatter
def output_report(report_context)
puts " #{report_context.title}"
report_context.text.each { |line| puts "#{line}" }
puts ""
end
end
class PlainTextFormatter
def output_report(report_context)
puts "*** #{report_context.title} ***"
report_context.text.each { |line| puts line }
end
end
report_data = ["This is line 1.", "This is line 2.", "Conclusion."]
html_report = Report.new("Monthly Report", report_data, HTMLFormatter.new)
html_report.output_report
# Output:
# Monthly Report
# This is line 1.
# This is line 2.
# Conclusion.
#
puts "\n--- Changing strategy ---\n"
plain_text_report = Report.new("Weekly Update", report_data, PlainTextFormatter.new)
plain_text_report.output_report
# Output:
# *** Weekly Update ***
# This is line 1.
# This is line 2.
# Conclusion.
# We can even change the strategy on an existing object
puts "\n--- Changing strategy on html_report ---\n"
html_report.formatter = PlainTextFormatter.new
html_report.output_report
# Output:
# *** Monthly Report ***
# This is line 1.
# This is line 2.
# Conclusion.
A Practical Example: Building a Mini Adventure Game
Let's tie some of these concepts together with a very simple text-based adventure game.
Modules for Capabilities
We start by defining modules for capabilities that can be mixed into different classes.
module Describable
attr_accessor :description
def look
description || "There's nothing particularly interesting about it."
end
end
module Carryable
attr_accessor :weight
def pickup_text
"You pick up the #{name}."
end
def drop_text
"You drop the #{name}."
end
end
GameItem Class
Next, we define a base class for game items, which includes the Describable
module.
class GameItem
include Describable
attr_reader :name
def initialize(name, description)
@name = name
self.description = description
end
end
Weapon and Potion Classes
We can create subclasses for specific types of items, like weapons and potions.
class Weapon < GameItem
include Carryable # Weapons can be carried
attr_reader :damage
def initialize(name, description, damage, weight)
super(name, description)
@damage = damage
@weight = weight
end
def attack_text(target_name)
"#{name} attacks #{target_name} for #{@damage} damage!"
end
end
class Potion < GameItem
include Carryable # Potions can be carried
attr_reader :heal_amount
def initialize(name, description, heal_amount, weight)
super(name, description)
@heal_amount = heal_amount
@weight = weight
end
def drink_text(drinker_name)
"#{drinker_name} drinks the #{name} and recovers #{heal_amount} health."
end
end
Scenery Class
For items that are part of the environment and can't be carried.
class Scenery < GameItem
# Scenery is just describable, not carryable
def initialize(name, description)
super(name, description)
end
end
Character Class
The Character
class represents both the player and NPCs, with methods for health management, inventory, and actions.
class Character
include Describable # Characters can be described
attr_reader :name, :max_hp
attr_accessor :hp, :current_room, :inventory
def initialize(name, description, hp)
@name = name
@description = description
@hp = hp
@max_hp = hp
@inventory = []
@current_room = nil
end
def alive?
@hp > 0
end
def take_damage(amount)
@hp -= amount
@hp = 0 if @hp < 0
puts "#{name} takes #{amount} damage. Current HP: #{@hp}."
die unless alive?
end
def heal(amount)
@hp += amount
@hp = @max_hp if @hp > @max_hp
puts "#{name} heals for #{amount}. Current HP: #{@hp}."
end
def die
puts "#{name} has been defeated!"
end
def add_to_inventory(item)
if item.is_a?(Carryable)
inventory << item
puts item.pickup_text
else
puts "You can't carry the #{item.name}."
end
end
def drop_from_inventory(item_name)
item = inventory.find { |i| i.name.downcase == item_name.downcase }
if item
inventory.delete(item)
current_room.items << item # Drop it in the current room
puts item.drop_text
else
puts "You don't have a #{item_name}."
end
end
def display_inventory
if inventory.empty?
puts "#{name}'s inventory is empty."
else
puts "#{name}'s inventory:"
inventory.each { |item| puts "- #{item.name} (#{item.description})" }
end
end
def attack(target, weapon)
if weapon.is_a?(Weapon) && inventory.include?(weapon)
puts weapon.attack_text(target.name)
target.take_damage(weapon.damage)
elsif !inventory.include?(weapon)
puts "You don't have the #{weapon.name} in your inventory."
else
puts "You can't attack with the #{weapon.name}."
end
end
def drink_potion(potion)
if potion.is_a?(Potion) && inventory.include?(potion)
puts potion.drink_text(self.name)
heal(potion.heal_amount)
inventory.delete(potion) # Potion is consumed
elsif !inventory.include?(potion)
puts "You don't have the #{potion.name} in your inventory."
else
puts "You can't drink the #{potion.name}."
end
end
end
Room Class
Rooms contain items and characters and have exits to other rooms.
class Room
include Describable
attr_accessor :items, :characters
attr_reader :name, :exits
def initialize(name, description)
super() # For Describable
@name = name
self.description = description # Use setter from Describable
@exits = {} # direction => room_object
@items = []
@characters = []
end
def add_exit(direction, room)
@exits[direction.to_sym] = room
end
def full_description
output = ["--- #{name} ---"]
output << description
output << "Items here: #{items.map(&:name).join(', ')}" if items.any?
output << "Others here: #{characters.reject { |c| c.is_a?(Player) }.map(&:name).join(', ')}" if characters.any? { |c| !c.is_a?(Player) }
output << "Exits: #{exits.keys.join(', ')}"
output.join("\n")
end
end
Player Class
The Player
class inherits from Character
and adds methods for movement and interaction.
class Player < Character
def initialize(name, description, hp)
super(name, description, hp)
end
def move(direction)
if current_room.exits[direction.to_sym]
self.current_room.characters.delete(self)
self.current_room = current_room.exits[direction.to_sym]
self.current_room.characters << self
puts "You move #{direction}."
puts current_room.full_description
else
puts "You can't go that way."
end
end
def look_around
puts current_room.full_description
end
def look_at(target_name)
# Check items in room or inventory, or characters in room
target = current_room.items.find { |i| i.name.downcase == target_name.downcase } ||
inventory.find { |i| i.name.downcase == target_name.downcase } ||
current_room.characters.find { |c| c.name.downcase == target_name.downcase } ||
(current_room.name.downcase == target_name.downcase ? current_room : nil)
if target
puts target.look
else
puts "You don't see a '#{target_name}' here."
end
end
def take_item(item_name)
item = current_room.items.find { |i| i.name.downcase == item_name.downcase }
if item
if item.is_a?(Carryable)
current_room.items.delete(item)
add_to_inventory(item)
else
puts "You can't take the #{item.name}."
end
else
puts "There is no '#{item_name}' here to take."
end
end
end
Game Setup and Loop
Finally, we set up the game world and implement a simple game loop for user interaction.
# --- Game Setup ---
# Items
sword = Weapon.new("Iron Sword", "A trusty iron sword.", 10, 5)
health_potion = Potion.new("Health Potion", "Restores 20 HP.", 20, 1)
old_tree = Scenery.new("Old Tree", "A gnarled, ancient tree. It looks climbable but you're busy.")
shiny_key = GameItem.new("Shiny Key", "A small, shiny brass key.")
shiny_key.extend(Carryable) # Make key carryable by extending the instance
shiny_key.weight = 0.5
# Rooms
forest_clearing = Room.new("Forest Clearing", "You are in a sun-dappled forest clearing. Paths lead north and east.")
dark_cave = Room.new("Dark Cave", "It's damp and dark here. You hear a faint dripping sound. A path leads south.")
treasure_room = Room.new("Treasure Chamber", "A small chamber, surprisingly well-lit. A path leads west.")
# Place items in rooms
forest_clearing.items << sword
forest_clearing.items << old_tree
dark_cave.items << health_potion
treasure_room.items << shiny_key
# Connect rooms
forest_clearing.add_exit("north", dark_cave)
dark_cave.add_exit("south", forest_clearing)
forest_clearing.add_exit("east", treasure_room)
treasure_room.add_exit("west", forest_clearing)
# Characters
player = Player.new("Hero", "A brave adventurer.", 100)
goblin = Character.new("Goblin", "A nasty-looking goblin.", 30)
# Place characters
player.current_room = forest_clearing
forest_clearing.characters << player
dark_cave.characters << goblin
# --- Simple Game Loop ---
puts "Welcome to Mini Adventure!"
puts player.current_room.full_description
loop do
break unless player.alive?
print "\n> "
command_line = gets.chomp.downcase.split
action = command_line[0]
target = command_line[1..-1].join(' ') if command_line.length > 1
case action
when "quit"
puts "Thanks for playing!"
break
when "look"
if target.nil? || target.empty?
player.look_around
else
player.look_at(target)
end
when "n", "north"
player.move("north")
when "s", "south"
player.move("south")
when "e", "east"
player.move("east")
when "w", "west"
player.move("west")
when "inv", "inventory"
player.display_inventory
when "take", "get"
if target
player.take_item(target)
else
puts "Take what?"
end
when "drop"
if target
player.drop_from_inventory(target)
else
puts "Drop what?"
end
when "attack"
if target
enemy = player.current_room.characters.find { |c| c.name.downcase == target.downcase && c != player }
weapon_to_use = player.inventory.find { |i| i.is_a?(Weapon) } # Simplistic: use first weapon
if enemy && weapon_to_use
player.attack(enemy, weapon_to_use)
# Simple enemy AI: goblin attacks back if alive
if enemy.alive? && enemy.current_room == player.current_room
puts "#{enemy.name} retaliates!"
# For simplicity, goblin has no weapon, just base damage
enemy_weapon_mock = Weapon.new("Claws", "Goblin Claws", 5, 0) # mock weapon for attack logic
enemy.inventory << enemy_weapon_mock # temporarily give it to goblin for attack logic
enemy.attack(player, enemy_weapon_mock)
enemy.inventory.delete(enemy_weapon_mock)
end
elsif !enemy
puts "There's no one here named '#{target}' to attack."
elsif !weapon_to_use
puts "You have no weapon to attack with!"
end
else
puts "Attack who?"
end
when "drink"
if target
potion_to_drink = player.inventory.find { |i| i.name.downcase == target.downcase && i.is_a?(Potion) }
if potion_to_drink
player.drink_potion(potion_to_drink)
else
puts "You don't have a potion named '#{target}'."
end
else
puts "Drink what?"
end
when "help"
puts "Commands: look, look [target], n/s/e/w, inv, take [item], drop [item], attack [target], drink [potion], quit, help"
else
puts "Unknown command. Type 'help' for a list of commands."
end
# Remove dead characters from rooms
player.current_room.characters.reject! { |c| !c.alive? }
end
puts "Game Over."
This example showcases:
-
Classes:
GameItem
,Weapon
,Potion
,Scenery
,Character
,Player
,Room
. -
Inheritance:
Player < Character
,Weapon < GameItem
, etc. -
Modules & Mixins:
Describable
,Carryable
for adding shared behavior. -
Encapsulation: Instance variables are generally accessed via methods (though some
attr_accessors
are used for simplicity here). -
Polymorphism:
look
method fromDescribable
used by various classes. Theattack
anddrink_potion
methods check item types (is_a?
). -
Object Composition:
Room
has items and characters;Player
has an inventory.
Feel free to expand on this! Add more rooms, items, puzzles, and character interactions.
Conclusion: Your OOP Journey with Ruby
Object-Oriented Programming is a powerful paradigm, and Ruby provides an exceptionally pleasant and productive environment to wield it. From the straightforward syntax for classes and objects to the flexibility of mixins and metaprogramming, Ruby empowers you to build elegant, maintainable, and expressive software.
We've covered a lot of ground:
- The basics of classes, objects, instance variables, and methods.
- The core pillars: Encapsulation, Inheritance, and Polymorphism (especially Ruby's duck typing).
- Advanced tools like Modules (for mixins and namespacing), Blocks/Procs/Lambdas, and a peek into Metaprogramming.
- How design patterns can be implemented in Ruby.
- A practical example to see these concepts in action.
The journey into mastering OOP is ongoing. Keep practicing, keep building, and keep exploring. Ruby's rich ecosystem and community are there to support you. Happy coding!