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

../_images/person04.png

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:

../_images/inheritance_service.png

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 subtype Exempted represents being excused from national service. For such cases, we record the reason for the exemption.

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 cases 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 matchable. 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.

../_images/inheritance_gender.png

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 and Selected should be sealed.

  • The NonBinary object should be similar to Male and Female, but it’s missing.

  • The Specified class is there, but it’s not marked as a subtype of GenderResponse.

  • 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 enums 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:

../_images/inheritance_rhesus.png
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:

../_images/inheritance_abo.png
  • There are four cases that correspond to specific blood types: A, B, AB, and O. These four objects are the only four instances of type ABO in existence.

  • ABO has an instance variable named antigens that receives its value from a constructor parameter. This variable stores the particular blood type’s antigens as a String.

    • 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 methods canDonateTo and canReceiveFrom, which work like the methods of the same name from Chapter 5.1 and the Rhesus 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 type ABO.

  • ABO also has a toString method that returns the blood type’s name. The name equals antigens, except that name of the O blood type is "O".

    • You don’t actually have to write this method. Scala enums automatically get a toString method that returns the enumerated object’s name. Assuming you’ve defined the ABO type otherwise correctly, you’ll see that its toString 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 enums.

../_images/inheritance_aborh.png

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 BloodTypes.

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 the enum 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.

a drop of ink
Posting submission...