Luet oppimateriaalin englanninkielistä versiota. Mainitsit kuitenkin taustakyselyssä osaavasi suomea. Siksi suosittelemme, että käytät suomenkielistä versiota, joka on testatumpi ja hieman laajempi ja muutenkin mukava.
Suomenkielinen materiaali kyllä esittelee englanninkielisetkin termit.
Kieli vaihtuu A+:n sivujen yläreunan painikkeesta. Tai tästä: Vaihda suomeksi.
Chapter 7.4: Sealed Types and Enumerations
Sealed Types
Introduction
If we define a trait as we did in Chapter 7.3, the trait can be extended freely: in the same file, in other code files, in other packages, anywhere. If we have multiple programmers working on the same software, any of them may extend the trait with whatever they please. In the worst-case scenario, such extensions may have undesirable effects on code that relies on our trait.
However, it’s pretty common that we can decide, in advance, which subtypes a particular trait will have. When that is the case, it’s a good idea to write down, as code, that “my trait has exactly those subtypes that I’m defining here and that’s it — other extensions of the trait are forbidden”. We’ll explore this idea below.
Example: Military and non-military service
Let’s create a few traits and classes to represent military service and its alternatives in a particular country. Our example is based on the Finnish system of mandatory service, but that’s not important. The basic idea is that a person will serve their country for a number of months in military service or civilian service, unless excused from this duty for some reason, such as medical issues.
First, let’s have a trait that represents the national service that a person has completed. This generic supertype is extremely simple:
trait NationalService
Let’s define the following subtypes to represent different kinds of service:
The trait Military
represents military service. People usually sign up for military
service in its usual, armed form, but there is an unarmed version of military service as
well, so we have two subtypes of Military
:
trait Military(val branch: String) extends NationalService
class Armed(branch: String) extends Military(branch)
class Unarmed(branch: String) extends Military(branch)
Now we can write, for example, Armed("navy")
to represent armed military service in the
navy.
Civilian service is represented by a class of its own. In this type of service, the person
contributes to a particular institution, such as a university. We record this in each
Civilian
object:
class Civilian(val position: String) extends NationalService
Our type hierarchy is completed by a branch for representing that person has completed neither military nor civilian service:
trait HasNotServed extends NationalService
class Exempted(val grounds: String) extends HasNotServed
object Unassigned extends HasNotServed
The singleton object Unassigned
represents all cases
where a person has not been exempted but hasn’t (yet)
been assigned to any form of military or civilian service
either. That is, they have not (yet) completed their
national service.
Our intention is that any object of type NationalService
is always of one of the
types Military
, Civilian
, or HasNotServed
. We want it to be impossible that
anyone working with this trait creates a new subtype for NationalService
somewhere
in the program. We want that a programmer working with NationalService
can fully
trust that the following code, for example, covers all the possible cases:
def testDescriptions() =
val examples = Vector(Armed("navy"), Unassigned, Civilian("Aalto"), Exempted("health"), Unarmed("army"))
examples.map(describeService).foreach(println)
def describeService(service: NationalService): String =
service match
case military: Military => s"military service in the ${military.branch}"
case civilian: Civilian => s"civilian service at ${civilian.position}"
case notServed: HasNotServed => "has not served"
We have a bunch of different NationalService
objects in
a vector. We construct a description of each by calling a
describeService
function.
Our match
command chooses a case based on the value’s dynamic
type (Chapter 7.3). It produces a string as appropriate for
each subtype.
We’d like to guarantee that these three are NationalService
’s
only direct subtypes. By doing so, we want to prevent
describeService
(or other code that similarly uses
NationalService
) to crash at runtime. As things stand,
describeService
will crash if someone creates a new subtype
for NationalService
and calls the function with an object
of that type, which the function isn’t prepared to handle.
We can achieve this guarantee by making NationalService
a sealed (suljettu) trait.
Sealing a trait
We just need one word:
sealed trait NationalService
// Etc. The subtypes are defined in the same file.
The word sealed
means that any class or object that directly
extends the trait must be defined in the same file as the
trait. It thus becomes impossible to define any direct subtypes
for NationalService
anywhere but here in this file. The Scala
compiler enforces this rule strictly.
Now NationalService
’s user can be confident that its type hierarchy has only those
three branches that we’ve defined.
Let’s seal our other traits — HasNotServed
and Military
— as well. Here’s our
whole type hierarchy again:
sealed trait NationalService
sealed trait Military(val branch: String) extends NationalService
class Armed(branch: String) extends Military(branch)
class Unarmed(branch: String) extends Military(branch)
class Civilian(val position: String) extends NationalService
sealed trait HasNotServed extends NationalService
class Exempted(val grounds: String) extends HasNotServed
object Unassigned extends HasNotServed
Together, these definitions guarantee that any NationalService
object will always be
of type Armed
, Unarmed
, Civilian
, or Exempted
— or then it’s the Unassigned
singleton object.
For comparison, consider the Option
type you know: its user can fully trust that an
object of type Option
is a Some
object or it’s the None
singleton. Other alternatives
do not exist, and do not need to be considered. (In the Scala API, Option
has indeed
been defined as sealed
; the class Some
and the None
object are defined in the same
file with their supertype Option
.)
Sealed types and match
One additional, concrete benefit from sealing a trait is that the Scala compiler gives us better warning messages. Here’s an example:
def describeServiceBadly(service: NationalService): String =
service match
case military: Military => s"military service in the ${military.branch}"
case civilian: Civilian => s"civilian service at ${civilian.position}"
case exempted: Exempted => s"exempt on grounds of ${exempted.grounds}"
Our function deals with three cases. The
compiler gives us a warning: “match may not be
exhaustive”. That is, we haven’t covered all the
possible values that a NationalService
may be,
and it’s thus unclear what we’d like this function
to do in certain circumstances. The warning has a
point: this function will crash if we pass in the
Unassigned
object.
Unless the trait is sealed, the compiler doesn’t issue such a warning.
For unsealed traits, there is no guarantee that any match
command
exhaustively covers all the cases.
Optional add-ons to the above example
Sealing a regular class
Above, we sealed traits. It’s also possible to seal a regular
class. For instance, we could define Civilian
like this:
sealed class Civilian(val position: String) extends NationalService
Now it’s impossible to extend this class except in the same file. Given what we’ve covered, that notion may seem odd, because so far we’ve been extending traits, not regular classes. However, under the right conditions, it is also quite possible to create subtypes for a regular class. We’ll be trying that out soon in Chapter 7.5.
On a regular class, you might also write an even stricter modifier,
final
. This means that the class has no subtypes at all — not
here in the same file nor anywhere else. There’s a bit more about
final
, too, in the next chapter.
Case classes and match
revisited
Chapter 4.4’s optional materials introduced the concept of a case class. As a simple example, we used this class:
case class Album(val name: String, val artist: String, val year: Int)
We further noted that case classes are convenient in combination with
match
. You can write case
s of a match
command so that their
structure corresponds to the case class’s constructor parameters. Such
a case “extracts” information from the objects, as illustrated here:
myAlbum match
case Album(_, _, year) if year < 2000 => "ancient"
case Album(name, creator, year) => creator + ": " + name + " (" + year + ")"
The same technique could well be applied to our NationalService
hierarchy in case we’d like such objects to be easily match
able.
To do that, we’d add the word case
in our class definitions and
in front of the Unassigned
singleton as well.
If you’d like to explore that topic further, take a look at the code
in o1.service
within the Traits module. You’ll find a slightly
extended version of our example there. That code exploits case classes;
it also incorporates the final
keyword that was brought up above.
Assignment: Genders
In this short assignment, we’ll construct a type hierarchy for another piece of personal
information: the trait GenderResponse
and its subtypes represent users’ responses to
a question about their gender.
In this problem there are three main kinds of responses: the user may choose among
a few predefined options (Selected
), describe their gender as text however they prefer
(Specified
), or not respond (PreferNotToSay
). There are three predefined options:
Female
, Male
, and NonBinary
.
This vector contains an example of each sort of GenderResponse
:
Vector(NonBinary, Male, PreferNotToSay, Female, Specified("agender"))
Let’s make it our goal to define this hierarchy in GenderResponse.scala
so that it
exhaustively cover every kind of GenderResponse
that can exist in this program. (If the
program is developed further and additions are needed, the changes would be to this file.)
In o1.gender
of the Traits module, you’ll find a partial implementation for this type
hierarchy. Fill it in. The missing parts are marked in the given code and listed here:
Since we want to limit any subtypes to this file, the traits
GenderResponse
andSelected
should be sealed.The
NonBinary
object should be similar toMale
andFemale
, but it’s missing.The
Specified
class is there, but it’s not marked as a subtype ofGenderResponse
.The
PreferNotToSay
singleton is missing.
A+ presents the exercise submission form here.
Sometimes You Already Know All the Instances
Introduction
In the examples above, we marked some types as sealed
to indicate that we already know
all the types that extend those types. It’s possible for things to be even more clear
cut: sometimes we want a type to have only a specific number of predefined instances and
no more. We’d like to simply list those instances in code and prevent any other instances
from being created.
For example, there are seven weekdays. If we define a Weekday
type to represent them,
it makes no sense to allow anybody to create any additional weekdays beyond the obvious
ones. We’d just like to write our seven objects into code and prevent any other instances.
One way to do this is to write a sealed trait. Like so:
sealed trait Weekday
object Monday extends Weekday
object Tuesday extends Weekday
object Wednesday extends Weekday
object Thursday extends Weekday
object Friday extends Weekday
object Saturday extends Weekday
object Sunday extends Weekday
Maybe we’d also like to represent months? Like so:
sealed trait Month
object January extends Month
object February extends Month
object March extends Month
object April extends Month
object May extends Month
object June extends Month
object July extends Month
object August extends Month
object September extends Month
object October extends Month
object November extends Month
object December extends Month
What we’re doing here is enumerate — list — every single instance of these types; there aren’t any others.
That’s one way to do it. But since such data types are pretty common, many programming languages provide further conveniences for defining them. Scala does, too.
Enumerated types: enum
There’s a very simple way to define weekdays and months:
enum Weekday:
case Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
enum Month:
case January, February, March, April, May, June, July,
August, September, October, November, December
The enum
keyword gives us an enumerated type
(luetelmatyyppi) It’s a class like any other,
but it has only these specific, named instances.
Each “case” of the enumerated type — each
instance — is listed after the case
keyword.
With our enum
definitions in place, we can use them like this:
val today = Weekday.Mondaytoday: Weekday = Monday val cruelest = Month.Aprilcruelest: Month = April
Above, we explicitly named the enum
to access
its instances. Indeed we need to do that unless we
import
those instances as shown below, after which
just mentioning each instance is enough:
import Weekday.*val deadlineDay = WednesdaydeadlineDay: Weekday = Wednesday val weekend = Vector(Saturday, Sunday)weekend: Vector[Weekday] = Vector(Saturday, Sunday)
Enumerations come with a few other handy properties as well, such as the
values
and fromOrdinal
methods:
Month.fromOrdinal(11)res0: Month = December Weekday.valuesres1: Array[Weekday] = Array(Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday)
fromOrdinal
takes a number and returns the
corresponding instance of the enumerated type.
The numbering runs from zero up, so December
is month number eleven.
values
returns a collection with all of
the enum
s instances as elements, in order.
(Array
is a type of collection much like
the ones we’ve used; Chapter 12.1.)
No doubt you can come up with some more examples of data, beyond weekdays and months, that we could represent with an enumerated type.
The example continues: Variables in an enum
The instances of an enumerated type can have variables and methods just like other objects
can. Let’s go back to our Month
type and associate each month with a length in days
(ignoring leap years). We end up with this longer definition:
enum Month(val days: Int):
case January extends Month(31)
case February extends Month(28)
case March extends Month(31)
case April extends Month(30)
case May extends Month(31)
case June extends Month(30)
case July extends Month(31)
case August extends Month(31)
case September extends Month(30)
case October extends Month(31)
case November extends Month(30)
case December extends Month(31)
Our Month
type now takes a constructor parameter and
stores its value in an instance variable named days
.
All the enumerated instances of Month
have a days
variable.
These instances aren’t identical to each other. We define
each as a case
of its own and indicate which value we’d
like to pass to that object as a constructor parameter.
There’s nothing unusual about using the days
variable:
import Month.*April.daysres2: Int = 30 Month.values.map( _.days ).sumres3: Int = 365
Another example: compass directions
In Chapter 6.3’s snake game, you already made use of a CompassDir
type, which represents the main compass directions. CompassDir
is actually an enumerated type with four instances: North
, East
,
South
, and West
. If you’d like to study how CompassDir
has
been implemented, take a look in the folder o1.grid
within the
O1Library module.
Blood Types Revisited
In Chapter 5.1, we modeled people’s blood types as objects. We used a combination of the ABO classification system and the Rhesus classification system as we represented each person’s blood as a combination of a string and a Boolean.
val myBlood = BloodType("AB", true)myBlood: o1.blood1.BloodType = AB+ val yourBlood = BloodType("A", true)yourBlood: o1.blood1.BloodType = A+ myBlood.canDonateTo(yourBlood)res4: Boolean = false
In that earlier version of the program:
We had only a single BloodType
concept: the combination of
a person’s ABO type and their Rhesus type.
However, in the real world, it’s possible to use the ABO and Rhesus types independently of each other. What’s more, there are many other blood systems beyond these two. Also:
The user of our class passed in parameters that specify the blood type. We trusted the user to always pass in valid strings.
Let’s rework our program. We’ll make it more versatile and represent the ABO and Rhesus classification systems separately. As we do so, we model each system as an enumerated type. Let’s consider the Rhesus system first.
Enumerated Rhesus types
Since there is a small number of Rhesus blood types (two), and since we know those types
in advance, an enum
is a natural choice:
enum Rhesus(val isPositive: Boolean):
case RhPlus extends Rhesus(true)
case RhMinus extends Rhesus(false)
def isNegative = !this.isPositive
def canDonateTo(recipient: Rhesus) = this.isNegative || this == recipient
def canReceiveFrom(donor: Rhesus) = donor.canDonateTo(this)
override def toString = if this.isPositive then "+" else "-"
end Rhesus
Rhesus
objects have an isPositive
variable,
which receives its value as a constructor parameter.
Let’s have the object RhPlus
stand for Rhesus-positive
blood and RhMinus
for Rhesus-negative blood. These
two objects have different values of isPositive
.
No other objects of type Rhesus
exist.
An enum
can define methods just like a regular
class or trait can. The methods defined here are
common to all objects of type Rhesus
— that is,
they’re common to both of the objects RhPlus
and
RhMinus
.
Now we can use the objects to work with Rhesus-system blood types:
import Rhesus.*RhPlus.canDonateTo(RhMinus)res5: Boolean = false RhMinus.canDonateTo(RhPlus)res6: Boolean = true RhMinus.canDonateTo(RhMinus)res7: Boolean = true RhMinus.isPositiveres8: Boolean = false
Assignment: Enumerating ABO types
In this assignment, you get to implement the ABO classification as an enumerated type
similar to what we have for Rhesus
above.
The above code for Rhesus
is provided in the package o1.blood2
, which is defined
in the Blood module. In the same file with Rhesus
, add the enumerated type ABO
.
It must meet the following requirements:
There are four cases that correspond to specific blood types:
A
,B
,AB
, andO
. These four objects are the only four instances of typeABO
in existence.ABO
has an instance variable namedantigens
that receives its value from a constructor parameter. This variable stores the particular blood type’s antigens as aString
.Each of the enumerated objects passes a different string as a constructor parameter. The string lists all the antigens present in the blood type:
"A"
,"B"
,"AB"
, or""
(the last being the empty string).
ABO
has the methodscanDonateTo
andcanReceiveFrom
, which work like the methods of the same name from Chapter 5.1 and theRhesus
type above. However, the new methods should consider only the ABO antigens and disregard the Rhesus factor entirely. Each method takes a single parameter of typeABO
.ABO
also has atoString
method that returns the blood type’s name. The name equalsantigens
, except that name of theO
blood type is"O"
.You don’t actually have to write this method. Scala
enum
s automatically get atoString
method that returns the enumerated object’s name. Assuming you’ve defined theABO
type otherwise correctly, you’ll see that itstoString
automatically works as specified.
If you have trouble remembering which blood types are compatible with which other ones, consult Chapter 5.1, your own solution to the earlier assignment, or that assignment’s example solution.
The file test.scala
provides a little program for testing your code. Some of the given
code has been commented out to suppress untimely error messages. Uncomment it when you’re
ready to test.
A+ presents the exercise submission form here.
What about combining the blood systems?
Our goal was to be able to use just one of the blood type systems
Rhesus
or ABO
separately, or both in combination, or even to
combine these systems with others. How can we combine the systems
now that we represent each one with a type of its own?
One approach is implemented below as class ABORh
, which you can
also find in the same file.
class ABORh(val abo: ABO, val rhesus: Rhesus):
def canDonateTo(recipient: ABORh) =
this.abo.canDonateTo(recipient.abo) && this.rhesus.canDonateTo(recipient.rhesus)
def canReceiveFrom(donor: ABORh) = donor.canDonateTo(this)
override def toString = this.abo.toString + this.rhesus.toString
end ABORh
The class provides essentially the same
functionality as our earlier BloodType
class. This class has been built differently,
though, as a combination of the ABO and
Rhesus systems.
This class doesn’t count on the user to
pass in valid strings. It accepts only
values of type ABO
and Rhesus
. In
practice, we pass in the objects that
we’ve listed in those enum
s.
A deeper dip into Scala’s type system
(This completely optional assignment is harder than the previous one and calls for independent study beyond this ebook. It is best suited only to those students who have prior experience from before O1. Beginners should probably skip this for now.)
We already have representations for the Rhesus
system, the ABO
system, and their combination ABORh
. Those three types have a
few things in common: they have a canDonateTo
method, they have
a canReceiveFrom
method (identically implemented in all three),
and they each represent a particular blood-type system.
Let’s define a supertype for the three types and other any other
blood-type systems we might wish to define in the future. Rhesus
,
ABO
, and ABORh
are all BloodType
s.
Here’s a first attempt:
trait BloodType:
def canDonateTo(recipient: BloodType): Boolean
enum Rhesus extends BloodType:
def canDonateTo(recipient: Rhesus): Boolean = ???
/* Etc. */
enum ABO extends BloodType:
def canDonateTo(recipient: ABO): Boolean = ???
/* Etc. */
class ABORh extends BloodType:
def canDonateTo(recipient: ABORh): Boolean = ???
/* Etc. */
The type annotation on the parameter
causes a problem that manifests itself in
the subtypes Rhesus
, ABO
, and ABORh
.
Find out what error message is caused by that code. Reflect on why
that happens. You can write your code in o1.blood3
.
Then look at this version:
trait BloodType[ThisSystem]:
def canDonateTo(recipient: ThisSystem): Boolean
We’ve added a type parameter on the trait.
Read about type parameters in Scala’s documentation
and other resources. Determine what we need to add to the subtypes
Rhesus
, ABO
, and ABORh
to make them compatible with the new
version of BloodType
? Make those changes in o1.blood3
.
That trait had only the canDonateTo
method. It would be better
to add canReceiveFrom
and remove its identical implementations
from the three subtypes:
trait BloodType[ThisSystem]:
def canDonateTo(recipient: ThisSystem): Boolean
def canReceiveFrom(donor: ThisSystem) = donor.canDonateTo(this)
That change broke our trait, however. Find out what the error is and see if it makes sense to you.
In this final version, both method definitions are fine:
trait BloodType[ThisSystem <: BloodType[ThisSystem]]:
this: ThisSystem =>
def canDonateTo(recipient: ThisSystem): Boolean
def canReceiveFrom(donor: ThisSystem) = donor.canDonateTo(this)
end BloodType
What’s that? In brief, it is an upper bound
and specifies that the type parameter ThisSystem
must be some subtype of BloodType[ThisSystem]
.
And what’s that? In a nutshell, it is a
self type and specifies that, in this trait,
this
refers to an object of type ThisSystem
.
Find out how these changes solve the problems in the previous version. Search online for more information as needed.
A+ presents the exercise submission form here.
Summary of Key Points
To limit where a supertype may be extended, you can mark the type as
sealed
. Then any extension of that type must happen in the same file.Sealing provides a guarantee against surprising subtypes defined elsewhere. Used appropriately, it can make code easier to read, protect against subtle bugs, and help the compiler provide better warnings about suspicious code.
If you have a type whose instances you can list in advance, you can define it as an enumerated type (
enum
). Such a type is like a regular class but cannot be instantiated as usual; all its instances are listed as part of theenum
definition.The main advantages of enumerated types are similar those of sealed types. Moreover, some things can be expressed very succinctly as an
enum
.
Links to the glossary: trait, type hierarchy;
sealed
; enumerated type;final
.
Feedback
Please note that this section must be completed individually. Even if you worked on this chapter with a pair, each of you should submit the form separately.
Credits
Thousands of students have given feedback and so contributed to this ebook’s design. Thank you!
The ebook’s chapters, programming assignments, and weekly bulletins have been written in Finnish and translated into English by Juha Sorva.
The appendices (glossary, Scala reference, FAQ, etc.) are by Juha Sorva unless otherwise specified on the page.
The automatic assessment of the assignments has been developed by: (in alphabetical order) Riku Autio, Nikolas Drosdek, Kaisa Ek, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Onni Komulainen, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Mikael Lenander, Ilona Ma, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, Joel Toppinen, Anna Valldeoriola Cardó, and Aleksi Vartiainen.
The illustrations at the top of each chapter, and the similar drawings elsewhere in the ebook, are the work of Christina Lassheikki.
The animations that detail the execution Scala programs have been designed by Juha Sorva and Teemu Sirkiä. Teemu Sirkiä and Riku Autio did the technical implementation, relying on Teemu’s Jsvee and Kelmu toolkits.
The other diagrams and interactive presentations in the ebook are by Juha Sorva.
The O1Library software has been developed by Aleksi Lukkarinen, Juha Sorva, and Jaakko Nakaza. Several of its key components are built upon Aleksi’s SMCL library.
The pedagogy of using O1Library for simple graphical programming (such as Pic
) is
inspired by the textbooks How to Design Programs by Flatt, Felleisen, Findler, and
Krishnamurthi and Picturing Programs by Stephen Bloch.
The course platform A+ was originally created at Aalto’s LeTech research group as a student project. The open-source project is now shepherded by the Computer Science department’s edu-tech team and hosted by the department’s IT services; dozens of Aalto students and others have also contributed.
The A+ Courses plugin, which supports A+ and O1 in IntelliJ IDEA, is another open-source project. It has been designed and implemented by various students in collaboration with O1’s teachers.
For O1’s current teaching staff, please see Chapter 1.1.
Additional credits appear at the ends of some chapters.
The subtype
Exempted
represents being excused from national service. For such cases, we record the reason for the exemption.