DEV Community

Cover image for Composing '99 Bottles of Beer' with Ruby's OOP Harmony'
Ahmed Nadar
Ahmed Nadar

Posted on

Composing '99 Bottles of Beer' with Ruby's OOP Harmony'

🌟 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:

  1. Single Responsibility Principle (SRP) - A class should have one, and only one, reason to change.

  2. Open/Closed Principle (OCP) - Classes should be open for extension but closed for modification.

  3. Liskov Substitution Principle (LSP) - Subtypes must be substitutable for their base types.

  4. Interface Segregation Principle (ISP) - No client should be forced to depend on methods it does not use.

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

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

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

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

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.rbfile.

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

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:

Until next time, Happy Coding πŸ˜€πŸ’ŽπŸ’»

Top comments (0)