tech-notes-and-questions

Behavioral Design Patterns

[definition, purpose, use cases, design, example, implementation]

Behavioral patterns are concerned with managing algorithms, relationships, and responsibilities between objects, ensuring that objects communicate effectively and efficiently to perform tasks.

  1. Chain of Responsibility - passes a request along a chain of handlers
  2. Command - encapsulates a request as an object
  3. Interpreter - interprets sentences in a language
  4. Iterator - provides a way to traverse a collection without exposing its underlying representation
  5. Mediator - defines communication between objects to reduce direct interactions
  6. Memento - saves and restores an object’s state
  7. Observer - notifies dependent objects of state changes
  8. State - changes an object’s behavior when its state changes
  9. Strategy - allows the selection of an algorithm at runtime
  10. Template Method - defines the structure of an algorithm but lets subclasses modify certain steps
  11. Visitor - adds new operations to a class without changing it

1. Chain of Responsibility Pattern

Definition

This pattern allows an object to pass the request along a chain of handlers. Upon receiving a request, each handler decides whether to process the request or to pass it to the next handler in the chain.

Purpose

When you have a set of conditions, and you need to pick the one to be applied based on priority.

Design

Example

Say, you have a shopping cart and a set of offers where we want to pick which to be applied based on a set priority. The offers (priority wise):

public class Offer4 extends OfferHandler {
    @Override
    public void applyOffer(Order order) {
        if(order.getCost()>25 && order.getItemCategories().contains("GROCERIES")) {
            order.setOfferType("OFFER 4");
            System.out.println(order.getOfferType() + " applied to " + order.getOrderId());
        }
        else if (nextOfferHandler!=null) {
            nextOfferHandler.applyOffer(order);
        }
    }
}
  1. Instantiate the offers & setup the chain & run the order through it
    public class ChainOfResponsibility {
     public static void main(String[] args) {
    
         // Initiate offers
         OfferHandler offer1 = new Offer1();
         OfferHandler offer2 = new Offer2();
         OfferHandler offer3 = new Offer3();
         OfferHandler offer4 = new Offer4();
    
         // Set up the chain
         offer1.setNextOfferHandler(offer2);
         offer2.setNextOfferHandler(offer3);
         offer3.setNextOfferHandler(offer4);
    
         Order order1 = new Order("1", 30, List.of("GROCERIES"), true, null);
         Order order2 = new Order("2", 30, List.of("GROCERIES"), false, null);
    
         offer1.applyOffer(order1);  // OFFER 2 applied to 1
         offer1.applyOffer(order2);  // OFFER 4 applied to 2
     }
    }
    


</br>

2. Command Pattern

Definition

Purpose

When you need to execute some commands, but also want to decouple the command invoker & command executor so you can make client requests encapsulated, queued, logged, or executed later.

Use Cases

1. Undo/Redo Functionalities in text editors, games, etc.

2. Logging or Transactional Mechanisms in Banking systems, File Systems

Design

Example

Let’s assume we have a Light class that can be turned on and off, and we want to implement the Command pattern to encapsulate these operations.

Implementation

  1. Command Interface
    // Command Interface
    public interface Command {
     void execute();
    }
    
  2. Concrete Command Classes ```java // Concrete Command to turn the light on public class LightOnCommand implements Command { private Light light;

    public LightOnCommand(Light light) { this.light = light; }

    @Override public void execute() { light.turnOn(); } }

// Concrete Command to turn the light off public class LightOffCommand implements Command { private Light light;

public LightOffCommand(Light light) {
    this.light = light;
}

@Override
public void execute() {
    light.turnOff();
} } ```
  1. Receiver class
    // Receiver Class
    public class Light {
     public void turnOn() {
         System.out.println("The light is on.");
     }
    
     public void turnOff() {
         System.out.println("The light is off.");
     }
    }
    
  2. Invoker class
    // Invoker Class
    public class RemoteControl {
     private Command command;
    
     // Set the command to be executed
     public void setCommand(Command command) {
         this.command = command;
     }
    
     // Execute the command
     public void pressButton() {
         command.execute();
     }
    }
    
  3. Client code
    // Client
    public class CommandPatternDemo {
     public static void main(String[] args) {
         // Receiver
         Light light = new Light();
    
         // Concrete Commands
         Command lightOnCommand = new LightOnCommand(light);
         Command lightOffCommand = new LightOffCommand(light);
    
         // Invoker
         RemoteControl remote = new RemoteControl();
    
         // Turn the light on
         remote.setCommand(lightOnCommand);
         remote.pressButton();
    
         // Turn the light off
         remote.setCommand(lightOffCommand);
         remote.pressButton();
     }
    }
    


</br>

3. Interpreter Pattern

Definition

The pattern defines

Purpose

Use cases

1. Interpreting Domain Specific Languages (DSLs)

2. Configuration or Scripting Languages

3. Spreadsheet Formulas

Design

Example

Let’s create a simple example where we interpret mathematical expressions using the Interpreter pattern. We’ll define an expression that can handle addition, subtraction, and numbers.

Implementation

  1. Define the Expression interface
    interface Expression {
     int interpret();
    }
    
  2. Create Terminal Expression classes for Numbers
    class NumberExpression implements Expression {
     private int number;
    
     public NumberExpression(int number) {
         this.number = number;
     }
    
     @Override
     public int interpret() {
         return this.number;
     }
    }
    
  3. Create Non-Terminal Expression classes for Add and Subtract
class AddExpression implements Expression {
    private Expression leftExpression;
    private Expression rightExpression;

    public AddExpression(Expression leftExpression, Expression rightExpression) {
        this.leftExpression = leftExpression;
        this.rightExpression = rightExpression;
    }

    @Override
    public int interpret() {
        return leftExpression.interpret() + rightExpression.interpret();
    }
}
class SubtractExpression implements Expression {
    private Expression leftExpression;
    private Expression rightExpression;

    public SubtractExpression(Expression leftExpression, Expression rightExpression) {
        this.leftExpression = leftExpression;
        this.rightExpression = rightExpression;
    }

    @Override
    public int interpret() {
        return leftExpression.interpret() - rightExpression.interpret();
    }
}
  1. Client code that builds the expression tree
public static void main(String[] args) {
        // Create number expressions
        Expression ten = new NumberExpression(10);
        Expression twenty = new NumberExpression(20);
        Expression five = new NumberExpression(5);

        // Create the expression tree: (10 + 20) - 5
        Expression addExpression = new AddExpression(ten, twenty);
        Expression subtractExpression = new SubtractExpression(addExpression, five);

        // Interpret the expression tree and print the result
        int result = subtractExpression.interpret();
        System.out.println("(10 + 20) - 5 = " + result);  // Output: (10 + 20) - 5 = 25
    }


</br>

4. Iterator Pattern

Definition

The pattern allows for sequential access of elements in a collection without exposing its underlying representation.

Purpose

It provides a standard way to iterate through collections like lists, arrays, or trees while hiding the complexities involved in the iteration process.

Use cases

1. Custom Iteration Logic

2. Batch Processing of Elements

3. Handling Composite Structures

Design

Example

Let’s consider a basic Java example of iterating through an array of Strings

Implementation

  1. Define the Iterator Interface
    public interface Iterator<T> {
     boolean hasNext();
     T next();
    }
    
  2. Create the Aggregator Interface
    public interface IterableCollection<T> {
     Iterator<T> createIterator();
    }
    
  3. Concrete Iterator Implementation
    public class NameIterator implements Iterator<T> {
     private String[] names;
     private int index;
        
     public NameIterator(String[] names) {
         this.names = names;
         this.index = 0;
     }
        
     @Override
     public boolean hasNext() {
         return index<names.length;
     }
        
     @Override
     public String next() {
         if(this.hasNext()) {
             return names[index++];
         }
         return null;
     }
    }
    
  4. Concrete Collection Implementation
public class NameCollection implements IterableCollection<T> {
    private String[] names;
    
    public NameCollection(String[] names) {
        this.names = names;
    }
    
    @Override
    public Iterator<String> createIterator() {
        return NameIterator(names);
    }
} 
  1. Client Code to Use the Iterator
public static void main (String[] args) {
    String[] names = { "Dave", "Joseph", "Jessica", "Nick" };
    
    NameCollection nameCollection = new NameCollection(names);
    Iterator<String> iterator = nameCollection.createIterator();
    
    while ((iterator.hasNext())) {
        String name = iterator.next();
        System.out.println("Name: " + name);
    }
}


</br>

5. Mediator Pattern

Definition

Purpose

When you want to loosely couple objects and manage their interactions (control and logic) in another class

Use cases

1. Chat Applications

2. Workflow Systems

3. Order Processing Systems

Design

Example

Let’s use a chatroom as an example where multiple users can communicate with each other. We’ll use the Mediator pattern to manage the interactions between users.

Implementation

  1. Define the Mediator interface
public interface Mediator {
    void sendMessage (String message, User user);
}
  1. Define the Colleague interface/abstract class
import java.awt.*;

public abstract class User {
    protected Mediator mediator;
    protected String name;

    public User(Mediator mediator, String name) {
        this.mediator = mediator;
        this.name = name;
    }

    public abstract void receiveMessage(String message);
    public abstract void sendMessage(String message);
}
  1. Implement the ConcreteMediator
public class ChatRoom implements Mediator {
    private List<User> users = new ArrayList<>();

    public void addUser(User user) {
        users.add(user);
    }
    
    @Override
    public void sendMessage(String message, User user) {
        for (User u : users) {
            if (u != user) {
                u.receiveMessage(message);
            }
        }
    }
}
  1. Implement the ConcreteColleague classes
public class ConcreteUser extends User {
    public ConcreteUser(Mediator mediator, String name) {
        super(mediator, name);
    }
    
    @Override
    public void receiveMessage (String message) {
        System.out.println(name + " received: " + message);
    }
    
    @Override
    public void sendMessage (String message) {
        System.out.println(name + " sent: " + message);
        mediator.sendMessage(message, this);
    }
}
  1. Client code to demonstrate the usage
public static void main (String[] args) {
    
    ChatRoom chatRoom = new ChatRoom();
    
    ConcreteUser dave = new ConcreteUser (chatRoom, "Dave");
    ConcreteUser jessica = new ConcreteUser (chatRoom, "Jessica");
    ConcreteUser nick = new ConcreteUser (chatRoom, "Nick");
    
    chatRoom.addUser(dave);
    chatRoom.addUser(jessica);
    chatRoom.addUser(nick);
    
    dave.sendMessage("Hi! I'm Dave, everyone.");
    jessica.sendMessage("Hi Dave and everyone else");
    nick.sendMessage("Hi Dave and Jessica, and everyone else");
}


</br>

6. Memento Pattern

Definition

The design pattern is used to capture and restore an object’s state without violating encapsulation.

Purpose

It is particularly useful when you need to implement undo/redo functionality or save and restore the state of an object.

Use cases

1. Undo/Redo Functionality

2. Version Control Systems

3. Simulation and Testing

Design

Example

Let’s consider a simple example of a text editor where we can save and restore the state of the text content.

Implementation

  1. Create Originator class whose state needs to be stored
public class TextEditor {
    private String content;
    
    public void setContent(String content) {
        this.content = content;
    }
    
    public String getContent() {
        return content;
    }
    
    public Memento save() {
        return new Memento(content);
    }
    
    public void restore(Memento memento) {
        this.content = memento.getContent();
    }
}
  1. Create Memento class which stores the state of Originator objects
public class Memento {
    private final String content;
    
    public Memento (String content) {
        this.content = content;
    }
    
    public String getContent() {
        return content;
    }
}
  1. Create Caretaker class that handles Memento’s requests to restore Originator objects
import java.util.Stack;

public class Caretaker {
    private final Stack<Memento> mementoStack = new Stack<>();
    
    public void saveState(TextEditor editor) {
        mementoStack.push(editor.save());
    }
    
    public void restoreState(TextEditor editor) {
        if(!mementoStack.isEmpty()) {
            Memento memento = mementoStack.pop();
            editor.restore(memento);
        }
    }
}
  1. Client code to demonstrate the usage
public static void main (String[] args) {
    TextEditor editor = new TextEditor();
    Caretaker caretaker = new Caretaker();
    
    editor.setContent("Version 1");
    caretaker.saveState(editor);

    editor.setContent("Version 2");
    caretaker.saveState(editor);

    editor.setContent("Version 3");
    
    System.out.println("Current Content: " + editor.getContent()); // Version 3
    
    caretaker.restoreState(editor);
    System.out.println("Restored to: " + editor.getContent()); // Version 2

    caretaker.restoreState(editor);
    System.out.println("Restored to: " + editor.getContent()); // Version 1
}


</br>

7. Observer Pattern

Definition

Purpose

This pattern is particularly useful for implementing distributed event handling systems.

Use cases

1. Notification Systems

2. Logging Frameworks

3. Social Media Feeds

Design

Example

Let’s create a simple example where a WeatherStation (the subject) notifies multiple displays (observers) about temperature updates.

Implementation

  1. Create Observer interface
    public interface Observer {
     void update(float temperature);
    }
    
  2. Create Subject interface
    public interface Subject {
     void registerObserver(Observer observer);
     void removeObserver(Observer observer);
     void notifyObservers();
    }
    
  3. Create a ConcreteSubject class - WeatherStation
    public class WeatherStation implements Subject {
     private List<Observer> observers;
     private float temperature;
    
     public WeatherStation() {
         observers = new ArrayList<>();
     }
    
     @Override
     public void registerObserver(Observer observer) {
         observers.add(observer);
     }
    
     @Override
     public void removeObserver(Observer observer) {
         observers.remove(observer);
     }
    
     @Override
     public void notifyObservers() {
         for (Observer observer : observers) {
             observer.update(temperature);
         }
     }
    
     public void setTemperature(float temperature) {
         this.temperature = temperature;
         notifyObservers();
     }
    }
    
  4. Create a ConcreteObserver class - Weather Display
public class WeatherDisplay implements Observer {
    private float temperature;
    
    @Override
    public void update(float temperature) {
        this.temperature = temperature;
        display();
    }

    public void display() {
        System.out.println("Temperature Display: " + temperature + "°C");
    }
}
  1. Client code to demonstrate the usage
public static void main (String[] args) {
    WeatherStation weatherStation = new WeatherStation();

    WeatherDisplay display1 = new WeatherDisplay();
    WeatherDisplay display2 = new WeatherDisplay();
    
    weatherStation.registerObserver(display1);
    weatherStation.registerObserver(display2);

    weatherStation.setTemperature(25.0f); // "Temperature Display: 25.0°C" x2
    weatherStation.setTemperature(30.0f); // "Temperature Display: 30.0°C" x2
}


</br>

8. State Pattern

Definition

Purpose

This pattern is typically used when an object needs to change its behavior based on its current state, without changing the object’s class.

Use cases

1. Vending Machine

2. Traffic Light System

3. Authentication Process

Design

Example

Let’s design a vending machine that has three states: NoCoinState, HasCoinState, and SoldState. The behavior of dispensing items and accepting coins changes based on the machine’s state.

Implementation

  1. Create State interface for declaring behaviors of different states
public interface State {
    void insertCoin();
    void ejectCoin();
    void pressButton();
    void dispense();
}
  1. Create Context class (vending machine) which has different states
class VendingMachine {
    private State noCoinState;
    private State hasCoinState;
    private State soldState;

    private State currentState;

    public VendingMachine() {
        noCoinState = new NoCoinState(this);
        hasCoinState = new HasCoinState(this);
        soldState = new SoldState(this);

        currentState = noCoinState;  // Initial state
    }

    public void setState(State state) {
        this.currentState = state;
    }

    public State getNoCoinState() {
        return noCoinState;
    }

    public State getHasCoinState() {
        return hasCoinState;
    }

    public State getSoldState() {
        return soldState;
    }
    
    // delegate behavior to the current state
    public void insertCoin() {
        currentState.insertCoin();
    }

    public void ejectCoin() {
        currentState.ejectCoin();
    }

    public void pressButton() {
        currentState.pressButton();
        currentState.dispense();
    }
}
  1. Create ConcreteState classes - NoCoinState, HasCoinState, and SoldState
public class NoCoinState implements State {
    private VendingMachine vendingMachine;

    public NoCoinState(VendingMachine vendingMachine) {
        this.vendingMachine = vendingMachine;
    }

    public void insertCoin() {
        System.out.println("Coin inserted.");
        vendingMachine.setState(vendingMachine.getHasCoinState());
    }

    public void ejectCoin() {
        System.out.println("No coin to eject.");
    }

    public void pressButton() {
        System.out.println("You need to insert a coin first.");
    }

    public void dispense() {
        System.out.println("No item dispensed.");
    }
}
class HasCoinState implements State {
    VendingMachine vendingMachine;
    
    public HasCoinState(VendingMachine vendingMachine) {
        this.vendingMachine = vendingMachine;
    }
    
    public void insertCoin() {
        System.out.println("Coin already inserted.");
    }
    
    public void ejectCoin() {
        System.out.println("Coin ejected.");
        vendingMachine.setState(vendingMachine.getNoCoinState());
    }
    
    public void pressButton() {
        System.out.println("Button pressed.");
        vendingMachine.setState(vendingMachine.getSoldState());
    }
    
    public void dispense() {
        System.out.println("No item dispensed.");
    }
}
class SoldState implements State {
    VendingMachine vendingMachine;
    
    public SoldState(VendingMachine vendingMachine) {
        this.vendingMachine = vendingMachine;
    }
    
    public void insertCoin() {
        System.out.println("Please wait, we're already giving you an item.");
    }
    
    public void ejectCoin() {
        System.out.println("You can't eject the coin, the item is being dispensed.");
    }
    
    public void pressButton() {
        System.out.println("Already pressed the button.");
    }
    
    public void dispense() {
        System.out.println("Item dispensed.");
        vendingMachine.setState(vendingMachine.getNoCoinState());  // Back to no coin state
    }
}
  1. Client code to test the Vending Machine
public static void main(String[] args) {
        VendingMachine vendingMachine = new VendingMachine();
        
        // Testing the behavior of the vending machine in different states
        vendingMachine.insertCoin();  // Coin inserted
        vendingMachine.pressButton();  // Button pressed, item dispensed
        
        vendingMachine.ejectCoin();  // No coin to eject, already in NoCoinState
}


</br>

9. Strategy Pattern

Definition

Benefits

Purpose

When we want to let the client choose the strategy/algorithm at runtime

Use cases

1. Payment Methods in E-commerce

2. Data Validation

3. Discount Calculation in Retail Applications

Design

Example

Imagine a scenario where we want to sort a list of numbers using different algorithms (e.g., Bubble Sort, Quick Sort, Merge Sort), and we want to dynamically choose the sorting strategy at runtime.

Implementation

  1. Create Strategy interface
public interface SortStrategy {
    void sort(int[] numbers);
}
  1. Create Concrete Strategies classes - BubbleSort, SelectionSort
public class SelectionSort implements SortStrategy {
    
    @Override
    public void sort (int[] numbers) {
        int n = numbers.length;
        
        for (int i = 0; i<n-1; i++) {
            int minIdx = i;
            for (int j = i+1; j<n; j++) {
                if(numbers[j]<numbers[minIdx]) {
                    minIdx = j;
                }
            }
            
            int temp = numbers[minIdx];
            numbers[minIdx] = numbers[i];
            numbers[i] = temp;
        }

        System.out.println("Sorted using Selection Sort");
    }
}
public class BubbleSort implements SortStrategy {
    
    @Override
    public void sort (int[] numbers) {
        int n = numbers.length;
        
        for (int i = 0; i<n-1; i++) {
            for (int j = 0; j<n-i-1; j++) {
                
                if(numbers[j]>numbers[j+1]) {
                    int temp = numbers[j];
                    numbers[j] = numbers[j+1];
                    numbers[j+1] = temp;
                }
            }
        }

        System.out.println("Sorted using Bubble Sort");
    }
}
  1. Create Context Class to switch between sorting strategies
public class SortContext {
    private SortStrategy sortStrategy;

    // Set strategy at runtime
    public void setSortStrategy(SortStrategy sortStrategy) {
        this.sortStrategy = sortStrategy;
    }

    // Sort the array using the selected strategy
    public void executeSortStrategy(int[] numbers) {
        sortStrategy.sort(numbers);
    }
}
  1. Client code to change strategies at runtime
public static void main (String[] args) {
    int[] numbers = { 5, -2, 7, 8, 1, -6, 4};
    
    SortContext context = new SortContext();
    SelectionSort selectionSort = new SelectionSort();
    BubbleSort bubbleSort = new BubbleSort();
    
    context.setSortStrategy(selectionSort);
    context.executeSortStrategy(numbers); // "Sorted using Selection Sort"
    
    context.setSortStrategy(bubbleSort);
    context.executeSortStrategy(numbers); // "Sorted using Bubble Sort"
}


</br>

10. Template Method Pattern

Definition

Benefits

Purpose

Useful in various scenarios where the structure of an algorithm remains the same but individual steps may vary.

Use cases

1. Frameworks and Libraries

2. File I/O Operations

3. Database Operations

Design

Example

Imagine a game where different players (like a Cricketer or Footballer) need to prepare for a game. The steps might include warming up, playing the game, and cooling down. The sequence is the same, but the specifics vary depending on the sport.

Implementation

  1. Create the BaseClass
public abstract class Player {
    // Template method
    public final void prepareForGame() {
        warmUp();
        playGame();
        coolDown();
    }

    // Common method, can be overridden if needed
    public void warmUp() {
        System.out.println("General warm-up exercises");
    }

    // Abstract methods that subclasses will implement
    public abstract void playGame();

    // Common method, can be overridden if needed
    public void coolDown() {
        System.out.println("Stretching and cooling down");
    }
}
  1. Create the Concrete Subclasses with the custom method implementations
public class Cricketer extends Player {
    @Override
    public void playGame() {
        System.out.println("Playing cricket: Batting, bowling, fielding");
    }

    @Override
    public void coolDown() {
        System.out.println("Cooling down with light jogging");
    }
}
public class Footballer extends Player {
    @Override
    public void playGame() {
        System.out.println("Playing football: Dribbling, passing, shooting");
    }

    @Override
    public void coolDown() {
        System.out.println("Cooling down with a light walk");
    }
}
  1. Client code
public static void main(String[] args) {
        Player cricketer = new Cricketer();
        cricketer.prepareForGame();

        System.out.println();

        Player footballer = new Footballer();
        footballer.prepareForGame();
}


</br>

11. Visitor Pattern

Definition

A pattern that allows you to add further operations to objects without modifying them. It enables you to separate algorithms from the objects on which they operate.

Benefits

Disadvantages

Purpose

This pattern is particularly useful when you have a complex object structure and need to perform operations on it that are subject to frequent change.

Use cases

1. UI Component Trees

2. Object Serialization and Deserialization

3.

Design

Example

Suppose we have a system with different types of files (e.g., TextFile, ImageFile, VideoFile) that we want to perform operations on, such as compression or rendering.

We want to avoid changing the file classes each time we add a new operation.

Implementation

  1. Create the Visitor interface/abstract class
public interface Visitor {
    void visit (TextFileElement textFile);
    void visit (ImageFileElement imageFile);
    void visit (VideoFileElement videoFile);
}
  1. Create the Element interface (File Base) that declares the accept() method for element implementations
public interface FileElement {
    void accept(Visitor visitor);
}
  1. Create the Concrete Element classes (File Types) with accept() implementations
public class TextFileElement implements FileElement {
    private String content;
    
    public TextFileElement (String content) {
        this.content = content;
    }
    
    public String getContent() {
        return content;
    }
    
    @Override
    public void accept (Visitor visitor) {
        visitor.accept(this);
    }
}
public class ImageFileElement implements FileElement {
    private String resolution;
    
    public ImageFileElement (String resolution) {
        this.resolution = resolution;
    }
    
    public String getResolution() {
        return resolution;
    }
    
    @Override
    public void accept (Visitor visitor) {
        visitor.accept(this);
    }
}
public class VideoFileElement implements FileElement {
    private String duration;
    
    public VideoFileElement (String duration) {
        this.duration = duration;
    }
    
    public String getDuration() {
        return duration;
    }
    
    @Override
    public void accept (Visitor visitor) {
        visitor.accept(this);
    }
}
  1. Create Concrete Visitor classes to define the operations to perform (rendering & compressing files)
public class CompressingVisitor implements Visitor {
    @Override
    void visit (TextFileElement textFile) {
        System.out.println("Compressing text file with content: " + textFile.getContent());
    }

    @Override
    void visit (ImageFileElement imageFile) {
        System.out.println("Compressing text file with resolution: " + imageFile.getResolution());
    }

    @Override
    void visit (VideoFileElement videoFile) {
        System.out.println("Compressing text file with duration: " + videoFile.getDuration);
    }
}
public class RenderingVisitor implements Visitor {
    @Override
    void visit (TextFileElement textFile) {
        System.out.println("Rendering text file with content: " + textFile.getContent());
    }

    @Override
    void visit (ImageFileElement imageFile) {
        System.out.println("Rendering text file with resolution: " + imageFile.getResolution());
    }

    @Override
    void visit (VideoFileElement videoFile) {
        System.out.println("Rendering text file with duration: " + videoFile.getDuration);
    }
}
  1. Client code to demonstrate the usage
public static void main (String[] args) {
    
    // create the object structure (collection of elements)
    FileElement[] files = new FileElement[] {
            new TextFileElement("Hello world"),
            new ImageFileElement("1920x1080"),
            new VideoFileElement("2 hours")
    };

    FileVisitor compressionVisitor = new CompressionVisitor();
    FileVisitor renderingVisitor = new RenderingVisitor();

    // Apply compression to all files
    System.out.println("Applying Compression Visitor:");
    for (FileElement file : files) {
        file.accept(compressionVisitor);
    }

    // Apply rendering to all files
    System.out.println("\nApplying Rendering Visitor:");
    for (FileElement file : files) {
        file.accept(renderingVisitor);
    }
}