DEV Community

Davide Santangelo
Davide Santangelo

Posted on

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 
Enter fullscreen mode Exit fullscreen mode

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: #<Dog:0x00007f9b1a0b3d40> (your object ID will vary) puts buddy # Output: #<Dog:0x00007f9b1a0b3d18> 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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. 
Enter fullscreen mode Exit fullscreen mode

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). 
Enter fullscreen mode Exit fullscreen mode

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. 
Enter fullscreen mode Exit fullscreen mode

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 by attr_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 as other_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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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. 
Enter fullscreen mode Exit fullscreen mode

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! 
Enter fullscreen mode Exit fullscreen mode

Here, we can treat Dog, Cat, and Cow objects uniformly as Animals 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:

  1. Namespacing: Grouping related classes, methods, and constants to prevent name collisions.
  2. 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! 
Enter fullscreen mode Exit fullscreen mode

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. 
Enter fullscreen mode Exit fullscreen mode

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 or proc {}): 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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 "<html><head><title>#{report_context.title}</title></head><body>" report_context.text.each { |line| puts "<p>#{line}</p>" } puts "</body></html>" 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: # <html><head><title>Monthly Report</title></head><body> # <p>This is line 1.</p> # <p>This is line 2.</p> # <p>Conclusion.</p> # </body></html> 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. 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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." 
Enter fullscreen mode Exit fullscreen mode

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 from Describable used by various classes. The attack and drink_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!

Top comments (0)