1. Introduction

In this tutorial, we’ll discuss the type-class concept in Scala. Type classes are a powerful concept that is heavily used in functional programming. They were first introduced in Haskell to achieve ad-hoc polymorphism. They don’t have native support in Scala, but we can use built-in features like traits and implicit classes to achieve them.

2. What’s a Type Class?

A type class is a group of types that satisfy a contract typically defined by a trait. They enable us to make a function more ad-hoc polymorphic without touching its code. This flexibility is the biggest win with the type-class pattern.

More formally put:

A type class is a type system construct that supports ad hoc polymorphism. This is achieved by adding constraints to type variables in parametrically polymorphic types. Such a constraint typically involves a type class T and a type variable a, and means that a can only be instantiated to a type whose members support the overloaded operations associated with T

To unpack this definition, we need to identify the different terms used and define them separately:

  • Type System – this is a logical system in the compiler that defines the rules for assigning the property we call type to different programming constructs such as variables, expressions, or functions. Int, String, and Long are the examples of types we are referring to here
  • Ad-hoc and Parametric Polymorphism – we covered these in our article about polymorphism in Scala

We’ll make a better sense of type variables and constraints applied to them using examples in upcoming sections.

3. Example Problem

Let’s create an extremely arbitrary example to see how we’d define and use type classes. Assume we want to build a printing mechanism to display a couple of objects in a school information system.

In the beginning, we have only StudentId, which wraps integer-based student IDs for type safety. But with time, we expect to add several other data wrappers like StaffId, and even business domain objects like Score, without touching the original implementation.

The flexibility of the type-class pattern is exactly what we need here. So, let’s define these objects:

case class StudentId(id: Int)
case class StaffId(id: Int)
case class Score(s: Double)

Since we’ve defined the problem, let’s go ahead and create a type-class to solve it. We’ll try to reference the formal definition we quoted in the previous section so that it’s easy to map it to implementation.

4. Defining Type Class T

In this section, we’ll define the type-class that provides the contract or constraint for our solution:

trait Printer[A] {
  def getString(a: A): String
}

We may find it a bit confusing that what we refer to as the type-class is only the trait, yet the formal definition seems to imply there’s more. For clarity, let’s think of a type-class as a trait applied in the context of what we referred to as the type-class constructor pattern. A trait on its own or used in any other way does not qualify as a type-class.

As we saw earlier in the second sentence in the formal definition: This is achieved by adding constraints to type variables in parametrically polymorphic types. In our case, the type class above is the parametrically polymorphic type since we defined it with an unbounded type A, meaning we can substitute A with any type when extending it.

We’ll define the type variables in a bit.

5. Defining the Printer

The reason we use type classes is to make programs ad-hoc polymorphic, so let’s define the function we want to apply it to:

def show[A](a: A)(implicit printer: Printer[A]): String = printer.getString(a)

We call the above function whenever we need to show data objects or wrappers in a human-friendly manner. But we still haven’t empowered it to print anything since we haven’t defined any type variables (a from the formal definition) for it.

Also notice that the method has two parameter lists. The first list contains ordinary functional parameters, albeit abstract ones. The second parameter list has the printer parameter prefixed with the implicit keyword. This should make sense after reading the article about implicit classes.

6. Defining Type Variable a

Again, referring to the formal definition of a type-class, the last sentence reads: …***a can only be instantiated to a type whose members support the overloaded operations associated with T***.

In our case, this simply means that our type variable should subtype the type-class Printer or directly instantiate it so it can overload the getString method. By implication, the type variable should also be implicit since that is how the show method expects it:

implicit val studentPrinter: Printer[StudentId] = new Printer[StudentId] { 
  def getString(a: StudentId): String = s"StudentId: ${a.id}" 
}

At this stage, we can call show for StudentId types:

it should "print StudentId types" in {
  val studentId = StudentId(25)

  assertResult(expected = "StudentId: 25")(actual = show(studentId))
}

7. Extending the Type Class

So far, we have a working type-class implementation. In this section, we’ll see the flexibility of type classes in action. We noted earlier that the type-class pattern enables us to achieve ad-hoc polymorphism. This means that, without touching the show method, we can increase the range of types it can handle.

We achieve a larger range by adding new type variables. In our case, we want to be able to print StaffId and Score types such that the following test passes:

it should "custom print different types" in {
  val studentId = StudentId(25)
  val staffId = StaffId(12)
  val score = Score(94.2)

  assertResult(expected = "StudentId: 25")(actual = show(studentId))
  assertResult(expected = "StaffId: 12")(actual = show(staffId))
  assertResult(expected = "Score: 94.2%")(actual = show(score))
}

Now let’s define the necessary type variables. Typically, we group them in a companion object (some people call the type-class pattern implicit objects for this reason):

object Printer {
  implicit val studentPrinter: Printer[StudentId] = new Printer[StudentId] {
    def getString(a: StudentId): String = s"StudentId: ${a.id}"
  }

  implicit val staffPrinter: Printer[StaffId] = new Printer[StaffId] {
    def getString(a: StaffId): String = s"StaffId: ${a.id}"
  }

  implicit val scorePrinter: Printer[Score] = new Printer[Score] {
    def getString(a: Score): String = s"Score: ${a.s}%"
  }
}

That’s it. Any time we need to support new object types, we only need to add a type variable in the Printer companion object.

8. Conclusion

In this article, we’ve discussed the type-class pattern in functional programming as it applies to Scala.

As always, the source code is available over on GitHub.


« 上一篇: Scala地图指南
» 下一篇: Slick简介