Introduction
Structural design patterns are an essential part of software development. They provide solutions for organizing classes and objects in a flexible and efficient manner. These patterns focus on the composition of objects and the relationships between them, enabling developers to build complex systems while ensuring code reusability and maintainability.
In this blog post, we will explore some common structural design patterns in Java, discuss their advantages and disadvantages, and provide illustrative examples. Let’s dive in!
Adapter Pattern
The Adapter pattern allows incompatible interfaces to work together by providing a bridge between them. It acts as a wrapper class, converting the interface of one class into another interface that clients expect. This pattern is useful when integrating existing or third-party components into your system.
Example
In the Client class, we use the LegacyPrinterAdapter to print a document by calling the print method of the Printer interface. When calling this method, the Adapter will forward the request to the LegacyPrinter and print the document using the printDocument method. With Adapter Pattern, we can use components that are not compatible with the defined interface to work with modern components in our system.
public interface Printer {
void print(String document);
}
public class LegacyPrinter {
public void printDocument(String document) {
System.out.println("Printing Legacy Document: " + document);
}
}
public class LegacyPrinterAdapter implements Printer {
private LegacyPrinter legacyPrinter;
public LegacyPrinterAdapter(LegacyPrinter legacyPrinter) {
this.legacyPrinter = legacyPrinter;
}
@Override
public void print(String document) {
legacyPrinter.printDocument(document);
}
}
public class Client {
public static void main(String[] args) {
// Using the LegacyPrinterAdapter to print a document
Printer printer = new LegacyPrinterAdapter(new LegacyPrinter());
printer.print("New Document"); // Output: Printing Legacy Document: New Document
}
}
Advantages
- Allows incompatible classes to work together.
- Provides a transparent interface to clients, hiding the complexities of the underlying classes.
- Supports the Open/Closed principle by allowing the addition of new adapters without modifying existing code.
Disadvantages
- Can introduce additional complexity and overhead.
- This may lead to an increased number of classes if multiple adapters are required.
When to use:
- Use the Adapter pattern when you need to make incompatible classes work together, convert the interface of one class to match the expectations of another class or provide a common interface for different classes.
Decorator Pattern
The Decorator pattern allows you to add new behaviors or responsibilities to objects dynamically without modifying their structure. It provides a flexible alternative to subclassing for extending functionality.
Example
In the client code, we create various laptops configurations:
- A basic laptop with no additional features.
- A laptop with an upgraded processor.
- A laptop with additional RAM.
- A laptop with a larger storage capacity.
The output of the client code shows the description and cost of each laptop configuration based on the decorators applied.
// Component interface
public interface Laptop {
String getDescription();
double getCost();
}
// Concrete Component
public class BasicLaptop implements Laptop {
@Override
public String getDescription() {
return "Basic laptop";
}
@Override
public double getCost() {
return 800.0;
}
}
// Decorator
public abstract class LaptopDecorator implements Laptop {
protected Laptop decoratedLaptop;
public LaptopDecorator(Laptop decoratedLaptop) {
this.decoratedLaptop = decoratedLaptop;
}
@Override
public String getDescription() {
return decoratedLaptop.getDescription();
}
@Override
public double getCost() {
return decoratedLaptop.getCost();
}
}
// Concrete Decorators
public class ProcessorDecorator extends LaptopDecorator {
public ProcessorDecorator(Laptop decoratedLaptop) {
super(decoratedLaptop);
}
@Override
public String getDescription() {
return super.getDescription() + ", with an upgraded processor";
}
@Override
public double getCost() {
return super.getCost() + 200.0;
}
}
public class RamDecorator extends LaptopDecorator {
public RamDecorator(Laptop decoratedLaptop) {
super(decoratedLaptop);
}
@Override
public String getDescription() {
return super.getDescription() + ", with additional RAM";
}
@Override
public double getCost() {
return super.getCost() + 100.0;
}
}
public class StorageDecorator extends LaptopDecorator {
public StorageDecorator(Laptop decoratedLaptop) {
super(decoratedLaptop);
}
@Override
public String getDescription() {
return super.getDescription() + ", with a larger storage capacity";
}
@Override
public double getCost() {
return super.getCost() + 150.0;
}
}
// Client
public class LaptopBuilder {
public static void main(String[] args) {
Laptop basicLaptop = new BasicLaptop();
System.out.println("Laptop: " + basicLaptop.getDescription() + ", Cost: $" + basicLaptop.getCost());
// Output: Laptop: Basic laptop, Cost: $800.0
Laptop laptopWithProcessor = new ProcessorDecorator(new BasicLaptop());
System.out.println("Laptop: " + laptopWithProcessor.getDescription() + ", Cost: $" + laptopWithProcessor.getCost());
// Output: Laptop: Basic laptop, with an upgraded processor, Cost: $1000.0
Laptop laptopWithRam = new RamDecorator(new BasicLaptop());
System.out.println("Laptop: " + laptopWithRam.getDescription() + ", Cost: $" + laptopWithRam.getCost());
// Output: Laptop: Basic laptop, with additional RAM, Cost: $900.0
Laptop laptopWithStorage = new StorageDecorator(new BasicLaptop());
System.out.println("Laptop: " + laptopWithStorage.getDescription() + ", Cost: $" + laptopWithStorage.getCost());
// Output: Laptop: Basic laptop, with a larger storage capacity, Cost: $950.0
}
}
Advantages
- Allows adding functionality to objects dynamically at runtime.
- Provides a flexible alternative to subclassing for extending behavior.
- Allows multiple decorators to be combined to create complex combinations of behaviors.
Disadvantages
- Can result in a large number of small classes if there are many possible combinations of decorators.
- Requires careful design to ensure that decorators are applied in the correct order.
When to use:
- Use the Decorator pattern when you need to add responsibilities or behaviors to objects dynamically without modifying their structure, or when you want to provide a flexible alternative to subclassing for extending functionality.
Composite Pattern
The Composite pattern allows you to treat individual objects and groups of objects uniformly. It represents a part-whole hierarchy where individual objects and groups are treated as interchangeable entities.
Example
import java.util.ArrayList;
import java.util.List;
// Component interface
interface LaptopComponent {
double getPrice();
}
// Leaf (Concrete Component)
class Processor implements LaptopComponent {
private String name;
private double price;
public Processor(String name, double price) {
this.name = name;
this.price = price;
}
@Override
public double getPrice() {
return price;
}
}
class Ram implements LaptopComponent {
private String name;
private double price;
public Ram(String name, double price) {
this.name = name;
this.price = price;
}
@Override
public double getPrice() {
return price;
}
}
class Storage implements LaptopComponent {
private String name;
private double price;
public Storage(String name, double price) {
this.name = name;
this.price = price;
}
@Override
public double getPrice() {
return price;
}
}
// Composite
class LaptopComposite implements LaptopComponent {
private String name;
private List<LaptopComponent> components = new ArrayList<>();
public LaptopComposite(String name) {
this.name = name;
}
public void addComponent(LaptopComponent component) {
components.add(component);
}
public void removeComponent(LaptopComponent component) {
components.remove(component);
}
@Override
public double getPrice() {
double totalPrice = 0;
for (LaptopComponent component : components) {
totalPrice += component.getPrice();
}
return totalPrice;
}
}
public class LaptopBuilder {
public static void main(String[] args) {
// Create individual components
Processor processor = new Processor("Intel Core i5", 300.0);
Ram ram = new Ram("8GB", 100.0);
Storage storage = new Storage("256GB SSD", 150.0);
// Create composite for additional features
LaptopComposite highEndLaptop = new LaptopComposite("High-End Laptop");
highEndLaptop.addComponent(processor);
highEndLaptop.addComponent(ram);
highEndLaptop.addComponent(storage);
// Create a mid-range laptop with less RAM
LaptopComposite midRangeLaptop = new LaptopComposite("Mid-Range Laptop");
midRangeLaptop.addComponent(processor);
midRangeLaptop.addComponent(new Ram("4GB", 50.0));
midRangeLaptop.addComponent(storage);
// Create a basic laptop with the default components
LaptopComposite basicLaptop = new LaptopComposite("Basic Laptop");
basicLaptop.addComponent(processor);
basicLaptop.addComponent(ram);
basicLaptop.addComponent(storage);
// Print the prices of different laptops
System.out.println("High-End Laptop Price: $" + highEndLaptop.getPrice());
// Output: High-End Laptop Price: $550.0
System.out.println("Mid-Range Laptop Price: $" + midRangeLaptop.getPrice());
// Output: Mid-Range Laptop Price: $500.0
System.out.println("Basic Laptop Price: $" + basicLaptop.getPrice());
// Output: Basic Laptop Price: $550.0 (Same as High-End Laptop, as they have the same components)
}
}
In the main
method:
- The individual components (Processor, Ram, and Storage) are created and initialized with their respective names and prices.
- Composite components (LaptopComposite) are created for different laptop configurations (highEndLaptop, midRangeLaptop, and basicLaptop).
- Components are added to the composite components using the addComponent method to create the desired configurations.
- The getPrice method is called on each composite component to calculate the total price of the laptop configurations, including the prices of their individual components.
Advantages:
- Simplifies the client code by treating individual objects and groups of objects uniformly.
- Allows the client to ignore the difference between leaf and composite objects.
- Supports the Open/Closed principle by allowing the addition of new components without modifying existing code.
Disadvantages:
- May not be suitable for scenarios where the hierarchy needs to be deep or dynamically changing.
- Can introduce a complex and hierarchical structure, making it harder to understand and maintain.
When to use:
- Use the Composite pattern when you want to represent part-whole hierarchies and treat individual objects and groups of objects uniformly, or when you need to perform operations on objects that can be either single objects or groups.
Proxy Pattern
The Proxy Pattern provides a surrogate or placeholder for another object to control access to it. It allows us to create an intermediary that acts as an interface to the underlying object, providing additional functionalities and controlling access to the real object.
Example
This simple example demonstrates how the Proxy Pattern can be used to cache website data and reduce unnecessary network requests for repeated URL accesses.
// WebsiteData interface
public interface WebsiteData {
String getPageContent(String url);
}
// RealWebsiteData class
public class RealWebsiteData implements WebsiteData {
@Override
public String getPageContent(String url) {
// Simulate fetching website content from the internet
System.out.println("Fetching content from the internet for URL: " + url);
return "Content for URL: " + url;
}
}
// ProxyWebsiteData class with caching
public class ProxyWebsiteData implements WebsiteData {
private WebsiteData realWebsiteData;
private Map<String, String> cachedPages;
public ProxyWebsiteData() {
realWebsiteData = new RealWebsiteData();
cachedPages = new HashMap<>();
}
@Override
public String getPageContent(String url) {
// Check if the page is already cached
if (cachedPages.containsKey(url)) {
return cachedPages.get(url);
} else {
// If not cached, fetch from the internet and store in the cache
String content = realWebsiteData.getPageContent(url);
cachedPages.put(url, content);
return content;
}
}
}
// Client
public class WebsiteUser {
public static void main(String[] args) {
WebsiteData websiteData = new ProxyWebsiteData();
// First time accessing the website, fetch from the internet and cache it
String content1 = websiteData.getPageContent("https://example.com");
System.out.println(content1);
// Subsequent access to the same URL, get it from the cache
String content2 = websiteData.getPageContent("https://example.com");
System.out.println(content2);
}
}
Advantages
- Allows the control of access to the real object.
- Provides additional functionalities before or after accessing the real object.
- Supports lazy object creation and caching.
Disadvantages
- Adds complexity to the system by introducing an additional layer.
- Can introduce overhead in accessing the real object.
When to use
- When you want to control access to the underlying object or add additional functionalities to it.
- When you want to implement lazy initialization or caching of objects.
Conclusion
Structural design patterns in Java provide powerful solutions for organizing classes and objects to build flexible and maintainable systems. Each pattern has its own purpose and use cases. By understanding these patterns and their characteristics, you can make informed design decisions and create robust software solutions.