Advanced Object-Oriented Concepts in Ruby — Ruby Deep Dive [02]

Bhavyansh @ DiversePixel
7 min readJul 19, 2024

--

In the previous article, we explored the basics of Ruby, focusing on its object-oriented nature, dynamic features, and comparison with other languages. Now, let’s delve deeper into advanced object-oriented concepts in Ruby, inspired by “Practical Object-Oriented Design in Ruby” (POODR) by Sandi Metz. We will explore practical examples to illustrate these concepts thoroughly.

Photo by Emile Perron on Unsplash

Single Responsibility Principle

A class should have a single responsibility, meaning it should do the smallest possible useful thing. This principle makes classes easier to maintain, understand, and extend.

Practical Example: Refactoring a User Class

Let’s consider a User class that has multiple responsibilities, including calculating the gear ratio and tire diameter.

# Before: A class with multiple responsibilities
class User
def initialize(name, email)
@name = name
@email = email
end

def send_welcome_email
# code to send email
puts "Sending welcome email to #{@name}"
end

def save
# code to save user to database
puts "Saving user #{@name} to database"
end
end

# After: Refactored into multiple classes, each with a single responsibility
class User
attr_reader :name, :email

def initialize(name, email)
@name = name
@email = email
end
end

class UserMailer
def send_welcome_email(user)
# code to send email
puts "Sending welcome email to #{user.name}"
end
end

class UserRepository
def save(user)
# code to save user to database
puts "Saving user #{user.name} to database"
end
end

user = User.new("Alice", "alice@example.com")
mailer = UserMailer.new
repository = UserRepository.new

mailer.send_welcome_email(user)
repository.save(user)

This separation of concerns adheres to the Single Responsibility Principle.

Singleton Methods

Singleton methods are methods that are defined on a single instance of a class rather than on all instances.

Example: Defining a Singleton Method

class Car
def drive
"Driving..."
end
end
my_car = Car.new
def my_car.paint(color)
@color = color
"Car painted #{color}"
end
puts my_car.paint("red") # Output: Car painted red
# Another instance won't have the `paint` method
another_car = Car.new
puts another_car.paint("blue") # Output: NoMethodError

Encapsulation and Data Hiding

Encapsulation is the practice of hiding the internal state and requiring all interaction to be performed through an object’s methods.

Example: Using Private Methods and Attr Accessors

class Account
attr_reader :balance
def initialize(balance)
@balance = balance
end
def deposit(amount)
@balance += amount if valid_amount?(amount)
end
private
def valid_amount?(amount)
amount > 0
end
end
account = Account.new(100)
account.deposit(50)
puts account.balance # Output: 150
account.deposit(-30) # This won't change the balance
puts account.balance # Output: 150

Law of Demeter

The Law of Demeter, also known as the principle of least knowledge, states that a method should only call methods on objects that it directly interacts with.

Example: Refactoring to Follow the Law of Demeter

# Before: Violating the Law of Demeter
class Order
attr_reader :customer
def initialize(customer)
@customer = customer
end
def total_amount
customer.wallet.balance - customer.wallet.rewards
end
end
# After: Complying with the Law of Demeter
class Wallet
attr_reader :balance, :rewards
def initialize(balance, rewards)
@balance = balance
@rewards = rewards
end
def net_balance
@balance - @rewards
end
end
class Customer
attr_reader :wallet
def initialize(wallet)
@wallet = wallet
end
end
class Order
attr_reader :customer
def initialize(customer)
@customer = customer
end
def total_amount
customer.wallet.net_balance
end
end
wallet = Wallet.new(100, 10)
customer = Customer.new(wallet)
order = Order.new(customer)
puts order.total_amount # Output: 90

Managing Dependencies

Managing dependencies is crucial to make code more maintainable and testable. Dependency injection is a common technique used for this purpose.

Example: Using Dependency Injection

class PaymentProcessor
def initialize(payment_gateway)
@payment_gateway = payment_gateway
end
def process_payment(amount)
@payment_gateway.charge(amount)
end
end
class StripeGateway
def charge(amount)
"Charging $#{amount} using Stripe"
end
end
class PaypalGateway
def charge(amount)
"Charging $#{amount} using PayPal"
end
end
stripe = StripeGateway.new
paypal = PaypalGateway.new
processor = PaymentProcessor.new(stripe)
puts processor.process_payment(100) # Output: Charging $100 using Stripe
processor = PaymentProcessor.new(paypal)
puts processor.process_payment(200) # Output: Charging $200 using PayPal

Duck Typing

Duck typing in Ruby refers to the idea that an object’s suitability is determined by the presence of certain methods and properties, rather than the object’s actual type. The name comes from the saying, “If it looks like a duck and quacks like a duck, it’s probably a duck.”

Example: Implementing Duck Typing

class Dog
def speak
"Woof!"
end
end
class Cat
def speak
"Meow!"
end
end
class Duck
def speak
"Quack!"
end
end
def make_it_speak(animal)
puts animal.speak
end
dog = Dog.new
cat = Cat.new
duck = Duck.new
make_it_speak(dog) # Output: Woof!
make_it_speak(cat) # Output: Meow!
make_it_speak(duck) # Output: Quack!

Inheritance

Inheritance is a fundamental concept in object-oriented programming where a class can inherit properties and methods from another class. This helps in code reuse and establishing a hierarchical relationship between classes.

Example: Using Inheritance in Ruby

class Animal
attr_reader :name
def initialize(name)
@name = name
end
def speak
"Some sound"
end
end
class Dog < Animal
def speak
"Woof!"
end
end
class Cat < Animal
def speak
"Meow!"
end
end
dog = Dog.new("Buddy")
cat = Cat.new("Whiskers")
puts "#{dog.name} says: #{dog.speak}" # Output: Buddy says: Woof!
puts "#{cat.name} says: #{cat.speak}" # Output: Whiskers says: Meow!

Access Control in Ruby: private, public, and protected

In Ruby, access control is managed using three keywords: private, public, and protected. These keywords determine how methods in a class can be accessed:

private

Methods declared as private can only be called within the same instance of the class or its subclasses. They cannot be called with an explicit receiver (an object). Typically, private methods are internal implementation details and not meant to be called directly from outside the class. Here's an example:

class MyClass
def public_method
puts "This is a public method"
private_method # Can be called without an explicit receiver
end
private
def private_method
puts "This is a private method"
end
end
obj = MyClass.new
obj.public_method # Output: This is a public method
# This is a private method
obj.private_method # Error: private method `private_method' called for #<MyClass:0x00007fd08f844708> (NoMethodError)

protected

Methods declared as protected can be called within the same instance of the class or its subclasses, similar to private methods. However, they can also be called with an explicit receiver as long as the receiver is of the same class or a subclass. protected methods are often used to define shared behaviors among instances of the same class. Here's an example:

class MyClass
def public_method
puts "This is a public method"
protected_method # Can be called with an explicit receiver
end
protected
def protected_method
puts "This is a protected method"
end
end
obj1 = MyClass.new
obj2 = MyClass.new
obj1.public_method # Output: This is a public method
# This is a protected method
obj2.protected_method # Error: protected method `protected_method' called for #<MyClass:0x00007fd08f844708> (NoMethodError)

Another example to understand the difference between private and protected better:

class MyClass
def call_protected_on_other(other)
other.protected_method # This works because 'other' is an instance of the same class
end

def call_private_on_other(other)
other.private_method # This will raise an error because 'private_method' can't be called with an explicit receiver
end

protected

def protected_method
puts "This is a protected method"
end

private

def private_method
puts "This is a private method"
end
end

obj1 = MyClass.new
obj2 = MyClass.new

# Works: because obj1 is calling protected_method on obj2, which is the same class
obj1.call_protected_on_other(obj2) # Output: This is a protected method

# Fails: private_method can't be called on obj2 because private methods don't allow explicit receivers
obj1.call_private_on_other(obj2) # Error: private method `private_method' called for #<MyClass:...> (NoMethodError)

public

Methods declared as public are accessible from outside the class. They can be called with an explicit receiver or without a receiver (if called within the same instance). By default, all methods in Ruby are public unless specified otherwise with private or protected.

Conclusion

In this article, we’ve explored advanced object-oriented concepts in Ruby, including the Single Responsibility Principle, singleton methods, encapsulation, the Law of Demeter, managing dependencies, duck typing, and inheritance. By adhering to these principles and techniques, you can write clean, maintainable, and scalable Ruby code.

To illustrate these concepts, here is a comprehensive example:

# A class representing a user with a single responsibility of holding user data
class User
attr_accessor :name, :email
def initialize(name, email)
@name = name
@email = email
end
end
# A class handling email notifications, following the single responsibility principle
class EmailNotifier
def send_welcome_email(user)
puts "Sending welcome email to #{user.email}"
# email sending logic here
end
end
# A class representing a user's wallet, adhering to encapsulation
class Wallet
attr_reader :balance
def initialize(balance = 0)
@balance = balance
end
def add_funds(amount)
@balance += amount
end
def deduct_funds(amount)
@balance -= amount if valid_amount?(amount)
end
private
def valid_amount?(amount)
amount > 0 && amount <= @balance
end
end
# A class demonstrating dependency injection and the Law of Demeter
class Order
def initialize(user, wallet)
@user = user
@wallet = wallet
end
def place_order(amount)
if @wallet.deduct_funds(amount)
puts "Order placed successfully for #{@user.name}"
# order placement logic here
else
puts "Insufficient funds for #{@user.name}"
end
end
end
# A class demonstrating inheritance and duck typing
class Animal
def speak
"Some sound"
end
end
class Dog < Animal
def speak
"Woof!"
end
end
class Cat < Animal
def speak
"Meow!"
end
end
# Main execution
user = User.new("Alice", "alice@example.com")
wallet = Wallet.new(100)
order = Order.new(user, wallet)
notifier = EmailNotifier.new
notifier.send_welcome_email(user)
wallet.add_funds(50)
order.place_order(30)
# Demonstrating duck typing
def make_it_speak(animal)
puts animal.speak
end
dog = Dog.new
cat = Cat.new
make_it_speak(dog) # Output: Woof!
make_it_speak(cat) # Output: Meow!

This example encapsulates many of the principles discussed:

  • Single Responsibility Principle: Each class has a clear responsibility.
  • Encapsulation: The Wallet class hides its internal state and only allows interaction through its methods.
  • Law of Demeter: The Order class interacts only with the objects it directly knows.
  • Dependency Injection: Dependencies are injected into classes, making them more flexible and testable.
  • Duck Typing: The make_it_speak method works with any object that responds to the speak method, regardless of its class.
  • Inheritance: The Dog and Cat classes inherit from the Animal class and override the speak method.
  • Access Control Interfaces: We can group our methods as public, private and protected based on the scope we want them to have.

By following these principles, you can create robust, flexible, and maintainable Ruby applications.

--

--

Bhavyansh @ DiversePixel
Bhavyansh @ DiversePixel

Written by Bhavyansh @ DiversePixel

Hey I write about Tech. Join me as I share my tech learnings and insights. 🚀

No responses yet