Pattern Matching vs. Polymorphism
Comparing two architectural approaches and when to favor one over the other.
TL;DR
Subtype polymorphism is great for adding more entity types. Pattern matching is a better choice for adding more functionality. In many use cases, it’s likelier that more functionality is requested than that more entity types are requested.
In this article, we implement a program based on a set of simple requirements. First, we implement it using subtype polymorphism, then using pattern matching. Given these implementations, we add two more requirements and compare the necessary changes to implement the new requirements.
Disclaimer
When we use the term pattern matching
, I’m referring to a limited subset. You can find a link to a more detailed explanation of pattern matching in the Resources
section.
Initial Requirements
Let’s consider the example of two-dimensional shapes. We want to allow two different types: rectangles and circles. All shapes have a perimeter and an area. A rectangle is defined by two sides with individual lengths. Its perimeter is (x + y) * 2
and its area x * y
. A circle is defined by a radius. Its perimeter is 2 * PI * r
and its area PI * r * r
.
Using Subtype Polymorphism
Let’s model this example with subtype polymorphism in Java.
We model a shape as an interface. It has a method for retrieving the perimeter and one for retrieving the area.
interface Shape {
double perimeter();
double area();
}
A rectangle is an implementation of the Shape
interface.
class Rectangle implements Shape {
double x, y;
public double perimeter() {
return (x + y) * 2;
}
public double area() {
return x * y;
}
}
A circle is a second implementation of the Shape
interface.
class Circle implements Shape {
double radius;
public double perimeter() {
return 2 * PI * radius;
}
public double area() {
return PI * radius * radius;
}
}
This class hierarchy successfully implements the requirements.
Our stakeholders can now compute the perimeter and area of rectangles and circles. Great.
Additional Requirements
Now our stakeholders come up with two additional requirements.
- They want to compute these properties for parallelograms too.
- They want to compute the diameter of a shape. That is the longest distance within a shape.
Adding a New Class Into a Subtype Architecture
With our given subtype architecture, satisfying the first requirement is easy.
We start with creating a class Parallelogram
and add an implements
clause to the class signature.
class Parallelogram implements Shape { }
At this point, the Java compiler already notifies us that the class is missing implementations of the methods perimeter
and area
. The IDE gives us the option to generate method stubs.
class Parallelogram implements Shape {
public double perimeter() {
return 0.;
}
public double area() {
return 0.;
}
}
Now we can add attributes to the class and implement both methods.
class Parallelogram implements Shape {
double base, height, side;
public double perimeter() {
return 2 * (base + side);
}
public double area() {
return base * height;
}
}
Subtype polymorphism allowed us to fulfill this requirement with a single new file. We did not need to touch the existing code base.
Subtype polymorphism simplifies adding new entity types.
Adding a New Method Into a Subtype Architecture
Let’s look at the second requirement.
The stakeholders want to compute the diameter of a shape. That is the longest distance within a shape.
We add a new method to the interface Shape
.
interface Shape {
double perimeter();
double area();
double diameter();
}
At this point, the Java compiler notifies us in the classes Rectangle
, Shape
, and Parallelogram
. Each class is missing an implementation of the new method diameter
.
We add an implementation of the method diameter
to the class Rectangle
.
class Rectangle implements Shape {
double x, y;
/* ... */
public double diameter() {
return Math.sqrt(x * x + y * y);
}
}
And to the class Circle
.
class Circle implements Shape {
double radius;
/* ... */
public double diameter() {
return 2 * radius;
}
}
Aaaand to the class Parallelogram
.
class Parallelogram implements Shape {
double base, height, side;
/* ... */
public double diameter() {
return /* Math */;
}
}
To add the diameter, we needed to touch every class in the hierarchy. The changes are split all over the application.
Subtype polymorphism complicates adding new methods.
Using Pattern Matching
Let’s look at a different approach: pattern matching. Pattern matching is currently not sufficiently supported in Java. (This is likely to change in upcoming versions: JEP 305, JEP 354.) For the time being, we’ll use Kotlin for the pattern matching approach. Kotlin provides sufficient support for pattern matching to showcase this example.
A shape is modeled as a sealed
class without any properties. The sealed
keyword enforces that all subclasses of Shape
are declared within the same file. We define Rectangle
and Circle
as the only subclasses of the class Shape
.
sealed class Shape
class Rectangle(val x: Double, val y: Double) : Shape()
class Circle(val radius: Double) : Shape()
The new hierarchy looks similar to the hierarchy using subtype polymorphism. The key difference is that we do not specify the methods in the class hierarchy. Instead, we define the methods as functions outside of the hierarchy.
Let’s implement the function area
.
fun area(shape: Shape): Double = when (shape) {
is Rectangle -> shape.x * shape.y
is Circle -> PI * shape.radius * shape.radius
}
Since the function is defined outside of the hierarchy, we do not know if the passed shape is a rectangle or a circle. We use pattern matching to differentiate the two cases. If the shape is a subtype of Rectangle
, the compiler casts the argument shape to a rectangle and evaluates shape.x * shape.y
. If the shape is a subtype of Circle
, the compiler casts the argument shape to a circle and evaluates PI * shape.radius * shape.radius
.
Note that it is not necessary to define a default clause since the class Shape
is sealed. If one class would not be covered, the compiler would notify us.
We can similarly implement the function perimeter.
fun perimeter(shape: Shape): Double = when (shape) {
is Rectangle -> (shape.x + shape.y) * 2
is Circle -> 2 * PI * shape.radius
}
We add two clauses for the two implementations: rectangles and circles.
Adding a New Method into a Pattern Matching Architecture
We successfully fulfilled the initial requirements. Now, let’s consider one of the additional requirements.
- The stakeholders want to compute the diameter of a shape. That is the longest distance within a shape.
With the new architecture, fulfilling this requirement is as easy as adding a new function.
fun diameter(shape: Shape): Double = when (shape) {
is Rectangle -> Math.sqrt(shape.x * shape.x + shape.y * shape.y)
is Circle -> 2 * shape.radius
}
No existing file needs to be touched. Also, as mentioned before, the compiler would automatically notify us if we missed one of the subclasses of Shape
.
Pattern matching simplifies adding new functions.
Adding a New Class Into a Pattern Matching Architecture
Let’s consider the other requirement.
- The stakeholders want to compute these properties for parallelograms too.
Fulfilling this requirement was easy with the subtype architecture. It’s harder with pattern matching.
First, we add a new subclass of Shape
.
sealed class Shape
class Rectangle(val x: Double, val y: Double) : Shape()
class Circle(val radius: Double) : Shape()
class Parallelogram(val base: Double, val height: Double): Shape()
At this point, the compiler notifies us that the when expressions in the functions perimeter
, area
, and diameter
are missing a clause for Parallelogram
.
We add the missing case to the function perimeter
.
fun perimeter(shape: Shape): Double = when (shape) {
is Rectangle -> (shape.x + shape.y) * 2
is Circle -> 2 * PI * shape.radius
is Parallelogram -> (shape.base + shape.side) * 2
}
And to the function area
.
fun area(shape: Shape): Double = when (shape) {
is Rectangle -> shape.x * shape.y
is Circle -> PI * shape.radius * shape.radius
is Parallelogram -> shape.side * shape.height
}
Aaaand to the function diameter
.
fun diameter(shape: Shape): Double = when (shape) {
is Rectangle -> Math.sqrt(shape.x * shape.x + shape.y * shape.y)
is Circle -> 2 * shape.radius
is Parallelogram -> /* Math */
}
To add the new class, Parallelogram
, we needed to touch every existing function. The changes are split all over the application.
Pattern matching complicates adding new entity types.
Summary
Pattern matching and subtype polymorphism are two different tools to design an architecture. While the former is great for adding new functionality, the latter is great for adding new entity types. Before deciding on an architecture, ask yourself if you’re likelier to need more functionality or more entity types. In my experience, the majority of use cases disproportionately require more functionality rather than more entity types over time.
Resources
- Example with Pattern Matching in Java
- Example with Subtype Polymorphism in Kotlin
- Explanation of Pattern Matching
- JEP 305
- JEP 325
Published by Fabian Böller
Visit author page