Beginner's Guide To Object-Oriented Programming - by Adekola Olawale - Medium
Beginner's Guide To Object-Oriented Programming - by Adekola Olawale - Medium
Programming
Mastering the Foundations of Object-Oriented Programming for Novice Developers
Listen Share
Table of Contents
· Introduction to Object-Oriented Programming (OOP)
∘ Definition and Concept of OOP
∘ Importance of OOP in Modern Software Development
∘ Basic Terminology: Classes, Objects, Methods, and Properties
· Key Principles of Object-Oriented Programming
∘ Encapsulation
∘ Inheritance
∘ Abstraction
∘ Polymorphism
· Getting Started with Object-Oriented Programming
∘ Choosing a Suitable Programming Language
∘ Popular OOP Languages (Java, Python, C++)
∘ Language-specific OOP Features
∘ Setting Up Your Development Environment
∘ Installing Compilers/Interpreters
∘ Creating Your First Class and Object
· Creating and Using Classes and Objects
∘ Defining Classes
∘ Working with Objects
· Implementing Encapsulation
∘ Controlling Access to Class Members
∘ Designing Effective Class Interfaces
· Understanding Inheritance
∘ Extending Classes through Inheritance
∘ Overriding Methods and Polymorphism
· Applying Abstraction
∘ Defining Abstract Classes
∘ Interfaces as Ultimate Abstractions
· Utilizing Polymorphism
∘ Leveraging Polymorphic Behaviors
∘ Achieving Runtime Polymorphism
· Best Practices in Object-Oriented Programming
∘ Single Responsibility Principle (SRP)
∘ Open/Closed Principle (OCP)
∘ Liskov Substitution Principle (LSP)
∘ Interface Segregation Principle (ISP)
∘ Dependency Inversion Principle (DIP)
· Case Study: Building a Simple Object-Oriented Application
∘ Problem Statement and Design Considerations
∘ Implementing the Solution Step by Step
∘ Applying OOP Concepts in the Case Study
· Conclusion
At its core, OOP introduces a novel way of structuring code by organizing data and
its related functions into cohesive units called “objects.” This section will provide an
insightful overview of OOP, highlighting its significance in modern software
development and introducing key terminology that lays the foundation for a
comprehensive understanding.
Definition and Concept of OOP
At its simplest, Object-Oriented Programming can be defined as a programming
paradigm that models real-world entities and their interactions through the creation
and manipulation of objects.
These objects are instances of classes, which act as blueprints or templates for
creating objects. OOP promotes the idea of breaking down complex problems into
manageable, modular components, making it easier to design, implement, and
maintain software.
This not only speeds up development but also reduces the likelihood of errors since
well-tested components can be reused. Moreover, OOP enhances the scalability of
projects, making them more adaptable to changing requirements.
1. Classes: Classes are the blueprints that define the structure and behavior of
objects. They encapsulate both the data (attributes or properties) and the
functions (methods) that operate on that data.
2. Objects: Objects are instances of classes. They represent real-world entities and
hold the actual data values as well as the ability to perform operations defined
by the class.
3. Methods: Methods are functions defined within a class that define the behavior
of objects. They can perform various operations, manipulate data, and interact
with other objects.
4. Properties: Properties, also known as attributes or fields, are the data members
of a class. They store the characteristics or data associated with an object.
Understanding these fundamental terms will pave the way for delving deeper into
the principles and practices of OOP, such as encapsulation, inheritance, abstraction,
and polymorphism. These principles collectively empower developers to create
more organized, flexible, and efficient software systems.
In the subsequent sections, we’ll delve into each of these principles, providing clear
explanations and practical examples that illustrate their application.
So, whether you’re a novice programmer or someone seeking to expand your coding
horizons, this guide will equip you with the knowledge and tools to harness the
power of Object-Oriented Programming.
In this section, we’ll delve into each principle, utilizing relatable analogies and
practical JavaScript code examples to deepen your comprehension.
Encapsulation
Encapsulation involves bundling data (properties) and the functions (methods) that
manipulate that data into a single unit known as a class. This unit enforces
controlled access to the data, allowing external entities to interact with the object’s
state only through designated methods.
class BankAccount {
constructor(accountNumber, balance) {
this.accountNumber = accountNumber;
this.balance = balance;
}
deposit(amount) {
this.balance += amount;
}
withdraw(amount) {
if (this.balance >= amount) {
this.balance -= amount;
} else {
console.log("Insufficient funds");
}
}
}
The BankAccount class encapsulates the attributes and methods related to a bank
account.
accountNumber and balance are private attributes encapsulated within the class.
They are accessed and modified through the class's methods, not directly from
outside the class.
Constructor:
The deposit and withdraw methods are responsible for modifying the balance
attribute. These methods encapsulate the logic for depositing and withdrawing
funds.
The withdraw method encapsulates the logic to ensure that a withdrawal cannot
occur if the account balance is insufficient. It displays "Insufficient funds" when
necessary.
When we create an instance of the BankAccount class with const myAccount = new
We interact with the myAccount object's data (e.g., depositing and withdrawing
funds) through its methods ( deposit and withdraw ), ensuring that the data
remains encapsulated and controlled by the class.
class Animal {
speak() {
console.log("Some sound");
}
}
Search
class Cat extends Animal {
speak() {
console.log("Meow!");
}
}
The Dog and Cat classes are subclasses or derived classes. They extend the
Animal class, inheriting its speak() method.
Method Overriding:
Both Dog and Cat classes override the speak() method. This means they
provide their own implementation of the speak() method, replacing the one
inherited from Animal .
We create instances of the Dog and Cat classes using const dog = new Dog();
and const cat = new Cat(); . These objects have access to the speak() method
due to inheritance.
When we call dog.speak() , it invokes the speak() method of the Dog class, and
"Woof!" is logged to the console.
When we call cat.speak() , it invokes the speak() method of the Cat class, and
"Meow!" is logged to the console.
Inheritance Relationship:
Dog and Cat inherit the speak() method from the Animal class. This represents
an is-a relationship, where a Dog is an Animal , and a Cat is an Animal .
Despite the inheritance, each subclass provides its own unique implementation
of the speak() method. This is called method overriding, where a subclass
provides a specialized implementation of a method inherited from the
superclass.
In summary, this code demonstrates inheritance by creating subclasses ( Dog and
Cat ) that inherit a common method ( speak() ) from the base class ( Animal ).
Each subclass customizes the inherited method to exhibit its own behavior.
Inheritance allows for code reuse, promoting a hierarchical structure in your code,
and enabling you to model relationships between classes in a more organized and
efficient manner.
Abstraction
Abstraction focuses on highlighting essential aspects of an object while hiding
irrelevant details. Abstract classes and interfaces define a contract that concrete
classes must adhere to. This enables high-level design without getting bogged down
in implementation specifics.
Picture a vending machine. Users select products without knowing the machine’s
internal mechanics. The machine abstracts the complexity behind a user-friendly
interface.
class Shape {
area() {
throw new Error("Subclasses must implement area");
}
}
area() {
return Math.PI * this.radius * this.radius;
}
}
The Shape class serves as an abstract base class. It defines an abstract method
called area() . An abstract method is a method declared in the base class but
without an implementation. In this case, area() is defined to throw an error
message indicating that subclasses must implement this method.
The Circle class is a concrete subclass that extends the Shape class. It inherits
the area() method from the Shape class but provides its own implementation.
We create an instance of the Circle class using const circle = new Circle(5); .
When we call circle.area() , it invokes the area() method of the Circle class,
which calculates and returns the area of the circle.
Abstraction in Action:
Concrete subclasses like Circle must implement the abstract method area() .
This enforces a contract that all shape classes must provide their own
implementation of the area() method.
In this way, abstraction allows us to define a common structure for shape classes
while leaving the specific implementation details to individual shape subclasses.
It simplifies the design by focusing on essential characteristics shared by all
shapes while hiding the complex math involved in calculating each shape’s area.
Consider a “Shape” class. Polymorphism lets you calculate areas of different shapes
— circles, rectangles, and triangles—using a common method, even though their
implementations differ.
class Shape {
area() {
throw new Error("Subclasses must implement area");
}
}
The Shape class serves as the base class. It defines an abstract method called
area() . This method is marked as abstract by throwing an error, indicating that
subclasses must implement it.
The Circle and Rectangle classes are concrete subclasses of Shape . They inherit
the area() method from Shape but provide their own implementations.
The Circle class calculates the area of a circle based on its radius, while the
Rectangle class calculates the area of a rectangle based on its width and height.
Polymorphism in calculateArea() :
We create instances of Circle and Rectangle classes using const circle = new
Calculating Areas:
When we call calculateArea(circle) , it calculates the area of the circle using the
area() method implemented in the Circle class.
Polymorphism in Action:
Armed with a solid grasp of these core principles, you’re well-equipped to navigate
the world of Object-Oriented Programming. Encapsulation, inheritance,
abstraction, and polymorphism empower you to design and implement intricate
systems in a structured and efficient manner.
In the upcoming sections, we’ll shift our focus to practical implementation, guiding
you through the process of creating classes, objects, and applying these principles in
real-world scenarios using JavaScript.
For this comprehensive guide, JavaScript has been chosen as the programming
language due to its widespread popularity and versatility in both web and
application development.
However, it’s important to acknowledge that there are several other popular OOP
languages like Java, Python, and C++, each with its own set of strengths.
Popular OOP Languages (Java, Python, C++)
1. Java: Java is renowned for its robustness and platform independence. It’s a
statically-typed language, meaning you must declare variable types explicitly.
Java enforces strong encapsulation and provides extensive libraries for building
a wide range of applications, from web services to Android mobile apps. It’s
known for its “Write Once, Run Anywhere” capability, making it an excellent
choice for cross-platform development.
2. Python: Python is celebrated for its readability and simplicity. It’s a dynamically-
typed language, allowing you to change variable types on the fly.
Python’s vast standard library and third-party packages make it ideal for web
development, data analysis, artificial intelligence, and more. Its clean syntax
promotes rapid development and ease of learning, making it a favorite among
beginners.
3. C++: C++ is a powerful language with a strong focus on performance. It’s often
used in systems programming, game development, and resource-intensive
applications.
C++ combines the features of low-level languages like C with the high-level
abstractions of OOP. It allows you to manage memory directly, making it suitable
for applications where performance is critical.
1. Text Editor or Integrated Development Environment (IDE): You can start with a
basic text editor like Visual Studio Code, Sublime Text, or Atom. These editors
offer syntax highlighting and extensions for JavaScript development.
Alternatively, you can opt for a full-fledged IDE like WebStorm.
2. Node.js: If you want to run JavaScript code outside of a web browser, you’ll need
Node.js. It provides a runtime environment for executing JavaScript on your
computer.
Installing Compilers/Interpreters
Before you can start coding in JavaScript, it’s essential to have a JavaScript
interpreter or engine installed on your computer. JavaScript is often run directly in
web browsers, but for other types of development, you might need Node.js, a
JavaScript runtime.
1. Node.js:
Download the LTS (Long-Term Support) version for your operating system
(Windows, macOS, or Linux).
2. Browser Console:
For client-side web development, your web browser comes equipped with a
JavaScript interpreter that can execute JavaScript code directly in the browser.
Right-click on any web page and select “Inspect” or press Ctrl+Shift+I (or
Cmd+Option+I on macOS) to open the DevTools.
Navigate to the “Console” tab within the DevTools, where you can input and
execute JavaScript code directly.
greet() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years
}
}
Explanation:
1. We define a class named Person using the class keyword. This class has a
constructor method that initializes name and age properties.
2. We create an object named person1 from the Person class using the new
3. We can access the object’s properties ( name and age ) and call its methods
( greet() ) using dot notation.
By following these steps, you’ve just created your first class and object in JavaScript.
You’ve encapsulated data (name and age) and behavior (greet function) into a single
unit — a fundamental concept of OOP.
greet() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years
}
}
1. Defining a Class: In this step, we define a class called Person . A class is like a
blueprint or template for creating objects. It encapsulates both data (attributes)
and behavior (methods) related to a specific concept, in this case, a person.
3. greet() Method: This class also has a method called greet() . Methods are
functions associated with the class. The greet() method prints a message to the
console using the console.log() function. It uses the name and age properties of
the object to introduce the person.
Accessing Properties and Methods: Once the object person1 is created, we can
access its properties and methods.
We use person1.name to access the name property, which gives us the output
"Alice" .
We use person1.age to access the age property, which gives us the output 30 .
We call the greet() method using person1.greet() . This method prints the
message "Hello, my name is Alice and I am 30 years old." to the console.
class Car {
constructor(make, model) {
this.make = make;
this.model = model;
}
start() {
console.log(`Starting the ${this.make} ${this.model}.`);
}
}
In this Car class, make and model are like the ingredients needed to bake cookies,
and start() is like the action of baking the cookies.
We define a Car class with a constructor method that initializes the make and
model properties when a new car object is created.
The start() method represents an action the car can perform, like starting the
engine.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
In this Person class, the constructor method welcomes a new person object by
assigning a name and age, like offering a guest a name tag.
Class Instantiation
Creating an object from a class is like ordering a dish from a menu. You specify what
you want, and the kitchen (constructor) prepares it for you.
myCar and person1 are like the dishes you ordered, prepared according to the
instructions given by the class (menu).
const square = new Rectangle(5, 5); // A square with equal width and height
In the code above, imagine the constant square is a robot with instructions to be a
square-shaped robot with equal sides.
Here, you read the car’s make, inquire about a person’s age, and instruct the car to
start its engine.
square.width = 7;
square.height = 7;
In this case, you’re altering the robot’s dimensions to make it slightly different from
its initial square shape by assigning a new width and height each of the value 7.
Implementing Encapsulation
Encapsulation is a fundamental concept in Object-Oriented Programming (OOP)
that focuses on controlling access to class members (properties and methods). It
enhances data security, simplifies maintenance, and promotes efficient code
organization.
class BankAccount {
constructor(accountNumber) {
this._accountNumber = accountNumber; // Public by convention
this._balance = 0; // Public by convention
this.#pin = '1234'; // Private field
}
deposit(amount) {
this._balance += amount;
}
#pin and #withdraw() are private, meaning they should not be accessible from
outside the class.
It also simplifies maintenance by allowing you to make changes within the class
without affecting external code. Think of it as the engine compartment of a car; you
don’t need to understand how it works to drive the car safely.
In this example, _accountNumber and _balance are modified, but attempts to access
#pin and #withdraw() result in errors.
class TVRemote {
constructor() {
this._powerOn = false;
this._volume = 0;
}
powerOn() {
this._powerOn = true;
}
changeVolume(volume) {
if (this._powerOn) {
this._volume = volume;
}
}
}
In this TVRemote class, only the powerOn() and changeVolume() methods are exposed
for interaction, while the _powerOn and _volume properties remain encapsulated.
class Calculator {
add(a, b) {
return a + b;
}
subtract(a, b) {
return a - b;
}
}
Understanding Inheritance
Inheritance is a powerful concept in Object-Oriented Programming (OOP) that
allows you to create new classes based on existing ones, promoting code reuse and
flexibility.
// Base Class
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound.`);
}
}
// Derived Class
class Dog extends Animal {
speak() {
console.log(`${this.name} barks.`);
}
}
In this example, Animal is the base class, and Dog is the derived class inheriting
from Animal .
Here, the Bird class inherits the name property and speak() method from Animal
Both Dog and Bird inherit the speak() method from Animal , allowing you to reuse
code for common behaviors.
Overriding Methods and Polymorphism
Modifying Inherited Behaviors
Inheritance allows you to override (modify) methods from the base class in the
derived class. This is like customizing a recipe passed down through generations.
animals.forEach(animal => {
animal.speak(); // Polymorphic behavior
});
In this example, we create an array of different animals and treat them uniformly
using polymorphism when calling the speak() method.
Inheritance and polymorphism are powerful tools for structuring your code
hierarchies and promoting code reuse. They allow you to create structured class
hierarchies, customize behaviors in derived classes, and achieve more flexible code
design. Understanding these concepts is essential for building complex and
maintainable software systems in JavaScript.
Applying Abstraction
Abstraction is a crucial concept in Object-Oriented Programming (OOP) that helps
simplify complex systems by focusing on essential properties and behaviors. In this
section, we’ll explore abstraction in JavaScript, including abstract classes and
interfaces, as well as their practical applications.
Defining Abstract Classes
Declaring Abstract Methods
An abstract class is like a template or blueprint that defines a structure for its
subclasses. Abstract classes can have abstract methods, which are declared but not
implemented in the abstract class itself.
// Abstract class
class Shape {
constructor(name) {
this.name = name;
}
In this code example, Shape is an abstract class with an abstract method area() .
area() {
return Math.PI * this.radius ** 2;
}
}
The Circle class extends Shape and provides an implementation for the area()
// Interface
class Drawable {
draw() {
throw new Error("Classes implementing Drawable must provide a draw meth
}
}
area() {
return Math.PI * this.radius ** 2;
}
draw() {
console.log(`Drawing ${this.name} with radius ${this.radius}`);
}
}
Here, CircleDrawer implements both Shape and Drawable , providing concrete
implementations for area() and draw() .
2. File Handling:
File systems can be complex, with various types of storage mechanisms and file
formats. In many programming languages, file handling libraries provide
interfaces for reading and writing files.
For instance, Java’s java.io package includes the InputStream and OutputStream
interfaces. Different classes can implement these interfaces to read and write
data from various sources like files, network streams, or in-memory buffers.
3. Sorting Algorithms:
Sorting is a fundamental operation in computer science, and there are
numerous sorting algorithms available. By defining a common sorting interface,
you can switch between different sorting algorithms easily.
For instance, in Python, the built-in sort() method expects objects in a
collection to implement the __lt__() (less than) method. This allows developers
to sort lists of custom objects without modifying the core sorting logic.
4. Database Access:
Object-Relational Mapping (ORM) libraries like Hibernate in Java or Entity
Framework in .NET rely on interfaces to provide a consistent way to interact
with databases.
These libraries define interfaces for database operations like CRUD (Create,
Read, Update, Delete), ensuring that different database providers can be used
interchangeably while maintaining a unified programming model.
5. Dependency Injection:
In software design, dependency injection is a technique that promotes loose
coupling between components. Many dependency injection frameworks use
interfaces to define service contracts.
Developers can create implementations of these interfaces and inject them into
other parts of the application as dependencies. This approach enhances
modularity and testability in large-scale applications.
7. Plug-in Architectures:
Applications that support plug-ins or extensions often define interfaces that
plug-ins must implement. This enables third-party developers to create custom
functionality that seamlessly integrates with the core application.
Examples include popular software like Adobe Photoshop, which allows
developers to create custom filters and effects using a plug-in interface.
8. Testing Frameworks:
Testing frameworks often employ interfaces to define the contract for test cases.
Test classes implementing these interfaces must provide specific testing
methods like setup, teardown, and assertions. This allows testing tools to
execute tests consistently across different test classes.
These real-world examples showcase how OOP interfaces provide a structured and
standardized way to define contracts between different parts of a software system.
Interfaces are like protocols in real life. For example, in aviation, there’s a protocol
for communication between air traffic controllers and pilots. Both parties must
adhere to the protocol for safe travel.
In code, interfaces help ensure that classes adhere to specific contracts, making it
easier to work with diverse implementations.
Utilizing Polymorphism
Polymorphism is a vital concept in Object-Oriented Programming (OOP) that
enables objects of different classes to be treated as objects of a common base class.
It promotes flexibility and code reusability by allowing you to write code that can
work with generalized objects, and it allows switching implementations at runtime.
class Shape {
draw() {
console.log("Drawing a shape.");
}
}
In this example, all shapes are treated as instances of the Shape class, and you can
call the draw() method without knowing their specific shapes.
class Animal {
speak() {
console.log("Animal speaks.");
}
}
In this example, speak() is overridden in the Dog class, and the appropriate method
is called based on the actual type of myPet .
class Bird {
fly() {
console.log("Bird is flying.");
}
}
class Airplane {
fly() {
console.log("Airplane is flying.");
}
}
function letSomethingFly(flyingObject) {
flyingObject.fly();
}
In this example, both Bird and Airplane classes implement a fly() method
according to the same flyable interface, allowing them to be treated
polymorphically.
class Dish {
constructor(name, ingredients) {
this.name = name;
this.ingredients = ingredients;
}
cook() {
console.log(`Cooking ${this.name}`);
// Cooking logic here
}
}
class FinancialManager {
calculateCost(ingredients) {
// Calculate cost logic here
}
}
class Waiter {
serve(dish) {
// Serving logic here
}
}
In this code example, the Dish class has a single responsibility, which is to
represent a dish and handle its cooking. The FinancialManager class calculates the
cost of ingredients, and the Waiter class serves the dish. Each class has a clear and
distinct responsibility.
class Dish {
constructor(name, ingredients) {
this.name = name;
this.ingredients = ingredients;
}
cook() {
console.log(`Cooking ${this.name}`);
// Cooking logic here
}
}
In this code snippet, we have three classes: Dish , FinancialManager , and Waiter .
Constructor: It has a constructor that initializes the name and ingredients of the
dish.
cook() Method: It has a cook() method, which is responsible for cooking the
dish. It prints a message indicating that it's cooking the dish. Presumably, this
method would also contain the actual cooking logic (e.g., instructions for
preparing the dish).
In SRP Terms: The Dish class seems to adhere to the Single Responsibility Principle
because it has a clear and single responsibility: managing information about a dish
and handling its cooking process. If you need to make changes related to the
representation or cooking of a dish, you would typically only need to modify this
class.
class FinancialManager {
calculateCost(ingredients) {
// Calculate cost logic here
}
}
class Waiter {
serve(dish) {
// Serving logic here
}
}
Waiter Class: This class seems to be responsible for serving dishes. Again, if the
serving logic becomes complex, it might be beneficial to encapsulate it in its
own class.
In terms of SRP, each class should have a clear and distinct responsibility. While the
Dish class seems to adhere to this principle by focusing on dish-related tasks, the
other classes may benefit from further consideration of their responsibilities if they
become more complex in the future.
Overall, applying SRP helps in designing classes that are easier to understand,
maintain, and extend, as each class’s responsibility is well-defined and limited to a
single area of concern.
Open/Closed Principle (OCP)
The OCP suggests that a class should be open for extension but closed for
modification. You should be able to add new functionality to a class without altering
its existing code.
Consider a smartphone with various apps. You can install new apps (extensions)
without opening the phone and modifying its hardware (closed). The phone’s
hardware remains unchanged, but its functionality can be extended.
class Shape {
area() {
throw new Error("This method must be overridden.");
}
}
area() {
return Math.PI * this.radius ** 2;
}
}
area() {
return this.side ** 2;
}
}
In this example, the Shape class is open for extension. You can create new shapes by
extending it without modifying the Shape class itself. This adheres to the OCP.
Open/Closed Principle (OCP): The OCP suggests that a class should be open for
extension but closed for modification. In simpler terms, you should be able to add
new functionality to a class without altering its existing code.
In this code snippet, we have a base class Shape that defines an area() method.
However, it doesn't provide a concrete implementation for calculating the area.
Instead, it throws an error indicating that this method must be overridden. This
adheres to the OCP because:
The Shape class is open for extension: You can create new shapes by extending
it.
The Shape class is closed for modification: You don't need to modify the Shape
class itself to add new shapes; you simply create new subclasses.
area() {
return Math.PI * this.radius ** 2;
}
}
area() {
return this.side ** 2;
}
}
Circle Class:
Square Class:
In OCP Terms: Both the Circle and Square classes follow the Open/Closed Principle
because:
They extend the Shape class, adding new functionality for calculating the area of
specific shapes.
You can add new shape classes without changing the Shape class itself, adhering
to the principle of extension without modification.
move() {
console.log(`${this.name} is moving.`);
}
}
swim() {
console.log(`${this.name} is swimming.`);
}
}
In this example, Penguin is a derived class of Bird . While it doesn't actually fly, it
still adheres to the LSP because you can replace a Bird with a Penguin without
causing issues; it just behaves differently.
Let’s further examine the provided code example to deeply comprehend the concept
of the Liskov Substitution Principle (LSP).
class Bird {
constructor(name) {
this.name = name;
}
move() {
console.log(`${this.name} is moving.`);
}
}
swim() {
console.log(`${this.name} is swimming.`);
}
}
This code snippet aligns with LSP while still representing the real-world behavior of
birds, including penguins.
Bird Class:
The Bird class now has a constructor that accepts a name parameter, allowing
us to give each bird a name.
method. This method reflects the common behavior among birds, which is to
move, but not all birds necessarily fly.
The Penguin class also has a constructor that takes a name parameter and passes
it to the base class constructor using super(name) .
In LSP Terms: The Liskov Substitution Principle states that objects of the derived
class ( Penguin ) should be able to replace objects of the base class ( Bird ) without
affecting the program's correctness, and both the Bird and Penguin classes align
with the Liskov Substitution Principle.
Here's how:
Objects of the Penguin class can replace objects of the Bird class without
affecting the correctness of the program. For example, you can call move() on
both Bird and Penguin objects to reflect their ability to move.
While penguins cannot fly, they exhibit a different behavior — swimming. The
introduction of the swim() method in the Penguin class allows it to extend the
behavior of the base class ( Bird ) without contradicting it.
In summary, this code example demonstrates how you can adhere to the Liskov
Substitution Principle by ensuring that derived classes do not violate the expected
behavior of the base class when they replace objects of the base class.
Instead, derived classes can extend or specialize the behavior of the base class while
maintaining consistency with the base class’s overall contract.
Interface Segregation Principle (ISP)
The ISP advises that clients should not be forced to depend on interfaces they don’t
use. In simpler terms, classes should not be forced to implement methods they have
no use for.
Think of a restaurant’s menu. If you only want to order drinks, you should be able to
choose from a separate drink menu, rather than having to look at the entire menu
that includes food items you’re not interested in.
// A monolithic interface
class Worker {
work() {}
eat() {}
sleep() {}
}
// Separated interfaces
class Workable {
work() {}
}
class Eatable {
eat() {}
}
class Sleepable {
sleep() {}
}
In this example, the monolithic Worker interface forces classes to implement all
methods, even if they don't need them. By segregating the interfaces into Workable ,
Eatable , and Sleepable , classes can choose to implement only the methods they
require, adhering to the ISP.
Interface Segregation Principle (ISP): The ISP suggests that clients should not be
forced to depend on interfaces they don’t use. In other words, classes should not be
required to implement methods they have no use for. This principle promotes the
idea that interfaces should be small and focused on a specific set of related
methods.
// A monolithic interface
class Worker {
work() {}
eat() {}
sleep() {}
}
// Separated interfaces
class Workable {
work() {}
}
class Eatable {
eat() {}
}
class Sleepable {
sleep() {}
}
In this code snippet, we have both a monolithic interface ( Worker ) and separated
interfaces ( Workable , Eatable , and Sleepable ). Let's examine each part:
The Worker class defines a monolithic interface that includes three methods:
work() , eat() , and sleep() . All of these methods are combined into a single
interface.
In ISP Terms:
interface.
The refactored code with separated interfaces ( Workable , Eatable , Sleepable ) aligns
with the ISP because each interface contains a specific set of related methods.
Classes can now implement only the interfaces that are relevant to their specific
functionality. For instance:
A class representing a chef can implement Workable and Eatable to indicate that
it can work and eat.
By separating interfaces, you adhere to the ISP, ensuring that clients (classes) are
not burdened with implementing methods they don’t use. This leads to cleaner and
more maintainable code by reducing unnecessary dependencies and improving the
flexibility of your class design.
Dependency Inversion Principle (DIP)
The DIP suggests that high-level modules should not depend on low-level modules;
both should depend on abstractions. In other words, the details of implementation
should depend on higher-level abstractions, not the other way around.
Consider a car. The driver doesn’t need to understand the intricacies of the engine;
they simply interact with the car’s controls (the abstraction). The engine, in turn,
relies on higher-level commands from the driver.
Code Example:
// Abstract Switch interface
class Switch {
flip() {
throw new Error("This method must be overridden.");
}
}
In this example, the Switch class defines an abstraction for flipping a switch. The
specific implementations for controlling lights and fans depend on this abstraction,
following the DIP.
The DIP suggests that high-level modules should not depend on low-level modules;
both should depend on abstractions. In simpler terms, the details of
implementation should depend on higher-level abstractions, not the other way
around.
We have two concrete classes, LightSwitch and FanSwitch , which extend the
abstract Switch class and provide concrete implementations of the flip()
method.
They depend on the abstraction defined by the Switch interface. This adheres to
the Dependency Inversion Principle because the low-level modules (concrete
implementations) depend on a high-level abstraction (the interface), not the
other way around.
This case study will help beginners grasp how to apply OOP principles in a real-
world scenario.
Design Considerations: Before we start coding, let’s consider some key design
decisions:
1. Classes: We’ll need classes to represent books and patrons. Each class should
encapsulate relevant data and behavior.
3. Properties and Methods: We’ll define properties to store data (e.g., book title,
author) and methods to perform actions (e.g., check out a book).
Implementing the Solution Step by Step
Step 1: Define the Book Class
class Book {
constructor(title, author, ISBN) {
this.title = title;
this.author = author;
this.ISBN = ISBN;
this.checkedOut = false;
this.checkedOutBy = null;
}
checkout(patron) {
if (!this.checkedOut) {
this.checkedOut = true;
this.checkedOutBy = patron;
console.log(`${this.title} has been checked out by ${patron.name}`)
} else {
console.log(`${this.title} is already checked out.`);
}
}
return() {
if (this.checkedOut) {
this.checkedOut = false;
this.checkedOutBy = null;
console.log(`${this.title} has been returned.`);
} else {
console.log(`${this.title} is not checked out.`);
}
}
}
In this step, we’ve defined the Book class with properties like title , author , and
ISBN . It also includes methods to check out a book and return it.
Class Properties:
title , author , and ISBN : These properties store information about the book,
such as its title, author, and ISBN (International Standard Book Number).
checkedOut : This property is a boolean flag that indicates whether the book is
currently checked out. It's initialized to false because initially, no book is
checked out.
checkedOutBy : This property stores a reference to the patron who has checked
out the book. It's initialized to null because initially, no one has checked it out.
Methods:
checkout(patron) : This method allows a patron to check out the book. It takes a
patron object as a parameter. If the book is not already checked out
( !this.checkedOut ), it sets checkedOut to true , assigns the patron who checked it
out to checkedOutBy , and logs a message. If the book is already checked out, it
logs a message indicating that it's already checked out.
return() : This method allows a patron to return the book. If the book is
currently checked out ( this.checkedOut is true ), it sets checkedOut to false ,
clears the checkedOutBy reference, and logs a message indicating that the book
has been returned. If the book is not checked out, it logs a message indicating
that it's not checked out.
class Patron {
constructor(name) {
this.name = name;
this.checkedOutBooks = [];
}
checkoutBook(book) {
if (book.checkedOutBy === this) {
console.log(`You already have ${book.title}`);
} else {
book.checkout(this);
this.checkedOutBooks.push(book);
}
}
returnBook(book) {
if (this.checkedOutBooks.includes(book)) {
book.return();
this.checkedOutBooks = this.checkedOutBooks.filter(b => b !== book)
} else {
console.log(`You don't have ${book.title}`);
}
}
}
The Patron class represents library patrons. They can check out books and return
them. We've also added a property to keep track of the books they've checked out.
Class Properties:
checkedOutBooks : This property is an array that keeps track of the books checked
out by the patron. Initially, it's an empty array.
Methods:
checkoutBook(book) : This method allows the patron to check out a book. It takes
a book object as a parameter. If the book is already checked out by the same
patron ( book.checkedOutBy === this ), it logs a message indicating that the patron
already has the book. Otherwise, it calls the checkout() method of the book to
check it out and adds the book to the checkedOutBooks array.
returnBook(book) : This method allows the patron to return a book. If the patron
has the book in their checkedOutBooks array
( this.checkedOutBooks.includes(book) ), it calls the return() method of the book
to return it and removes the book from the checkedOutBooks array. If the patron
doesn't have the book, it logs a message indicating that they don't have it.
Now, let’s use our classes to create instances and interact with them:
// Create books
const book1 = new Book("The Hobbit", "J.R.R. Tolkien", "978-0618002214");
const book2 = new Book("To Kill a Mockingbird", "Harper Lee", "978-0061120084")
// Create patrons
const patron1 = new Patron("Alice");
const patron2 = new Patron("Bob");
In this final step, we create instances of Book and Patron classes and simulate
interactions between patrons and books. We check out books, return them, and
handle scenarios where books are already checked out or not checked out.
This step demonstrates how the classes we defined in steps 1 and 2 can be used to
model and manage real-world library operations.
By following these steps, we’ve created a simple library management system using
object-oriented programming principles like encapsulation, methods, classes,
relationships, and object interactions.
This case study illustrates how OOP concepts can be applied to design and
implement a practical software solution.
Classes: We defined classes ( Book and Patron ) to encapsulate data and behavior.
Inheritance: While we didn’t explicitly use inheritance here, both Book and
Patron classes inherit from the base Object class, which is a fundamental
aspect of OOP.
Conclusion
In this comprehensive guide, we’ve explored the fundamental concepts of Object-
Oriented Programming (OOP). Let’s recap the key concepts covered:
1. Classes and Objects: OOP revolves around the concept of classes and objects.
Classes define the blueprint for objects, while objects are instances of classes.
6. Interfaces: Interfaces define contracts that classes must adhere to. They enable
multiple classes to implement common behavior in a consistent way.
Practice Coding: The best way to learn OOP is by writing code. Create your own
projects, implement OOP principles, and experiment with different scenarios.
Stay Updated: Technology evolves rapidly. Stay updated with the latest
developments in OOP and programming languages.
Explore Design Patterns: Study design patterns like Singleton, Factory, and
Observer. These are reusable solutions to common programming problems.
Remember, OOP is a skill that improves with practice. Whether you aspire to
become a software developer, data scientist, or engineer, mastering OOP principles
will be a valuable asset in your journey towards becoming a proficient coder.
Continue to explore, experiment, and expand your coding horizons. Happy coding!
🤘🏽
Oop Object Oriented JavaScript Software Engineering
Software Development
Follow
A Full Stack Developer with a combined 3+ years of experience on frontend and backend. I write mostly on
React, Vue, Blockchain Tech/Web3 Firebase & Cloud Dev
Adekola Olawale
160
1.94K 17
Olivier Combe in Bits and Pieces
888 3
Adekola Olawale
Firebase Authentication
Build a Smooth Authentication Flow System with Firebase
236
Lewis Baxter
20
Carlos Rojas in ScriptSerpent
Lists
Leadership
39 stories · 168 saves
81
HolySpirit
1
Fareedat Bello
71 1