Chapter 5. Scala Type System

In the previous chapter, we covered how to work with lists, which made us familiar with some design principles of the whole collections library. We also covered how to generalize to sequences and covered some more relevant data structures. Finally, we also covered how collections relate to monads and how we can use that knowledge to use some powerful abstractions in our code.

In this chapter, we will cover the type system and polymorphism. We will also cover the different types of variance, which provides a way to constrain parameterized types. Finally, we will cover some advanced types such as abstract type members, option, and so on.

Scala is statically typed. This means that the type of variables are known at compile time. The main advantage of statically typed languages is that a lot of checks can be done by the compiler, thus increasing the number of trivial bugs that are caught at an early stage. Statically typed languages are also friendlier to refactoring, as the programmer can feel safer about their changes as long as the code compiles.

However, Scala is more than statically typed. In this chapter, we will see how Scala's expressive type system enables and enforces statically typed sound abstractions. The ability to infer types reduces the programmers' workload of annotating the program with redundant type information. This chapter will build upon the fundamentals required for the next chapter, where will be talking about type classes and a type of polymorphism they enable: ad hoc polymorphism.

By the end of this chapter, you will be able to:

  • Identify the Scala type hierarchy
  • Use the features the Scala type system provides
  • Identify abstractions that the Scala type system enables

Type Basics and Polymorphism

In this section, we'll look at different types and polymorphism. We'll start with the unified type system of Scala and end with existential types.

A Unified Type System

Scala has a unified type system. What this means is that all types, including "primitive" types, inherit from a common type. Any is a supertype of all types. It is often called the top type, and defines universal methods such as equals, hashCode, and toString. Nothing is a subtype of all types, and is often called the bottom type. There is no value that has a type of Nothing, so a common use case for it is to signal non-termination: a thrown exception, a program exit, or an infinite loop.

Any has two direct subclasses: AnyVal and AnyRef. Value types are represented by AnyVal. AnyRef represents the reference types. There are nine non-nullable predefined value types: Double, Float, Long, Boolean, Unit, Byte, Char, Short, and Int. All of these types are similar in other programming languages, except Unit. There is one instance of Unit, which is declared like (). Unit is an important return type as all the functions in Scala must return something. All non-value types are defined as reference types. Every user-defined type in Scala is a subtype of AnyRef. Comparing AnyRef to a Java runtime environment, AnyRef is similar to java.lang.Object.

Null is a subtype of all reference types. It contains a single value identified by the literal null. Null is used for operating between other programming languages but it is not recommended to use it in Scala. Scala provides other safer options to null, which we shall cover later in the chapter.

Parametric Polymorphism

Parametric polymorphism is what allows you to write generic code for values of different types without losing the advantages of static typing.

Without polymorphism, a generic list type structure would always look like this:

scala> 2 :: 1 :: "bar" :: "foo" :: Nil
res0: List[Any] = List(2, 1, bar, foo)

Having to deal with the Any type in these cases means that we can't recover any type information about the individual members:

scala> res0.head
res1: Any = 2

Without polymorphism, we would be forced to use casts and thus would lack type safety (since casts are dynamic and happen at runtime).

Scala enables polymorphism through the specification of type variables, which you probably already came across when implementing generic functions:

scala> def drop1[A](l: List[A]) = l.tail
drop1: [A](l: List[A])List[A]

scala> drop1(List(1, 2, 3))
res1: List[Int] = List(2, 3)

Type Inference

A common problem of statically typed languages is that they provide too much "syntactic overhead". Scala rectifies this issue by introducing type interface. In Scala, type inference is local and it will consider one expression at a time.

Type inference reduces the need for most type annotations. For example, declaring the type of variable is not necessary in Scala, as the compiler can identify the type from the initialization expression. Return types of methods are also successfully identified by the compiler, as they resemble to the body type:

val x = 3 + 4 * 5         // the type of x is Int
val y = x.toString()      // the type of y is String
def succ(x: Int) = x + 2  // method succ returns Int values

The compiler is not able to infer a result type from recursive methods, though. The following declaration will not compile:

def fac(n: Int) = if (n == 0) 1 else n * fac(n - 1)

The error message is enough to identify the issue:

<console>:11: error: recursive method fac needs result type
       def fac(n: Int) = if (n == 0) 1 else n * fac(n - 1)

Parameterized Types

Parameterized types are the same as generic types in Java. A generic type is a generic class or interface that is parameterized over types. For example:

class Stack[T] {
  var elems: List[T] = Nil
  def push(x: T) { elems = x :: elems }
  def top: T = elems.head
  def pop() {elems = elems.tail }
}

Generic types can interact with type checking using bounds or variance. We'll cover variance in the next section.

Bounds

Scala allows programmers to restrict polymorphic variables using bounds. These bounds express subtype ( <:) or supertype ( :>) relationships. For example, if we've defined our drop1 method before as the following:

scala> def drop1[A <: AnyRef](l: List[A]) = l.tail
drop1: [A <: AnyRef](l: List[A])List[A]

The following wouldn't compile:

scala> drop1(List(1, 2, 3))
<console>:13: error: inferred type arguments [Int] do not conform to method drop1's type parameter bounds [A <: AnyRef]
       drop1(List(1, 2, 3))
       ^
<console>:13: error: type mismatch;
 found   : List[Int]
 required: List[A]
       drop1(List(1, 2, 3))

Existential Types

An existential type in Scala is a type with some unknown parts in it. For example:

Foo[T] forSome { type T }

An existential type includes references to type members that we know exist, but whose concrete values we don't care about. In the preceding code, T is a type we don't know concretely, but that we know exists. Using existential types, we can leave some parts of your program unknown, and still typecheck it with different implementations for those unknown parts.

Imagine that you have the following method:

scala> def foo(x: Array[Any]) = x.length
foo: (x: Array[Any])Int

If you try the following, it won't compile, because an Array[String] is not an Array[Any] (will see why in the next section):

scala> val a = Array("foo", "bar", "baz")
a: Array[String] = Array(foo, bar, baz)

scala> foo(a)
<console>:14: error: type mismatch;
 found   : Array[String]
 required: Array[Any]
We can fix this by adding a type parameter:
scala> def foo[T](x: Array[T]) = x.length
foo: [T](x: Array[T])Int
scala> foo(a)
res0: Int = 3

Now, foo is parameterized to accept any T. But now we have to carry around this type parameter, and we only care about methods on Array and not what the Array contains. We can therefore use existential types to get around this.

scala> def foo(x: Array[T] forSome { type T }) = x.length
foo: (x: Array[_])Int

scala> foo(a)
res0: Int = 3

This pattern is common, so Scala provides us with "wildcards" for when we don't want to name a type variable:

scala> def foo(x: Array[_]) = x.length
foo: (x: Array[_])Int

scala> foo(a)
res0: Int = 3

Activity: Generalizing the Implementation of the Binary Tree

In this activity, we'll be generalizing an implementation of a binary search tree. Let's assume you have the following definition for a binary search tree of integers. We want to generalize our implementation of a binary search tree from an IntTree to a Tree[A]. Perform the necessary modifications to the code to support the new definition and also have the insert and search methods work on the new definition. You may need to modify the insert and search definitions to provide a generic comparison function. We would like to use this new generic data structure to store information about the users that visit our website, which are being modeled as the User(username: String, country: String) case class:

trait IntTree
case class IntNode(value: Int, left: IntTree, right: IntTree) extends IntTree
case object IntEmpty extends IntTree

The previous definition supports these methods:

def insert(value: Int, tree: IntTree): IntTree =
  tree match {
    case IntEmpty => IntNode(value, IntEmpty, IntEmpty)
    case IntNode(currentValue, left, right) =>
      if (value < currentValue)
        IntNode(currentValue, insert(value, left), right)
      else
        IntNode(currentValue, left, insert(value, right))
  }

def search(value: Int, tree: IntTree): Boolean =
  tree match {
    case IntEmpty => false
    case IntNode(currentValue, left, right) =>
      value == currentValue ||
        (value < currentValue && search(value, left)) ||
       (value >= currentValue && search(value, right))
  }
  1. Start by modifying the ADT for trees from IntTree to a Tree[A]. Perform the necessary modifications to IntNode (to become Node[A] and IntEmpty to become Empty).

    Note

    Note that IntEmpty is an object, so there's a single instance for the type IntEmpty.type. What should Empty be a subtype of? For now, transform Empty into a case class: case class Empty[A]() extends Tree[A]. We'll look at a better way to define this type later.

  2. Modify the insert definition to accept an extra comparison function as a function parameter:
    insert[A](value: A, tree: Tree[A], comp: (A, A) => Boolean).
  3. Modify the code accordingly to take the new comp parameter into account.
  4. Modify the search definition to accept an extra comparison function as a function parameter:
    search[A](value: A, tree: Tree[A], comp: (A, A) => Boolean)
  5. Modify the code accordingly to take the new comp parameter into account.
  6. Create a comparison function for User and use it to populate a Tree[User].
  7. Implement the def usersOfCountry(country: String, tree: Tree[User]): Int function that returns the number of users of a given country in a Tree[User].

In this section, we covered the unified type system of Scala and how Scala achieves polymorphism. We also introduced type inference and the basic rules of when it's applied. Bounds were also introduced as a convenient way to restrict polymorphic types.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset