In the previous chapter, we learned the various aspects of setting up the development environment wherein we covered the structure of a Scala project and identified the use of
sbt
for building and running projects. We covered REPL, which is a command-line interface for running Scala code, and how to develop and run code over the IDEA IDE. Finally, we implemented interactions with our simple
chatbot
application.
In this chapter, we will explore the so-called 'OO' part of Scala, which allows us to build constructions similar to analogs in any mainstream language, such as Java or C++. The object-oriented part of Scala will cover classes and objects, traits, pattern matching, case class, and so on. Finally, we will implement the object-oriented concepts that we learn to our chatbot application.
Looking at the history of programming paradigms, we will notice that the first generation of high-level programming languages (Fortran, C, Pascal) were procedure oriented, without OO or FP facilities. Then, OO become a hot topic in programming languages in the 1980s.
By the end of this chapter, you will be able to do the following:
Scala is a multiparadigm language, which unites functional and OO programming. Now, we will explore Scala's traditional object-oriented programming facilities: object, classes, and traits.
These facilities are similar in the sense that each one contains some sets of data and methods, but they are different regarding life cycle and instance management:
Note that it is not worth navigating through code, as this is exposed in examples.
We have
seen
an object in the previous chapter. Let's scroll through our codebase and open the file named
Main
in
Lesson 2/3-project
:
object Chatbot3 { val effects = DefaultEffects def main(args: Array[String]): Unit = { ….} def createInitMode() = (Bye or CurrentDate or CurrentTime) otherwise InterestingIgnore }
It's just a set of definitions, grouped into one object, which is available statically. That is, the implementation of a singleton pattern: we only have one instance of an object of a given type.
Here, we can
see the definition of the value (
val effects
) and main functions. The syntax is more-or-less visible. One non-obvious thing is that the
val
and
var
definitions that are represented are not plain field, but internal field and pairs of functions: the
getter
and
setter
functions for
var-s
. This allows overriding
def-s
by
val-s
.
Note that the name in the object definition is a name of an object, not a name of the type. The type of the object,
Chatbot3
, can be accessed as
Chatb
ot3.type.
Let's define the object and call a method. We will also try to assign the object to a variable.
com.packt.courseware.l3
package. create class
in the context menu.ExampleObject
in the name field and choose object
in the kind field of the form.def hello(): Unit = { println("hello") } - navigate to main object Insert before start of main method: val example = ExampleObject Insert at the beginning of the main method: example.hello()
Classes form the next step in abstractions. Here is an example of a class definition:
package com.packt.courseware.l4 import math._ class PolarPoint(phi:Double, radius:Double) extends Point2D { require(phi >= - Pi && phi < Pi ) require(radius >= 0) def this(phi:Double) = this(phi,1.0) override def length = radius def x: Double = radius*cos(phi) def y: Double = radius*sin(phi) def * (x:Double) = PolarPoint(phi,radius*x) }
Here is a class
with parameters (
phi
,
radius
) specified in the class definition. Statements outside the class methods (such as require statements) constitute the body of a primary constructor.
The next definition is a secondary constructor, which must call the primary constructor at the first statement.
We can create an object instance using the
new
operator:
val p = new PolarPoint(0)
By default, member
access modifiers are
public
, so once we have created an object, we can use its methods. Of course, it is possible to define the method as
protected
or
private
.
Sometimes, we want to have constructor parameters available in the role of class members. A special syntax for this exists:
case class PolarPoint(val phi:Double, val radius:Double) extends Point2D
If we write
val
as a modifier of the constructor argument (
phi
), then
phi
becomes a member of the class and will be available as a field.
If you browse the source code of a typical Scala project, you will notice that an object with the
same name as a class is often defined along with the class definition. Such objects are called
companion
objects of a class:
object PolarPoint{ def apply(phi:Double, r:Double) = new PolarPoint(phi,r) }
This is a typical place for utility functions, which in the Java world are usually represented by
static
methods.
Method names also exist, which allow you to use special syntax sugar on the call side. We will tell you about all of these methods a bit later. We will talk about the
apply
method now.
When a method is named
apply
, it can be called via functional call braces (for example,
x(y)
is the same as
x.apply(y),
if
apply
is defined in
x
).
Conventionally, the
apply
method in the companion object is often used for instance creation to allow the syntax without the
new
operator. So, in our example,
PolarPoint(3.0,5.0)
will be demangled to
PolarPoint.apply(3.0,5.0)
.
Now, let's define a case class, CartesianPoint, with the method length.
In general, two flavors of equality exist:
hashCode
methods of an object to achieve such a behavior.x == y
is a shortcut of x.equals(y)
if x
is a reference type (for example, a class or object).(x == y)
in Java and (x eq y)
in Scala.Looking at our
PolarPoint
, it looks as though if we want
PolarPoint(0,1)
to be equal
PolarPoint(0,1)
, then we must override
equals
and
hashCode
.
The Scala language provides a flavor of classes, which will do this work (and some others) automatically.
case class PolarPoint(phi:Double, radius:Double) extends Point2D
When we mark a class as a case class, the Scala compiler will generate the following:
equals
and hashCode
methods, which will compare classes by componentsA toString
method which will output componentsA copy
method, which will allow you to create a copy of the class, with some of the fields changed:val p1 = PolarPoint(Pi,1) val p2 = p1.copy(phi=1)
val
)unapply
method (for deconstruction in case patterns)Now, we'll look at illustrating the differences between value and reference equality.
test/com.packt.courseware.l4
, create a worksheet.class NCPoint(val x:Int, val y:Int) val ncp1 = new NCPoint(1,1) val ncp2 = new NCPoint(1,1) ncp1 == ncp2 ncp1 eq ncp2
case class CPoint(x:Int, y:Int)
val cp1 = CPoint(1,1)val cp2 = CPoint(1,1)cp1 == cp2cp1 eq cp2
Pattern matching is a construction that was first introduced into the ML language family near 1972 (another similar technique can also be viewed as a pattern-matching predecessor, and this was in REFAL language in 1968). After Scala, most new mainstream programming languages (such as Rust and Swift) also started to include pattern-matching constructs.
Let's look at pattern-matching usage:
val p = PolarPoint(0,1) val r = p match { case PolarPoint(_,0) => "zero" case x: PolarPoint if (x.radius == 1) => s"r=1, phi=${x.phi}" case v@PolarPoint(x,y) => s"(x=${x},y=${y})" case _ => "not polar point" }
On the second line, we see a match/case expression; we match
p
against the sequence of case-e clauses. Each case clause contains a pattern and body, which is evaluated if the matched expression satisfies the appropriative pattern.
In this example, the first case pattern will match any point with a radius of
0
, that is,
_
match any.
Second–This will satisfy any
PolarPoint
with a radius of one, as specified in the optional pattern condition. Note that the new value (
x
) is introduced into the body context.
Third – This will match any point; bind
x
and
y
to
phi
and the
radius
accordingly, and
v
to the pattern (
v
is the same as the original matched pattern, but with the correct type).
The final case
expression is a
default
case, which matches any value of
p
.
Note that the patterns can be nested.
As we can see, case classes can participate in case expression and provide a method for pushing matched values into the body's content (which is deconstructed).
Now, it's time to use match/case statements.
Person
.Person
with the members firstName
and lastName:
case class Person(firstName:String,lastName:String)
person
and returns String:
def classify(p:Person): String = { // insert match code here .??? } }
case
statement, which will print:A
" if the person's first name is "Joe
"B
" if the person does not satisfy other casesC
" if the lastName
starts in lowercaseclass PersonTest extends FunSuite { test("Persin(Joe,_) should return A") { assert( Person.classify(Person("Joe","X")) == "A" ) } } }
Traits are
used for grouping methods and values which can be used in other classes. The functionality of traits is mixed into other traits
and classes, which in other languages are appropriative constructions called
mixins
. In Java 8, interfaces are something similar to traits, since it is possible to define default implementations. This isn't entirely accurate, though, because Java's default method can't fully participate in inheritance.
Let's look at the following code:
trait Point2D { def x: Double def y: Double def length():Double = x*x + y*y}
Here is a trait, which can be extended by the
PolarPoint
class, or with the
CartesianPoint
with the next definition:
case class CartesianPoint(x:Double, y:Double) extends Point2D
Instances of traits cannot be created, but it is possible to create anonymous classes extending the trait:
val p = new Point2D {override def x: Double = 1 override def y: Double = 0} assert(p.length() == 1)
Here is an example of a trait:
trait A { def f = "f.A" } trait B {def f = "f.B"def g = "g.B" } trait C extends A with B {override def f = "f.C" // won't compile without override. }
As we can see, the conflicting method must be overridden:
Yet one puzzle:
trait D1 extends B1 with C{override def g = super.g} trait D2 extends C with B1{override def g = super.g}
The
result of
D1.g
will be
g.B
, and
D2.g
will be
g.C
. This is because traits are linearized into sequence, where each trait overrides methods from the previous one.
Now, let's try to represent the diamond in a trait hierarchy.
Create the following entities:
Component
– A
base
class with the
description()
method, which outputs the description of a component.
Transmitter
– A component which generates a signal and has a method called
generateParams
.
Receiver
– A component which accepts a signal and has a method called
receiveParams
.
Radio – A
Transmitter
and
Receiver
. Write a set of traits, where
A
is modelled as inheritance.
The answer to this should be as follows:
trait Component{ def description(): String } trait Transmitter extends Component{ def generateParams(): String } trait Receiver extends Component{ def receiverParame(): String } trait Radio extends Transmitter with Receiver
In Scale-trait, you can sometimes see the self-types annotation, for example:
trait Drink { def baseSubstation: String def flavour: String def description: String } trait VanillaFlavour { thisFlavour: Drink => def flavour = "vanilla" override def description: String = s"Vanilla ${baseSubstation}" } trait SpecieFlavour { thisFlavour: Drink => override def description: String = s"${baseSubstation} with ${flavour}" } trait Tee { thisTee: Drink => override def baseSubstation: String = "tee" override def description: String = "tee" def withSpecies: Boolean = (flavour != "vanilla") }
Here, we see the
identifier => {typeName}
prefix, which is usually a self-type annotation.
If the type
is specified, that trait can only be mixed-in to this type. For example,
VanillaTrait
can only be mixed in with Drink. If we try to mix this with another object, we will receive an error.
Also, self-annotation can be used without specifying a type. This can be useful for nested traits when we want to call "this" of an enclosing trait:
trait Out{ thisOut => trait Internal{def f(): String = thisOut.g() def g(): String = . } def g(): String = …. }
Sometimes, we can see the organization of some big classes as a set of traits, grouped around one 'base'. We can visualize this as 'Cake', which consists of the 'Pieces:' self-annotated trait. We can change one piece to another by changing the mix-in traits. Such an organization of code is named the 'Cake pattern'. Note that using the Cake pattern is often controversial, because it's relative easy to create a 'God object'. Also note that the refactor class hierarchy with the cake-pattern inside is harder to implement.
Now, let's explore annotations.
VanillaFlavour
which refers to description
:val tee = new Drink with Tee with VanillaFlavour val tee1 = new Drink with VanillaFlavour with Tee tee.description tee1.description
Tee
class:Uncomment
Tee
:
def description = plain tee
in the
Drinks
file.
Check if any error message arises.
Drink
with Tee
and VanillaFlavour
with an overloaded description:val tee2 = new Drink with Tee with VanillaFlavour{ override def description: String ="plain vanilla tee" }
Also note that special syntax for methods exists, which must be 'mixed' after the overriding method, for example:
trait Operation { def doOperation(): Unit } trait PrintOperation { this: Operation => def doOperation():Unit = Console.println("A") } trait LoggedOperation extends Operation { this: Operation => abstract override def doOperation():Unit = { Console.print("start") super.doOperation() Console.print("end") } }
Here, we see
that the methods marked as
abstract override
can call
super
methods, which are actually defined in traits, not in this base class. This is a relatively rare technique.