Case Classes and Design
In some ways, Scala is a functional programming language for the JVM ecosystem. Not only does it embrace class-based object oriented features; it also makes functional programming bearable.
This leads to a large space of possible programming styles. This post will explore the connection between algebraic data types (Scala’s case classes) and interfaces (as in Java).
Scary Words
‘Algebraic data types’ are really not that fancy. If you have been programming in Scala, you will probably have run into the sealed trait
+ case classes
pattern.
sealed trait Message
case class Request (requestId : Int, requestBody : String) extends Message
case class Response (responseTo : Int,
responseBody : String,
closeConnection : Boolean) extends Message
Here Message
is not just a Scala type. It could have been defined using an ‘algebra of types’ – some objects (types) and operations that result in such objects. The sealed trait and its subtypes represent the ‘sum’ operation and the comma separating the fields represents the ‘product’ operation. Another way to think of it is that Message
can be defined using type aliases:
type Message = Either[Request, Response]
type Request = Tuple[Int, String]
type Response = Tuple[Int, Tuple[String, Boolean]]
Here ‘Tuple’ is used to represent the ‘product’ operation and ‘Either’ is used to represent the ‘sum’ operation.
Modeling
Algebraic data types, or equivalently, case classes, provide a means to declare types that are closed. To continue the example, a new module can not define a new subclass of Message
. It can only use the existing subtypes.
Such a construct is typically not offered by object oriented programming languages, although in Java and similar language, something equivalent has been present for a long time. Surprisingly, it is the interface
, but used in a somewhat obscure way.
Need More Lambdas
It turns out that the seed of this solution was discovered eight decades ago. When Alonzo Church invented the lambda calculus, he found a way to represent any information using just functions.
We can do the same thing he did, but in Scala 1:
def tru[X] : X => X => X = t => f => t
def fals[X] : X => X => X = t => f => f
def ifThenElse[X](condition : (X => X => X))(then : X)(else_ : X) : X
= condition(then)(else_)
> ifThenElse(tru)("Hello")("world")
res0: java.lang.String = Hello
> ifThenElse(fals)("Hello")("world")
res1: java.lang.String = world
Indeed, you can define a bit of data without defining a class!
Actually this is a very basic sum type. Lets see if we can squeeze in a field.
sealed trait Option[A]
case class Some[A](a : A) extends Option[A]
case object None extends Option[Nothing]
def some[A,X] : A => (A => X) => X => X = value => caseSome => caseNone => caseSome(value)
def none[A,X] : (A => X) => X => X = caseSome => caseNone => caseNone
def eliminateOption[A,X](opt : (A => X) => X => X)(caseSome : A => X)(caseNone : X) : X = opt(caseSome)(caseNone)
> eliminateOption[String,String](some("happy string"))(y => "a " + y)("none!") // this is a bit like pattern matching!
res2: String = a happy string
Note that some
now has an extra argument; you can now see that it is a constructor.
ADTs are Interfaces
Now that we know we can represent data as functions, let’s visit that interface
claim again.
The eliminateOption
function above is a bit unwieldy. The second and third argument are related, so let’s put them together.
interface UseOption<A,X> {
X some(A a);
X none();
}
class Example extends UseOption<Integer, String> {
String some(Integer a) {
return "number " + a;
}
String none() {
return "none!";
}
}
interface Option<A> {
// Sensible implementations of Option.use make only one call to either some or none.
X use(UseOption<A,X> continuation);
}
You might even argue that UseOption is a Visitor interface, which has, not coincidentally, received much of the same criticism as ADTs have.
However, this is not about the Option class. Imagine that some and none are actually mouseMoved and mouseClicked. How would the corresponding ADT look?
By using the Church encoding, sealed trait alternatives can become methods in an interface and case class fields can become method parameters. Suddenly pattern matching is similar to programming with callbacks.
Conclusion
Although case classes are not the only way to represent closed data types, they are more convenient. In fact, algebraic data types turn out to be related to the visitor pattern.
The equivalence can be helpful when dealing with situations where a closed data type is desirable, but case classes are not. You may even use the interface approach because it lets you re-use ADT alternatives through interface inheritance, which could be considered a solution to the expression problem…
In my experience, being able to perform the discussed transformations is helpful in navigating the design space for the internals of large systems.
Related Terms
- Church encoding
- Inversion of control
- Open/closed principle
- Algebraic data types
- Sum types
- Lambda’s, lexical scope and closures
Update: after realising that solving the expression problem is somewhat profound, I found Extensibility for the Masses (Oliveira, Cook) which presents a thorough discussion of such a solution similar to mine.
This code does reveal a weakness in Scala’s type system, because the type parameter
X
intru
andfals
will be determined too early. So don’t pay too much attention to the types here. They aren’t very good.↩