Scala 3 — Book

OOP Modeling

Language

This chapter provides an introduction to domain modeling using object-oriented programming (OOP) in Scala 3.

Introduction

Scala provides all the necessary tools for object-oriented design:

  • Traits let you specify (abstract) interfaces, as well as concrete implementations.
  • Mixin Composition gives you the tools to compose components from smaller parts.
  • Classes can implement the interfaces specified by traits.
  • Instances of classes can have their own private state.
  • Subtyping lets you use an instance of one class where an instance of a superclass is expected.
  • Access modifiers lets you control which members of a class can be accessed by which part of the code.

Traits

Perhaps different from other languages with support for OOP, such as Java, the primary tool of decomposition in Scala is not classes, but traits. They can serve to describe abstract interfaces like:

trait Showable {
  def show: String
}
trait Showable:
  def show: String

and can also contain concrete implementations:

trait Showable {
  def show: String
  def showHtml = "<p>" + show + "</p>"
}
trait Showable:
  def show: String
  def showHtml = "<p>" + show + "</p>"

You can see that we define the method showHtml in terms of the abstract method show.

Odersky and Zenger present the service-oriented component model and view:

  • abstract members as required services: they still need to be implemented by a subclass.
  • concrete members as provided services: they are provided to the subclass.

We can already see this with our example of Showable: defining a class Document that extends Showable, we still have to define show, but are provided with showHtml:

class Document(text: String) extends Showable {
  def show = text
}
class Document(text: String) extends Showable:
  def show = text

Abstract Members

Abstract methods are not the only thing that can be left abstract in a trait. A trait can contain:

  • abstract methods (def m(): T)
  • abstract value definitions (val x: T)
  • abstract type members (type T), potentially with bounds (type T <: S)
  • abstract givens (given t: T) Scala 3 only

Each of the above features can be used to specify some form of requirement on the implementor of the trait.

Mixin Composition

Not only can traits contain abstract and concrete definitions, Scala also provides a powerful way to compose multiple traits: a feature which is often referred to as mixin composition.

Let us assume the following two (potentially independently defined) traits:

trait GreetingService {
  def translate(text: String): String
  def sayHello = translate("Hello")
}

trait TranslationService {
  def translate(text: String): String = "..."
}
trait GreetingService:
  def translate(text: String): String
  def sayHello = translate("Hello")

trait TranslationService:
  def translate(text: String): String = "..."

To compose the two services, we can simply create a new trait extending them:

trait ComposedService extends GreetingService with TranslationService
trait ComposedService extends GreetingService, TranslationService

Abstract members in one trait (such as translate in GreetingService) are automatically matched with concrete members in another trait. This not only works with methods as in this example, but also with all the other abstract members mentioned above (that is, types, value definitions, etc.).

Classes

Traits are great to modularize components and describe interfaces (required and provided). But at some point we’ll want to create instances of them. When designing software in Scala, it’s often helpful to only consider using classes at the leafs of your inheritance model:

Traits T1, T2, T3
Composed traits S1 extends T1 with T2, S2 extends T2 with T3
Classes C extends S1 with T3
Instances new C()
Traits T1, T2, T3
Composed traits S1 extends T1, T2, S2 extends T2, T3
Classes C extends S1, T3
Instances C()

This is even more the case in Scala 3, where traits now can also take parameters, further eliminating the need for classes.

Defining Classes

Like traits, classes can extend multiple traits (but only one super class):

class MyService(name: String) extends ComposedService with Showable {
  def show = s"$name says $sayHello"
}
class MyService(name: String) extends ComposedService, Showable:
  def show = s"$name says $sayHello"

Subtyping

We can create an instance of MyService as follows:

val s1: MyService = new MyService("Service 1")
val s1: MyService = MyService("Service 1")

Through the means of subtyping, our instance s1 can be used everywhere that any of the extended traits is expected:

val s2: GreetingService = s1
val s3: TranslationService = s1
val s4: Showable = s1
// ... and so on ...

Planning for Extension

As mentioned before, it is possible to extend another class:

class Person(name: String)
class SoftwareDeveloper(name: String, favoriteLang: String)
  extends Person(name)

However, since traits are designed as the primary means of decomposition, it is not recommended to extend a class that is defined in one file from another file.

Open Classes Scala 3 only

In Scala 3 extending non-abstract classes in other files is restricted. In order to allow this, the base class needs to be marked as open:

open class Person(name: String)

Marking classes with open is a new feature of Scala 3. Having to explicitly mark classes as open avoids many common pitfalls in OO design. In particular, it requires library designers to explicitly plan for extension and for instance document the classes that are marked as open with additional extension contracts.

Instances and Private Mutable State

Like in other languages with support for OOP, traits and classes in Scala can define mutable fields:

class Counter {
  // can only be observed by the method `count`
  private var currentCount = 0

  def tick(): Unit = currentCount += 1
  def count: Int = currentCount
}
class Counter:
  // can only be observed by the method `count`
  private var currentCount = 0

  def tick(): Unit = currentCount += 1
  def count: Int = currentCount

Every instance of the class Counter has its own private state that can only be observed through the method count, as the following interaction illustrates:

val c1 = new Counter()
c1.count // 0
c1.tick()
c1.tick()
c1.count // 2
val c1 = Counter()
c1.count // 0
c1.tick()
c1.tick()
c1.count // 2

Access Modifiers

By default, all member definitions in Scala are publicly visible. To hide implementation details, it’s possible to define members (methods, fields, types, etc.) to be private or protected. This way you can control how they are accessed or overridden. Private members are only visible to the class/trait itself and to its companion object. Protected members are also visible to subclasses of the class.

Advanced Example: Service Oriented Design

In the following, we illustrate some advanced features of Scala and show how they can be used to structure larger software components. The examples are adapted from the paper “Scalable Component Abstractions” by Martin Odersky and Matthias Zenger. Don’t worry if you don’t understand all the details of the example; it’s primarily intended to demonstrate how to use several type features to construct larger components.

Our goal is to define a software component with a family of types that can be refined later in implementations of the component. Concretely, the following code defines the component SubjectObserver as a trait with two abstract type members, S (for subjects) and O (for observers):

trait SubjectObserver {

  type S <: Subject
  type O <: Observer

  trait Subject { self: S =>
    private var observers: List[O] = List()
    def subscribe(obs: O): Unit = {
      observers = obs :: observers
    }
    def publish() = {
      for ( obs <- observers ) obs.notify(this)
    }
  }

  trait Observer {
    def notify(sub: S): Unit
  }
}
trait SubjectObserver:

  type S <: Subject
  type O <: Observer

  trait Subject:
    self: S =>
      private var observers: List[O] = List()
      def subscribe(obs: O): Unit =
        observers = obs :: observers
      def publish() =
        for obs <- observers do obs.notify(this)

  trait Observer:
    def notify(sub: S): Unit

There are a few things that need explanation.

Abstract Type Members

The declaration type S <: Subject says that within the trait SubjectObserver we can refer to some unknown (that is, abstract) type that we call S. However, the type is not completely unknown: we know at least that it is some subtype of the trait Subject. All traits and classes extending SubjectObserver are free to choose any type for S as long as the chosen type is a subtype of Subject. The <: Subject part of the declaration is also referred to as an upper bound on S.

Nested Traits

Within trait SubjectObserver, we define two other traits. Let us begin with trait Observer, which only defines one abstract method notify that takes an argument of type S. As we will see momentarily, it is important that the argument has type S and not type Subject.

The second trait, Subject, defines one private field observers to store all observers that subscribed to this particular subject. Subscribing to a subject simply stores the object into this list. Again, the type of parameter obs is O, not Observer.

Self-type Annotations

Finally, you might have wondered what the self: S => on trait Subject is supposed to mean. This is called a self-type annotation. It requires subtypes of Subject to also be subtypes of S. This is necessary to be able to call obs.notify with this as an argument, since it requires a value of type S. If S was a concrete type, the self-type annotation could be replaced by trait Subject extends S.

Implementing the Component

We can now implement the above component and define the abstract type members to be concrete types:

object SensorReader extends SubjectObserver {
  type S = Sensor
  type O = Display

  class Sensor(val label: String) extends Subject {
    private var currentValue = 0.0
    def value = currentValue
    def changeValue(v: Double) = {
      currentValue = v
      publish()
    }
  }

  class Display extends Observer {
    def notify(sub: Sensor) =
      println(s"${sub.label} has value ${sub.value}")
  }
}
object SensorReader extends SubjectObserver:
  type S = Sensor
  type O = Display

  class Sensor(val label: String) extends Subject:
    private var currentValue = 0.0
    def value = currentValue
    def changeValue(v: Double) =
      currentValue = v
      publish()

  class Display extends Observer:
    def notify(sub: Sensor) =
      println(s"${sub.label} has value ${sub.value}")

Specifically, we define a singleton object SensorReader that extends SubjectObserver. In the implementation of SensorReader, we say that type S is now defined as type Sensor, and type O is defined to be equal to type Display. Both Sensor and Display are defined as nested classes within SensorReader, implementing the traits Subject and Observer, correspondingly.

Besides, being an example of a service oriented design, this code also highlights many aspects of object-oriented programming:

  • The class Sensor introduces its own private state (currentValue) and encapsulates modification of the state behind the method changeValue.
  • The implementation of changeValue uses the method publish defined in the extended trait.
  • The class Display extends the trait Observer, and implements the missing method notify.

It is important to point out that the implementation of notify can only safely access the label and value of sub, since we originally declared the parameter to be of type S.

Using the Component

Finally, the following code illustrates how to use our SensorReader component:

import SensorReader._

// setting up a network
val s1 = new Sensor("sensor1")
val s2 = new Sensor("sensor2")
val d1 = new Display()
val d2 = new Display()
s1.subscribe(d1)
s1.subscribe(d2)
s2.subscribe(d1)

// propagating updates through the network
s1.changeValue(2)
s2.changeValue(3)

// prints:
// sensor1 has value 2.0
// sensor1 has value 2.0
// sensor2 has value 3.0

import SensorReader.*

// setting up a network
val s1 = Sensor("sensor1")
val s2 = Sensor("sensor2")
val d1 = Display()
val d2 = Display()
s1.subscribe(d1)
s1.subscribe(d2)
s2.subscribe(d1)

// propagating updates through the network
s1.changeValue(2)
s2.changeValue(3)

// prints:
// sensor1 has value 2.0
// sensor1 has value 2.0
// sensor2 has value 3.0

With all the object-oriented programming utilities under our belt, in the next section we will demonstrate how to design programs in a functional style.

Contributors to this page: