Blog Infos
Author
Published
Topics
Published
Topics

In this article, we are going to look at how the functionality of sealed classes and exhaustive pattern matching can be an alternative solution for the Visitor pattern in Kotlin and Java 21.

As you may already know, Java 21 has been officially announced which adds the functionality for sealed classes and interfaces and pattern matching. In this article we are going to explore how this functionality can be an alternative solution to a problem that can be solved by the Visitor pattern.

While this article is written in Java (21), it is completely applicable to Kotlin as well.

This article is structured into four parts:

  1. An overview of a problem
  2. A simple approach to solve the problem without patterns or sealed classes
  3. What the visitor pattern is and how it can solve the problem
  4. How sealed classes and interfaces can also solve the problem

You can access the code of this article in its GitHub repository.

1. Problem Overview

To keep the problem short and simple, let’s say we want to create a program that manages Boxes and Containers. A container is a something that can contain boxes and other containers.

Requirements

  1. The ability to create containers and boxes, and putting boxes inside containers in a recursive pattern.
  2. Calculate the total weight of our ensemble.
  3. Print the composition of our ensemble.
  4. Ability to implement more types of calculations (such as calculating total price) and more types of items (such as a shiny box).

Boxes have a weight parameter and Containers have a weight of 2 plus the weight of what’s inside of them.

2. A Simple Approach

This problem is screaming to use object orientation and for-loops to sift through containers and boxes to calculate and print their insides.

So let’s just do that.

public interface Item {
void printStructure();
int weight();
}
view raw 1Item.java hosted with ❤ by GitHub
public record Box(int weight) implements Item {
@Override
public int weight() {
return weight;
}
@Override
public void printStructure() {
System.out.print("*Box");
}
}
view raw 2Box.java hosted with ❤ by GitHub
public record Container(Item[] items) implements Item {
@Override
public int weight() {
var itemsWeight = Arrays.stream(items).mapToInt(Item::weight).sum();
return 2 + itemsWeight;
}
@Override
public void printStructure() {
System.out.print(" Container[");
for (Item item : items) {
item.printStructure();
}
System.out.print("]");
}
}
view raw 3Container.java hosted with ❤ by GitHub
public static void main(String[] args) {
var ensemble = new Container(
new Item[]{
new Box(2),
new Container(new Item[]{
new Box(3),
new Box(4)}
)});
ensemble.printStructure();
System.out.println();
System.out.println("Total weight is: " + ensemble.weight());
}
// Container[*Box Container[*Box*Box]]
//Total weight is: 13
view raw 4Main.java hosted with ❤ by GitHub

Simple implementation using Records and usage of base methods

 

We have created two record classes: Box and Container(Box[]). In order to calculate weights, we simply add a weight function to the Item interface and implement it in Container and Box.

Just to note, Item is an interface that can have boxes and containers of other items which is essentially a Composite pattern.

In order to print the structure of the items, we can do a similar job, but instead of weight calculation and returning an int, we can print a structure recursively.

All is well on the output part of our application, but the code has a few pros and cons:

  • Pro: We can¬†add¬†more types of items (such as ShinyBox) by only¬†adding¬†code which means our code is¬†open for extension¬†on this part.
  • Con: The code of¬†Box¬†and¬†Container¬†is coupled with the code for calculating their weights and printing their structure. This becomes problematic if we want to add more functionality like calculating total price.
  • Con: In order to¬†add¬†the functionality for the printing the structure of the boxes, we had to¬†modify¬†the code and structure of the items. This means our code was not¬†closed for modification¬†on this part.

In order to have a more clear vision on the issues, imagine having a new requirement for printing an XML for our structure. We have to modify the contents of the classes to add this feature.

3. The Visitor Pattern

Let’s attack this problem from another angle.

3.1. Why Visitor Pattern Exists

Let’s change the structure of our code, so we can add more functionality (like adding more types of items and more types of calculation) by only/mostly adding code.

public record Box(int size) implements Item {}
public record Container(Item[] items) implements Item { }
view raw 1.java hosted with ❤ by GitHub
public class WeightCalculator {
public int calculate(Box box) {
return box.size();
}
public int calculate(Container container) {
int sum = 2;
for (Item item : container.items()) {
sum += calculate(item); // [!] Cannot resolve method 'calculate(Item)'
}
return sum;
}
}
view raw 2.java hosted with ❤ by GitHub

Clean but problematic solution

Job Offers

Job Offers


    Information Security Engineer

    MongoDB
    London, UK
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

Jobs

In here, we have a simple WeightCalculator class which contains a calculate function that accepts both containers and boxes. To calculate the total weight of an ensemble, we can pass our root container to the second function and let it recursively calculate the weight.

If only that code worked ūüėÖ

Let’s look at the compile error.

Cannot resolve method ‚Äėcalculate(Item)‚Äô

Basically, there is no calculate(Item) function. This problem arises from the fact that Java, Kotlin and many other languages cannot determine the subtype of the Item inside the for-loop since it is an Item and dispatch it to the proper function, so they want a function that accepts an Item.

To fix the error, we can update our code to have a function that accepts an item and use a switch-case to handle different items.

public class WeightCalculator {
public int calculate(Item item) {
return switch (item) {
case Box box -> calculate(box);
case Container container -> calculate(container);
default -> throw new IllegalStateException("Unexpected value: " + item);
};
}
public int calculate(Box box) {
return box.size();
}
public int calculate(Container container) {
int sum = 2;
for (Item item : container.items()) {
sum += calculate(item);
}
return sum;
}
}

Adding a switch for handling the default Item

 

Compile issue is resolved, but let’s look at the new pros/cons list.

  • Pro: We can¬†add¬†more¬†calculation¬†logic by adding more code, instead of modifying the structure of the old ones.
  • Not-so-pro: We can¬†add¬†more types of items, if we¬†promise¬†to update calculation logic classes.
  • Con: In order to add more types of items, we have to be careful to update the switch-case to add a case for it, since there is no compile-time error. This makes the code error-prone.

In programming, remembering to do stuff is a no-no, so let’s look at how the visitor pattern can bring back the compile-time safety.

3.2. Using The Visitor Pattern

A visitor

By using the visitor pattern, we remove the calculate(Item) function and make each implementation of an item to be responsible to call its own function on the calculator (I know, I know, this sounds a bit complicated).

public interface ItemVisitor<T> {
T visit(Box box);
T visit(Container container);
}
public interface Item {
<T> T visit(ItemVisitor<T> itemVisitor);
}
public record Box(int size) implements Item {
@Override
public <T> T visit(ItemVisitor<T> itemVisitor) {
return itemVisitor.visit(this);
}
}
public record Container(Item[] items) implements Item {
@Override
public <T> T visit(ItemVisitor<T> itemVisitor) {
return itemVisitor.visit(this);
}
}
public class PrinterVisitor implements ItemVisitor<Void> {
@Override
public Void visit(Box box) {
System.out.print("*Box");
return null;
}
@Override
public Void visit(Container container) {
System.out.print(" Container[");
for (Item item : container.items()) {
item.visit(this);
}
System.out.print("]");
return null;
}
}
public class WeightVisitor implements ItemVisitor<Integer> {
@Override
public Integer visit(Box box) {
return box.size();
}
@Override
public Integer visit(Container container) {
int sum = 2;
for (Item item : container.items()) {
sum += item.visit(this);;
}
return sum;
}
}

Visitor pattern implementation

 

As you can see, there are a few changes:

  • The¬†WeightCalculator¬†class now implements an interface called¬†ItemVisitor. This has been done to have more types of calculations (Visitors).
  • The items accept a¬†Visitor¬†and each item will call their own function of the visitor. This weird method of calling functions is called¬†double-dispatch, which is the main method for the visitor pattern.
public static void main(String[] args) {
var ensemble = new Container(
new Item[]{
new Box(2),
new Container(new Item[]{
new Box(3),
new Box(4)}
)});
ensemble.visit(new PrinterVisitor());
System.out.println();
System.out.println("Total weight is: " + ensemble.visit(new WeightVisitor()));
}
view raw Main.java hosted with ❤ by GitHub

Visitor usage

 

We can now add a new XMLVisitor class by implementing the visitor and let the logic of XML calculation be completely decoupled from the items.

So let’s review the pros and cons of the visitor pattern.

  • Pro: We have found a good solution for our problem in which we can¬†add¬†more types of calculations by only¬†adding¬†more code.
  • Pro: Adding a new type of¬†Item¬†requires you to implement the¬†visit¬†function, which itself requires the addition of a function in the¬†ItemVisitor¬†which will generate compile-time errors until all visitors have implemented the new¬†visit¬†function for that item.
  • Con: This pattern is known for its¬†complexity. The method of double-dispatching makes things hard to understand or follow.
4. Sealed Classes

Kotlin and Java 21 (here and here) bring sealed classes and interfaces, in which different implementations of classes and interfaces are known at compile time.

By using a sealed interface, we can have a switch-case that doesn’t need a default branch, since all the implementations of the class are known at compile-time. This makes the compiler throw an error when we add a new Item and not implement a case for it.

This means we can update our simple code in part 2 to use a sealed interface and remove the default branch in our switch-case.

public sealed interface Item permits Box, Container { }
view raw 1Sealed.java hosted with ❤ by GitHub
public class WeightCalculator {
public int calculate(Item item) {
return switch (item) {
case Box box -> calculate(box);
case Container container -> calculate(container);
// the default case is removed
};
}
public int calculate(Box box) {
return box.size();
}
public int calculate(Container container) {
int sum = 2;
for (Item item : container.items()) {
sum += calculate(item);
}
return sum;
}
}
view raw Usage.java hosted with ❤ by GitHub

Note: In Kotlin, there is no need for the permits part, since the compiler will pick up on the implementations automatically.

Let’s compare this solution with the previous ones.

  • Item,¬†Box¬†and¬†Container¬†are decoupled from any other logic such as¬†calculation¬†or¬†visitation.
  • If we add a new calculation logic (a¬†Visitor¬†in the visitor pattern), we can do so without modifying our structure and adding a new class.
  • If we add a new type of¬†Item, we can do so by¬†adding¬†the code for its calculation logic in the calculators¬†and¬†get a compiler error until we have updated all the calculators (same as visitor pattern).
  • We can still have a base interface for our calculators, if they actually share logic (in here they don‚Äôt, so the classes are not related to each other).
  • There is a repetition of the switch-case in each calculator (/Visitor) which is comparable to the repetition of the¬†visit¬†function in each¬†Item¬†in the¬†Visitor¬†pattern. This switch case can be removed if it is implemented in a¬†base calculator¬†with abstract¬†visit(Box)¬†and¬†visit(Container).
  • The¬†Visitor¬†pattern has the advantage of creating¬†custom visit functions¬†for each item if needed. For example, instead of having a¬†visit(Box)¬†in the base¬†Visitor¬†and accessing the¬†Box‚Äôs¬†public¬†fields, we can have a¬†visitBox(size)¬†function and let the size property be¬†completely private, since we can call the¬†visitBox¬†inside the¬†Box¬†class.
  • The switch-cases can still have a¬†default¬†branch (which it shouldn‚Äôt) and become compile-time unsafe, something that the Visitor pattern can still prevent.¬†It‚Äôs always better to never have default branches for sealed classes.
Conclusion

In this article we went through different ways to solve a boxing problem. We saw how a simple solution can have a coupling drawback and how the visitor pattern can fix it while having more complexity.

In the end we explored sealed classes and how they can solve the problem by restricting switch-cases for sealed classes and making them exhaustive.

Let me know your questions and what you think of sealed classes and visitor pattern down in the comments ūüí¨

If you enjoyed this article, be sure to check out my other articles:

ūüĒí Synchronization, Thread-Safety and Locking Techniques in Java and Kotlin

ūüĖĆ The Guide To Your First Annotation Processor with KSP (And Becoming A Kotlin Artist)

Kotlin Contracts: Make Great Deals With The Compiler! ūü§úūü§õ

Follow me on¬†Medium¬†If you‚Äôre interested in more informative and in-depth articles about Java, Kotlin and Android. You can also ping me on¬†LinkedIn¬†and¬†Twitter¬†ūüćĽ.

 

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
We know that Kotlin goes beyond Java by adding new features and improving existing…
READ MORE
blog
This is an updated version of an old post I wrote some years ago.…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu