- Object-Oriented Programming
- Encapsulation
- Abstraction
- Inheritance
- Composition
- Polymorphism
- Coupling
- Composition vs inheritance
- Fragile base class problem
- Association vs Composition
- SOLID
- S: Single resposability principle (
SRP
) - O: Open-Closed principle (
OCP
) - L: Liskov substitution principle (
LSP
) - I: Interface segregration principal (
ISP
) - D: Dependency Inversion principal (
DIP
)
- S: Single resposability principle (
- Design patterns
- ...
Encapsulation
Encapsulation is a way to restrict the direct access to some components of an object, so users cannot access state values for all of the variables of a particular object. Encapsulation can be used to hide both data members and data functions or methods (implementation details) associated with an instantiated class or object.
#python example
class Temperature:
def __init__(self):
# Private attribute (convention: prefix with underscore)
self._celsius = 0
# Public method to set temperature in Celsius
def set_celsius(self, value):
if value < -273.15:
print("Temperature cannot be below absolute zero!")
else:
self._celsius = value
# Public method to get temperature in Celsius
def get_celsius(self):
return self._celsius
# Public method to get temperature in Fahrenheit
def get_fahrenheit(self):
return (self._celsius * 9/5) + 32
# Create a Temperature object
temp = Temperature()
# Set temperature in Celsius
temp.set_celsius(25)
# Get temperature in Celsius and Fahrenheit
print(f"Celsius: {temp.get_celsius()}ยฐC") # Output: Celsius: 25ยฐC
print(f"Fahrenheit: {temp.get_fahrenheit()}ยฐF") # Output: Fahrenheit: 77.0ยฐF
# Try to set an invalid temperature
temp.set_celsius(-300) # Output: Temperature cannot be below absolute zero!
-
The
_celsius
attribute is marked as private by prefixing it with an underscore (_). This indicates that it should not be accessed directly from outside the class. -
The
set_celsius
,get_celsius
, andget_fahrenheit
methods provide a controlled interface to interact with the_celsius
attribute.
Abstraction
Abstraction refers to the concept of hiding the complex implementation details and showing only the essential features of an object. In other words, abstraction allows you to focus on what an object does rather than how it does it. Abstraction is about hiding complexity and showing only the essential features.
- Abstract Classes: that cannot be instantiated and may contain abstract methods (methods without implementation).
- Interfaces/Protocols: Define a contract for what methods a class should implement without providing the implementation.
// swift example
// Step 1: Define a protocol (abstract interface)
protocol Animal {
func makeSound()
}
// Step 2: Create concrete classes that conform to the protocol
class Dog: Animal {
func makeSound() {
print("Woof!")
}
}
class Cat: Animal {
func makeSound() {
print("Meow!")
}
}
// Step 3: Use the abstraction
let myDog = Dog()
let myCat = Cat()
myDog.makeSound() // Output: Woof!
myCat.makeSound() // Output: Meow!
- The Animal protocol is the abstraction, and Dog and Cat are the concrete implementations.
- The Animal protocol defines a single method makeSound(). This is the abstractionโit tells us what an animal should do (make a sound) but not how it does it.
- Dog and Cat are concrete classes that conform to the Animal protocol. They provide their own implementations of makeSound().
Inheritance
Inheritance allows a class (called a child class or subclass) to inherit properties and methods from another class (called a parent class or superclass). This promotes code reusability and establishes a hierarchical relationship between classes.
// Dart example
// Parent class
class Animal {
String name;
// Constructor
Animal(this.name);
// Method
void makeSound() {
print("$name makes a sound");
}
}
// Child class inheriting from Animal
class Dog extends Animal {
// Constructor
Dog(String name) : super(name);
// Overriding the makeSound method
@override
void makeSound() {
print("$name barks!");
}
}
void main() {
// Create an object of the Dog class
Dog myDog = Dog("Buddy");
// Call methods
myDog.makeSound(); // Overridden method
}
graph TD;
A[Animal] -- "Dog is a animal" --> B[Dog];
Composition
Composition involves creating complex objects by combining simpler objects or components. In composition, objects are assembled together to form larger structures, with each component object maintaining its own state and behavior. Composition is often described in terms of a "has-a" relationship.
# Ruby example
# Define the Car class, which is composed of Engine, Wheels, and Transmission class
class Car
def initialize
@engine = Engine.new
@wheels = Wheels.new
@transmission = Transmission.new
end
def drive
@engine.start
@wheels.rotate
@transmission.shift_gear(1)
puts "Car is moving!"
end
def park
@transmission.shift_gear(0)
@engine.stop
puts "Car is parked."
end
end
# Create a Car object and use it
my_car = Car.new
my_car.drive
my_car.park
- Reusability: Components like Engine, Wheels, and Transmission can be reused in other classes (e.g., a Truck class).
- Maintainability: Changes to one component (e.g., Engine) do not affect other components or the Car class.
- Flexibility: You can dynamically change the behavior of the Car by swapping out components at runtime.
Polymorphism
Polymorphism allows objects of different classes to be treated as objects of a common superclass. The term "polymorphism" comes from Greek, meaning "many forms." In OOP, it refers to the ability of a single function, method, or operator to work in different ways depending on the context.
-
Compile-time Polymorphism (Method Overloading): This is achieved by defining multiple methods with the same name but different parameters.
-
Runtime Polymorphism (Method Overriding): This is achieved when a subclass provides a specific implementation of a method that is already defined in its superclass.
// Kotlin example
// Superclass
open class Shape {
open fun draw() {
println("Drawing a shape")
}
}
// Subclass
class Circle : Shape() {
override fun draw() {
println("Drawing a circle")
}
}
fun main() {
val shape: Shape = Circle() // Polymorphism: Shape reference, Circle object
shape.draw() // Output: Drawing a circle
}
- Superclass (
Shape
): Defines a methoddraw()
. - Subclass (
Circle
): Overrides thedraw()
method to provide its own implementation. - Polymorphism: The
shape
variable is of typeShape
, but it holds an object of typeCircle
. Whendraw()
is called, the overridden method inCircle
is executed.
Coupling
Coupling measures how closely two classes are connected or dependent on each other. High coupling means that classes are tightly interconnected, making the system harder to maintain, modify, and test.
Benefits of Loose Coupling: Flexibility: You can easily replace or modify components without affecting other parts of the system.
Maintainability: Changes in one class are less likely to break other classes.
Testability: It's easier to test classes in isolation when they are not tightly coupled.
Bad pratice Coupling Example:
# Ruby example
class Car
def initialize
@engine = Engine.new
end
def start
@engine.start
end
end
class Engine
def start
puts "Engine started!"
end
end
car = Car.new
car.start
Good pratice Loose Coupling Example:
# Ruby example
class Car
def initialize(engine)
@engine = engine
end
def start
@engine.start
end
end
class Engine
def start
puts "Engine started!"
end
end
class ElectricEngine
def start
puts "Electric engine started!"
end
end
# Using a regular engine
regular_engine = Engine.new
car = Car.new(regular_engine)
car.start
# Using an electric engine
electric_engine = ElectricEngine.new
car = Car.new(electric_engine)
car.start
Composition vs inheritance
When to Use Composition:
- When you need more flexibility in constructing objects by assembling smaller, reusable components.
- When there is no clear "is-a" relationship between classes, and a "has-a" relationship is more appropriate.
- When you want to avoid the limitations of inheritance, such as tight coupling and the fragile base class problem - which we will look into shortly. When to Use Inheritance:
- When there is a clear "is-a" relationship between classes, and subclass objects can be treated as instances of their superclass.
- When you want to promote code reuse by inheriting properties and behaviors from existing classes.
- When you want to leverage polymorphism to allow objects of different subclasses to be treated uniformly through their common superclass interface.
Fragile base class problem
Fragile Base Class Problem and why you should use composition over inheritance
- The Fragile Base Class Problem is a software design issue that arises in object-oriented programming when changes made to a base class can inadvertently break the functionality of derived classes. This problem occurs due to the tight coupling between base and derived classes in inheritance hierarchies.
- Inheritance Coupling: Inheritance creates a strong coupling between the base class (superclass) and derived classes (subclasses). Any changes made to the base class can potentially affect the behavior of all derived classes.
- Limited Extensibility: The Fragile Base Class Problem limits the extensibility of software systems, as modifications to the base class can become increasingly risky and costly over time. Developers may avoid making necessary changes due to the fear of breaking existing functionality - Brittle software.
Mitigation Strategies: To mitigate the Fragile Base Class Problem, software developers can use design principles such as the Open/Closed Principle (OCP) and Dependency Inversion Principle (DIP), as well as design patterns like Composition over Inheritance. These approaches promote loose coupling, encapsulation, and modular design, reducing the impact of changes in base classes.
Association vs Composition
The difference between Association relationship and Composition relationship:
- Association: A Person has a Car, but is not composed of Car. A person holds a reference to Car so it can interact with it, but a Person can exist without a Car.
- Composition: When a child object wouldn't be able to exist without its parent object, e.g. a hotel is composed of its rooms, and HotelBathroom cannot exist without Hotel (destroy the hotel, you destroy the hotel bathroom - it can't exist by itself). Also, if a Customer is destroyed, their ShoppingCart and Orders are lost too - therefore Customer is composed of ShoppingCart and Orders. And if Orders are lost, OrderDetails and Shippinginfo are lost - so Orders are composed of Shippinginfo and OrderDetails.
S: Single Resposability Principle SRP
- A class should have only one reason to change, meaning it should have only one job or responsibility.
- This principle helps to keep classes focused and manageable.
Problem: Violation of SRP
The User
class has two responsibilities:
- Managing user data.
- Saving the user to a database.
- Sending an email.
Solution: Applying SRP We can refactor the code to separate the responsibilities into different classes or modules:
// JavaScript example
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
class UserRepository {
save(user) {
// Logic to save user to a database
console.log(`Saving user ${user.name} to the database...`);
}
}
class EmailService {
sendEmail(user) {
// Logic to send an email to the user
console.log(`Sending email to ${user.email}...`);
}
}
// Usage
const user = new User("John", "john@example.com");
const userRepository = new UserRepository();
const emailService = new EmailService();
userRepository.save(user);
emailService.sendEmail(user);
O: Open-Closed Principle OCP
-
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
-
This means you should be able to add new functionality without altering existing code, promoting the use of abstractions and interfaces.
Problem: Violation of OCP
// C# example
public class ReportGenerator
{
public void GenerateReport(string reportType)
{
if (reportType == "PDF")
{
Console.WriteLine("Generating PDF Report...");
}
else if (reportType == "Excel")
{
Console.WriteLine("Generating Excel Report...");
}
// If we need to add a new report type (e.g., "Word"), we have to modify this class.
}
}
In this example, the ReportGenerator
class
violates OCP because if we want to add a new report type (e.g., "Word"), we have to modify the GenerateReport
method
. This makes the code harder to maintain and less flexible.
Solution: Applying OCP
// C# example
// Define an interface for all report generators
public interface IReportGenerator
{
void GenerateReport();
}
// PDF Report Generator
public class PdfReportGenerator : IReportGenerator
{
public void GenerateReport()
{
Console.WriteLine("Generating PDF Report...");
}
}
// Excel Report Generator
public class ExcelReportGenerator : IReportGenerator
{
public void GenerateReport()
{
Console.WriteLine("Generating Excel Report...");
}
}
// Word Report Generator (added later without modifying existing code)
public class WordReportGenerator : IReportGenerator
{
public void GenerateReport()
{
Console.WriteLine("Generating Word Report...");
}
}
// Report Generator class that works with any IReportGenerator
public class ReportGenerator
{
public void GenerateReport(IReportGenerator reportGenerator)
{
reportGenerator.GenerateReport();
}
}
// Usage
class Program
{
static void Main(string[] args)
{
var pdfReport = new PdfReportGenerator();
var excelReport = new ExcelReportGenerator();
var wordReport = new WordReportGenerator(); // New report type added without modifying existing code
var reportGenerator = new ReportGenerator();
reportGenerator.GenerateReport(pdfReport); // Output: Generating PDF Report...
reportGenerator.GenerateReport(excelReport); // Output: Generating Excel Report...
reportGenerator.GenerateReport(wordReport); // Output: Generating Word Report...
}
}
-
IReportGenerator
Interface: Defines a contract for all report generators, requiring them to implement aGenerateReport
method
. -
PdfReportGenerator
andExcelReportGenerator
Classes: Implement theIReportGenerator
interface and provide their own logic for generating reports. -
WordReportGenerator
Class: Added later without modifying the existingReportGenerator
class. -
ReportGenerator
Class: Works with any report generator that implements theIReportGenerator
interface. It doesnโt need to be modified when new report types are added.
Liskov Substitution Principle LSP
-
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
-
This principle ensures that a subclass can stand in for its superclass without causing unexpected behavior.
Problem: Violation of LSP example:
-
The
Bird
class has a fly method, assuming all birds can fly. -
The
Ostrich
class extendsBird
but overrides the fly method tothrow
an exception because ostriches cannot fly. -
This violates LSP because an
Ostrich
object cannot be substituted for aBird
object without breaking the program (itthrows
an exception).
Solution: Applying LSP
- To adhere to LSP, we need to ensure that subclasses can be used in place of their superclass without causing unexpected behavior. One way to fix this is by restructuring the class hierarchy to reflect the correct behavior.
// Java example
// Base class for all birds
class Bird {
// Common bird behavior
}
// Interface for birds that can fly
interface Flyable {
void fly();
}
// Sparrow is a bird that can fly
class Sparrow extends Bird implements Flyable {
@Override
public void fly() {
System.out.println("Sparrow is flying...");
}
}
// Ostrich is a bird that cannot fly
class Ostrich extends Bird {
// Ostrich-specific behavior
public void run() {
System.out.println("Ostrich is running...");
}
}
public class Main {
public static void main(String[] args) {
Flyable sparrow = new Sparrow();
sparrow.fly(); // Output: Sparrow is flying...
Ostrich ostrich = new Ostrich();
ostrich.run(); // Output: Ostrich is running...
// Now, Ostrich is not forced to implement fly(), and the program works as expected.
}
}
-
Bird
Class: Acts as a base class for all birds, containing common behavior. -
Flyable
Interface: Defines thefly
method for birds that can fly. -
Sparrow
Class: Extends Bird and implementsFlyable
because sparrows can fly. -
Ostrich
Class: Extends Bird but does not implementFlyable
because ostriches cannot fly. Instead, it has its own behavior (run
).
I: Interface Segregration Principal ISP
- Clients should not be forced to depend on interfaces they do not use.
- This principle encourages the creation of smaller, more specific interfaces rather than large, general-purpose ones.
Problem: Violation of ISP
-
The
Worker
interface has two methods:work
andeat
. -
The
HumanWorker
class implements both methods because humans can work and eat. -
The
RobotWorker
class is forced to implement theeat
method, even though robots donโt eat. This violates ISP because theRobotWorker
depends on an interface method it doesnโt use.
Solution: Applying ISP
-
To adhere to ISP, we can split the Worker interface into smaller, more specific interfaces. This way, classes only implement the methods they need.
-
Workable
Interface: Defines thework
method for workers who can work. -
Eatable
Interface: Defines theeat
method for workers who can eat. -
HumanWorker
Class: Implements bothWorkable
andEatable
because humans can work and eat. -
RobotWorker
Class: Implements onlyWorkable
because robots donโt eat.
D: Dependency Inversion Principal DIP
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
- This principle promotes the decoupling of software modules, making the system more modular and easier to maintain.
// C++ example
#include <iostream>
using namespace std;
// Abstraction (interface)
class Switchable {
public:
virtual void turnOn() = 0;
virtual void turnOff() = 0;
virtual ~Switchable() = default; // Virtual destructor for proper cleanup
};
// Low-level module
class LightBulb : public Switchable {
public:
void turnOn() override {
cout << "LightBulb: Turned ON" << endl;
}
void turnOff() override {
cout << "LightBulb: Turned OFF" << endl;
}
};
// Another low-level module
class Fan : public Switchable {
public:
void turnOn() override {
cout << "Fan: Turned ON" << endl;
}
void turnOff() override {
cout << "Fan: Turned OFF" << endl;
}
};
// High-level module
class Switch {
private:
Switchable& device; // Depends on abstraction, not a concrete implementation
public:
Switch(Switchable& device) : device(device) {}
void operate(bool on) {
if (on) {
device.turnOn();
} else {
device.turnOff();
}
}
};
// Usage
int main() {
LightBulb bulb;
Fan fan;
Switch lightSwitch(bulb);
lightSwitch.operate(true); // Output: LightBulb: Turned ON
lightSwitch.operate(false); // Output: LightBulb: Turned OFF
Switch fanSwitch(fan);
fanSwitch.operate(true); // Output: Fan: Turned ON
fanSwitch.operate(false); // Output: Fan: Turned OFF
return 0;
}
-
Switchable
Interface: Acts as an abstraction that both high-level and low-level modules depend on. -
LightBulb
andFan
Classes: Implement the Switchable interface, providing their own implementations ofturnOn
andturnOff
. -
Switch
Class: Depends on theSwitchable
interface instead of a concrete implementation. This makes it flexible and reusable for any device that implementsSwitchable
.