The latest instance of the course can be found at: O1: 2024
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.3: Traits and Type Hierarchies
About This Page
Questions Answered: How can I represent supertype–subtype relationships in my program? Concepts that have subconcepts? How can I make my program more generic and easier to extend?
Topics: Using traits for representing supertypes and subtypes. Abstract methods and variables. Static types vs. dynamic types. Type hierarchies.
What Will I Do? Read and program.
Rough Estimate of Workload:? Three or four hours.
Points Available: B100.
Related Modules: Traits (new). AuctionHouse2 (new) in an optional assignment.
Introduction: Two-Dimensional Shapes
Suppose we’re writing a program that deal with two-dimensional shapes such as circles and rectangles. Among other things, the program must be able to compute the areas of such shapes.
Note that we’re not dealing with images of circles or rectangles, for which we’ve used
the Pic
type. Instead, we seek to represent the mathematical concepts of circle and
rectangle.
Here’s a first go at defining class Circle
:
import scala.math.Pi
class Circle(val radius: Double):
def area = Pi * this.radius * this.radius
// Etc. Other Circle methods go here.
And class Rectangle
:
class Rectangle(val sideLength: Double, val anotherSideLength: Double):
def area = this.sideLength * this.anotherSideLength
// Etc. Other Rectangle methods go here.
There’s nothing wrong with either class if we consider them individually. We could use each one independently in a program, but:
But #1
What if we want to compare different shapes by area?
We could add methods like so:
import scala.math.Pi
class Circle(val radius: Double):
def area = Pi * this.radius * this.radius
def isBiggerThan(another: Circle): Boolean = this.area > another.area
def isBiggerThan(rectangle: Rectangle): Boolean = this.area > rectangle.area
end Circle
class Rectangle(val sideLength: Double, val anotherSideLength: Double):
def area = this.sideLength * this.anotherSideLength
def isBiggerThan(another: Rectangle): Boolean = this.area > another.area
def isBiggerThan(circle: Circle): Boolean = this.area > circle.area
end Rectangle
But must we really write two separate methods? And replicate them in each class? Not very DRY. And what if there were more than two kinds of shapes?
And that’s not all.
But #2
What if we want a collection that stores references to different shape objects: circles, rectangles, and perhaps other shapes, too? Like this:
@main def shapeTest() =
val shapes = Buffer[?????]()
shapes += Circle(10)
shapes += Rectangle(10, 100)
shapes += Circle(5)
var sumOfAreas = 0.0
for current <- shapes do
sumOfAreas += current.area
println("The sum of the areas is: " + sumOfAreas)
end shapeTest
On a related note: what is the type of current
? It should be
something like “any object that has an area
method”, right?
Surely we don’t need to make separate lists for circles, rectangles, etc.? That wouldn’t be too practical.
We can shed some light on the problem by initializing the buffer’s elements as we create it:
@main def shapeTest() =
val shapes = Buffer(Circle(10), Rectangle(10, 100), Circle(5))
var sumOfAreas = 0.0
for current <- shapes do
sumOfAreas += current.area
println("The sum of the areas is: " + sumOfAreas)
end shapeTest
The command is perfectly valid. Scala infers the types of
expressions automatically where possible (Chapter 1.8). Since
the elements include circles and a rectangle, the Scala compiler
determines that this isn’t a “buffer of circles” or a “buffer of
rectangles” but a “buffer of miscellaneous objects”. (The name of
the inferred type is Buffer[AnyRef]
. We’ll discuss the meaning
of AnyRef
further in Chapter 7.5.)
Since the buffer contains miscellaenous objects, current
may
refer to any such object. Which is why the attempt to call
current.area
now gives a compile time error to the effect
that “The area
method does not exist on any old object.”
It’s true that the method doesn’t exist on all objects. It’s great that the compiler
questions our method call current.area
. If we have a buffer of truly miscellaneous
objects, we indeed should not be able call area
on the buffer’s contents.
On the other hand, we happen to know that the objects that we put in this buffer do have
something in common: the area
method. It would be both natural and convenient if our
shapeTest
program worked.
What to do?
Subordinate and Superordinate Concepts
We humans think of circles and rectangles as shapes. Shapes, in general, have an area. To us, it’s natural that a thing can be an instance of a more specific concept (such as circle) as well as its superordinate concept (such as shape). We may conceive of the properties of our program in these terms:
“This program computes the total area of some shapes.”
“All shapes have an
area
method.”“
isBiggerThan
expects any shape object as a parameter.”
This diagram illustrates the relationships:
An “is-a relationship” links the subordinate concepts to the more general one: every circle is a shape, for example.
We can also express this notion as code. This chapter describes one way to do that: traits. Chapter 7.5 describes another way: superclasses.
Defining a Superordinate Concept as a Trait
Let’s represent the concept of shape as a data type:
trait Shape:
def isBiggerThan(another: Shape) = this.area > another.area
def area: Double
end Shape
We define a trait (piirreluokka) named Shape
. Its purpose
is to provide a generic definition for shapes: “In this program,
in order for an object to qualify as a shape, it must possess the
following methods — no matter what other properties it has or
doesn’t have.”
We use the keyword trait
rather than class
but, that aside,
this is pretty much like a regular class definition.
We define: all shapes have an isBiggerThan
method that compares
the areas of two shapes.
The parameter has the type Shape
, meaning that we can pass in
exactly the sort of object that the trait describes. This illustrates
that we can use a trait as a data type just like we’ve used regular
classes.
We define: all shapes have an area
method for computing the size
of the shape. But: we define only the method’s name, its parameters
(none, in this case), and its return type (Double
).
What we don’t define is an actual implementation for computing the area. Our method has no body! Such a method is known as an abstract method (abstrakti metodi).
The end marker is, once again, optional. Since there are blank lines within this trait’s definition, we choose to write it here. (More about end markers in the style guide.)
Unlike the regular classes that we’ve written so far, a trait can contain abstract,
unimplemented methods. The Shape
trait says any shape’s area can be computed with
a parameterless area
method that produces a Double
. How that Double
is produced
must be defined separately for different shapes.
Defining a subtype
We have now defined the generic concept of Shape
but haven’t yet stated that circles
and rectangles are shapes. Here’s how:
import scala.math.Pi
class Circle(val radius: Double) extends Shape:
def area = Pi * this.radius * this.radius
class Rectangle(val sideLength: Double, val anotherSideLength: Double) extends Shape:
def area = this.sideLength * this.anotherSideLength
The keyword extends
marks Circle
as a subtype of Shape
.
You can read the definition as: “The Circle
class extends the
Shape
trait.” Or: “Class Circle
mixes in trait Shape
.”
In any case, the idea is that all objects of type Circle
aren’t
only Circle
s but also have the type Shape
and all the properties
defined in trait Shape
.
A class can implement the abstract methods of a trait. For instance, here we define that a circle is a shape whose area comes from the formula π * r2 and a rectangle is a shape whose area is the product of its two sides.
Note: extends Shape
implies that circle and rectangle objects now also have an
isBiggerThan
method even though we didn’t write that method in the two classes.
A trait as a type for objects
Given the above, we now know that:
Like regular classes, traits define data types.
You can use the name of a trait to annotate a type onto a variable, just like you can use a class name.
It’s possible to create objects that are of type
Shape
.
Does that mean you can write Shape()
to create a new Shape
object? If you do, what
do you get?
Let’s try it:
Shape()-- Error ...
No. There are no Shape
objects that are “just shapes”, and you can’t create such an
instance. Which is good, since we haven’t defined an implementation of area
for such
hypothetical objects.
We must instantiate a trait indirectly by creating instances of classes that extend the trait, as illustrated below. Let’s start with a circle:
val myCircle = Circle(1)myCircle: o1.shapes.Circle = o1.shapes.Circle@1a1a02e
Next, we’ll try out a previously unfamiliar method named isInstanceOf
, which is
available on all Scala objects. It tells us whether or not an object is of a given type.
myCircle.isInstanceOf[Circle]res0: Boolean = true
Unlike most of the methods that we’ve used,
isInstanceOf
takes a type parameter,
which goes in square brackets.
We ask the object that myCircle
refers to
whether it is of type Circle
. It is.
Now let’s see if the same object is a Shape
:
myCircle.isInstanceOf[Shape]res1: Boolean = true
True again. Our object has multiple types at once, as we wanted.
Of course, our object does not have all possible types. It’s not a Rectangle
, for example.
myCircle.isInstanceOf[Rectangle]-- Warning: |myCircle.isInstanceOf[Rectangle] |^^^^^^^^ |this will always yield false since type o1.shapes.Circle and class Rectangle are unrelated res2: Boolean = false
A trait as a type for collection elements
Vector(Circle(1), Circle(2))res3: Vector[o1.shapes.Circle] = Vector(o1.shapes.Circle@e17571, o1.shapes.Circle@1e56bea) Vector(Circle(1), Rectangle(2, 3))res4: Vector[o1.shapes.Shape] = Vector(o1.shapes.Circle@876228, o1.shapes.Rectangle@3d619a)
When the collection contains only circles, Scala infers the type
as Circle
.
When the collection also contains one or more rectangles, Scala
finds the shared supertype, namely Shape
.
The Introductory Problems, Solved
The problems we faced at the beginning of the chapter simply vanish with the introduction
of the Shape
trait.
isBiggerThan
for all shapes
At the top of this chapter, we wrote multiple isBiggerThan
methods on Circle
and
Rectangle
. We don’t need them anymore. Our Shape
trait defines a generic isBiggerThan
method (replicated below) that works for comparing the areas of circles and rectangles —
and any other specific shapes we might wish to add later! The method doesn’t care if the
two shapes are instances of the same class or not, as long as they are Shape
s of some
kind.
trait Shape:
def isBiggerThan(another: Shape) = this.area > another.area
def area: Double
end Shape
The two invocations of area
in isBiggerThan
are valid because of the abstract method
a couple of lines below. Despite being abstract, the method guarantees that no matter which
specific sort of objects this
and another
happen to refer to, they will definitely have
some sort of implementation for area
.
Implementation required!
To make it possible to instantiate Circle
and Rectangle
, we must implement abstract
area
method as stipulated in the Shape
trait.
If we had left out area
from Circle
or Rectangle
, we would have received a compile-time
error about the missing method. That is, the Scala toolkit ensures that any concrete object
that we create has actual implementations for any abstract methods in its supertypes. We can
count on objects of any type to have implementations for all methods of that type.
shapeTest
also works now
Now that we have the Shape
trait, our original test program works unchanged:
@main def shapeTest() =
val shapes = Buffer(Circle(10), Rectangle(10, 100), Circle(5))
var sumOfAreas = 0.0
for current <- shapes do
sumOfAreas += current.area
println("The sum of the areas is: " + sumOfAreas)
end shapeTest
The inferred type of the buffer’s elements is Shape
. We could
have also written Buffer[Shape](
...)
here if we had felt
like it.
Calling current.area
is now legal because current
is of type
Shape
and the trait guarantees that all Shape
objects have an
implementation for area
.
Questions about Variables and Types
Extending a Trait with a Singleton
You can write extends
to mix in a trait to a singleton object just like you can mix
in a trait to a class. Such a singleton object represents a special case of the general
concept captured in the trait. If the trait has any abstract methods, you’ll need to
implement them in the singleton object.
object SingularShape extends Shape:
def area = 51 // implements the abstract method
val description = "non-Euclidian" // an additional variable on this object alone
In fact, you’ve already done that. Scala’s App
is not a regular class but a trait
that represents the concept of a runnable application. When you write extends App
on
a singleton object, you mix in the App
trait to that object. In other words: you make
that singleton object a special case of the App
supertype.
Static Types vs. Dynamic Types
Let’s recap a couple of terms from Chapter 1.2:
We use the word static for the aspect of a program that exists even when the program is not running. That is, the word refers to things that are present in the program text or directly deducible from it.
We use the word dynamic for the aspect that a program gains by being run: the process of program execution and things that happen during that process. All the dynamic properties of a program aren’t present in the program code alone; user input can impact on what happens during a program run, for example.
In Chapter 1.3, we noted that expressions have values and those values have types. In Chapter 1.4, we saw that variables have types. That chapter also told us that when you assign a value to a variable, the value’s type must be compatible with the variable’s type. Until now, “must be compatible” has effectively meant “must be the same as”. However, now that we’ve introduced traits, it’s time to consider compatibility a bit more carefully.
In statically typed languages such as Scala, we can distinguish between static types and dynamic types, as discussed below.
Static types
A static type (staattinen tyyppi) is a type that can be determined by examining program code, without running the program, and that is unaffected by runtime factors such as user inputs. Here are a few examples:
We annotate each method parameter with its static type.
Given the code
val text = "llama"
, it’s clear that the variabletext
has a static type ofString
.The expression
1 + 1
can be inferred to have the static typeInt
given that each of its subexpressions is a literal with the static typeInt
.
Variables and expressions have static types. That is, it is those parts of program code, not their values, that have static types!
Static types dictate which operations and method calls are valid and which ones aren’t.
A method call like myObject.someMethod()
produces a compile-time error unless the
myObject
has a static type that defines a parameterless someMethod
.
Dynamic types
The values of variables and expressions have dynamic types (dynaaminen tyyppi). These types are affected by what happens during a program run.
In many cases, a value’s dynamic type will exactly match the static type of the
corresponding variable or expression. For instance, the expression 1 + 1
evaluates
to the integer value two, whose dynamic type is Int
just like the expression’s
static type is.
The following program demonstrates the distinction between static and dynamic types:
var test: Shape = Rectangle(10, 20)
println(test.area)
test = Circle(10)
println(test.area)
val selected = readLine("Would you like a circle? Say 'hip' if you do, or you'll get a square. ")
if selected == "hip" then
test = Circle(readLine("Radius: ").toInt)
else
val side = readLine("Side length: ").toInt
test = Rectangle(side, side)
println(test.area)
We give test
the type Shape
so that it can store a reference
to any object whose class extends Shape
.
Each use of the name test
as an expression therefore has the
static type Shape
. That means it’s legal to call test.area
,
since the area
method is defined on Shape
.
However, the value of the expression test
has a dynamic type
that isn’t Shape
. As this program is run, the variable first
refers to a Rectangle
object, then a Circle
. On the last line,
the value of test
has a dynamic type that depends on which
string the user has entered.
Each time we call area
, one of the different implementations for that method is
activated. Which one, depends on the dynamic type of the value that we call the method on;
the names of methods are dynamically bound to implementations. In other words: what
happens as a reaction to a message sent to an object depends on the specific type of the
object that receives the message.
As you see, a value’s dynamic type doesn’t need to be identical with the static type
of the corresponding variable; it needs to be type compatible (tyyppiyhteensopiva).
You can store a reference to a Circle
object in a variable of type Circle
or a
variable of type Shape
, but not in a variable of type String
or Obstacle
.
Types in the REPL
Here are a few more examples in the REPL. Observe how the REPL outputs the static types of the variables to the left of the equals sign, as well as the dynamic types on the right as part of the objects’ descriptions.
var test1 = Rectangle(5, 10)test1: o1.shapes.Rectangle = o1.shapes.Rectangle@38c8ed
The static type of the variable test1
is inferred from the initial value assigned to
the variable. Since that static type is Rectangle
, we can’t make the variable refer to
a circle object. An attempt to do so provokes a clearly worded error message:
test1 = Circle(10)-- Error: |test1 = Circle(10) | ^^^^^^^^^^ | Found: o1.shapes.Circle | Required: o1.shapes.Rectangle
However, if we specifically give the variable the Shape
type, that static
type is “wider” than the value’s dynamic type:
var test2: Shape = Rectangle(5, 10)test2: o1.shapes.Shape = o1.shapes.Rectangle@bdee1c
This variable can also hold a reference to a circle:
test2 = Circle(10)test2: o1.shapes.Shape = o1.shapes.Circle@1071884
Questions about traits and types
What’s the point of that restriction?
Why does it make sense that the static type of an expression decides which operations
are valid? Why should it be illegal to use radius
as shown in the last example above?
This section should give you an idea of the answers. If you feel you have a good idea already, it’s fine to skip ahead.
Restricting operations by static type is especially relevant in methods that use Shape
or another supertype in their parameter. An example is isBiggerThan
in trait Shape
:
trait Shape:
def isBiggerThan(another: Shape) = this.area > another.area
def area: Double
end Shape
The variable another
refers to some shape object; the variable’s
static type is Shape
. The method call another.area
is valid
only because area
is a method on this static type.
In contrast, we couldn’t rewrite this method to compare shapes
by their radius
es. The expression another.radius
would be
invalid since not all the objects that can be passed as parameters
to isBiggerThan
have a radius. If another.radius
was accepted by
the compiler, and we then called the method and passed in a
rectangle, the method would fail and crash our program at runtime.
The same goes for this
, whose static type is Shape
because
the code is within the Shape
trait. this.radius
is just as
invalid as another.radius.
In more general terms: when you use a supertype in a method parameter, that method can use the parameter only for the things that are shared by all instances of the supertype. This ensures that the method works for any and all instances of all subtypes.
Static types help the programmer’s tools (the compiler, in particular) locate errors and to notify you before you run your program. If the tools didn’t enforce method calls in this way, we’d suffer a great loss in type safety (Cf. what was said about static and dynamic typing at the end of Chapter 1.8.)
Working around that restriction
But what if you do have a reference to a Circle
object stored in a Shape
variable and
really want to access its radius
property? Or what if you want to make a decision during a
program run based on whether a Shape
variable happens to refer to a circle or some other
shape?
In such cases, you can turn to match
(Chapter 4.3) for assistance. match
uses the
dynamic type of a value as it selects among alternatives:
var someShape: Shape = Circle(10)someShape: Shape = Circle@1de2de2 someShape match case someCircle: Circle => println("It's a circle and its radius is " + someCircle.radius) case _ => println("It's not a circle!")It's a circle and its radius is 10.0
In case someShape
happens to hold a reference to
a Circle
, that reference gets stored in the variable
someCircle
. The static type of someCircle
is Circle
.
You can read case _
as “in all other cases” (Chapter 4.4).
An alternative method that is usually worse
Another option is to use the asInstanceOf
method, which all Scala objects
have. In the example below, notice the type parameter and the static type of
the return value.
someShape.asInstanceOf[Circle]res5: Circle = Circle@1de2de2 someShape.radius-- Error: ... value radius is not a member of o1.shapes.Shape
Be warned! This would have caused a runtime error in case someShape
hadn’t happened to refer to a circle. The compiler can’t check the dynamic
type for you. By electing to use asInstanceOf
, you the programmer bypass
the strong typing of the language and weaken your program’s type safety.
It’s then entirely up to you to ensure that the object’s dynamic type
really matches what you wrote in square brackets after asInstanceOf
.
Use this method carefully if at all.
asInstanceOf
corresponds to what is known in many other programming
languages as a type cast or simply a cast (tyyppimuunnos).
Programming Assignment: Using and Editing a Trait
Part 1 of 2: a new kind of shape
Write a new subtype for Shape
: class RightTriangle
, which represents triangles with a
90-degree angle. Right triangles should have all the general properties that shapes
have as well as an additional method hypotenuse
. The class should work as shown below.
val triangle = RightTriangle(3.0, 4.0)triangle: o1.shapes.RightTriangle = o1.shapes.RightTriangle@18bcb2d triangle.hypotenuseres6: Double = 5.0 triangle.areares7: Double = 6.0 Circle(3).isBiggerThan(triangle)res8: Boolean = true triangle.isBiggerThan(Rectangle(7, 5))res9: Boolean = false
In this assignment, you must not define isBiggerThan
within the RightTriangle
class,
nor is there any reason to do so. Triangles, like circles and rectangles, should have
this method by virtue of being Shape
s.
Part 2 of 2: perimeters of shapes
Add an abstract method
perimeter
in traitShape
in the Traits module. This parameterless method should return the shape’s perimeter (the total length of the shape’s border) as aDouble
.After adding this method in
Shape.scala
, notice how IntelliJ disapproves: the definitions of the three subtypes are no longer valid because they fail to implementperimeter
.Implement
perimeter
in your newRightTriangle
class and in the existing classesCircle
andRectangle
. (The module also comes with a class namedSquare
, which we’ll discuss later.) Use cases:
triangle.perimeterres10: Double = 12.0 Circle(5).perimeterres11: Double = 31.41592653589793 Rectangle(2, 5).perimeterres12: Double = 14.0
A+ presents the exercise submission form here.
Optional add-ons
You can try adding a triangle or two to the buffer in shapeTest
.
This should be easy, assuming you defined RightTriangle
appropriately.
You may also wish to write toString
methods in RightTriangle
and the other shape classes so that they are easier to work with
in the REPL. Or you could write a generic toString
for all shapes
in trait Shape
, if you prefer.
Thoughts about traits
Traits are handy. What’s more, defining supertypes can greatly improve program quality. For example:
With traits, you can create program components that are more generic. You can, for instance, define a method such as
isBiggerThan
, which works equally well on shapes of different types.Traits reduce redundancy, which makes your code more maintainable. They can also make your programs easier to extend by adding new subtypes. For instance, when you added triangles to our program, you just wrote
extends Shape
and implementedarea
. That was enough to give triangle objectsisBetterThan
, too, and to make triangles usable in any part of the program that calls for aShape
.
Practice on Traits and Abstract Methods
Multiple Supertypes
Supertypes at different levels
You can extend a trait with another trait. Here is an example:
trait PersonAtAalto:
// Here, we can add methods and/or instance variables that are common to
// all people at Aalto, whether they are employees, students, or visitors.
end PersonAtAalto
trait Employee extends PersonAtAalto:
// Here we can add methods and/or instance variables for Aalto employees.
// All employees have these properties in addition to the generic ones
// they get from the PersonAtAalto trait.
end Employee
Now we can define class TeachingAssistant
so that it has the Employee
trait, thus
turning assistants into employees and persons:
class TeachingAssistant extends Employee:
// Assistant objects have all the methods and instance variables defined
// in the Employee trait as well as those defined in PersonAtAalto.
// Here, we can add variables and methods that are specific to teaching
// assistants.
end TeachingAssistant
A “family tree” of data types is called a type hierarchy (tyyppihierarkia).
Multiple immediate supertypes
In our example, the TeachingAssistant
class had an immediate supertype in Employee
and an additional indirect supertype in PersonAtAalto
. You can also give a class
(or trait) multiple direct supertypes, as shown below.
Suppose we have an additional trait, Student
. We wish to model the fact that teaching
assistants are students in addition to being employees. This is easy:
class TeachingAssistant extends Employee, Student:
// Now assistants are of all the following types:
// TeachingAssistant, Student, PersonAtAalto, Employee.
// (They gain the properties of PersonAtAalto just once
// even though there are two “paths” up to that trait.)
end TeachingAssistant
If there are still more traits you wish to mix in, just list them:
class X extends MyTrait1, MyTrait2, MyTrait3, MyTrait4
The traits don’t need to have a shared supertype (like PersonAtAalto
is for Employee
and Student
in our example). You can mix in multiple traits that are otherwise unrelated
to each other.
The word with
also works where we wrote the commas. Use whichever style you prefer, but
it’s good to be aware of both:
class TeachingAssistant extends Employee with Student class X extends MyTrait1 with MyTrait2 with MyTrait3 with MyTrait4
Overriding Supertype Methods
Since Week 2, we have used override
to replace default method implementations with
class- or object-specific ones. In particular, we have used this keyword in:
toString
methods: the methods that we’ve written have overridden the default implementation (which produces descriptions such aso1.shapes.Square@ecfb83
).the event handlers on
View
s (such asonClick
; Chapter 3.1): the default implementations in classView
react to events by doing nothing at all but you can override them with whichever behavior you wish your application to have.
You can also override other methods in type hierarchies. As an experiment, let’s write a few mini-classes.
trait TraitX:
def greeting = "Hello from TraitX"
class Class1 extends TraitX
class Class2 extends TraitX:
override def greeting = "Hello from Class2"
Now let’s use our classes in the REPL:
Class1().greetingres13: String = Hello from TraitX Class2().greetingres14: String = Hello from Class2
Class1
gets its greeting
method from TraitX
.
Class2
overrides the trait’s method with an implementation
of its own.
In Scala, you must always explicitly use the override
keyword whenever you wish to
override a method.
Why require an explicit override
? Because it is “salty”.
By writing override
in your program, you acknowledge that you
are deliberately superseding a method implementation defined on a
supertype. If the keyword was optional, you might very easily give
your method a name that is already used in a supertype. Such a
thing could happen quite accidentally and unknowingly, possibly
spawning exotic bugs.
Like static typing, this requirement is a language feature that reduces the chance of human error. Such a language feature, which makes it harder to write bad code, is sometimes called “syntactic salt” (cf. the more common term syntactic sugar).
As an added benefit, the keyword makes overriding explicit to the program’s readers.
Let’s add another trait and a couple of classes to our type hierarchy:
trait TraitY extends TraitX:
override def greeting = "Hello from TraitY"
class Class3 extends TraitY
class Class4 extends TraitY:
override def greeting = "Hello from Class4"
Our second trait is a subtype of the first, and the new classes subtypes of the second trait.
Now to try them out:
Class3().greetingres15: String = Hello from TraitY Class4().greetingres16: String = Hello from Class4
Class3
doesn’t define its own implementation for the greeting
method. It gets the method from its immediate supertype TraitY
(which overrides the method from TraitX
further up in the
hierarchy).
Class4
supplies an overriding implementation, which supersedes
both the one in TraitY
and the one in TraitX
.
Let’s explore further:
var myObject: TraitX = Class1()myObject: TraitX = Class1@6d293b41 myObject.greetingres17: String = Hello from TraitX myObject = Class4()myObject: TraitX = Class4@dc6c5ca myObject.greetingres18: String = Hello from Class4
The variable’s static type is TraitX
. Its value has the
dynamic type Class4
.
We can call greeting
on any expression with the static type
TraitX
(or one of TraitX
’s subtypes); that is, we can call
it on any object that is guaranteed to have the method. On the
other hand, what happens when we do so depends on the dynamic
type of the object that receives the message. Here, we execute
the greeting
implementation defined on Class4
objects, even
though the variable is of type TraitX
.
The super
keyword
One more class:
class Class5 extends TraitY:
override def greeting = super.greeting + " and from Class5 too"
You can use the super
keyword to refer to an implementation defined
in a supertype. Here, we call the supertype’s version of
greeting
. As a consequence, the greeting
method of an F
object
first obtains whichever string the greeting
method on its supertype
TraitY
returns and adds some more text that is specific to Class5
.
See below for an example.
Class5().greetingres19: String = Hello from TraitY and from Class5 too
Instance Variables in a Trait
A trait can define instance variables just like a class can.
trait Supertype: val magicNumber = 42 val text: String// defined trait Supertype class Subtype extends Supertype: val text = "value of 'text' for all Subtype instances"// defined class Subtype
Like a method, an instance variable on a trait can be abstract.
All instances of extending classes are guaranteed to have the trait’s variables:
val myObject: Supertype = Subtype()myObject: Supertype = Subtype@714aadf7 myObject.magicNumberres20: Int = 42 myObject.textres21: String = value of 'text' for all Subtype instances
Traits vs. Java’s interfaces
The word interface has many meanings, including these two interrelated ones:
An interface is the “façade” of a program component, such as a class. We use a component via its interface (Chapter 3.2).
In some programming languages (most prominently in Java), there is a specific construct known as an interface, which resembles Scala’s concept of a trait:
// This is Java, not Scala. interface Shape { /* etc. */ } class Circle implements Shape { /* etc. */ }
The primary differences between Java’s interface construct and Scala’s traits is that a trait may contain instance variables whereas an interface can’t.
Constructor Parameters on a Trait
Example: medical professionals
Let’s write a trait to represent healthcare professionals:
trait MedicalPro
On the right is a diagram of the type hierarchy that we’ll soon define.
As a first step, let’s focus on just one of MedicalPro
’s subtypes: Paramedic
.
Paramedic
s work in emergency services; for each Paramedic
object, we’ll record
whether they are part of an ambulance crew (or stationed at a hospital).
class Paramedic(val inAmbulance: Boolean) extends MedicalPro
Suppose we also want to record, for all kinds of MedicalPro
objects, an employer.
We’d like the employer to be stored in a String
variable and to receive a value from
a constructor parameter when the object is created.
We can add such a constructor parameter, and a corresponding val
, in a trait just
like we’d write them in a regular class:
trait MedicalPro(val employer: String)
MedicalPro
objects don’t get created directly but through the trait’s subtype. So how
should we pass a value to that constructor parameter? And how should we take employers
into account in class Paramedic
?
Let’s say we want to create Paramedic
instances as illustrated below, by passing in
first an employer and then a Boolean
to be stored in inAmbulance
.
val medic = Paramedic("City of Helsinki", true)medic: Paramedic = Paramedic@61c98b6c
Below is a class definition that works like that. For example’s sake, let’s assume that all paramedics work in the public sector and are employed by a city.
class Paramedic(city: String, val inAmbulance: Boolean) extends MedicalPro(city)
We added a constructor parameter. Note that this parameter is not
marked with val
so that an instance variable named city
would
also get defined. What we want instead is to pass the value of
the constructor parameter city
onward to the MedicalPro
trait, which will store in the employer
variable.
The constructor parameter inAmbulance
and the corresponding
instance variable we already had in our first version of
Paramedic
. Nothing new here.
The interesting part is this: to create an instance of a subtype,
any initialization steps that the supertype demands must also
be performed. It’s common to pass constructor parameters from a
subtype to a supertype, as shown here. In this example, we state
that whenever a Paramedic
object is created, we initialize
a MedicalPro
by passing it the first of the two constructor
parameters that Paramedic
received. (See the animation below.)
The example continues
Now to deepen our type hierarchy. Let’s add a Doctor
trait. For the purposes of this
basic example, we’re not planning to record any information about Doctor
s apart from
their employer, which is already covered by MedicalPro
. So just this will do:
trait Doctor extends MedicalPro
Our simple hierarchy divides doctors in two groups: general practitioners and specialists.
The Specialist
trait indicates that a subfield of medicine should be recorded for every
specialist doctor:
trait Specialist(val specialization: String) extends Doctor
We mean the GeneralPractitioner
class to be used like this:
val gp = GeneralPractitioner("InstaCare Hospital")gp: GeneralPractitioner = GeneralPractitioner@4df03572
Here’s a first try at implementing it:
class GeneralPractitioner(employer: String) extends Doctor // fails to work
The basic idea is just fine there: we’ve made GeneralPractitioner
a subtype of Doctor
,
and Doctor
’s already defined as a subtype of MedicalPro
. Something’s missing, though.
We get a compile-time error that suggests what’s wrong:
class GeneralPractitioner(employer: String) extends Doctor-- Error: |class GeneralPractitioner(employer: String) extends Doctor | ^ | parameterized trait MedicalPro is indirectly implemented, | needs to be implemented directly so that arguments can be passed
In other words, we have a MedicalPro
trait higher up in the hierarchy, and since that
trait takes a constructor parameter, we need to supply one. The fix is to mention that
supertype explicitly:
class GeneralPractitioner(employer: String) extends MedicalPro(employer), Doctor
The new GeneralPractitioner
object received an employer string
as a constructor parameter. We state that we pass precisely that
string to the supertype MedicalPro
.
Again, that we haven’t written val
in front of the constructor
parameter. The instance variable named employer
is already
defined as part of MedicalPro
. What we have here is just
GeneralPractitioner
’s constructor parameter, whose name
we’re free to pick; employer
is as good a choice as any.
We’re still missing Neurologist
, which is supposed to represent doctors specialized
in neural matters. We mean this class to work like this:
val doc = Neurologist("Chicago Grace Hospital")doc: Neurologist = Neurologist@4f13e602 doc.specializationres22: String = neurology
Let’s recap the traits we’ve already defined:
trait MedicalPro(val employer: String)
trait Doctor extends MedicalPro
trait Specialist(val specialization: String) extends Doctor
Given those definitions, we can implement Neurologist
like this:
class Neurologist(employer: String) extends MedicalPro(employer), Specialist("neurology")
We pass the employer as a constructor parameter
to MedicalPro
, just like we did previously.
The Specialist
trait, too, demands a string
as a constructor parameter. We give it a specific
text what we want each Neurologist
object to
have as a specialization.
The Doctor
trait is also a supertype of Neurologist
. We didn’t need to specifically
mention it after extends
, since Doctor
is a supertype of Specialist
and since
no constructor parameters need to be passed to Doctor
.
The code for this example hierarchy is available in the Traits module.
Assignment: Messages of Different Kinds
In o1.messages
of the Traits module, you’ll find a handful of toy classes that
represent messages sent by users on social media. A few different kinds of messages
are represented:
A
DirectMessage
is a message sent to a specific recipient.A
Post
is a message that hasn’t been directed at a specific person.A
Comment
is a message that is response to an originalPost
.Message
is a supertype for all these different kinds of messages; it’s a trait. What allMessage
s have in common is an instance variable namedcontent
, which holds the message text as aString
.And then there’s another trait,
Reply
. It represents messages that are responses to something. As shown in the diagram to the right,Comment
is meant to be a subtype ofReply
, although that bit hasn’t actually been written into the given code yet.
Your task is to study the given code and made the following modifications to it:
Have
Comment
extendReply
, too, by filling inComment
’sextends
clause. Remember thatReply
takes a constructor parameter; you can give it thePost
object that the comment replies to (which aComment
receives via itsoriginal
parameter).Make all
Message
objects hold an additional piece of information: whether the message is public or not. Do this by declaring a constructor parameter and an instance variable onMessage
’s first line:val isPublic: Boolean
. Then edit classesDirectMessage
,Post
, andComment
to be compatible with the revisedMessage
trait, as described below.Direct messages aren’t public: from
DirectMessage
, pass in the literalfalse
as the second constructor parameter toMessage
.A
Post
may or may not be public. Add a second constructor parameter to thePost
class; it should be aBoolean
. Pass the value of that parameter onward to theMessage
trait.You can name the parameter
isPublic
or something else. Note that you shouldn’t mark it as aval
, since the intention is not to givePost
s a new instance variable.
A
Comment
can also be either public nor non-public. Give it a (third) constructor parameter as you did forPost
.
In the same file, you’ll also find a little test program. Once you’re done editing the classes, you can uncomment the test program and try it out.
A+ presents the exercise submission form here.
Practice on Type Hierarchies and Overriding
The frivolous program below features a combination of many of the techniques that we’ve just discussed. You can use it for checking your understanding. If you can tell — in detail! — what this program does, then you will have grasped some of the main principles of traits and type hierarchies. This practice task is optional.
A story about driving
Read the code below. Painstakingly consider which exact lines of text it outputs and in which order. Ideally, you should write down what you think the program outputs.
@main def driveAbout() =
val car = Car()
car.receivePassenger(Schoolkid())
car.receivePassenger(ChemicalEngineer("C. Chemist"))
car.receivePassenger(MechanicalEngineer("M. Machine"))
car.receivePassenger(ElectricalEngineer("E. Electra"))
car.receivePassenger(ComputerScientist("C.S. Student"))
car.start()
class Car:
private val passengers = Buffer[Passenger]()
def receivePassenger(passenger: Passenger) =
passenger.sitDown()
this.passengers += passenger
def start() =
println("(The car won't start.)")
for passenger <- this.passengers do
passenger.remark()
end Car
trait Passenger(val name: String):
def sitDown() =
println(this.name + " finds a seat.")
def speak(sentence: String) =
println(this.name + ": " + sentence)
def diagnosis: String
def remark() =
this.speak(this.diagnosis)
end Passenger
trait Student extends Passenger:
def diagnosis = "No clue what's wrong."
class Schoolkid extends Passenger("Anonymous pupil"), Student
trait TechStudent extends Student:
override def remark() =
super.remark()
this.speak("Clear as day.")
class ChemicalEngineer(name: String) extends TechStudent, Passenger(name):
override def diagnosis = "It's the wrong octane. Next time, I'll do the refueling."
class MechanicalEngineer(name: String) extends TechStudent, Passenger(name):
override def diagnosis = "Nothing wrong with the gas. It must be the pistons."
override def speak(sentence: String) =
super.speak(sentence.replace(".", "!"))
class ElectricalEngineer(name: String) extends TechStudent, Passenger(name):
override def sitDown() =
println(this.name + " claims a front seat.")
override def diagnosis = "Hogwash. The spark plugs are faulty."
class ComputerScientist(name: String) extends TechStudent, Passenger(name):
override def remark() =
this.speak(super.diagnosis)
this.speak(this.diagnosis)
override def diagnosis = "Let's all get out of the car, close the doors, reopen, and try again."
Did you read the program carefully? Did you write down the output you expect?
Now open the Traits module and run o1.cruising.Cruising
(which is
the above program). Was the output precisely what you expected? If
not, find out why.
You may wish to use the debugger as an aid.
Assignment: Legal Entities
Task description
Study the documentation of o1.legal
in module Traits. The documentation lays out
a number of classes and traits that represent court cases and the legal entities involved in such cases (also known as
“legal persons”).
Implement the classes in the given files. (See the comments in the files for where to place each class.)
There are quite a few classes, but they are very simple. This assignment is primarily concerned with the relationships between these classes, which are illustrated in the diagram below.
Recommended steps and other hints
You may wish to proceed as follows.
Browse the documentation for
CourtCase
,Entity
,NaturalPerson
, andJuridicalPerson
to get an overall sense of those four types.Write
CourtCase
. Note that each court case is associated with two variables whose type is defined by theEntity
trait. The referred objects are legal entities of some kind, butCourtCase
doesn’t care which.Write trait
Entity
in a file of its own.Write
NaturalPerson
in a file of the same name.As stated in the docs, you’ll need to extend another trait.
The Scaladocs tell you which methods of each class come from a supertype and which ones are new to the specific type described on the page. Near the top of each Scaladoc page, there is a section where you can filter what’s shown on the page. Open that section fully with a click, and you’ll see an area labeled Inherited, whose buttons adjust which methods are shown. Try the buttons.
Write
FullCapacityPerson
.Especially since we’re dealing with simple, closely related types, it makes sense to define the subtypes of
NaturalPerson
in the same file with their supertype.Note the class header at the top of the doc page: this class should be marked as extending both
Entity
andNaturalPerson
. Just the latter would enough otherwise, but you need to pass a constructor parameter toEntity
, too. (This is similar to what we had in theMedicalPro
andMessage
examples above.)The supertype already defines an instance variable for storing the entity’s name, so so you don’t need a
val
for that here. (This, too, is similar to the earlier examples.)
Open
Restriction.scala
, which will help you represent people whose capacity to act on their own behalf in court is reduced for one reason or another. TheRestriction
trait is there already, as is a singleton objectIllness
that extends it. AddUnderage
, a similar singleton.Write
ReducedCapacityPerson
.You’ll again need to pass a constructor parameter to two separate supertypes.
If you have implemented the earlier methods correctly, this should work as an implementation for the
kind
method:override def kind = super.kind + " with " + this.restriction
Write
JuridicalPerson
. Just one line of code will do (since no additional methods are needed).Implement
HumanOrganization
andGeographicalFeature
.A parameterless, abstract
def
in a supertype can be implemented with a variable in a subtype. Define acontact
variable inHumanOrganization
and akind
variable inGeographicalFeature
.
A further note about implementing a
def
with a variableIt is indeed valid to implement an abstract, parameterless method with a variable, as suggested above for
contact
, for example. The important thing is that an expression such assomeOrganization.contact
must have aNaturalPerson
as a value, one way or another. For a user of theHumanOrganization
class, it’s generally irrelevant whether they are dealing with aval
or an effect-free, parameterless method that efficiently returns the same value every time. (Further reading: the Wikipedia article on the uniform access principle and an educational slide deck by Philip Schwartz.)You don’t have to bother writing
Nation
,Municipality
, andCorporation
; there is nothing novel about these classes. You can just uncomment the given implementations. If you have correctly defined the supertypes, these subtypes should work as given.Implement
Group
.In this program, groups don’t have distinct names. Read the documentation carefully and pass the appropriate string literal as a constructor parameter to the
Entity
trait.
A+ presents the exercise submission form here.
Reimplementing Auctions as a Type Hierarchy
The following programming assignment revisits our earlier auction-themed programs. The assignment itself is optional, but we highly recommend that you at least read what it’s about.
In Chapter 5.1, you presumably wrote FixedPriceSale
and may have also written
DutchAuction
and EnglishAuction
. These classes represents items put up for sale in a
variety of ways. Then, in Chapter 5.5, we designed AuctionHouse
to represent auction
houses where all the items are sold in the traditional “English” style.
You can use your own earlier solutions as a basis for the upcoming assignment. If you didn’t do some or all of them, feel free to use the example solutions (FixedPriceSale, DutchAuction, EnglishAuction).
A new class hierarchy
Here’s how the existing classes relate to each other:
In other words: an AuctionHouse
contains EnglishAuction
s. The classes
FixedPriceSale
and DutchAuction
are unconnected to the others.
In this assignment, you’ll refactor the classes. The purpose of refactoring
is to improve program quality: you’ll modify FixedPriceSale
-, EnglishAuction
, and
DutchAuction
so that it’s easier to use them all in combination. At the same time,
you’ll eliminate a great deal of redundant code. In this exercise, the main tool for
refactoring will be traits.
The goal is a hierarchy of classes that looks like this:
At the heart of our plan is the trait ItemForSale
, which will serve as a generic
supertype for items being sold in all sorts of ways. We’ll be able to use this
supertype to write a more generic AuctionHouse
class. We’ll also introduce an
InstantPurchase
class to capture what fixed-price items and Dutch-style auctions
have in common.
Implement the refactoring
Implement ItemForSale
, EnglishAuction
, InstantPurchase
, FixedPriceSale
,
DutchAuction
, and AuctionHouse
so that they match the documentation
provided in module AuctionHouse2.
Instructions and hints:
We recommend implementing the classes in the order listed above.
As you read the Scaladocs, be sure to note which methods are abstract. Also note which methods each class inherits from its supertype(s).
Once again: if a supertype already defines a concrete instance variable, don’t repeat the
val
in subtypes. For instance, thedescription
variable is defined in the supertypeItemForSale
, so don’t redefine it as aval
in the subtypes. The subtypes do need a description as a constructor parameter, though.You can use the given test app to try some of the key methods. You’ll observe that the app object
o1.auctionhouse.gui.TestApp
generates a bunch of error messages to begin with, but they’ll vanish once you make the requested changes.In the
AuctionHouse
class, you’ll need to replaceEnglishAuction
with a more general type, but that’s the only change needed there.
A+ presents the exercise submission form here.
Once you finish the assignment, pause for a moment to admire the results: the type hierarchy turned the disconnected and redundant classes into a beautiful conceptual model. The definition of each concept includes only what is necessary to distinguish it from related concepts.
More Optional Practice
Tools in package o1
(and FlappyBug, once more)
The course library o1
contains a HasVelocity
trait, which is
a generic type for objects that have a location and a velocity in
two-dimensional space. We have already modeled some such objects
in our programs, albeit without using this trait. Let’s explore how
the trait can help us.
Take a look at the Scaladocs
for HasVelocity
. It extends another trait, HasPos
; take a
look at that, too. If you didn’t
do the optional assignment in Chapter 3.6 that introduced class
Velocity
, start by reading about that class.
Consider how you could add the HasVelocity
trait to the classes
Bug
and Obstacle
in FlappyBug. Consider the implications of
doing so. Do so. See below for hints if you want them.
Hints for Obstacle
:
You’ll need an
extends
at the top, of course.The class needs to implement the abstract method
velocity
as specified in theHasVelocity
trait. Add the method. An obstacle’s speed is constant along the x axis and zero along the y axis.HasVelocity
provides anextPos
method. Use it to (re)implementapproach
.
Hints for Bug
:
You’ll need another
extends
and anothervelocity
method.You can again use
nextPos
to simplify the code that moves the bug. (You’ll probably either editfall
or the helper methodmove
if you wrote it in an earlier chapter; the latter becomes rather unnecessary withnextPos
, though.)Perhaps you’ll also come up with a way to simplify
touches
in classObstacle
a bit?
A+ presents the exercise submission form here.
Summary of Key Points
A trait is a programming construct similar to a class. It, too, defines a data type. It, too, is useful for modeling concepts in the program’s domain.
You can use a trait to represent a superordinate domain concept: the trait defines instance variables and methods that are common to multiple classes and/or traits.
A class may extend (or “mix in”) one or more traits, giving the class the properties defined in those traits and making those traits its supertypes.
Using classes and traits, you can form a “family tree” of concepts, a type hierarchy.
A trait may define abstract methods. An abstract method doesn’t have a generic implementation (method body) in the trait itself; the trait’s subtypes define separate implementations for the method instead. The presence of an abstract method on a trait guarantees that all actual objects with that trait have some implementation for the method.
A subtype may override its supertype’s method with a subtype-specific implementation.
Links to the glossary: trait, abstract method, abstract variable; class hierarchy; static type, dynamic type; DRY; abstraction; to override.
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, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, 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 and Juha Sorva. 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. Markku Riekkinen is the current lead developer; 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 for this page
Thanks to whoever came up with the original joke that the driving example is based on.
What should we write in place of
?????
? What can we specify as the type of the elements inshapes
?