Abstract types

One of the most common ways to parameterize classes is by using values. This is quite simple, and it is achieved by passing different values for the constructor parameters of a class. In the following example, we can pass different values for the name parameter of the Person class, and this is how we create different instances:

case class Person(name: String)

This way we can create different instances and distinguish them, but this is neither interesting nor rocket science. Going further, we will focus on some more interesting parametrizations that will help us make our code better.

Generics

Generics are another way of parameterizing classes. They are useful when we write a functionality whose application is the same throughout various types, and we can simply defer choosing a concrete type until later. One example every developer should be familiar with is collection classes. List, for example, can store any type of data, and we can have lists of integers, doubles, strings, custom classes, and so on. Still, the list implementation is always the same.

We can also parameterize methods. For example, if we want to implement addition, it will not change between different numerical data types. Hence, we can use generics and just write our method once instead of overloading and trying to accommodate every single type in the world.

Let's see some examples:

trait Adder {
  def sum[T](a: T, b: T)(implicit numeric: Numeric[T]): T = numeric.plus(a, b)

}

The preceding code is a bit more involved and it defines a method called sum, which can be used with all numeric types. This is actually a representation of ad hoc polymorphism, which we will talk about later in this chapter.

The following code shows how to parameterize a class to contain any kind of data:

class Container[T](data: T) {
  def compare(other: T) = data.equals(other)
}

The following snippet shows some example uses:

object GenericsExamples extends Adder {
  def main(args: Array[String]): Unit = {
    System.out.println(s"1 + 3 = ${sum(1, 3)}")

    System.out.println(s"1.2 + 6.7 = ${sum(1.2, 6.7)}")
    // System.out.println(s"abc + cde = ${sum("abc", "cde")}") // compilation fails

    val intContainer = new Container(10)
    System.out.println(s"Comparing with int: ${intContainer.compare(11)}")

    val stringContainer = new Container("some text")
    System.out.println(s"Comparing with string: ${stringContainer.compare("some text")}")
  }
}

The output of this program will be as follows:

1 + 3 = 4
1.2 + 6.7 = 7.9
Comparing with int: false
Comparing with string: true

Abstract types

Another way to parameterize classes is by using abstract types. Generics have their counterparts in other languages such as Java. Unlike them, however, abstract types do not exist in Java. Let's see how our preceding Container example will translate into one with abstract types, rather than generics:

trait ContainerAT {
  type T
  val data: T

  def compare(other: T) = data.equals(other) 
}

We will use the trait in a class, as follows:

class StringContainer(val data: String) extends ContainerAT {
  override type T = String
}

After we've done this, we can have the same example as before:

object AbstractTypesExamples {
  def main(args: Array[String]): Unit = {
    val stringContainer = new StringContainer("some text")
    System.out.println(s"Comparing with string: ${stringContainer.compare("some text")}")
  }
}

The expected output is as follows:

Comparing with string: true

We could, of course, use it in a similar way to the generic example by creating an instance of the trait and specifying the parameters there. This means that generics and abstract types really give us the possibility to achieve the same thing in two different ways.

Generics versus abstract types

So why are there both generics and abstract types in Scala? Are there any differences, and when should one be used over the other? We will try to give answers to these questions here.

Generics and abstract types can be interchangeable. One might have to do some extra work, but in the end, we could get what the abstract types provide using generics. Which one is chosen depends on different factors, some of which are personal preferences, such as whether someone is aiming for readability or a different kind of usage of the classes.

Let's have a look at an example and try to get an idea of when and how generics and abstract types are used. In this current example, we will talk about printers. Everyone knows that there are different types—paper printers, 3D printers, and so on. Each of these use different materials to print with, for example toner, ink, or plastic, and they are used to print on different types of media—paper or actually in the air. We can represent something like this using an abstract type:

abstract class PrintData
abstract class PrintMaterial
abstract class PrintMedia
trait Printer {
  type Data <: PrintData
  type Material <: PrintMaterial
  type Media <: PrintMedia
  
  def print(data: Data, material: Material, media: Media) = s"Printing $data with $material material on $media media."
}

In order to call the print method, we need to have different media, types of data, and materials:

case class Paper() extends PrintMedia
case class Air() extends PrintMedia
case class Text() extends PrintData
case class Model() extends PrintData
case class Toner() extends PrintMaterial
case class Plastic() extends PrintMaterial

Let's now make two concrete printer implementations, a laser and a 3D printer:

class LaserPrinter extends Printer {
  type Media = Paper
  type Data = Text
  type Material = Toner
}
class ThreeDPrinter extends Printer {
  type Media = Air
  type Data = Model
  type Material = Plastic
}

In the preceding code, we actually gave some specifications about the kind of data, media, and materials which these printers can be used with. This way we can't ask our 3D printer to use toner to print something or our laser printer to print in the air. This is how we will use our printers:

object PrinterExample {
  def main(args: Array[String]): Unit = {
    val laser = new LaserPrinter
    val threeD = new ThreeDPrinter
    
    System.out.println(laser.print(Text(), Toner(), Paper()))
    System.out.println(threeD.print(Model(), Plastic(), Air()))
  }
}

The preceding code is really readable, and it allows us to specify concrete classes easily. It makes things easier to model. It is interesting to see how the preceding code would translate to generics:

trait GenericPrinter[Data <: PrintData, Material <: PrintMaterial, Media <: PrintMedia] {
  def print(data: Data, material: Material, media: Media) = s"Printing $data with $material material on $media media."
}

The trait is easily represented, and readability and logical correctness are not compromised here. However, we must represent concrete classes in this way:

class GenericLaserPrinter[Data <: Text, Material <: Toner, Media <: Paper] extends GenericPrinter[Data, Material, Media]
class GenericThreeDPrinter[Data <: Model, Material <: Plastic, Media <: Air] extends GenericPrinter[Data, Material, Media]

This becomes quite long, and a developer could easily make a mistake. The following snippet shows how to create instances and use the classes:

val genericLaser = new GenericLaserPrinter[Text, Toner, Paper]
val genericThreeD = new GenericThreeDPrinter[Model, Plastic, Air]
System.out.println(genericLaser.print(Text(), Toner(), Paper()))
System.out.println(genericThreeD.print(Model(), Plastic(), Air()))

Here we can see that we must specify the types every time we create instances. Imagine now if we have more than three generic types, some of which could be based on generics as well, for example collections. This could quickly get quite tedious and make the code look harder than it actually is.

On the other hand, using generics allows us to reuse GenericPrinter without explicitly subclassing it multiple times for each different printer representation. There, however, is the risk of making logical mistakes:

class GenericPrinterImpl[Data <: PrintData, Material <: PrintMaterial, Media <: PrintMedia] extends GenericPrinter[Data, Material, Media]

If used as follows, there is a danger of making a mistake:

val wrongPrinter = new GenericPrinterImpl[Model, Toner, Air]
System.out.println(wrongPrinter.print(Model(), Toner(), Air()))
Usage advice

The previous examples show a relatively simple comparison between the use of generics and abstract types. Both are useful concepts; however, it is important to be aware of what exactly is being done in order to use the right one for the situation. Here are some tips that could help in making the right decision:

  • Generics:
    • If you need, just type instantiation. A good example is the standard collection classes.
    • If you are creating a family of types.
  • Abstract types:
    • If you want to allow people to mix in types using traits.
    • If you need better readability in scenarios where both could be interchangeable.
    • If you want to hide the type definition from the client code.