Advanced Object-Oriented Concepts in Ruby — Ruby Deep Dive [02]
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.
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 thespeak
method, regardless of its class. - Inheritance: The
Dog
andCat
classes inherit from theAnimal
class and override thespeak
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.