Ruby OOP Principles: Encapsulation, Inheritance and Polymorphism

Marwan Zaarab
11 min readMay 24, 2022

--

In this article, we delve deep into the core concepts of Object-Oriented Programming (OOP) in Ruby, including objects, encapsulation, inheritance, and polymorphism.

Objects

In Ruby, everything is an object, and every object belongs to a class. These classes, in turn, inherit from the Object class, which is derived from the BasicObject class. This hierarchical structure forms the backbone of OOP in Ruby, facilitating the creation of a wide variety of objects with distinct attributes and functionalities.

The BasicObject class is akin to the master blueprint in OOP, containing the essential elements needed to craft a myriad of other objects. Various entities such as Module, Numeric (encompassing Integer and Float), String, Array, Hash, and custom Class objects are all subclasses of the Object class, which is derived from BasicObject. These objects are defined by their abilities, characterized by a blend of data and methods that facilitate communication through message exchanges and data processing. It can be said that objects in Ruby are defined by what they can do and not what type of thing they are.

To illustrate this, consider the human body, where each organ, encapsulated and isolated, performs specific functions based on its inherent capabilities. This system operates efficiently thanks to a well-coordinated communication network facilitated by our blood circulation, akin to a public interface in OOP. For instance, while the pancreas is the sole producer of insulin, it responds to signals from the liver, which monitors blood sugar levels. This intricate communication, grounded in trust and cooperation, mirrors the foundational philosophy of OOP, setting the stage for discussing the encapsulation principle.

Encapsulation

Understanding the Concept

In object-oriented programming (OOP), classes create objects that interact with users through a public interface, akin to how organs perceive and respond to signals. This interaction is streamlined by translating external signals into a language comprehensible to the object, establishing a distinct boundary between the public interface and the object’s internal mechanics.

Classes house a blueprint of attributes and behaviors essential for creating objects. While each object possesses a unique set of attributes, they share common behaviors delineated in the class. These attributes, exclusive to individual class instances, shape the object’s state, which is not inherited, unlike attribute names and behaviors.

Consider a CodingSchool class where student objects share attributes like @name, @age, and @city, yet hold unique values for these attributes, defining their state. Despite their distinct identities, they share common behaviors, uniting them under the CodingSchool class.

Encapsulation: Classes vs Modules

Modules perform encapsulation via namespacing. Namespacing allows modules to contain and shield any number of variables, constants, methods, and even classes, so that they don’t collide with other similarly named objects. By placing a class inside a module, we can more explicitly access the class inside the module that we’ve created, which would be distinct and encapsulated away from the same class outside or inside another module. The Math module, housing constants like Math::PI and methods like Math.sqrt, exemplifies this.

Classes, as explained above, perform encapsulation by wrapping up their definition within a boundary that outlines the blueprint (attributes, methods) for creating objects. To instantiate an object from a class, we invoke the ::new method on the class name, e.g., Person.new. The constructor method is a special kind of private method that is automatically called when an object is created and does not return any values. In Ruby, the constructor method is called initialize. Just like its name suggests, its purpose is to initiate the state of an object. Initiation of instance variables is also a typical job for constructors. Objects created at runtime from a class are called instances of that particular class.

Classes offer a public interface through methods, enabling communication with external objects, possibly instances of other classes. They may also define private or protected methods to conceal a method’s implementation or facilitate instance comparisons without revealing variable values. For instance:

In this example, the Student class encapsulates the methods, which are accessible solely through objects of the Student class, such as rob and tom. Enhancing encapsulation involves declaring getter/setter methods as private or protected and creating public methods to manage variable values. In doing so, we separate the implementation from the public interface, allowing for a higher level of abstraction.

Initially, the private keyword is commented out to avoid raising an error during the rob == tom operation. If the #id method were private, it would restrict calls to explicit receivers other than self, resulting in an error for the other.id method call, but not for self.id.

To circumvent this, the #id getter method is declared as protected, allowing it to be called with explicit receivers within the class definition. This ensures the error-free execution of other.id and the rest of the code, demonstrating the flexibility and utility of protected methods in maintaining encapsulation while facilitating inter-object communication.

Encapsulation Summary

  • What is it? A strategy to abstract functionality, separating implementation from the public interface.
  • How is it achieved? Through namespacing in modules, method access control, and state encapsulation in objects.
  • Why is it important? It ensures data protection, reduces dependency, enhances maintainability, and facilitates abstraction.

Inheritance

Inheritance is a cornerstone of object-oriented programming (OOP) that facilitates the creation of hierarchies in code by allowing new classes to inherit attributes and methods from existing ones, thereby extending their functionality. This principle promotes code reusability by grouping general behaviors into a superclass and defining more specific behaviors in subclasses, adhering to the DRY (Don’t Repeat Yourself) principle. For instance, a Car class inheriting from a Vehicle class or a Dog class inheriting from an Animal class illustrates this concept.

Inheritance: Classes vs. Modules

Class inheritance is appropriate when there exists a clear hierarchical or ‘is-a’ relationship between classes, such as a Dog being an Animal or a Car being a Vehicle. While Ruby supports single inheritance, allowing a class to inherit from one superclass at a time, it technically facilitates indirect multiple inheritance through a chain of superclasses. However, to effectively implement multiple inheritance, we employ modules, utilized as mixins in this context.

Modules facilitate interface inheritance, where a class inherits methods from a module, enhancing functionality in scenarios lacking a clear parent-child relationship but exhibiting a ‘has-a’ relationship. For example, defining common behaviors like #eat, #swim, and #fly in modules allows various animal classes to include only the relevant methods, avoiding repetitive method definitions in individual classes.

To implement this, we first establish a module using the module keyword, followed by incorporating it into our classes using the include keyword coupled with the module name, as demonstrated below:

Both class and module inheritance serve to maintain DRY code. It’s important to note that modules cannot instantiate objects, yet they can be included in numerous classes, extending their functionalities to the subclasses as well. When choosing between classes and modules, consider the following:

  • Creating instances or objects? Opt for classes.
  • Existence of a hierarchical relationship? Choose classes.
  • Anticipating repeated method usage across unrelated classes? Modules are your go-to.

Super

In Ruby, the super keyword plays a crucial role in invoking methods located higher up in the inheritance chain. Depending on its usage within a method definition, it seeks a method with the identical name in the superclass and triggers it. Below, we explore the three common ways to utilize super, each accompanied by an example:

1. Implicit Invocation–super

When super is called without parentheses or arguments, it automatically passes all the arguments received by the current method to the corresponding method in the superclass. It is crucial that both the current method and the superclass method agree on the number of arguments.

class Person
attr_reader :first_name, :last_name

def initialize(first_name, last_name)
@first_name = first_name
@last_name = last_name
end

def greet
"Hello, my name is #{first_name} #{last_name}"
end
end

class Teacher < Person
def greet
puts super + " and I'm a #{self.class}."
end
end

teacher = Teacher.new("Horace", "Slughorne")
teacher.greet

# => Hello, my name is Horace Slughorne and I'm a Teacher.

2. No Argument Invocation–super()

Utilizing super() with empty parentheses calls the superclass method without any arguments. This approach is beneficial when the superclass method doesn't require arguments, yet the subclass method does.

class Student < Person
def greet(teacher)
puts super() + " and Mr. #{teacher.last_name} is my teacher."
end
end

student = Student.new("Tom", "Riddle")
teacher = Teacher.new("Horace", "Slughorne")
student.greet(teacher)

# => Hello, my name is Tom Riddle and Mr. Slughorne is my teacher.

3. Explicit Argument Invocation–super(arg1, arg2)

Calling super with specific arguments allows you to dictate the arguments passed to the superclass method. This is typically used when the subclass method accepts more arguments than the superclass method.

class Student < Person
def initialize(first_name, last_name, id)
super(first_name, last_name)
@id = id
end
end

student = Student.new("Tom", "Riddle", 606)

Polymorphism

Polymorphism, derived from the Greek words “poly” meaning many and “morph” meaning forms, is a cornerstone of OOP. It allows methods to do different things based on the object it is acting upon. This concept can be likened to a real-world scenario where the phrase “open this” can entail different actions depending on whether you’re handed a jar of cookies or a taped storage box.

In OOP, polymorphism is manifested when a method performs various actions depending on the type of object it receives as arguments. This means that a method can be applied to multiple objects, potentially yielding different outcomes. However, the method generally needs to be designed to be polymorphic. Let’s explore the different avenues of achieving polymorphism:

Unintentional vs. Intentional Polymorphism

  • Unintentional: Occurs when different classes have methods with the same name but different functionalities. For instance, an add method in a Library class might add a Book object to an array, while in a Calculator class, it might perform a mathematical addition.
  • Intentional: Here, methods are designed to work with various object types. Common examples include the ::new method for creating new objects and the #p method for inspecting and printing object representations. See the example below:

Polymorphism through Inheritance

Inheritance in Ruby allows a class to adopt the behaviors of another class, known as the superclass. This facilitates the creation of basic classes with broad applicability and subclasses for more detailed functionalities. Polymorphism through inheritance is achieved when a subclass leverages a behavior from a superclass rather than defining its own. This can be extended further through method overriding, where a subclass modifies an inherited behavior to suit its needs.

Consider the example below where different animal classes respond differently to the same speak method:

In the example provided, we observe that both the Turtle and Worm classes do not have a bespoke speak method; they inherit this behavior from the overarching Animal class. Consequently, invoking the speak method on objects of the Turtle or Worm class triggers the execution of the speak method defined in the Animal class, resulting in the output "nothing to say". This phenomenon, where a method from a superclass is accessible to various subclasses, epitomizes polymorphism through inheritance.

In contrast, the Dog and Cat classes override the inherited speak method to articulate sounds characteristic to them — "woof woof" and "meow," respectively. This instance showcases polymorphism vividly as different object types — Dog and Cat — respond distinctively to the same method call, each offering a unique interpretation of an inherited method.

One line 23, it is evident that the array animals encompasses objects of diverse animal types. Despite their differences, the client code interacts with them under a unified perception — as generic "animals" equipped with the ability to "speak". While this bears resemblance to duck typing, it is predominantly characterized as polymorphism through inheritance, given that we are engaging with objects that are derivatives of a common superclass, thereby sharing a familial or related type connection.

Polymorphism through Interface Inheritance (Modules)

Modules, akin to classes, house shared behaviors but cannot instantiate objects. They are integrated with classes using the include method, a process termed as mixin. This grants the class and its objects access to the module's behaviors, offering another pathway to polymorphism.

Polymorphism through Duck Typing

Duck typing is grounded in the principle that an object’s functionality is defined by the methods it can respond to, not its type. This approach fosters trust between objects, allowing them to request what they want without delving into the underlying implementation.

In other words, if the object behaves like a duck then we treat it like a duck, even if it isn’t an actual duck.

Duck typing allows us to reduce many of the costly dependencies on classes and, instead, gives us a more forgiving dependency on methods. Going back to the liver and pancreas analogy, this dependency on methods enforces the idea that our objects should trust each other. This enhances our code’s maintainability, flexibility, and extensibility while reducing dependencies on classes. It also avoids having to use conditional statements like if-else and case.

Below, we illustrate good and bad practices in duck typing using a culinary preparation scenario involving ducks:

In the code example above, notice the way in which the BadDuck class is defined. It is not as flexible/reusable as adding more workers down the line means having to alter code within the BadDuck class. Many subtle dependencies are also unnecessarily included:

  • BadDuck knows about specific classes.
  • BadDuck knows how to invoke specific methods from those classes.

A better approach would be to put our trust in other classes and focus on sending the core message — #prepare_duck.

Contrastingly, the GoodDuck class embodies the principles of duck typing, promoting a more dynamic and adaptable approach. Here, the prepare method is designed to collaborate with any worker class, provided it implements a prepare_duck method. This not only adds a huge amount of flexibility to our application, but also simplifies the integration of new worker classes. Despite sharing a common method signature, each class can define its unique implementation (even though they’re empty in this example).

Collaborator Objects

Collaborator objects are integral components in object-oriented programming, serving as objects that are stored as a state within another object. This relationship is established by assigning an object to another object’s instance variable, effectively embedding it as a value within that variable.

Although collaborator objects can be built-in types (such as a String, Integer or Array), they are more commonly described as custom defined objects we create as part of our program.

These collaborator objects forge connections between various actors in our program, facilitating interactions and collaborations between them. By leveraging these objects as individual building blocks, we can dissect complex problems into manageable, self-sufficient segments, each encapsulating a fragment of the functionality needed to address the overarching issue. Collaborator objects can be instantiated either during the creation of the object they are to collaborate with or be integrated into the object’s state at a later stage.

In the example above, the PetShelter class operates as a repository for Pet objects, which function as collaborator objects. The Pet objects maintain their individual interfaces, yet they integrate seamlessly with the PetShelter class, becoming attributes of the latter. This symbiotic relationship enables the PetShelter to interact with the Pet interface, granting us the ability to access and manipulate the attributes of the Pet objects through the PetShelter class, without necessitating a direct interaction with the Pet interface. This illustrates the power of collaborator objects in fostering cohesive yet flexible program structures, where distinct entities can work in harmony through well-defined interfaces.

Conclusion

To summarize, I’ve included a few diagrams I’ve made as a way to gain a bird’s eye view of the main topics discussed in this article. I hope they help you as much as they’ve helped me in solidifying that knowledge and in improving my ability to verbalize my understanding of those concepts to others.

Encapsulation
Polymorphism
Classes vs. Modules

--

--