1. Overview

In this tutorial, we’ll look at the advantages of Scala generics in implementing containers. We’ll examine how Scala generics provide type-safety while helping us adhere to the DRY principle.

We’ll go through the steps of writing generic classes and methods and explore the generic types available in the standard Scala library.

2. Container Classes

The most common scenario for using generic classes is containers.

Let’s say we are designing a class for a magician’s hat. It can have a cute bunny or just an apple or anything else. There are a few ways we might approach this.

2.1. For a Specific Type

We would want the compiler to guarantee the type of magic object our hat contains so that we can predictably use it. We could just write a class that is specific to the type we’re interested in:

case class AppleMagicHat(magic: Apple)

def run(): Unit = {
  val someHat = AppleMagicHat(Apple("gala"))
  val apple: Apple = someHat.magic
  println(apple.name)
}

Now the compiler guarantees that the magic item in someHat can only be an Apple instance.

But, what if we also had a type of object called Rabbit:

case class Rabbit(cuteness: Int)

To create a magic hat that provides a Rabbit instance, we would need to write another class called RabbitMagicHat that works with Rabbit instances. With this approach, we would end up with many container classes that behave very similarly. They would only differ by the type of object they contain.

This is contrary to the DRY principle. So, let’s try using just one container class.

2.2. Without Type-Safety

We could have a single magic hat class, capable of holding any kind of magical object:

case class MagicHat(magic: AnyRef)

val someHat = MagicHat(Rabbit(2))
val apple: Apple = someHat.magic.asInstanceOf[Apple]
println(apple.name)

The reason we have to use AnyRef for the magic item inside the hat is that it can be of more than one type. In Scala, all objects inherit from AnyRef.

However, this is error-prone. Here, we can see that a cast exception is thrown on line 4 when we accidentally treat the Rabbit inside the hat as an Apple instance.

We would like a type-safe implementation that requires only one container class implementation. This is the purpose of Scala generics.

3. Type-Safety With Generic Classes

When declaring a class in Scala, we can specify type parameters. We use square brackets to surround these type parameters.

For example, we can declare class Foo[A]. The placeholder A can then be used in the body of the class to refer to the type. When the generic class is used, the code will specify which type to use in each case.

Unlike value parameters, type parameters are rarely more than one letter long, and by convention, they start with the letter A.

A two-parameter example would be something like class Bar[A, B].

The advantage of using a type parameter is that we now know the type of the thing inside the magic hat. The compiler can check the type for us and will fail if we are not adding the correct one:

case class MagicHat[A](magic: A)

val rabbitHat = MagicHat[Rabbit](Rabbit(2))
val rabbit: Rabbit = rabbitHat.magic
println(rabbit.cuteness)

Here we have a type parameter A, and the caller of the case class constructor, on line 3, specifies that the type is Rabbit.

Now, MagicHat[Rabbit] provides the same type-safety as though we had created a special RabbitMagicHat class. And, we achieved this having declared only one MagicHat class. Also, there’s no need for casting our references.

4. Examples from the Scala Standard Library

One of the best ways to understand use cases for generic classes is to look at examples in the Scala standard library.

Most Scala generic classes are collections, such as the immutable List, Queue, Set, Map, or their mutable equivalents, and Stack.

Collections are containers of zero or more objects. We also have generic containers that aren’t so obvious at first. For example, Option is a container that can take zero objects or one object. A Try is a container that contains either a value or a Throwable. And a Future represents the possibility of a value when the action completes.

5. Generic Methods

When writing Scala methods that take generic inputs or produce generic return values, the methods themselves may or may not be generic.

5.1. Declaration Syntax

Declaring a generic method is very similar to declaring a generic class. We still put type parameters in square brackets.

And, the return type of the method can also be parameterized:

def middle[A](input: Seq[A]): A = input(input.size / 2)

This method takes a Seq containing items of a chosen type and returns the item from the middle of the Seq:

val rabbits = List[Rabbit](Rabbit(2), Rabbit(3), Rabbit(7)) 
val middleRabbit: Rabbit = middle[Rabbit](rabbits)

Let’s now look at an example with more than one type parameter:

def itemsAt[A, B](index: Int, seq1: Seq[A], seq2: Seq[B]): (A, B) = (seq1(index), seq2(index))

This method takes the elements at index of both Seqs, and returns a tuple, matching the types used in the inputs:

val apples = List[Apple](Apple("gala"), Apple("pink lady"))
val items: (Rabbit, Apple) = itemsAt[Rabbit, Apple](1, rabbits, apples)

We should note that the above examples are not production-ready, as they do not correctly handle edge cases such as empty Seq. However, they show how type parameters can help us enforce the types of arguments and of return values.

5.2. Using Generic Classes in Non-Generic Methods

We don’t always write generic methods when using generic classes. For example, a method that takes two Lists of any type and returns the total length can be declared as:

def totalSize(list1: List[_], list2: List[_]): Int

The _ (underscore) means it doesn’t matter what’s in the List. There’s no type parameter, and so, there’s no need for the caller to provide the type for this method:

val rabbits = List[Rabbit](Rabbit(2), Rabbit(3), Rabbit(7))
val strings = List("a", "b")
val size: Int = totalSize(rabbits, strings)

In this example, the type is not supplied to the function when it is called.

6. Upper Type Bounds

To illustrate Scala’s upper type bounds, let’s reinvent the wheel by writing an elementary and yet generic function to find the maximum element in a collection. Here’s our first attempt:

def findMax[T](xs: List[T]): Option[T] = xs.reduceOption((x1, x2) => if (x1 >= x2) x1 else x2)

Even though the logic seems to be ok, the >=  function is not defined on the generic type T. Therefore, the findMax function won’t compile at all.

We know that the Ordered[T] is the home for such comparison functions. So, we should somehow tell the compiler that T is a subtype of Ordered[T] in this example.

As it turns out, the upper bound type in Scala generics will do this for us:

def findMax[T <: Ordered[T]](xs: List[T]): Option[T] = xs.reduceOption((x1, x2) => if (x1 >= x2) x1 else x2)

With the “T <: Ordered[T]” syntax we indicate that Ordered[T] is the supertype of type parameter T. That is, each element in the xs list should be a subtype of Ordered[T]. This way, we can use >= and other comparison functions with them.

7. Lower Type Bounds

Let’s borrow an example from the Programming in Scala book:

class Queue[+T](private val leading: List[T], trailing: List[T]) {
  def head(): T = // returns the first element
  def tail(): List[T] = // everything but the first element
  def enqueue(x: T): Queue[T] = // appending to the end
}

Here, we’re trying to represent a queue with two lists.

Now, suppose we need to work with a Queue[String]. Since we want Queue[String] to be a subtype of Queue[Any], we used the covariant type annotation [+T]. However, now the enqueue method won’t compile:

covariant type T occurs in contravariant position in type T of value x
  def enqueue(x: T): Queue[T] = new Queue(leading, x :: trailing)

The type parameter [+T] is covariant, but we’ve used it in a contravariant position (function argument), and the compiler complains about that.

One way to fix this issue is to use lower bound types:

def enqueue[U >: T](x: U): Queue[U] = new Queue(leading, x :: trailing)

Here, we’re defining a new type parameter U. Also, the “U >: T” syntax means that U should be a supertype of T. Now, we can use the enqueue method:

val empty = new Queue[String](Nil, Nil)
val stringQ: Queue[String] = empty.enqueue("The answer")
val intQ: Queue[Any] = stringQ.enqueue(42)

When we add an Int to a Queue[String], the compiler automatically infers the nearest supertype for String and Int. Hence the returned type is Queue[Any].

8. Conclusion

In this article, we demonstrated that, with generic classes, we can achieve type-safety while avoiding having to declare a new container class for each type it contains.

We saw steps to declare generic classes and visited examples in the standard Scala library.

Finally, we also saw the difference in declaring both generic and non-generic methods.

As always, the example code used in this article can be found over on GitHub.

guest
0 Comments
Inline Feedbacks
View all comments