Yan Cui
I help clients go faster for less using serverless technologies.
This article is brought to you by
MongoDB 8.0 is here to change the game. Faster reads and inserts, and brand-new vector search to support modern AI-powered apps.
Read the whole series:
Part 1 – type inference
Part 2 – traits
Part 3 – case class/object (ADTs) <- you’re here
Part 4 – apply & unapply functions
Part 5 – implicits
Continuing on from where we left off with traits last time around, let’s look at Scala’s case class/object which can be used to create Algebraic Data Types (ADTs) in Scala.
Case Class
You can declare an ADT in F# using Discriminated Unions (DUs). For example, a binary tree might be represented as the following.
In Scala, you can declare this ADT with a pair of case class.
Here is how you construct and pattern match against F# DUs.
This looks very similar in Scala (minus all the comments).
Also, one often use single case DUs in F# to model a domain, for instance…
From what I can see, this is also common practice in Scala (at least in our codebase).
From this snippet, you can also see that case classes do not have to be tied together to a top level type (via inheritance), but more on this shortly.
UPDATE 16/01/2017: as @clementd pointed out, you can turn case classes with a single member into a value class and avoid boxing by extending from anyVal. For more details, see here.
On the surface, case classes in Scala looks almost identical to DUs in F#. But as we peek under the cover, there are some subtle differences which you ought to be aware of.
Case Object
In F#, if you have a union case that is not parameterised then the compiler will optimise and compile it as a singleton. For instance, the NotStarted case is compiled to a singleton as it’s always the same.
You can declare this GameStatus with case classes in Scala.
But reference equality check (which is done with .eq in Scala) reveals that:
- NotStarted() always create a new instance
- but equals is overridden to perform structural equality comparison
If you want NotStarted to be a singleton then you need to say so explicitly by using a case object instead.
Couple of things to note here:
- as mentioned in my last post, object in Scala declares a singleton, so does a case object
- a case object cannot have constructor parameters
- a case object cannot be generic (but a normal object can)
When you pattern match against a class object you can also lose the parentheses too (see the earlier example in print[T]).
Cases as Types
For me, the biggest difference between DUs in F# and case classes in Scala is that you declare an ADT in Scala using inheritance, which has some interesting implications.
As we saw earlier, each case class in Scala is its own type and you can define a function that takes Node[T] or Empty[T].
This is not possible in F#. Instead, you rely on pattern matching (yup, you can apply pattern matching in the function params) to achieve a similar result.
It’s also worth mentioning that, case objects do not define their own types and would require pattern matching.
What this also means is that, each case class/object can define their own members! Oh, and what we have learnt about traits so far also holds true here (multiple inheritance, resolving member clashes, etc.).
In F#, all members are defined on the top level DU type. So, a like-for-like implementation of the above might look like this.
Whilst this is a frivolous example, I think it is still a good demonstration of why the ability to define members and inheritance on a per-case basis can be quite powerful. Because we can’t do that with F#’s union cases, we had to sacrifice some compile-time safety and resort to runtime exceptions instead (and the implementation became more verbose as a result).
The autoPlay function also looks slightly more verbose than its Scala counterpart, but it’s mainly down to a F# quirk where you need to explicitly cast status to the relevant interface type to access its members.
sealed and finally
“make illegal states unpresentable” – Yaron Minsky
Ever since Yaron Minsky uttered these fabulous words, it has been repeated in many FP circles and is often achieved in F# through a combination of DUs and not having nulls in the language (apart from when inter-opting with C#, but that’s where your anti-corruption layer comes in).
This works because DU defines a finite and closed set of possible states that can be represented, and cannot be extended without directly modifying the DU. The compiler performs exhaustive checks for pattern matches and will warn you if you do not cover all possible cases. So if a new state is introduced into the system, you will quickly find out which parts of your code will need to be updated to handle this new state.
For instance, using the GameStatus type we defined in the previous section…
the compiler will issue the following warning:
warning FS0025: Incomplete pattern matches on this expression. For example, the value ‘GameOver (_)’ may indicate a case not covered by the pattern(s).
You can also upgrade this particular warning – FS0025 – to error to make it much more prominent.
In Scala, because case classes/objects are loosely grouped together via inheritance, the set of possible states represented by these case classes/objects is not closed by default. This means new states (potentially invalid states introduced either intentionally or maliciously) can be added to the system and it’s not possible for you or the compiler to know all possible states when you’re interacting with the top level trait.
There’s a way you can help the compiler (and yourself!) in this case is to mark the top level trait as sealed.
A sealed trait can only be extended inside the file it’s declared in. It also enables the compiler to perform exhaustive checks against pattern matches to warn you about any missed possible input (which you can also upgrade to an error to make them more prominent).
Since case objects cannot be extended further we don’t have to worry about it in this case. But case classes can be extended by a regular class (case class-to-case class inheritance is prohibited), which presents another angle for potential new states to creep in undetected.
So the convention in Scala is to mark case classes as final (which says it cannot be extended anywhere) as well as marking the top level trait as sealed.
Voila! And, it works on abstract classes too.
But wait, turns out sealed is not transitive.
If your function takes a case class then you won’t get compiler warnings when you miss a case in your pattern matching.
You could, make the case class sealed instead, which will allow the compiler to perform exhaustive checks against it, but also opens up the possibility that the case class might be extended in the same file.
Unfortunately you can’t mark a case class as both final and sealed, so you’d have to choose based on your situation I suppose.
Reuse through Inheritance
Because case classes are their own types and they can inherit multiple traits, it opens up the possibility for you to share case classes across multiple ADTs.
For instance, many collection types have the notion of an empty case. It’s possible to share the definition of the Empty case class.
I think it’s interesting you can do this in Scala, although I’m not sure that’s such a good thing. It allows for tight coupling between unrelated ADTs, all in the name of code reuse.
Sure, “no one would actually do this”, but one thing I learnt in the last decade is that if something can be done then sooner or later you’ll find it has been done
Summary
To wrap up this fairly long post, here are the main points we covered:
- you can declare ADTs in Scala using case class and/or case object
- case classes/objects are loosely grouped together through inheritance
- a case class defines its own type, unlike discriminated unions in F#
- a case object creates a singleton
- case classes/objects can define their own members
- case classes/objects support multiple inheritance
- marking the top level trait as sealed allows compiler to perform exhaustive checks when you pattern match against it
- Scala convention is to seal the top level trait and mark case classes as final
- sealed is not transitive, you lose the compiler warnings when pattern matching against case classes directly
- you can mark case classes as final or sealed, but not both
- multiple inheritance allows you to share case classes across different ADTs, but you probably shouldn’t
Links
- Scala School – case classes
- SO – ADTs in F# and Scala
- SO – What is Scala’s counterpart of Discriminated Union in F#
- Everything you need to know about sealed traits in Scala
- F# for Fun and Profit : making illegal states unrepresentable
- This is why you need composition over inheritance
- Value Classes and Universal Traits
Whenever you’re ready, here are 3 ways I can help you:
- Production-Ready Serverless: Join 20+ AWS Heroes & Community Builders and 1000+ other students in levelling up your serverless game. This is your one-stop shop for quickly levelling up your serverless skills.
- I help clients launch product ideas, improve their development processes and upskill their teams. If you’d like to work together, then let’s get in touch.
- Join my community on Discord, ask questions, and join the discussion on all things AWS and Serverless.
Pingback: F# Weekly #4, 2017 – Sergey Tihon's Blog