Scala 3: Well-designed Object-Oriented Type Hierarchies
This post discusses a few changes and additions in Scala 3 that make designing robust, object-oriented type hierarchies a little easier.
Join me for Scala Love in the City, February 13th. I’m doing a talk about Scala 3’s contextual abstractions.
For an even more concise summary of most of the notable changes in Scala 3, see my new Scala 3 Highlights page.
You can start reading the rough draft of Programming Scala, Third Editionon the O’Reilly Learning Platform. The first half or so of the chapters are available. I am refining them still, but any feedback is welcome!
While object-oriented programming (OOP) is much maligned, Scala embaces it as a useful tool for solving many design problems. Fortunately, Scala also offers several features to encourage “best practices”. Scala 2 offered these features.
Sealed Type Hierarchies
Declaring a parent type sealed
means that only subtypes defined in the same file are allowed. This is a way of implementing algebraic data types, where the types obey well defined properties.
A classic example is Option[T]
, which is declared as sealed abstract class
, with two concrete subtypes, Some[T]
and None
, representing the only two possible cases, either an item of type T
exists or it doesn’t.
Mixin Composition
“Mixin” traits make it easy to compose behaviors, rather than add too much state and behavior in a type definition.
Override Keyword
If you override a concrete method, you must add the override
keyword. This catches a few possible surprises. If misspell a method name you think you are overriding, so you aren’t actually overriding anything, the compiler won’t let you use this keyword. Conversely, if you think your subtype defines a new method, but a subsequent version of the supertype adds a method with the same signature, your subtype will now fail to compile.
However, these are relatively rare issues. My favorite benefit is to treat override
as a warning; overriding methods is an easy way to introduce bugs. Should you call the parent method or just replace it? If you call it, should you call it first and do something with the returned value or should you call it last? Answering these questions incorrectly for the particular method can cause the contract of the original method to be broken in the override. Scala can’t verify you did the right thing.
Unfortunately, the languages like Java have made us accustomed to overriding methods. We do this with toString
all the time, but really, it should be rare. Use the Template Method Pattern to design methods that avoid the need for overriding.
Final Keyword
You can declare a type or method final
, which prevents extension, i.e., subtyping of a type and overriding of a method, respectively. This is used less often than it should be, because you have to take the initiative. It’s all to easy to omit final
.
Scala 3 Enhancements
Scala 3 adds several features that refine our ability to design types.
Enums vs. Sealed Hierarchies
The Scala 2 enumeration syntax was a bit awkward to use and hard to remember. Scala 3 introduces a more concise enum
syntax with improved features. It’s also a suitable tool for implementing algebraic data types as a more concise alternative to sealed hierarchies.
Here’s how Option
could be implemented with an enum
, omitting all the usual methods, for simplicity.
In the first variant, the parent type of Some
is inferred to be Option[T]
, while the parent type of the None
value is inferred to be Option[Nothing]
. From the Dotty documentation, “Generally, all covariant type parameters of the enum class are minimized in a compiler-generated extends
clause whereas all contravariant type parameters are maximized. If Option
was non-variant, you would need to give the extends clause of None
explicitly.” The second variant shows explicitly the typing relationships.
Notice what happens when we use them:
First, unless we import Some
and None
, we have to refer to them using Option
. Second, the return types for o1
and o2
are Option[...]
, whereas for the sealed hierarchy Option
type, o1
would have the type Some[Int]
and o2
would have the type None
. (Note that Scala 3.0 uses the Scala 2.13 library, so this will still be true in Scala 3, at least for a few point releases.)
Once we import Some
and None
, then the inferred types for o3
and o4
are Some[Int]
and None.type
, respectively.
We could add all the usual methods we have for the library’s Option
. Also, like the library Option
, we can’t create our own subtypes of the enum Option
or its members. We can also pattern match with them, just the like the library case class Some
and case object None
.
In fact, enums are implemented as sealed
classes that extend the scala.reflect.Enum
trait.
Types Open for Extension
I discussed the new open
keyword in a previous post. Now the default is for the compiler to disallow subtyping concrete types, unless they are declared open
or the language extension adhocExtensions
is enabled. (Actually, it will become the default as of Scala 3.1…) You don’t have to declare such types final
, but you still have an “escape hatch” when you need to override a concrete type (e.g., to create test doubles).
Export Clauses
Export clauses are “inverses” of import clauses, sort-of. Suppose you declare a type that instantiates instances of other types inside its body and you want some of the members in those instances to be part of the public interface of the compound type. Note that if you use mixin traits or a parent type, this happens in the usual, automatic way. In Scala 2, you would have to write boilerplate “delegator” members that just invoke the instance members.
Scala 3 adds an export
clause that allows you to expose these members directly. Here’s an example that starts with a few “domain” types for a user name and password, then a trait for authentication and a concrete class that uses a directory service:
The object Service
declares a private instance of the DirectoryAuthenticate
, then uses an export
clause to make the instance apply
method part of the Service
API, but with the name authenticate
. The method isAuthenticated
is exported as is.
Here’s Service
in action:
Conclusion
I’ve always felt that Scala’s thoughtful combination of OOP and FP leverages the best of both worlds. In particular, because Scala has a carefully designed type system, a lot of thought has gone into how to encourage type designs that avoid many of the mistakes of applied OOP, like sloppy, poorly-defined types.
Scala 3 adds to the feature set that encourages careful type design. Sealed hierarchies, with enums as an alternative mechanism, encourage the use of algebraic data types and empower safe and effective pattern matching. Final types and methods prevent undesired extension, but now concrete types will be final by default (evolving over several 3.X releases), but they can be declared open
when subtyping is desirable. Export clauses eliminate another form of boilerplate, delegation methods, when exposing members of instances nested within a compound type.
For an even more concise summary of most of the notable changes in Scala 3, see my new Scala 3 Highlights page.
You can start reading the rough draft of Programming Scala, Third Editionon the O’Reilly Learning Platform. The first half or so of the chapters are available. I am refining them still, but any feedback is welcome!