Design pattern (part 5): Behavioural pattern II

Patterns are just what they are: repeating behaviors, not rules.

Design pattern (part 5): Behavioural pattern II

Photo by Ashni on Unsplash

In our previous post, we provided a basic introduction to the topic of behavioral design patterns, explaining what they are and why they are important. We discussed some of the most common behavioral patterns that can be used in software design to make code more modular, extensible, and easier to maintain.

In this post, we will dive deeper into the topic and explore the remaining behavioral design patterns that are commonly used in software engineering. We will examine the specifics of each pattern, how it works, and when it is best applied. I aim to provide a comprehensive overview of these patterns so that developers and software engineers can have a better understanding of how to apply them to their projects.

Observer

This pattern is pretty straightforward if you think about it.

At one end, we have a publisher that holds valuable information that needs to be transmitted to certain components. And on the other end, we have components that need to be notified of changes happening on the client side.

Now, we could resort to having the receiving end check the publisher a frequent intervals, but that would be highly resource-intensive and could potentially miss out on important updates otherwise.

Similarly, pushing data from the publisher to all components is not an ideal solution either. It would be highly resource-consuming, not to mention it could pose significant security risks. So, we need to come up with a better approach that's both efficient and secure.

The solution here is for consumers to register themselves with the publisher. Once registered, when the publisher has an update that needs to be transmitted, it will proactively send the data to the registered consumer, and even invoke the consumer as necessary. This approach follows the Observer pattern, which is a popular design pattern in software engineering.

Example:

public class Publisher {
    private List<Observer> observers = new ArrayList<>();

    public void addObserver(Observer observer) {
        observers.add(observer);
    }

    public void removeObserver(Observer observer) {
        observers.remove(observer);
    }

    public void notifyObservers() {
        observers.forEach(Observer::handleEvent);
    }
}

public interface Observer {
    void handleEvent();
}

public class Subscriber implements Observer {
    @Override
    public void handleEvent() {
        System.out.println("Event handled");
    }
}

public class OtherSubscriber implements Observer {
    @Override
    public void handleEvent() {
        System.out.println("Other event handled");
    }
}

public class Main {
    public static void main(String[] args) {
        Publisher publisher = new Publisher();
        publisher.addObserver(new Subscriber());
        publisher.addObserver(new OtherSubscriber());
        publisher.notifyObservers();
    }
}

State

Take the behavior of traffic signals, for instance. A traffic signal has different states, such as red, green, and yellow, each of which represents a specific behavior. Depending on the current state, the traffic signal changes its behavior. So when it's red, it's programmed to stop traffic. And once the timer runs out, it transitions to yellow and then to green. But here's the kicker: the next behavior of the traffic light not only comes from its input but also from its previous behaviors. Pretty cool, right?

The State design pattern is a clever solution to the problem of managing and maintaining the behavior of an object or system that can exist in multiple states. The main idea behind the pattern is to create separate classes for each state of the object or system and delegate the state transition logic and state-specific behavior to those classes. By doing so, the main class only needs to store a reference to the current state and can let the state classes do all the heavy lifting.

This approach not only simplifies the code and makes it more maintainable, but it also allows for greater flexibility and extensibility in managing the object or system's behavior.

Example:

public class Context {
    private State state;

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

    public void doAction() {
        state.doAction();
    }
}

public interface State {
    void doAction();
}

public class ConcreteState1 implements State {

    private Context context;

    @Override
    public void doAction() {
        System.out.println("Action 1");
        context.changeState(new ConcreteState2());
    }
}

public class ConcreteState2 implements State {
    @Override
    public void doAction() {
        System.out.println("Action 2");
        context.changeState(new ConcreteState1());
    }
}

public class Main {
    public static void main(String[] args) {
        Context context = new Context();
        context.changeState(new ConcreteState1());
        context.doAction();
        context.doAction();
    }
}

Strategy

It's interesting when you bring up the similarities between the Strategy and State design patterns. As you can see, both patterns involve delegating responsibility to specific objects. In the case of the Strategy pattern, the responsibility of execution is delegated to each Strategy object.

Now, while the State and Strategy patterns share some similarities, there are also some key differences to consider. For instance, in the Strategy pattern, the client is responsible for deciding which Strategy object to use, whereas, in the State pattern, the state object itself is responsible for transitioning between different states.

Despite these differences, it's worth noting that the Strategy pattern can still be seen as a specialized case of the State pattern that deals specifically with the execution of input. By leveraging the flexibility and modularity of the Strategy pattern, developers can more easily swap in and out different execution strategies as needed, without having to modify the client code.

Example:

public class Context {
    private Strategy strategy;

    public void setStrategy(Strategy strategy) {
        this.strategy = strategy;
    }

    public void executeStrategy() {
        strategy.execute();
    }
}

public interface Strategy {
    void execute();
}

public class ConcreteStrategy1 implements Strategy {
    @Override
    public void execute() {
        System.out.println("Strategy 1");
    }
}

public class ConcreteStrategy2 implements Strategy {
    @Override
    public void execute() {
        System.out.println("Strategy 2");
    }
}

public class Main {
    public static void main(String[] args) {
        Context context = new Context();
        context.setStrategy(new ConcreteStrategy1());
        context.executeStrategy();
        context.setStrategy(new ConcreteStrategy2());
        context.executeStrategy();
    }
}

Template method

So, let's say you're in the process of creating documentation for your project. You want to make sure that each function in your code has its page in the documentation, but you're concerned that without a clear structure, the documentation flow for each functionality will end up being inconsistent and hard to follow.

To address this issue, you decide to use the Template Method design pattern. You create a template for each component, which establishes the structure of the documentation. All you have to do is fill in the details that the template leaves blank.

By using this design pattern, you can eliminate a lot of the boilerplate and common components that would otherwise be present on each documentation page. Additionally, if you need to make changes to the structure of the documentation, you can simply update the template, and those changes will be reflected in all the relevant pages.

It's worth noting that this pattern can potentially reduce the flexibility of your code. When you're using the pattern, it can be more challenging to create a custom page for certain special functionalities, as the structure of the page is largely dictated by the template.

Critics of the Template Method pattern have argued that this is because it relies heavily on inheritance. While inheritance can be a powerful tool for creating modular and reusable code, it can also make it more difficult to introduce changes or customizations without affecting the entire system.

So, it's important to weigh the pros and cons of using the Template Method pattern in your specific project and determine whether it makes sense for your needs. While it can certainly help streamline the documentation process and make it more consistent, it may also limit your ability to create custom pages or introduce changes to the structure of the documentation.

Example:

public abstract class AbstractClass {
    public void templateMethod() {
        System.out.println("Template method");
        boolean result = primitiveOperation1();
        if (result) {
            primitiveOperation2();
        }
    }

    protected abstract boolean primitiveOperation1();

    protected abstract void primitiveOperation2();
}

public class ConcreteClass extends AbstractClass {
    @Override
    protected boolean primitiveOperation1() {
        System.out.println("Primitive operation 1");
        return true;
    }

    @Override
    protected void primitiveOperation2() {
        System.out.println("Primitive operation 2");
    }
}

public class ConcreteClass2 extends AbstractClass {
    @Override
    protected boolean primitiveOperation1() {
        System.out.println("Primitive operation 1");
        return false;
    }

    @Override
    protected void primitiveOperation2() {
        System.out.println("Primitive operation 2");
    }
}

public class Main {
    public static void main(String[] args) {
        AbstractClass abstractClass = new ConcreteClass();
        abstractClass.templateMethod();
        abstractClass = new ConcreteClass2();
        abstractClass.templateMethod();
    }
}

Visitor

Imagine you are working as a museum tour guide.

When giving a museum tour, it can be quite challenging to provide a coherent and consistent introduction to each of the art pieces on display. After all, art pieces can have wildly different attributes - some may be drawings, while others are statues, and so on.

And of course, it's not feasible to rely on the carer of the art pieces to give an introduction to each piece - after all, that's not their job. To make matters even more challenging, since each tour route is random, you don't know ahead of time what type of art piece you'll encounter next.

The Visitor pattern is an elegant solution to this problem. Essentially, this pattern involves grouping a set of methods that perform similar tasks but on different objects.

The beauty of this approach lies in the fact that, for each component that needs to be operated on by the Visitor object, the component itself will choose the most suitable method for the Visitor to invoke. By grouping these methods and leaving the responsibility of choosing the appropriate method for the object itself, we can maintain high cohesion inside each component.

This way, each object only cares about what it does best, and we can achieve a clean separation of concerns between the Visitor and the object structure. All in all, the Visitor pattern is a great way to keep your code organized and maintainable!

Example:

public interface Visitor {
  void doForElement1(Element1 element1);
  void doForElement2(Element2 element2);
}

public class ConcreteVisitor implements Visitor {
  @Override
  public void doForElement1(Element1 element1) {
    System.out.println("Visitor do for element 1");
  }

  @Override
  public void doForElement2(Element2 element2) {
    System.out.println("Visitor do for element 2");
  }
}

public interface Element {
  void accept(Visitor visitor);
}

public class Element1 implements Element {
  @Override
  public void accept(Visitor visitor) {
    visitor.doForElement1(this);
  }
}

public class Element2 implements Element {
  @Override
  public void accept(Visitor visitor) {
    visitor.doForElement2(this);
  }
}

public class Main {
  public static void main(String[] args) {
    Element element1 = new Element1();
    Element element2 = new Element2();
    Visitor visitor = new ConcreteVisitor();
    element1.accept(visitor);
    element2.accept(visitor);
  }
}

Summary

This article is only a simplified explanation of the patterns. To fully understand the benefits of behavioral design patterns, it is important to study and apply them in practice. By reading more about these patterns, we can become familiar with the different types of patterns and when to use them. It is equally important to self-explain and apply the patterns to real-world scenarios. By doing so, we can develop a deeper understanding of how these patterns work, and how they can be used to solve real problems.

Overall, by studying and applying behavioral design patterns, we can create more robust and efficient software systems. We can also become more efficient at solving common problems, saving time and effort in the long run.

Read more