I fielded a question on regarding the design and visibility of a sealed class hierarchy that prompted some immediate introspection on my thought processes while programming. I’ll come to that question in a moment.
First, for the uninitiated what the heck is does a sealed class hierarchy look like in Scala? In short, a sealed hierarchy of classes is very useful for things like as it provides you a compile time guarantee that the all of the possible types that can be pattern matched against are covered in a match expression without needing a catch-all case at the end. As an easy to grasp example, let’s look at the type declarations of a singly-linked list as defined from chapter 3 of :
package com.example
sealed trait List[+A]
case object Nil extends List[Nothing]
case class Cons[+A](head: A, tail: List[A]) extends List[A]
object Hello {
def main(args: Array[String]): Unit = {
val l: List[String] = Cons("foo",Nil)
l match {
case Cons(h,t) => println("cons found")
case Nil => println("nil found")
//case _ => this is not needed, match is exhaustive
}
}
}
Sealed hierarchies are how one also defines in Scala.
In the above snippet, all cases of List are known because subtypes may only be defined in this compilation unit. The commented out case statement isn’t needed because the Scala compiler will not warn you of a non-exhaustive match since it derives that the only subtypes of List that can exist are Nil and Cons.
But are they the only types that can exist?
Let’s say I come along and add your library containing this List type to my Java classpath and decide in horrible judgment to add a new subclass of List.
package com.example;
public class DestroyYourHierarchy<T> implements List<T> {
//Magical stuff goes here...
}
Scala traits when compiled to bytecode become Java interfaces. Now there is a new type that exists outside of the sealed hierarchy that is a completely legal subclass of List[+A] at runtime. All of those pattern matching expressions are now non-exhaustive and now there is potentially another class in my hierarchy which may violate the laws the rest of my code expects to exist. So much for compile time guarantees!
This was the concern of person “A” from the question in #scala I alluded to. I am person “J” in the exchange below.
A | is there some way to prevent Java code from subclassing my sealed abstract class?
A | make it have a package-private ctor and make subclasses of it final?
J | one could still subclass it in java if the subclass was defined in the same package
A | yeah
A | Java has no internal like C#
J | or said differently, anyone motivated enough could use your hierarchy in a way that isn't meant to be used - and if so, they are the morons. Just write it in a way that makes sense and move on.
I have this conversation with myself frequently when I’m writing code targeting the JVM, mostly Java, and I become hyper obsessed with compile time guarantees and such. These “guarantees” are things like enforcing invariants on a class, enforcing immutability, ensuring that a class is thread-safe etc. etc. These are good programming practices for many reasons, sure, but you can’t ever trust their enforcement when your code hits the wild. Any sufficiently motivated and able developer can come along and use generally useful tools like Java’s built in reflection library, bytecode weaving, jar shading, and probably a dozen other things to wreck your compile time guarantees if they wanted to.
This is where I remind myself to not obsess over such details. I move on, write the code in a way that expresses my intended usage as obviously as possible, while remembering that code is “communicating to other humans what the computer should do”.
If a sealed hierarchy of classes in Scala is the best way to codify types in your system, then write it as such and completely disregard all of the ways that it can be abused since you can’t prevent it anyways. Make the best choices given the options you have in your languages tools for the readability, maintainability, and correctness, insomuch as you can control, of your code.