Contents
Overview
Object-oriented design patterns are fundamental to programming. They are reusable solutions to common problems encountered during programming, often making heavy use of interfaces, information hiding, polymorphism, and intermediary objects. There are three main categories of design patterns that will be discussed in this blog:
- Creational Patterns
- Structural Patterns
- Behavioral Patterns
Other engineers have faced issues while developing applications, and they have come up with specific design patterns. We can leverage their knowledge in our own software design to simplify the process.
Design Properties
Cohesion
- The degree to which a module relies on members within the module to deliver functionality
- Ideally HIGH
Coupling
- The degree to which a module relies on other modules to deliver functionality
- Ideally LOW
Structural Patterns
Structural patterns concern the process of assembling objects and classes.
Application 1 - Adapter
The Adapter Design Pattern is a structural design pattern that allows two incompatible interfaces to work together. This design pattern acts as a bridge between two interfaces, converting one interface into the other without modifying the original code. This pattern is useful when you want to integrate existing code or libraries that have different interfaces.
Components:
- Target Interface: The desired interface that the client code expects
- Adaptee: The existing code that needs to be adapted
- Adapter: This is the class that implements the target interface and wraps the adaptee, making it compatible with the target
- Client: Collaborates with objects conforming to the target interface
Types of Adapters:
- Class Adapters: This type uses multiple inheritance (inheritance from both target and adaptee) to adapt the interface. It is less flexible as it relies on inheritance and doesn’t allow dynamic adaptation at runtime.
- Object Adapters: This type uses composition to adapt the interface, where the adapter has a reference to the adaptee. It is more flexible and allows for dynamic adaptation at runtime, but requires more code if the interface changes.
Example 1:
You are building a weather app that fetches data from a third-party weather API. The API returns data in a different format than what the app requires. In this case, we will need to convert the API response into a format the app can understand:
data class WeatherApiResponse(val temperatureF: Double)
data class WeatherData(val temperatureC: Double)
Now we define an adapter class:
class WeatherDataAdapter {
fun adapt(apiResponse: WeatherApiResponse): WeatherData {
val temperatureC = (apiResponse.temperatureF - 32) * 5 / 9
return WeatherData(temperatureC)
}
}
This can be used as follows:
fun main() {
val apiResponse = WeatherApiResponse(68.0)
// Define an adapter
val weatherDataAdapter = WeatherDataAdapter()
val weatherData = weatherDataAdapter.adapt(apiResponse)
}
Example 2:
Another example is when we want to create a condition on the same type, but one of the object interfaces is of another type. Thus, we need to adapt it to the new interface. In this example, StripeGateway
and PaypalGateway
are both of type PaymentGateway
. However, BankGateway
is of another type, but we must adapt it to use it in a when statement which returns an object of the same type:
fun processPayment(selectedGatewayId: Int, paymentAmount: Double): Boolean {
val stripeGateway = StripeGateway()
val payPalGateway = PayPalGateway()
val bankGateway = BankGateway()
val bankGatewayAdapter = BankGatewayAdapter(bankGateway)
val paymentGateway = when (selectedGatewayId) {
1 -> stripeGateway
2 -> payPalGateway
3 -> bankGatewayAdapter
else -> throw IllegalArgumentException("Invalid payment gateway selected.")
}
return paymentGateway.processPayment(paymentAmount)
}
In this example:
- Target Interface: return type of
BankGatewayAdapter
- Client: The function
processPayment
orwhen
statement could be considered as the client - Adapter:
BankGatewayAdapter
- Adaptee:
bankGateway
Application 2. - Composite
Allows you to compose objects into tree structures to represent part-whole hierarchies. This pattern enables clients to treat individual object and compositions of objects uniformly. This pattern is designed to be used when working with complex hierarchies of objects where objects may have children.
Components:
- Component: Is the base class or the interface that defines common patterns and properties for both composite and leaf objects.
- Leaf: Individual objects without children, implements component interface or extends component class, which provides actual behaviour.
- Composite: Class that represents objects that have children, it also implements component interface or extends the component class. It contains a collection of child components (either leaf or composite).
- Client: The code that interacts with the component interface or class, treats both Leaf and Composite uniformly.
Example 1:
import java.util.ArrayList;
import java.util.List;
// Component
interface FileSystemElement {
void display(String indent);
}
// Leaf
class File implements FileSystemElement {
private String name;
public File(String name) {
this.name = name;
}
@Override
public void display(String indent) {
System.out.println(indent + "File: " + name);
}
}
// Composite
class Directory implements FileSystemElement {
private String name;
private List<FileSystemElement> elements;
public Directory(String name) {
this.name = name;
this.elements = new ArrayList<>();
}
@Override
public void display(String indent) {
System.out.println(indent + "Directory: " + name);
for (FileSystemElement element : elements) {
element.display(indent + " ");
}
}
public void add(FileSystemElement element) {
elements.add(element);
}
public void remove(FileSystemElement element) {
elements.remove(element);
}
}
// Client code
public class Main {
public static void main(String[] args) {
File file1 = new File("file1.txt");
File file2 = new File("file2.txt");
File file3 = new File("file3.txt");
Directory dir1 = new Directory("dir1");
dir1.add(file1);
dir1.add(file2);
Directory dir2 = new Directory("dir2");
dir2.add(file3);
Directory root = new Directory("root");
root.add(dir1);
root.add(dir2);
root.display("");
}
}
In this example, we have an interface FileSystemElement
which requires a display
function. This interface is implemented as a File
and Directory
as a Leaf and Composite. This works well because a composite can be composed of other composite or other leaves, just like a directory can be composed of other directories or files. Finally, we show a client code which implements composite and client classes in a functional way.
Application 3. - Facade
The Facade Design Pattern provides a simplified, unified interface to a set of interfaces or subsystems. It reduces complexity and improves the ease of use of a system by hiding internal details/complexities from client code.
Components:
- Facade: Class that provides a simple, unified interface to interact with a subsystem. The Facade class contains methods that delegate the client’s requests to the appropriate subsystem, while taking care of any complex logic/coordination.
- Subsystem: The set of classes or components that make up the subsystem, these classes are directly used by the client code.
- Client: The code that interacts with the facade to access the subsystem functionalities.
Example 1:
// Subsystem classes
class FlightBooking {
public void searchFlights() { System.out.println("Searching flights..."); }
public void bookFlight() { System.out.println("Booking flight..."); }
}
class HotelBooking {
public void searchRooms() { System.out.println("Searching hotel rooms..."); }
public void bookRoom() { System.out.println("Booking hotel room..."); }
}
class CarRental {
public void searchCars() { System.out.println("Searching rental cars..."); }
public void bookCar() { System.out.println("Booking rental car..."); }
}
// Facade
class TravelFacade {
private FlightBooking flightBooking;
private HotelBooking hotelBooking;
private CarRental carRental;
public TravelFacade(FlightBooking flightBooking, HotelBooking hotelBooking, CarRental carRental) {
this.flightBooking = flightBooking;
this.hotelBooking = hotelBooking;
this.carRental = carRental;
}
public void planTrip() {
flightBooking.searchFlights();
hotelBooking.searchRooms();
carRental.searchCars();
}
public void bookTrip() {
flightBooking.bookFlight();
hotelBooking.bookRoom();
carRental.bookCar();
}
}
// Client code
public class Main {
public static void main(String[] args) {
FlightBooking flightBooking = new FlightBooking();
HotelBooking hotelBooking = new HotelBooking();
CarRental carRental = new CarRental();
TravelFacade travelFacade = new TravelFacade(flightBooking, hotelBooking, carRental);
travelFacade.planTrip();
System.out.println("-----");
travelFacade.bookTrip();
}
}
In this example, we have three subsystem classes: FlightBooking
, HotelBooking
, and CarRental
. We have a Facade to interact with these subsystem classes called TravelFacade
. Finally, we use the Facade to plan and book trips, which groups searching and booking flights, hotel rooms, and cars.
Example 2:
// Subsystem classes
class InventoryManagement {
public boolean checkProductAvailability(String productId) {
// Logic to check product availability in inventory
System.out.println("Checking product availability for " + productId);
return true;
}
}
class PaymentProcessing {
public boolean processPayment(String paymentDetails) {
// Logic to process payment using paymentDetails
System.out.println("Processing payment: " + paymentDetails);
return true;
}
}
class Shipping {
public void shipProduct(String productId, String address) {
// Logic to ship the product to the given address
System.out.println("Shipping product " + productId + " to " + address);
}
}
// Facade
class OrderFacade {
private InventoryManagement inventoryManagement;
private PaymentProcessing paymentProcessing;
private Shipping shipping;
public OrderFacade(InventoryManagement inventoryManagement, PaymentProcessing paymentProcessing, Shipping shipping) {
this.inventoryManagement = inventoryManagement;
this.paymentProcessing = paymentProcessing;
this.shipping = shipping;
}
public void placeOrder(String productId, String paymentDetails, String address) {
if (inventoryManagement.checkProductAvailability(productId)) {
if (paymentProcessing.processPayment(paymentDetails)) {
shipping.shipProduct(productId, address);
System.out.println("Order placed successfully!");
} else {
System.out.println("Payment processing failed.");
}
} else {
System.out.println("Product is not available.");
}
}
}
// Client code
public class Main {
public static void main(String[] args) {
InventoryManagement inventoryManagement = new InventoryManagement();
PaymentProcessing paymentProcessing = new PaymentProcessing();
Shipping shipping = new Shipping();
OrderFacade orderFacade = new OrderFacade(inventoryManagement, paymentProcessing, shipping);
orderFacade.placeOrder("PRODUCT-123", "CREDIT-CARD-INFO", "123 Main St, New York, NY");
}
}
Another more realistic example, we have the subsystem classes InventoryManagement
, PaymentProcessing
, and Shipping
, which are interacting with and handled by the facade, OrderFacade
, which places an order as shown in the client code.
Application 4. - Decorator
Design patterns that allow you to add new functionality to an object dynamically without modifying its structure. This is achieved by wrapping the object with a decorator object that contains the additional functionality. The decorator conforms to the same interface as the original object so it can be used interchangeably.
Components:
- Component (Interface): Interface or abstract class that defines the common methods for both concrete components and decorators.
- Concrete Component: A concrete implementation of the component interface. This is the object to which we want to add new functionality dynamically.
- Decorator: An abstract class that implements the component interface and maintains a reference to a Component object. It is used to wrap components or other decorators.
- Concrete Decorator: Concrete implementation of the Decorator class that adds or overrides the functionality of the wrapped component.
Example:
// Component
interface FileStream {
void read();
void write();
}
// Concrete Component
class BasicFileStream implements FileStream {
@Override
public void read() {
System.out.println("Reading file");
}
@Override
public void write() {
System.out.println("Writing to file");
}
}
// Decorator
abstract class FileStreamDecorator implements FileStream {
protected FileStream fileStream;
public FileStreamDecorator(FileStream fileStream) {
this.fileStream = fileStream;
}
@Override
public void read() {
fileStream.read();
}
@Override
public void write() {
fileStream.write();
}
}
// Concrete Decorator
class BufferedFileStream extends FileStreamDecorator {
public BufferedFileStream(FileStream fileStream) {
super(fileStream);
}
@Override
public void read() {
System.out.println("Setting up buffer for reading");
super.read();
}
@Override
public void write() {
System.out.println("Setting up buffer for writing");
super.write();
}
}
// Concrete Decorator
class CompressedFileStream extends FileStreamDecorator {
public CompressedFileStream(FileStream fileStream) {
super(fileStream);
}
@Override
public void read() {
super.read();
System.out.println("Decompressing data");
}
@Override
public void write() {
System.out.println("Compressing data");
super.write();
}
}
// Concrete Decorator
class EncryptedFileStream extends FileStreamDecorator {
public EncryptedFileStream(FileStream fileStream) {
super(fileStream);
}
@Override
public void read() {
super.read();
System.out.println("Decrypting data");
}
@Override
public void write() {
System.out.println("Encrypting data");
super.write();
}
}
// Client code
public class Main {
public static void main(String[] args) {
FileStream fileStream = new BasicFileStream();
// Add buffering
fileStream = new BufferedFileStream(fileStream);
// Add compression
fileStream = new CompressedFileStream(fileStream);
// Add encryption
fileStream = new EncryptedFileStream(fileStream);
fileStream.read();
System.out.println("-----");
fileStream.write();
}
}
Output:
Setting up buffer for reading
Decompressing data
Decrypting data
Reading file
-----
Setting up buffer for writing
Compressing data
Encrypting data
Writing to file
In this example, we extend the behavior of a file I/O system without modifying its structure. You can dynamically add different combinations of functionalities like buffering, compressing and encryption to the file reading and writing process as needed.
Creational Patterns
Concerns the process of object creation.
Application 1. - Abstract Factory
Provides an interface for creating families of related or dependent objects without specifying their concrete classes. It is useful when you want to provide a library or a framework where the users can extend or customize the components.
Components:
- Abstract Factory (Interface): An interface that declares methods for creating abstract products.
- Concrete Factory: A concrete class that implements the Abstract Factory interface. It’s responsible for creating concrete products from a specific family.
- Abstract Product (Interface): An interface that defines the product to be created. Each product family will have its interface.
- Concrete Product: A concrete implementation of the Abstract Product interface. Each concrete factory will create a specific Concrete Product.
- Client: (Do you see the pattern) code that uses factory and product interfaces to work with the concrete factories and products.
Example 1:
// Abstract Products
interface Button {
void render();
}
interface Checkbox {
void render();
}
// Concrete Products - Light Theme
class LightButton implements Button {
@Override
public void render() {
System.out.println("Rendering Light Theme Button");
}
}
class LightCheckbox implements Checkbox {
@Override
public void render() {
System.out.println("Rendering Light Theme Checkbox");
}
}
// Concrete Products - Dark Theme
class DarkButton implements Button {
@Override
public void render() {
System.out.println("Rendering Dark Theme Button");
}
}
class DarkCheckbox implements Checkbox {
@Override
public void render() {
System.out.println("Rendering Dark Theme Checkbox");
}
}
// Abstract Factory
interface ThemeFactory {
Button createButton();
Checkbox createCheckbox();
}
// Concrete Factories
class LightThemeFactory implements ThemeFactory {
@Override
public Button createButton() {
return new LightButton();
}
@Override
public Checkbox createCheckbox() {
return new LightCheckbox();
}
}
class DarkThemeFactory implements ThemeFactory {
@Override
public Button createButton() {
return new DarkButton();
}
@Override
public Checkbox createCheckbox() {
return new DarkCheckbox();
}
}
// Client code
public class Main {
public static void main(String[] args) {
ThemeFactory themeFactory;
// Choose the theme based on user preference
String userPreferredTheme = "dark";
if ("light".equalsIgnoreCase(userPreferredTheme)) {
themeFactory = new LightThemeFactory();
} else {
themeFactory = new DarkThemeFactory();
}
// Create and render UI components for the chosen theme
Button button = themeFactory.createButton();
button.render();
Checkbox checkbox = themeFactory.createCheckbox();
checkbox.render();
}
}
Application 2. - Singleton
This pattern ensures a class has only one instance and provides a global point of access to that instance. It is useful when you need to control access to shared resources, such as a database connection or application configuration.
Example - Java:
public class Singleton {
// 2. Declare a static private variable to store the single instance of the class
private static Singleton instance;
// 1. Make the constructor private so that no other class can create an instance of it
private Singleton() {
// Initialize your class here, for example, set up configuration or resources
}
// 3. Create a public static method to get the single instance of the class
public static Singleton getInstance() {
if (instance == null) {
// If the instance doesn't exist, create one
instance = new Singleton();
}
// Return the existing instance
return instance;
}
// Add other methods specific to your Singleton class
public void doSomething() {
System.out.println("Singleton is doing something.");
}
}
// Client code
public class Main {
public static void main(String[] args) {
// Access the Singleton instance
Singleton singleton = Singleton.getInstance();
singleton.doSomething();
// Trying to access the Singleton instance again
Singleton anotherSingleton = Singleton.getInstance();
System.out.println("Are singleton and anotherSingleton the same instance? " + (singleton == anotherSingleton));
}
}
Example - Kotlin:
// Singleton class using object declaration
object Singleton {
init {
// Initialize your class here, for example, set up configuration or resources
}
// Add other methods specific to your Singleton class
fun doSomething() {
println("Singleton is doing something.")
}
}
// Client code
fun main() {
// Access the Singleton instance
val singleton = Singleton
singleton.doSomething()
// Trying to access the Singleton instance again
val anotherSingleton = Singleton
println("Are singleton and anotherSingleton the same instance? " + (singleton === anotherSingleton))
}
Application 3. - Factory Method
The Factory Method is a creational pattern that provides an interface for creating objects in a superclass while allowing subclasses to decide the type of the objects to create.
Components:
- Product: An interface that defines the general structure and behavior of the objects the factory method creates.
- ConcreteProduct: Classes that implement Product interface providing the specific implementation details for each product.
- Creator: This is an interface that declares the factory method which returns an object of the product type.
- ConcreteCreator: These are the classes that implement the creator interface.
Example 1:
// Product
interface DocumentParser {
fun parse(document: ByteArray): ParsedDocument
}
// ConcreteProduct - PDFParser
class PDFParser : DocumentParser {
override fun parse(document: ByteArray): ParsedDocument {
// Parsing logic specific to PDF files
}
}
// ConcreteProduct - WordParser
class WordParser : DocumentParser {
override fun parse(document: ByteArray): ParsedDocument {
// Parsing logic specific to Word files
}
}
// ConcreteProduct - ExcelParser
class ExcelParser : DocumentParser {
override fun parse(document: ByteArray): ParsedDocument {
// Parsing logic specific to Excel files
}
}
// Creator
abstract class DocumentParserFactory {
abstract fun createParser(): DocumentParser
}
// ConcreteCreator - PDFParserFactory
class PDFParserFactory : DocumentParserFactory() {
override fun createParser(): DocumentParser {
return PDFParser()
}
}
// ConcreteCreator - WordParserFactory
class WordParserFactory : DocumentParserFactory() {
override fun createParser(): DocumentParser {
return WordParser()
}
}
// ConcreteCreator - ExcelParserFactory
class ExcelParserFactory : DocumentParserFactory() {
override fun createParser(): DocumentParser {
return ExcelParser()
}
}
class DocumentProcessor(private val parserFactory: DocumentParserFactory) {
fun processDocument(document: ByteArray) {
val parser = parserFactory.createParser()
val parsedDocument = parser.parse(document)
// Further processing of the parsed document
}
}
Here are the benefits that can be seen in the code above:
- Decoupling: Separates object creation from object usage, resulting in more modular and maintainable code
- Encapsulation: Encapsulates complex object creation logic in a dedicated factory class, keeping the client code doing its primary responsibility
- Flexibility: Easy addition of new types of objects
Behavioral Patterns
Concerns the interaction between classes or objects.
Application 1. - Iterator
Provides a way to access the elements of an aggregate object sequentially without exposing its underlying representation. The iterator pattern allows you to traverse the elements of different types of collections in a uniform manner, without knowing the specifics of how these collections are implemented.
Components:
- Iterator: An interface that defines the methods required for accessing and traversing elements in a collection. Common methods include
hasNext()
,next()
andremove()
. - ConcreteIterator: Implements the interface above and provides specific implementations of the functions.
- Aggregate: An interface that defines the method for creating an Iterator object for a specific collection type.
- ConcreteAggregate: Provides a specific implementation for creating a ConcreteIterator for the particular collection.
Example:
// Aggregate
interface Library {
fun createIterator(): Iterator<Book>
}
// ConcreteAggregate
class ConcreteLibrary(private val books: List<Book>) : Library {
override fun createIterator(): Iterator<Book> {
return BookIterator(books)
}
}
data class Book(val title: String, val author: String)
// Iterator
interface Iterator<T> {
fun hasNext(): Boolean
fun next(): T
fun remove()
}
// ConcreteIterator
class BookIterator(private val books: List<Book>) : Iterator<Book> {
private var currentIndex = 0
override fun hasNext(): Boolean {
return currentIndex < books.size
}
override fun next(): Book {
if (!hasNext()) throw NoSuchElementException()
return books[currentIndex++]
}
override fun remove() {
throw UnsupportedOperationException("Remove operation is not supported.")
}
}
fun main() {
val library: Library = ConcreteLibrary(
listOf(
Book("Clean Code", "Robert C. Martin"),
Book("Effective Java", "Joshua Bloch"),
Book("Design Patterns", "Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides")
)
)
val iterator = library.createIterator()
while (iterator.hasNext()) {
val book = iterator.next()
println("Title: ${book.title}, Author: ${book.author}")
}
}
- Encapsulation: This pattern encapsulates the internal structure of a collection, providing a cleaner and more flexible way to access its elements.
- Uniform traversal: This pattern enables uniform traversal of different types of collections, allowing you to write clean code that can work with various types of collection types.
- Separation of concerns: Separate the traversal logic from the collection itself, this adheres to the single responsibility principle.
Application 2. - Observer
The observer pattern defines a one-to-many dependency between objects, so that when one object (the subject) changes its state, all its dependents (observers) are notified and updated automatically. This pattern is often used to implement event-driven systems or to establish loose coupling between components.
Components:
- Subject (Observable): An interface that maintains a list of observers and provides methods for attaching, detaching, and notifying observers of state changes.
- ConcreteSubject: A class that implements the subject and provides specific implementation for managing its state and notifying observers when the state changes.
- Observer: An interface that defines the method for receiving updates from the subject (usually called by
update()
). - ConcreteObserver: Implementation of Observer class and provides a specific implementation for handling updates from subjects.
Example:
// Subject (Observable)
interface Stock {
fun addObserver(observer: StockListener)
fun removeObserver(observer: StockListener)
fun notifyObservers()
}
// ConcreteSubject
class ConcreteStock(private val symbol: String, private var price: Double) : Stock {
private val observers = mutableListOf<StockListener>()
override fun addObserver(observer: StockListener) {
observers.add(observer)
}
override fun removeObserver(observer: StockListener) {
observers.remove(observer)
}
override fun notifyObservers() {
for (observer in observers) {
observer.update(symbol, price)
}
}
fun setPrice(price: Double) {
this.price = price
notifyObservers()
}
}
// Observer
interface StockListener {
fun update(symbol: String, price: Double)
}
// ConcreteObserver - Investor
class Investor(private val name: String) : StockListener {
override fun update(symbol: String, price: Double) {
println("$name received an update: $symbol is now trading at $$price")
}
}
fun main() {
val googleStock = ConcreteStock("GOOG", 1000.0)
val investor1 = Investor("Alice")
val investor2 = Investor("Bob")
googleStock.addObserver(investor1)
googleStock.addObserver(investor2)
googleStock.setPrice(1010.0)
// Output:
// Alice received an update: GOOG is now trading at $1010.0
// Bob received an update: GOOG is now trading at $1010.0
}
- Loose coupling: Promotes loose coupling between the subject and its observers, allowing them to evolve independently, which makes the overall system more flexible and maintainable.
Application 3. - Template
Provides a skeleton of an algorithm in an operation, deferring some steps to subclasses. The Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.
Components:
- Abstract Class (Template): defines the template method, which outlines the algorithm’s structure. This class also includes abstract methods representing the algorithm’s steps.
- Concrete Class: Implementation
Example:
// Abstract Class (Template)
abstract class SocialMediaSharer {
fun share(content: String) {
val formattedContent = formatContent(content)
val platformSpecificContent = preparePlatformSpecificContent(formattedContent)
postContent(platformSpecificContent)
}
private fun formatContent(content: String): String {
// Perform common formatting (e.g., remove extra spaces, fix typos)
return content.trim()
}
protected abstract fun preparePlatformSpecificContent(formattedContent: String): String
protected abstract fun postContent(platformSpecificContent: String)
}
// Concrete Class - TwitterSharer
class TwitterSharer : SocialMediaSharer() {
override fun preparePlatformSpecificContent(formattedContent: String): String {
// Prepare content according to Twitter's guidelines (e.g., truncate if > 280 characters)
return if (formattedContent.length > 280) formattedContent.substring(0, 277) + "..." else formattedContent
}
override fun postContent(platformSpecificContent: String) {
println("Posting on Twitter: $platformSpecificContent")
}
}
// Concrete Class - FacebookSharer
class FacebookSharer : SocialMediaSharer() {
override fun preparePlatformSpecificContent(formattedContent: String): String {
// Prepare content according to Facebook's guidelines (e.g., add a hashtag)
return "#MyPost $formattedContent"
}
override fun postContent(platformSpecificContent: String) {
println("Posting on Facebook: $platformSpecificContent")
}
}
fun main() {
val content = " This is a sample social media post! "
val twitterSharer = TwitterSharer()
twitterSharer.share(content)
// Output: Posting on Twitter: This is a sample social media post!
val facebookSharer = FacebookSharer()
facebookSharer.share(content)
// Output: Posting on Facebook: #MyPost This is a sample social media post!
}
- Consistency: The Template Method pattern ensures that the content sharing process follows a consistent sequence of steps across different social media platforms, reducing the chance of errors or inconsistencies in the shared content.
- Code reusability: The pattern promotes code reusability by extracting common parts of the content sharing process (e.g., formatting) into a base class, while allowing subclasses to provide custom behavior for platform-specific requirements.
- Flexibility: The pattern allows for easy addition of new social media platforms by simply extending the base class and providing implementations for the preparePlatformSpecificContent() and postContent() methods, without having to change the existing code.
Application 4. - Strategy
This design pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The strategy pattern lets algorithms vary independently from clients that use it.
Components:
- Context: Class that maintains a reference to a strategy object and delegates some operations to it.
- Strategy Interface: An interface that is implemented by all concrete strategies defining a common set of methods.
- Concrete Strategy: Implementation of different variations of the algorithm.
Example:
// Strategy Interface
interface RouteStrategy {
fun calculateRoute(start: String, destination: String): List<String>
}
// Concrete Strategy - FastestRouteStrategy
class FastestRouteStrategy : RouteStrategy {
override fun calculateRoute(start: String, destination: String): List<String> {
// Calculate the fastest route between start and destination
return listOf("Fastest route: $start -> $destination")
}
}
// Concrete Strategy - ShortestRouteStrategy
class ShortestRouteStrategy : RouteStrategy {
override fun calculateRoute(start: String, destination: String): List<String> {
// Calculate the shortest route between start and destination
return listOf("Shortest route: $start -> $destination")
}
}
// Concrete Strategy - ScenicRouteStrategy
class ScenicRouteStrategy : RouteStrategy {
override fun calculateRoute(start: String, destination: String): List<String> {
// Calculate the most scenic route between start and destination
return listOf("Scenic route: $start -> $destination")
}
}
// Context
class NavigationApp(private var routeStrategy: RouteStrategy) {
fun setRouteStrategy(strategy: RouteStrategy) {
routeStrategy = strategy
}
fun buildRoute(start: String, destination: String): List<String> {
return routeStrategy.calculateRoute(start, destination)
}
}
fun main() {
val start = "New York"
val destination = "San Francisco"
val navigationApp = NavigationApp(FastestRouteStrategy())
println(navigationApp.buildRoute(start, destination))
// Output: [Fastest route: New York -> San Francisco]
navigationApp.setRouteStrategy(ShortestRouteStrategy())
println(navigationApp.buildRoute(start, destination))
// Output: [Shortest route: New York -> San Francisco]
navigationApp.setRouteStrategy(ScenicRouteStrategy())
println(navigationApp.buildRoute(start, destination))
// Output: [Scenic route: New York -> San Francisco]
}
- Flexibility: The Strategy pattern allows for easy addition or removal of routing strategies without modifying the code that uses them.
- Encapsulation: The pattern encapsulates the implementation details of each routing algorithm, exposing a clean interface to the clients.
- Maintainability: The pattern separates the concerns of different routing algorithms, making it easier to maintain and modify individual strategies.
- Open/Closed Principle: The Strategy pattern adheres to the Open/Closed Principle, as it allows for extending the functionality by adding new strategies without modifying the existing code that uses them.
Application 5. Visitor
Visitor design pattern is a behavioral design pattern that allows you to perform operations on ob jects of different types that belong to a related hierarchy or structure, without modifying the classes of these objects. It decouples the operations to be performed from the object structure itself. This is particular useful when you need to add new functionality to an existing object hierarchy without changing the existing code.
Components:
- Element: An interface or abstract class that defines an accept method that takes a visitor object as an argument.
- Concrete Elements: Classes implement the Element interface or inherit from the Element class. These classes represent different types of elements in object structure.
- Visitor: An interface or abstract class that declares the visit methods for each type of concrete element.
- Concrete Visitor: Implementation of the visitor interface. This class contains logic for performingoperations on concrete elements.
Example:
// Element interface
interface Shape {
fun accept(visitor: ShapeVisitor)
}
// Concrete Elements
data class Circle(val radius: Double) : Shape {
override fun accept(visitor: ShapeVisitor) {
visitor.visit(this)
}
}
data class Rectangle(val width: Double, val height: Double) : Shape {
override fun accept(visitor: ShapeVisitor) {
visitor.visit(this)
}
}
data class Triangle(val base: Double, val height: Double) : Shape {
override fun accept(visitor: ShapeVisitor) {
visitor.visit(this)
}
}
// Visitor interface
interface ShapeVisitor {
fun visit(circle: Circle)
fun visit(rectangle: Rectangle)
fun visit(triangle: Triangle)
}
// Concrete Visitors
class AreaCalculator : ShapeVisitor {
var area: Double = 0.0
override fun visit(circle: Circle) {
area = Math.PI * circle.radius * circle.radius
}
override fun visit(rectangle: Rectangle) {
area = rectangle.width * rectangle.height
}
override fun visit(triangle: Triangle) {
area = 0.5 * triangle.base * triangle.height
}
}
class PerimeterCalculator : ShapeVisitor {
var perimeter: Double = 0.0
override fun visit(circle: Circle) {
perimeter = 2 * Math.PI * circle.radius
}
override fun visit(rectangle: Rectangle) {
perimeter = 2 * (rectangle.width + rectangle.height)
}
override fun visit(triangle: Triangle) {
// Here, we assume it's an equilateral triangle for simplicity.
perimeter = 3 * triangle.base
}
}
// Client code
fun main() {
val circle = Circle(5.0)
val rectangle = Rectangle(10.0, 20.0)
val triangle = Triangle(6.0, 4.0)
val shapes: List<Shape> = listOf(circle, rectangle, triangle)
val areaCalculator = AreaCalculator()
val perimeterCalculator = PerimeterCalculator()
for (shape in shapes) {
shape.accept(areaCalculator)
println("Area: ${areaCalculator.area}")
shape.accept(perimeterCalculator)
println("Perimeter: ${perimeterCalculator.perimeter}")
}
}
In this example, we use the Visitor pattern to separate the shape properties’ calculations (area and perimeter) from the shape classes themselves (Circle, Rectangle, and Triangle). Each shape class has an accept method that takes a visitor as an argument. The visitor is an object that implements the ShapeVisitor trait, which has methods for visiting each concrete shape type.
When the accept method is called on a shape, it passes itself to the appropriate visitor’s method. The visitor then performs the desired calculation based on the shape type it visits. This approach allows us to compute properties like area and perimeter for various shapes without having to know their specific types during runtime.
By using the Visitor pattern, we can add new properties or calculations related to shapes without modifying the shape classes themselves. Instead, we can simply extend the ShapeVisitor trait and create a new visitor class to handle these new properties. This promotes the separation of concerns and enhances the code’s maintainability and flexibility.