The type class design pattern

A lot of times when we write software, we encounter similarities between different implementations. An important principle of good code design is to avoid repetition and it is known as do not repeat yourself (DRY). There are multiple ways that help us to avoid repetitions—inheritance, generics, and so on.

One way to make sure we do not repeat ourselves is through type classes. The purpose of type classes is to:

Note

Define some behavior in terms of operations that a type must support in order to be considered a member of the type class.

A concrete example would be Numeric. We can say that it is a type class and defines the the operations: addition, subtraction, multiplication, and so on, for the Int, Double, and such other classes. We have actually already encountered type classes earlier in this book in Chapter 4, Abstract and Self Types. Type classes are the ones that allow us to implement ad-hoc polymorphism.

Type class example

Let's see an actual example that is also somehow useful to developers in this case. In machine learning, developers tend to use some statistics functions quite often in their work. There are statistical libraries and, if we try them out, we will see that these statistics functions exist for different numeric types—Int, Double, and so on. Now we could come up with something simple and implement these functions for all the numeric types we think about. This, however, is not feasible and makes our library impossible to extend. Moreover, statistics functions have the same definitions, no matter the type. So we don't want to repeat our code as many times as there are numeric types.

So let's first define our type class:

trait Number[T] {
  def plus(x: T, y: T): T
  def minus(x: T, y: T): T
  def divide(x: T, y: Int): T
  def multiply(x: T, y: T): T
  def sqrt(x: T): T
}

The preceding is just a trait that defines some operations that will require numbers to support.

Tip

Numeric in Scala

The Scala programming language has a Numeric trait that defines many of the previously mentioned operations.

If we had used the Numeric trait in the preceding code, we could have saved ourselves from some code writing, but for the sake of this example, let's use our custom type.

After we have defined a trait for the numbers, we can now write our library as follows:

object Stats {
//  same as
//  def mean[T](xs: Vector[T])(implicit ev: Number[T]): T =
//    ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
  def mean[T: Number](xs: Vector[T]): T = 
    implicitly[Number[T]].divide(
      xs.reduce(implicitly[Number[T]].plus(_, _)),
      xs.size
    )
  
  // assumes the vector is sorted
  def median[T: Number](xs: Vector[T]): T =
    xs(xs.size / 2)
  
  def variance[T: Number](xs: Vector[T]): T = {
    val simpleMean = mean(xs)
    val sqDiff = xs.map {
      case x => 
        val diff = implicitly[Number[T]].minus(x, simpleMean)
        implicitly[Number[T]].multiply(diff, diff)
    }
    mean(sqDiff)
  }
  
  def stddev[T: Number](xs: Vector[T]): T = 
    implicitly[Number[T]].sqrt(variance(xs))
}

There is quite a lot of code in the preceding example. Defining the functions is pretty straightforward. Let's, however, explain the role of the implicitly keyword. It uses the so called context bounds from Scala, and it is the crucial part that allows us to implement the type class design pattern. In order to use the preceding methods, it requires a type class member of Number for the T to be implicitly available. As you can see in the comment above mean, we can alternatively have an implicit parameter to the methods.

Let's now write some example code that will use the previously mentioned methods:

object StatsExample {
  def main(args: Array[String]): Unit = {
    val intVector = Vector(1, 3, 5, 6, 10, 12, 17, 18, 19, 30, 36, 40, 42, 66)
    val doubleVector = Vector(1.5, 3.6, 5.0, 6.6, 10.9, 12.1, 17.3, 18.4, 19.2, 30.9, 36.6, 40.2, 42.3, 66.0)
    
    System.out.println(s"Mean (int): ${mean(intVector)}")
    System.out.println(s"Median (int): ${median(intVector)}")
    System.out.println(s"Std dev (int): ${stddev(intVector)}")

    System.out.println(s"Mean (double): ${mean(doubleVector)}")
    System.out.println(s"Median (double): ${median(doubleVector)}")
    System.out.println(s"Std dev (double): ${stddev(doubleVector)}")
  }
}

Compiling the preceding code right now will not be successful and we will see error similar to the following:

Error:(9, 44) could not find implicit value for evidence parameter of type com.ivan.nikolov.type_classes.Number[Int]

    System.out.println(s"Mean (int): ${mean(intVector)}")

                                           ^

The reason is that we have not yet defined any implicitly available Number members for Int and Double. Let's define them in the companion object for the Number trait:

object Number {
  implicit object DoubleNumber extends Number[Double] {
    override def plus(x: Double, y: Double): Double = x + y
    override def divide(x: Double, y: Int): Double = x / y
    override def multiply(x: Double, y: Double): Double = x * y
    override def minus(x: Double, y: Double): Double = x - y
    override def sqrt(x: Double): Double = Math.sqrt(x)
  }
  
  implicit object IntNumber extends Number[Int] {
    override def plus(x: Int, y: Int): Int = x + y
    override def divide(x: Int, y: Int): Int =  round(x.toDouble / y.toDouble).toInt
    override def multiply(x: Int, y: Int): Int = x * y
    override def minus(x: Int, y: Int): Int = x - y
    override def sqrt(x: Int): Int = round(Math.sqrt(x)).toInt
  }
}

Now our code will compile successfully. But how did this whole thing work when we had just defined these implicits in a companion object in a completely different file? First of all, our nested objects are implicit and second of all, they are available in the companion object.

Tip

Defining your default type class members in the companion object

The companion object of the implicit type class parameter is the last place the compiler looks for implicit values. This means that nothing extra has to be done and users can easily override our implementations.

We can now run our code easily:

Type class example

Of course, we can put our implicit values anywhere we want. If they are not in the companion object, however, we will have to do extra imports in order to make them available.

Alternatives

There are, of course, alternatives to the type class design pattern. We can use the adapter design pattern. It will, however, make it much harder to read our code because things will be wrapped all the time and they will be much more verbose. The type class design pattern takes advantage of the nice features of the Scala type system.

Looking at our preceding code we can also see that there is a fair bit of boilerplate. This can become problematic in bigger projects, or when we try to define more complex type classes. A library that was written specifically to deal with these issues can be found at https://github.com/mpilquist/simulacrum/.

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

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