Design Patterns
In software development, a design pattern is a reusable solution to a commonly occurring problem in software design. These are best practices that developers follow to solve issues related to object creation, interaction, and structure.
Design patterns are not specific pieces of code but templates or blueprints that provide guidance on how to solve specific software design challenges efficiently.
Why Do We Need Design Patterns?
- Reusability of Solutions:
Patterns provide time-tested, proven solutions, which means developers can avoid reinventing the wheel for common problems. - Improved Code Readability:
Code designed with familiar patterns is easier to understand and maintain, especially when new developers join the project. - Reduces Code Complexity:
Patterns guide developers toward modular, maintainable, and extensible code structures. - Enhances Maintainability:
Proper design patterns help separate concerns and minimize tight coupling, leading to easier modifications and bug fixes. - Improves Scalability:
Since patterns encourage modular design, it becomes easier to scale the application when new features are required. - Promotes Best Practices:
Design patterns reflect best practices that professional developers use, making code more robust and future-proof.
Types of Design Patterns in Java
There are three main categories of design patterns:
- Creational Patterns:
These deal with object creation mechanisms to promote flexibility and reuse.- Singleton: Ensures only one instance of a class exists.
- Factory Method: Creates objects without specifying the exact class.
- Builder: Helps construct complex objects step-by-step.
- Prototype: Creates objects by copying an existing object (cloning).
- Abstract Factory: Provides a way to create families of related objects.
- Structural Patterns:
These deal with object composition and the relationships between entities.- Adapter: Converts one interface to another.
- Decorator: Adds behavior to an object dynamically.
- Facade: Provides a simplified interface to a complex system.
- Composite: Treats individual objects and groups of objects uniformly.
- Proxy: Controls access to an object.
- Brdige: Decouples abstraction from implementation so that both can vary independently.
- Flyweight :Reduces memory usage by sharing common objects instead of creating new ones for every instance.
- Behavioral Patterns:
These focus on how objects interact and distribute responsibility.- Observer: Notifies objects about state changes in another object.
- Strategy: Selects an algorithm at runtime.
- Command: Encapsulates a request as an object.
- State: Allows an object to alter its behavior when its state changes.
- Chain of Responsibility: Passes requests along a chain of handlers.
- Iterator: Provides a way to access elements of a collection sequentially without exposing the underlying structure.
- Mediator: Defines an object that encapsulates communication between other objects to reduce coupling.
- Interpreter: Provides a way to interpret sentences or expressions from a language.
- Memento: Captures and restores an object’s state without exposing its implementation.
- Template: Defines the skeleton of an algorithm and allows subclasses to implement specific steps.
Adapter Design Pattern
The Adapter Design Pattern is a structural design pattern in Java (and other languages) used to enable incompatible interfaces to work together. The Adapter pattern allows you to wrap an incompatible object with a “wrapper” class that translates the interface of a class into another interface that a client expects.
Why We Need the Adapter Pattern
In real-world applications, we often face scenarios where we need to integrate third-party libraries or legacy code into our application, but their interfaces may not be compatible with our system’s interface. The Adapter Pattern enables us to make these incompatible classes work together without modifying the original code.
Real-World Example
Consider a payment system where you want to integrate two payment providers: a legacy payment provider (OldPaymentProcessor
) and a new one (NewPaymentProcessor
). You want to create a single PaymentService
that your application uses for all payment processing, irrespective of the provider.
Here’s how the Adapter Pattern can be applied to make both payment processors compatible with PaymentService
.
Code Example
- Define the Target Interface (
PaymentService
): This is the interface your application works with. - Implement Adapters for each of the payment processors.
- Integrate the
PaymentService
interface in the main application code.
Here’s the code for this scenario:
Step 1: Define the Target Interface (PaymentService
)
// Target Interface
public interface PaymentService {
void processPayment(double amount);
}
Step 2: Implement Legacy and New Payment Processors
// Legacy Payment Processor (not compatible with PaymentService interface)
public class OldPaymentProcessor {
public void makePayment(double amount) {
System.out.println("Processing payment through Old Payment Processor: $" + amount);
}
}
// New Payment Processor (compatible with PaymentService interface)
public class NewPaymentProcessor implements PaymentService {
@Override
public void processPayment(double amount) {
System.out.println("Processing payment through New Payment Processor: $" + amount);
}
}
Step 3: Create an Adapter for the Legacy Processor
// Adapter for OldPaymentProcessor to make it compatible with PaymentService
public class OldPaymentAdapter implements PaymentService {
private OldPaymentProcessor oldPaymentProcessor;
public OldPaymentAdapter(OldPaymentProcessor oldPaymentProcessor) {
this.oldPaymentProcessor = oldPaymentProcessor;
}
@Override
public void processPayment(double amount) {
oldPaymentProcessor.makePayment(amount); // Adapting the method call
}
}
Step 4: Use the Adapters in the Application
public class PaymentApplication {
public static void main(String[] args) {
// Using New Payment Processor directly
PaymentService newPaymentService = new NewPaymentProcessor();
newPaymentService.processPayment(100);
// Using Old Payment Processor through Adapter
PaymentService oldPaymentService = new OldPaymentAdapter(new OldPaymentProcessor());
oldPaymentService.processPayment(200);
}
}
Explanation of the Code
PaymentService
: The common interface that the application uses for payment processing.OldPaymentProcessor
: The legacy payment system that is incompatible withPaymentService
.OldPaymentAdapter
: The Adapter class that translates theOldPaymentProcessor
methods to make it compatible withPaymentService
.NewPaymentProcessor
: Implements thePaymentService
directly and is compatible.
Output
Running the PaymentApplication
will produce output as follows:
Processing payment through New Payment Processor: $100
Processing payment through Old Payment Processor: $200
Benefits of the Adapter Pattern
- Compatibility: Allows incompatible interfaces to work together without modifying the original code.
- Reusability: Can reuse legacy code or third-party libraries without changing them.
- Flexibility: Adapters can be easily replaced if a new system or interface is introduced.
This pattern is widely used in scenarios requiring integration with external systems, databases, or legacy systems.
The Adapter Design Pattern is used internally in the Java SDK in many places to allow different classes to work together without requiring changes to the original class interfaces. Here are some examples where the Adapter Pattern is used in the Java SDK:
Example 1: java.util.Arrays#asList
Method
Example 2: java.io.InputStreamReader
and java.io.OutputStreamWriter
Proxy Design Pattern
The Proxy Design Pattern is a structural design pattern, that provides a surrogate or placeholder for another object to control access to it. It’s commonly used to control access to an object, particularly when additional functionality (like security checks, lazy initialization, logging, or access control) needs to be added without modifying the actual object.
Why Use the Proxy Design Pattern?
- Lazy Initialization (Virtual Proxy): Delay the creation of a resource-intensive object until it is actually needed.
- Access Control (Protection Proxy): Control access to certain functionalities or sensitive objects.
- Logging and Auditing (Logging Proxy): Log access or operations on an object for monitoring purposes.
- Remote Proxy: Provide a local representative for an object located in a different address space (e.g., remote objects in RMI).
- Smart Reference: Add additional actions when an object is accessed, such as keeping track of the number of references.
Real-Time Application Example: Security and Access Control
Suppose we have a DatabaseExecutor
interface to execute database commands. However, we want to restrict certain commands (like DELETE
) to users with admin rights only. A proxy can be used to enforce this access control.
Code Example
- Step 1: Define the
DatabaseExecutor
interface. - Step 2: Implement the
DatabaseExecutorImpl
class, which executes actual database commands. - Step 3: Implement the
DatabaseExecutorProxy
, which controls access to theDatabaseExecutorImpl
.
1. Define the DatabaseExecutor
Interface
public interface DatabaseExecutor {
void executeQuery(String query) throws Exception;
}
2. Implement the DatabaseExecutorImpl
Class
public class DatabaseExecutorImpl implements DatabaseExecutor {
@Override
public void executeQuery(String query) throws Exception {
System.out.println("Executing Query: " + query);
}
}
3. Implement the DatabaseExecutorProxy
Class
public class DatabaseExecutorProxy implements DatabaseExecutor {
private boolean isAdmin;
private DatabaseExecutor executor;
public DatabaseExecutorProxy(String user, String password) {
// Simple authentication check for demonstration
if ("admin".equals(user) && "admin123".equals(password)) {
isAdmin = true;
}
executor = new DatabaseExecutorImpl();
}
@Override
public void executeQuery(String query) throws Exception {
if (isAdmin) {
executor.executeQuery(query);
} else {
if (query.trim().toLowerCase().startsWith("delete")) {
throw new Exception("DELETE operation is not allowed for non-admin users.");
} else {
executor.executeQuery(query);
}
}
}
}
Usage Example in a Real-Time Scenario
public class ProxyPatternDemo {
public static void main(String[] args) {
try {
DatabaseExecutor adminExecutor = new DatabaseExecutorProxy("admin", "admin123");
adminExecutor.executeQuery("DELETE FROM Employee"); // Allowed for admin
DatabaseExecutor userExecutor = new DatabaseExecutorProxy("user", "user123");
userExecutor.executeQuery("SELECT * FROM Employee"); // Allowed
userExecutor.executeQuery("DELETE FROM Employee"); // Not allowed, throws Exception
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
Explanation of the Code
- DatabaseExecutor: An interface representing the action of executing a query.
- DatabaseExecutorImpl: The real subject that performs the actual database operations.
- DatabaseExecutorProxy: The proxy that restricts access based on the user’s credentials.
- ProxyPatternDemo: A demonstration of how the Proxy pattern is used in a real-time scenario where only admins can execute
DELETE
operations.
Benefits of Using the Proxy Pattern
- Separation of Concerns: The main business logic doesn’t need to handle access control; it’s delegated to the proxy.
- Flexibility: You can change access policies in the proxy without affecting the underlying implementation.
- Security: Sensitive operations are protected and accessed in a controlled way.
In the Java SDK, the Proxy Design Pattern is implemented using the java.lang.reflect.Proxy
class and InvocationHandler
interface. Java’s dynamic proxy allows you to create a proxy instance at runtime instead of writing a specific proxy class, making it flexible and powerful for cases where you want to add common behavior across multiple implementations.
How Java Dynamic Proxy Works
- Proxy Class: Java’s
Proxy
class provides static methods to create dynamic proxy classes and instances. - InvocationHandler Interface:
InvocationHandler
is implemented to define the behavior that should happen when a method is invoked on the proxy instance.
Singleton Design Pattern
The Singleton design pattern ensures that a class has only one instance and provides a global point of access to that instance. It is commonly used when exactly one instance of a class is needed to control certain system operations, such as a logging service, configuration settings, or database connections.
Why We Need the Singleton Pattern
- Resource Optimization: Limits resource usage by ensuring only one instance of a heavy resource like database connection or file handler is created.
- Controlled Access: Provides a global point of access, ensuring that only a single instance of a class is used across the application.
- Consistency: Ensures consistent and reliable access to shared data or resources.
Achieving Singleton in Java
There are several ways to implement a Singleton pattern in Java. Here are three common approaches:
1. Eager Initialization
In this method, the instance is created at the time of class loading.
public class Singleton {
private static final Singleton instance = new Singleton();
// Private constructor prevents instantiation from other classes
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
2. Lazy Initialization (Thread-Safe with synchronized
)
In lazy initialization, the instance is created when it is first requested. This approach is thread-safe.
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
//OR
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
}
3. Bill Pugh Singleton Design (Using Inner Static Helper Class)
This approach leverages the Java ClassLoader mechanism to ensure thread safety without synchronized
. The Singleton instance is created when the Helper
class is loaded.
public class Singleton {
private Singleton() {}
// Inner static class responsible for holding the Singleton instance
private static class SingletonHelper {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHelper.INSTANCE;
}
}
Usage Example
Here’s how you would typically call a Singleton instance:
public class Main {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
// Use the singleton instance
}
}
How to Break Singleton Pattern?
Despite the Singleton pattern’s design to ensure only one instance of a class, it can be broken in Java in several ways. Here are some common methods:
1. Using Reflection
Reflection can be used to access the private constructor and create a new instance, which breaks the Singleton pattern.
import java.lang.reflect.Constructor;
public class SingletonBreaker {
public static void main(String[] args) {
Singleton instanceOne = Singleton.getInstance();
Singleton instanceTwo = null;
try {
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true); // Allows access to private constructor
instanceTwo = constructor.newInstance();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("Instance One: " + instanceOne.hashCode());
System.out.println("Instance Two: " + instanceTwo.hashCode());
}
}
class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
The hashCode
values of instanceOne
and instanceTwo
will differ, showing that reflection has broken the Singleton pattern.
Solution
To prevent this, you can modify the constructor to throw an exception if an instance already exists.
class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {
if (instance != null) {
throw new IllegalStateException("Instance already exists!");
}
}
public static Singleton getInstance() {
return instance;
}
}
2. Serialization and Deserialization
Serialization can create a new instance when an object is deserialized.
import java.io.*;
public class SingletonBreakerSerialization {
public static void main(String[] args) {
Singleton instanceOne = Singleton.getInstance();
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("singleton.ser"))) {
out.writeObject(instanceOne);
} catch (IOException e) {
e.printStackTrace();
}
Singleton instanceTwo = null;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("singleton.ser"))) {
instanceTwo = (Singleton) in.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println("Instance One: " + instanceOne.hashCode());
System.out.println("Instance Two: " + instanceTwo.hashCode());
}
}
class Singleton implements Serializable {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
// Prevent new instance creation on deserialization
protected Object readResolve() {
return instance;
}
}
The readResolve()
method ensures the existing instance is returned instead of creating a new one during deserialization.
3. Cloning
Cloning can break Singleton by creating a new instance from an existing one.
public class SingletonBreakerCloning {
public static void main(String[] args) throws CloneNotSupportedException {
Singleton instanceOne = Singleton.getInstance();
Singleton instanceTwo = (Singleton) instanceOne.clone();
System.out.println("Instance One: " + instanceOne.hashCode());
System.out.println("Instance Two: " + instanceTwo.hashCode());
}
}
class Singleton implements Cloneable {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
// Prevent new instance creation by overriding clone method
@Override
protected Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException();
}
}
By overriding clone()
to throw CloneNotSupportedException
, you can prevent cloning from breaking the Singleton pattern.
4. Using Multiple Class Loaders
If a Singleton class is loaded by multiple class loaders, each class loader can create a separate instance of the Singleton class. This issue often arises in environments like application servers where different modules have different class loaders.
Solution
This is harder to guard against in general, but in environments where this is a concern, you can use a combination of readResolve()
, reflection guards, and careful class loading configurations.
5. Using Enum Singleton
Using an enum
to implement Singleton is a way to make it inherently resistant to all these breaking mechanisms. Enum Singletons are automatically thread-safe, cannot be cloned, and maintain Singleton properties even with serialization.
public enum SingletonEnum {
INSTANCE;
public void someMethod() {
// Your method code here
}
}
The SingletonEnum.INSTANCE
approach provides a guaranteed Singleton that’s safe from all the above pitfalls.
Factory Design Pattern
The Factory Design Pattern is a creational design pattern that provides an interface for creating objects in a superclass but allows subclasses to alter the type of objects that will be created. It’s particularly useful when the exact type of object to be created isn’t known until runtime.
Why We Need the Factory Pattern
- Encapsulation of Object Creation Logic: It abstracts the process of object creation and centralizes it, making the code more maintainable and adaptable.
- Promotes Loose Coupling: The client code depends on an abstract interface rather than a concrete implementation, allowing flexibility.
- Single Responsibility Principle: The responsibility of creating an object is separated, making the system easier to extend without modifying existing code.
- Reduces Code Duplication: If creating an object requires multiple steps or configurations, the Factory pattern ensures this is done in one place.
Example: Creating Doctor
and Teacher
Objects Using the Factory Pattern
Let’s create a scenario where Doctor
and Teacher
implement a Profession
interface, and we have a ProfessionFactory
to create objects of these types.
Step 1: Define the Profession
Interface
public interface Profession {
void print();
}
Step 2: Create Doctor
and Teacher
Classes Implementing Profession
public class Doctor implements Profession {
@Override
public void print() {
System.out.println("I am a Doctor.");
}
}
public class Teacher implements Profession {
@Override
public void print() {
System.out.println("I am a Teacher.");
}
}
Step 3: Implement the ProfessionFactory
Class
The ProfessionFactory
class will contain a method getProfession()
that takes a String
as input and returns the appropriate Profession
object based on the input.
public class ProfessionFactory {
// Factory method to create instances based on input
public Profession getProfession(String professionType) {
if (professionType == null) {
return null;
}
if (professionType.equalsIgnoreCase("Doctor")) {
return new Doctor();
} else if (professionType.equalsIgnoreCase("Teacher")) {
return new Teacher();
}
return null;
}
}
Step 4: Using the ProfessionFactory
to Create Objects
Here’s how you would use the factory to create and use Doctor
and Teacher
objects:
public class Main {
public static void main(String[] args) {
ProfessionFactory professionFactory = new ProfessionFactory();
// Creating a Doctor instance
Profession doctor = professionFactory.getProfession("Doctor");
doctor.print(); // Output: I am a Doctor.
// Creating a Teacher instance
Profession teacher = professionFactory.getProfession("Teacher");
teacher.print(); // Output: I am a Teacher.
}
}
Explanation
- Interface:
Profession
defines the common methodprint()
. - Concrete Classes:
Doctor
andTeacher
implement theProfession
interface, each providing specific behavior in theprint()
method. - Factory Class:
ProfessionFactory
encapsulates the logic of object creation. It decides which subclass instance to return based on input, hiding this logic from the client code. - Client Code (Main Class): Uses the factory to obtain the appropriate
Profession
object, promoting loose coupling and flexibility in the code.
By using the Factory pattern, you can now extend the code easily to add new Profession
implementations without modifying the existing client code.
Builder Design Pattern
The Builder Design Pattern is a creational design pattern used to construct complex objects with a clear and understandable process. Instead of creating a large constructor with many parameters, the Builder pattern allows us to create an object step-by-step, optionally setting only those parameters that are relevant to the specific use case.
Why We Need the Builder Pattern
- Improves Readability: For complex objects with many attributes, the Builder pattern makes the construction readable and avoids telescoping constructors (constructors with an increasing number of parameters).
- Immutable Objects: The Builder pattern helps create immutable objects where each attribute is set during the object construction phase.
- Flexibility in Object Creation: The client can selectively set only the attributes it needs, while default values can be provided for the rest.
Implementing the Builder Pattern in Java
Let’s take an example of creating a House
class that has multiple optional attributes, such as the number of rooms, color, hasGarage, and hasGarden. We’ll implement the Builder pattern for this class.
Step 1: Define the Class with a Private Constructor
In this example, we’ll use a nested static Builder
class that will provide methods to set each attribute.
public class House {
// Required attributes
private final int rooms;
private final String color;
// Optional attributes
private final boolean hasGarage;
private final boolean hasGarden;
// Private constructor to prevent direct instantiation
private House(HouseBuilder builder) {
this.rooms = builder.rooms;
this.color = builder.color;
this.hasGarage = builder.hasGarage;
this.hasGarden = builder.hasGarden;
}
@Override
public String toString() {
return "House [rooms=" + rooms + ", color=" + color +
", hasGarage=" + hasGarage + ", hasGarden=" + hasGarden + "]";
}
// Static nested Builder class
public static class HouseBuilder {
// Required attributes
private final int rooms;
private final String color;
// Optional attributes with default values
private boolean hasGarage = false;
private boolean hasGarden = false;
// Constructor for required attributes
public HouseBuilder(int rooms, String color) {
this.rooms = rooms;
this.color = color;
}
// Method to set the optional attribute hasGarage
public HouseBuilder setHasGarage(boolean hasGarage) {
this.hasGarage = hasGarage;
return this; // Returns the builder instance to enable chaining
}
// Method to set the optional attribute hasGarden
public HouseBuilder setHasGarden(boolean hasGarden) {
this.hasGarden = hasGarden;
return this; // Returns the builder instance to enable chaining
}
// Method to build the final House object
public House build() {
return new House(this);
}
}
}
Step 2: Create an Instance Using the Builder
public class Main {
public static void main(String[] args) {
// Creating a House instance with the Builder
House house = new House.HouseBuilder(3, "Blue")
.setHasGarage(true)
.setHasGarden(true)
.build();
System.out.println(house);
// Output: House [rooms=3, color=Blue, hasGarage=true, hasGarden=true]
}
}
Explanation
- Constructor with Builder: The
House
class has a private constructor that takes theHouseBuilder
as a parameter. - Required Attributes:
rooms
andcolor
are marked as required and are set through theHouseBuilder
constructor. - Optional Attributes:
hasGarage
andhasGarden
have default values that can be changed using thesetHasGarage()
andsetHasGarden()
methods. - Fluent API: Each
set
method in theHouseBuilder
class returnsthis
, allowing method chaining. - Building the Object: The
build()
method inHouseBuilder
returns the finalHouse
object.
Benefits of Using the Builder Pattern
- Simplifies Object Creation: Especially useful for objects with many fields or optional parameters.
- Ensures Immutability: Once built, the object is immutable, as all fields are final.
- Flexible Object Configuration: Clients can selectively set only the fields they need.
The Builder pattern is particularly useful when creating complex objects where readability and flexibility are essential.
Prototype Design Pattern
The Prototype Design Pattern is a creational design pattern that allows you to create new objects by copying an existing object (prototype), rather than creating them from scratch. This pattern is particularly useful when object creation is costly in terms of time or resources and when creating new instances directly is complex.
Use Case for the Prototype Design Pattern
The Prototype pattern is useful when creating an instance of a class is resource-intensive or involves a complex setup process. Instead of creating new objects from scratch, the pattern allows you to copy (clone) an existing instance, which can be faster and more efficient. This is particularly valuable in scenarios such as:
- Object Creation Cost: When object creation involves a significant amount of computation or time (e.g., database calls, complex initialization).
- Complex Object Structure: When the object has many dependencies or configurations.
- Multiple Configurations: When you need many instances with similar properties that only differ slightly.
Pros of the Prototype Design Pattern
- Performance Improvement: Reduces the cost of creating complex objects by cloning an existing instance instead of building from scratch.
- Simplified Object Creation: Makes creating an object easier when its structure is intricate or expensive to instantiate.
- Customization: You can create customized copies of objects while maintaining the prototype as a base.
- Decoupling: Reduces dependency on classes that are responsible for creating new instances, thus simplifying client code.
Cons of the Prototype Design Pattern
- Cloning Complexity: Deep cloning can be complex and require careful handling of nested objects to avoid issues such as shared references or inconsistent states.
- Memory Overhead: If prototypes have a large memory footprint, storing multiple copies may lead to high memory consumption.
- Maintaining Prototypes: Keeping the prototype registry up to date and ensuring consistent state can add overhead to the codebase.
- Limited Use Cases: Not all applications benefit from cloning; in simple cases, direct object instantiation might be more straightforward and readable.
Real-World Example Use Case
Consider a game development scenario where characters or assets (e.g., NPCs, vehicles) have numerous attributes and take significant time to set up (e.g., textures, behaviors). Instead of creating new instances each time, you could clone existing prototypes from a registry, adjusting specific attributes as needed, which improves performance and allows for quick replication of game assets with similar properties.
This pattern provides a robust way to handle object creation when performance and flexibility are priorities but should be used with careful consideration due to potential maintenance challenges.
// Step 1: Create an interface for the Prototype pattern
interface Shape extends Cloneable {
Shape cloneShape();
void draw();
}
// Step 2: Implement concrete classes for the Prototype pattern
class Circle implements Shape {
private String color;
public Circle(String color) {
this.color = color;
}
@Override
public Shape cloneShape() {
try {
return (Shape) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException("Failed to clone Circle", e);
}
}
@Override
public void draw() {
System.out.println("Drawing a " + color + " Circle");
}
}
class Rectangle implements Shape {
private String color;
public Rectangle(String color) {
this.color = color;
}
@Override
public Shape cloneShape() {
try {
return (Shape) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException("Failed to clone Rectangle", e);
}
}
@Override
public void draw() {
System.out.println("Drawing a " + color + " Rectangle");
}
}
// Step 3: Create a registry to store and manage prototypes
class ShapeRegistry {
private Map<String, Shape> shapeMap = new HashMap<>();
public void registerShape(String key, Shape shape) {
shapeMap.put(key, shape);
}
public Shape getShape(String key) {
Shape prototype = shapeMap.get(key);
return (prototype != null) ? prototype.cloneShape() : null;
}
}
// Step 4: Demonstrate the Prototype pattern with the registry
public class PrototypeRegistryDemo {
public static void main(String[] args) {
// Create a registry and add prototypes
ShapeRegistry registry = new ShapeRegistry();
registry.registerShape("blueCircle", new Circle("Blue"));
registry.registerShape("redRectangle", new Rectangle("Red"));
// Clone shapes from the registry
Shape clonedCircle = registry.getShape("blueCircle");
Shape clonedRectangle = registry.getShape("redRectangle");
// Use the cloned shapes
if (clonedCircle != null) {
clonedCircle.draw(); // Output: Drawing a Blue Circle
}
if (clonedRectangle != null) {
clonedRectangle.draw(); // Output: Drawing a Red Rectangle
}
}
}