Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Scala Type Classes Best Practices

I'm reading through and working my way through using type classes and I came across this way of defining type classes from the Shapeless guide:

So here goes the example:

object CsvEncoder {
  // "Summoner" method
  def apply[A](implicit enc: CsvEncoder[A]): CsvEncoder[A] =
    enc
  // "Constructor" method
  def instance[A](func: A => List[String]): CsvEncoder[A] =
    new CsvEncoder[A] {
      def encode(value: A): List[String] =
        func(value)
      }
    // Globally visible type class instances
}

What I do not understand is the need for the apply method? What is it doing in this context above?

Later on, the guide describes how I could create a type class instance:

implicit val booleanEncoder: CsvEncoder[Boolean] =
  new CsvEncoder[Boolean] {
    def encode(b: Boolean): List[String] =
      if(b) List("yes") else List("no") 
  } 

is actually shortened to:

implicit val booleanEncoder: CsvEncoder[Boolean] =
instance(b => if(b) List("yes") else List("no"))

So my question now is, how does this work? What I do not get is the need for the apply method?

EDIT: I came across a blog post that describes the steps in creating type classes as below:

  1. Define typeclass contract trait Foo.
  2. Define a companion object Foo with a helper method apply that acts like implicitly, and a way of defining Foo instances typically from a function.
  3. Define FooOps class that defines unary or binary operators.
  4. Define FooSyntax trait that implicitly provides FooOps from a Foo instance.

So what is the deal with point number 2, 3 and 4?

like image 487
joesan Avatar asked Apr 24 '17 19:04

joesan


1 Answers

Most of those practices came from Haskell (basically an intention to mimic Haskell's type-classes is a reason for so much boilerplate), some of it is just for convenience. So,

2) As @Alexey Romanov mentioned, companion object with apply is just for convenience, so instead of implicitly[CsvEncoder[IceCream]] you could write just CsvEncoder[IceCream] (aka CsvEncoder.apply[IceCream]()), which will return you a required type-class instance.

3) FooOps provides convenience methods for DSLs. For instance you could have something like:

trait Semigroup[A] {
   ...
   def append(a: A, b: A)
}

import implicits._ //you should import actual instances for `Semigroup[Int]` etc.
implicitly[Semigroup[Int]].append(2,2)

But sometimes it's inconvenient to call append(2,2) method, so it's a good practice to provide a symbolic alias:

  trait Ops[A] {
    def typeClassInstance: Semigroup[A]
    def self: A
    def |+|(y: A): A = typeClassInstance.append(self, y)
  }

  trait ToSemigroupOps {
    implicit def toSemigroupOps[A](target: A)(implicit tc: Semigroup[A]): Ops[A] = new Ops[A] {
      val self = target
      val typeClassInstance = tc
    }
  }

  object SemiSyntax extends ToSemigroupOps

4) You can use it as follows:

import SemiSyntax._ 
import implicits._ //you should also import actual instances for `Semigroup[Int]` etc.

2 |+| 2

If you wonder why so much boilerplate, and why scala's implicit class syntax doesn't provide this functionality from scratch - the answer is that implicit class actually provides a way to create DSL's - it's just less powerful - it's (subjectively) harder to provide operation aliases, deal with more complex dispatching (when required) etc.

However, there is a macro solution that generates boilerplate automatically for you: https://github.com/mpilquist/simulacrum.


One another important point about your CsvEncoder example is that instance is convenience method for creating type-class instances, but apply is a shortcut for "summoning" (requiring) those instances. So, first one is for library extender (a way to implement interface), another one is for a user (a way to call a particular operation provided for that interface).

like image 105
dk14 Avatar answered Sep 28 '22 16:09

dk14