π Introduction
Welcome back π! In a previous post, I tackled the classic "99 Bottles of Beer" problem using a procedural approach, leveraging Ruby's case expression to create a straightforward solution. Today, I revisit this catchy tune with a fresh perspective: Object-Oriented Programming (OOP). Thanks to insights from Sandi Metz, Katrina Owen, and my friend Koichi.
π€ Understanding Object-Oriented Programming in Our Digital Narrative
As we prepare to revisit the classic '99 Bottles of Beer' with an object-oriented approach, it's essential to grasp the fundamental concepts of Object-Oriented Programming (OOP) that we'll be employing:
-Encapsulation: This is about bundling data with the methods that operate on it, encapsulating behaviour with the data it manipulates.
Inheritance: A mechanism for creating new classes from existing ones, this enhances code reusability and establishes a natural hierarchy.
Polymorphism: It allows entities to take on more than one form, enabling a single function to handle different types of objects.
Abstraction: By hiding the complex reality behind simpler interfaces, we expose only the necessary components, making our code more approachable and easier to understand.
Embracing these principles doesn't merely organize our code; it's akin to composing a digital story where each object plays a part, modelled after real-world behaviours and interactions. In the context of our '99 Bottles of Beer' rendition, we're not just coding β we're crafting a symphony of objects that each carry their tune, harmonizing to create a cohesive and scalable application.
πΌ The SOLID Principles Concerto
In OOP, SOLID principles guide our design towards harmony:
Single Responsibility Principle (SRP) - A class should have one, and only one, reason to change.
Open/Closed Principle (OCP) - Classes should be open for extension but closed for modification.
Liskov Substitution Principle (LSP) - Subtypes must be substitutable for their base types.
Interface Segregation Principle (ISP) - No client should be forced to depend on methods it does not use.
Dependency Inversion Principle (DIP) - Depend on abstractions, not on concretions.
Let's see how these principles are integrated into our '99 Bottles' code.
π οΈ Step-by-Step OOP Solution
Act I: Crafting the Bottle Class with SRP
First, we encapsulate the idea of a bottle into its own class, which knows how to represent itself and its actions. In this Bottle
class, we have defined methods to represent the bottle textually (to_s
), the action to take (action
), and to get the next bottle object (next
). The attr_reader
provides a way to read the quantity
attribute outside the class. Create bottle.rb
file.
# This file contains the Bottle class which represents a bottle in the song.
class Bottle
attr_reader :quantity
def initialize(quantity)
@quantity = quantity
end
# Define a method to represent the bottle count in a verse.
def to_s
case quantity
when 0
"no more bottles"
when 1
"1 bottle"
else
"#{quantity} bottles"
end
end
# Method to print the action part of the verse.
def action
if quantity.positive?
"Take one down and pass it around"
else
"Go to the store and buy some more"
end
end
# Method to get the next bottle object.
def next
Bottle.new(quantity.positive? ? quantity - 1 : 99)
end
end
Why OOP? Encapsulation ensures the bottle's behaviour is closely tied to its data.
Act II: Building the BeerWall Class and OCP
Now, let's construct our wall. The BeerWall
class interacts with bottle objects in a high-level way. We don't need to know how each bottle composes its verse; we only need to know that it can do so. The sing
method prints all the verses of the song by iterating over each bottle and printing its verse. The verse
method is a clear application of SRP as it has a single responsibility: to return the verse text for a given number of bottles. The print_verse
method supports these principles internally, ensuring that the public interface remains unchanged while the class's internal workings can evolve as needed. Create beer_wall.rb
file.
# This class is responsible for printing the entire song.
require_relative "bottle"
class BeerWall
def initialize(bottles)
@bottles = bottles
end
# Prints a single verse for a given bottle.
def verse(number)
bottle = Bottle.new(number)
"#{bottle} of beer on the wall, #{bottle} of beer.\n" \
"#{bottle.action}, #{bottle.next} of beer on the wall.\n"
end
# Prints the entire song.
def sing
@bottles.downto(0) { |i| print_verse(Bottle.new(i)) }
end
private
# Prints a single verse for a given bottle.
def print_verse(bottle)
puts "#{bottle} of beer on the wall, #{bottle} of beer."
puts "#{bottle.action}, #{bottle.next} of beer on the wall.\n\n"
end
end
Why OOP? The BeerWall
class showcases abstraction by allowing us to interact with bottle objects in a high-level way.
While it is optionally, a driver script or a file that uses these classes to perform the desired actions. Create ruby_sing.rb
file.
require_relative "beer_wall"
BeerWall.new(99).sing
Run ruby ruby_sing.rb
to see the desired output.
Act III: Respecting LSP with Inheritance
Imagine we introduce a BottleVariant
class for a special kind of bottle. This class would inherit from Bottle
, and thanks to LSP, we can substitute Bottle
with BottleVariant
in our program without issues.
class BottleVariant < Bottle
# Specialized implementation...
end
Act IV: ISP and the Art of Interfaces
Our classes use simple, clear interfaces. Each method does one thing and is used by the clients of the class. This adherence to ISP ensures that we don't have unnecessary dependencies in our classes.
Act V: Embracing DIP with Abstractions
Finally, our BeerWall
class depends on an abstraction (Bottle
) and not on a concrete class. This is a simple form of DIP and allows us to change the underlying class (Bottle
) without affecting BeerWall
.
π§ͺ The Testing Suite
No OOP discussion is complete without testing. We'll demonstrate how to write simple tests for our classes using MiniTest, a testing suite included with Ruby by default. Create 99_bottels_oop_test.rb
file.
require 'minitest/autorun'
require_relative '99_bottels_oop'
class BottleNumberTest < Minitest::Test
def setup
@beer_wall = BeerWall.new(99)
end
def test_the_first_verse
expected = "99 bottles of beer on the wall, 99 bottles of beer.\n" \
"Take one down and pass it around, 98 bottles of beer on the wall.\n"
assert_equal expected, @beer_wall.verse(99)
end
def test_another_verse
expected = "89 bottles of beer on the wall, 89 bottles of beer.\n" \
"Take one down and pass it around, 88 bottles of beer on the wall.\n"
assert_equal expected, @beer_wall.verse(89)
end
def test_verse_2
expected = "2 bottles of beer on the wall, 2 bottles of beer.\n" \
"Take one down and pass it around, 1 bottle of beer on the wall.\n"
assert_equal expected, @beer_wall.verse(2)
end
def test_verse_1
expected = "1 bottle of beer on the wall, 1 bottle of beer.\n" \
"Take one down and pass it around, no more bottles of beer on the wall.\n"
assert_equal expected, @beer_wall.verse(1)
end
def test_verse_0
expected = "no more bottles of beer on the wall, no more bottles of beer.\n"
"Go to the store and buy some more, 99 bottles of beer on the wall.\n"
assert_equal expected, @beer_wall.verse(0)
end
end
Rub ruby 99_bottels_oop_test.rb
Why OOP? Testing is a fundamental part of OOP, ensuring each object behaves as expected.
π Comparison with Procedural Approach
Let's compare this OOP solution with the procedural approach from the previous article.
In the procedural approach, we wrote a script where the logic flowed in a straight line. Functions were called in sequence, and the data was passed around from one function to another. This approach is straightforward to understand when the problem is simple. It's like following a recipe: each step is laid out, and you move from one to the next in order.
Pros of the Procedural Approach:
Simplicity: For small scripts or simple problems, procedural code can be more straightforward to write and understand.
Performance: Sometimes, procedural code can be faster because it involves less abstraction and fewer method calls.
Cons of the Procedural Approach:
Scalability: As the complexity of the problem grows, procedural code can become harder to maintain and understand.
Reusability: It's often more challenging to reuse parts of procedural code in other programs without modification.
On the other hand, the OOP approach encapsulates data and behaviour into objects. This mirrors real-world entities and allows for more complex interactions. Objects know how to manage their state and behaviour, leading to code that is more modular and easier to extend.
Pros of the OOP Approach:
Modularity: Objects can be easily reused across different parts of the program or even in different programs.
Maintainability: OOP makes it easier to keep the codebase organized and to manage complexity, especially as the project grows.
Flexibility: Through inheritance and polymorphism, new functionality can be introduced with minimal changes to existing code.
Cons of the OOP Approach:
Complexity: The additional layers of abstraction in OOP can make it more difficult to understand for beginners.
Performance Overhead: Object creation and method calls can introduce performance overhead.
𧩠Conclusion and share
Through this exploration of the "99 Bottles of Beer" problem, we've delved deep into the nuances of Object-Oriented Programming. By shifting our perspective from procedural to OOP, we've transformed a straightforward script into a well-orchestrated ensemble of objects, each playing its part in harmony.
This journey has been as much about learning and applying OOP principles as it has been about inviting collaboration and sharing. In the previous post, I shared my GitHub repository with the solution that employed a procedural approach. Now, I encourage you to visit the repository again to see the OOP solution in action.
I'm eager to hear your thoughts on the approaches we've discussed. Which do you lean towards in your own practiceβprocedural or OOPβand why? Your insights and diverse solutions enrich the conversation and contribute to a broader understanding of problem-solving in programming.
π Further Resources
For those looking to deepen their understanding of OOP in Ruby, here are some resources to get you started:
Sandi Metz's "99 Bottles of OOP" - A deeper dive into OOP using our beloved beer song.
Practical Object-Oriented Design in Ruby - Sandi Metz's guide to OOP principles in Ruby.
Until next time, Happy Coding πππ»