Chapter 2. Basic Language Features
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:
- Identify the structure of non-trivial Scala programs
- Identify how to use main object-oriented facilities: objects, classes, and traits
- Recognize the details of function call syntax and parameter-passing modes
Objects, Classes, and Traits
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.
Object
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.
Note
You should have project-3
opened in IDEA.
- Navigate to the project structure and find the
com.packt.courseware.l3
package. - Right-click and select
create class
in the context menu. - Enter
ExampleObject
in the name field and chooseobject
in the kind field of the form. - IDEA will generate the file in the object.
- Insert the following in the object definition:
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
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.
Equality and Case Classes
In general, two flavors of equality exist:
- Extensional, where two objects are equal when all external properties are equal.
- In JVM, a user can override equals and
hashCode
methods of an object to achieve such a behavior. - In a Scala expression,
x == y
is a shortcut ofx.equals(y)
ifx
is a reference type (for example, a class or object).
- In JVM, a user can override equals and
- Intentional (or reference), where two objects with the same properties can be different because they had been created in a different time and context.
- In JVM, this is the comparison of references;
(x == y)
in Java and(x eq y)
in Scala.
- In JVM, this is the comparison of references;
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
andhashCode
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)
- All parameter constructors will become class values (therefore, we do not need to write
val
) - The companion object of a class with the apply method (for constructor shortcuts) and
unapply
method (for deconstruction in case patterns)
Now, we'll look at illustrating the differences between value and reference equality.
- In
test/com.packt.courseware.l4
, create a worksheet.Note
To create a worksheet, navigate to package, and right-click and choose create a Scala worksheet from the drop-down menu.
- Define a non-case class with fields in this file after import:
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
Note
Notice that the results are
false
. - Define the case class with the same fields:
case class CPoint(x:Int, y:Int)
- Write a similar test. Note the differences:
val cp1 = CPoint(1,1)val cp2 = CPoint(1,1)cp1 == cp2cp1 eq cp2
Pattern Matching
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.
- Create a class file in the test sources of the current project with the name
Person
. - Create a case class called
Person
with the membersfirstName
andlastName:
case class Person(firstName:String,lastName:String)
- Create a companion object and add a method which accepts
person
and returnsString:
def classify(p:Person): String = { // insert match code here .??? } }
- Create a
case
statement, which will print:- "
A
" if the person's first name is "Joe
" - "
B
" if the person does not satisfy other cases - "
C
" if thelastName
starts in lowercase
- "
- Create a test-case for this method:
class PersonTest extends FunSuite { test("Persin(Joe,_) should return A") { assert( Person.classify(Person("Joe","X")) == "A" ) } } }
Traits
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
Self-Types
In Scale-trait, you can sometimes see the self-types annotation, for example:
Note
For full code, refer to Code Snippets/Lesson 2.scala
file.
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.
Note
If Flavor
is not extended from Drink
, but has access to Drink
methods such as looks, as in Flavor,
we situate it inside Drink.
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.
- Create an instance of Drink with Tee with
VanillaFlavour
which refers todescription
:val tee = new Drink with Tee with VanillaFlavour val tee1 = new Drink with VanillaFlavour with Tee tee.description tee1.description
- Try to override the description in the
Tee
class:Uncomment
Tee
:def description = plain tee
in theDrinks
file.Check if any error message arises.
- Create the third object, derived from
Drink
withTee
andVanillaFlavour
with an overloaded description:val tee2 = new Drink with Tee with VanillaFlavour{ override def description: String ="plain vanilla tee" }
Note
For full code, refer to
Code Snippets/Lesson 2.scala
file.
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.