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:
- An overview of a problem
- A simple approach to solve the problem without patterns or sealed classes
- What the visitor pattern is and how it can solve the problem
- 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
- The ability to create containers and boxes, and putting boxes inside containers in a recursive pattern.
- Calculate the total weight of our ensemble.
- Print the composition of our ensemble.
- 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(); | |
} |
public record Box(int weight) implements Item { | |
@Override | |
public int weight() { | |
return weight; | |
} | |
@Override | |
public void printStructure() { | |
System.out.print("*Box"); | |
} | |
} |
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("]"); | |
} | |
} |
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 |
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 { } |
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; | |
} | |
} |
Clean but problematic solution
Job Offers
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
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())); | |
} |
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 { } |
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; | |
} | |
} |
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)
This article was previously published on proandroiddev.com