📦 Sealed Classes: An Alternative Approach To The Visitor Pattern In Kotlin And Java 21

Adib Faramarzi
ProAndroidDev
Published in
7 min readOct 19, 2023

--

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.

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.

Clean but problematic solution

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.

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).

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.
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.

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 🍻.

--

--