Professional Scala
上QQ阅读APP看书,第一时间看更新

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:

  • Objects are used when we need a type with one instance (such as singletons)
  • Classes are used when we need to have many instances, which can be created with the help of the new operator
  • Traits are used for mix-ins into other classes

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.

  1. Navigate to the project structure and find the com.packt.courseware.l3 package.
  2. Right-click and select create class in the context menu.
  3. Enter ExampleObject in the name field and choose object in the kind field of the form.
  4. IDEA will generate the file in the object.
  5. 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.

  1. Ensure that the Lesson 2/4-project project is open in IDE.
  2. Create a new Scala class with the name CartesianPoint.
  3. The code should be something like this:
    case class CartesianPoint(x:Double, y:Double) extends Point2D {
    override def length(): Double = x*x + y*y
    }

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 of x.equals(y) if x is a reference type (for example, a class or object).
  • 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.

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.

Let's see the case classes:

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 components
  • A toString method which will output components
  • A 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.

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

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

  3. Define the case class with the same fields:
    case class CPoint(x:Int, y:Int)
  4. 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.

  1. Create a class file in the test sources of the current project with the name Person.
  2. Create a case class called Person with the members firstName and lastName:
    case class Person(firstName:String,lastName:String)
  3. Create a companion object and add a method which accepts person and returns String:
    def classify(p:Person): String = {
    // insert match code here .???
    }
    }
  4. 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 the lastName starts in lowercase
  5. 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.

  1. Create an instance of Drink with Tee with 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
  2. Try to override the description in the Tee class:

    Uncomment Tee: def description = plain tee in the Drinks file.

    Check if any error message arises.

  3. Create the third object, derived from Drink with Tee and VanillaFlavour 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.

Special Classes

There are a few classes with special syntax, which play a significant role in the Scala type system. We will cover this in detail later, but now let's just enumerate some:

  • Functions: In Scala, this can be coded as A => B
  • Tuples: In Scala, this can be coded as (A,B), (A,B,C) … and so on, which is a syntax sugar for Tuple2[A,B], Tuple3[A,B,C], and so on