scalatypesafe

Creating a list of single type of objects


I have an Animal trait and a few case classes as follows

sealed trait Animal

trait Domestic extends Animal
trait Wild extends Animal

case class Dog(id: UUID = UUID.randomUUID()) extends Domestic
case class Lion(id: UUID = UUID.randomUUID()) extends Wild

Here is my Herd class which can contain a list of a single type of animals

case class Herd[T <: Animal](get: T*)

I want to create is a herd of a single type of animal.

val herd1 = Herd(Cat(), Cat())
val herd2 = Herd(Cat(), Lion())

In Scala both are valid, but if you look at the meaning a herd of cats and lions it doesn't make sense. Is there a way to restrict Herd to be a single type?


Solution

  • Try introducing two type parameters A and B and then relate them with a generalised type constraint A =:= B

    case class Herd[A <: Animal, B](x: A, y: B*)(implicit ev: A =:= B)
    
    Herd(Lion())         // ok
    Herd(Lion(), Lion()) // ok
    Herd(Cat(), Lion())  // compile-time error
    

    what exactly is =:=

    Consider the following method with two type parameters A and B where we aim to convey that they should be equal or at least A should be a subtype of B

    scala> def f[A, B](a: A, b: B): B = {
         |   val x: B = a
         |   x
         | }
             val x: B = a
                        ^
    On line 2: error: type mismatch;
            found   : a.type (with underlying type A)
            required: B
    

    The two type parameters are totally unrelated in the above definition and the method body cannot influence the type inference of type parameters so it errors. Now let's try to relate them with a type bound A <: B

    scala> def f[A <: B, B](a: A, b: B): B = {
         |   val x: B = a
         |   x
         | }
    def f[A <: B, B](a: A, b: B): B
    

    So this compiles, however compiler will always try to satisfy the type bounds by calculating the least upper bound of the given arguments

    scala> f(Lion(), Dog())
    val res32: Product with Animal with java.io.Serializable = Lion(...)
    

    We need something more to get around compiler's tendency to deduce a least upper bound, and this is where generalised type equality constraint comes into play

    scala> def f[A <: B, B](a: A, b: B)(implicit ev: A =:= B): B = {
         |   val x: B = a
         |   x
         | }
    def f[A <: B, B](a: A, b: B)(implicit ev: A =:= B): B
    
    scala> f(Lion(), Cat())
            ^
           error: Cannot prove that Lion =:= Product with Animal with java.io.Serializable.
    

    Now compiler still has to try to generate the least upper bound of given arguments, however it also has to satisfy the additional requirement of being able to generate the witness ev for two types A and B being equal. (Note the witness ev will be automagically instantiated by the compiler if it is possible.)

    Once we have the witness ev we can freely move between types A and B via its apply method, for example, consider

    scala> type A = Lion
    type A
    
    scala> type B = Lion
    type B
    
    scala> val a: A = Lion()
    val a: A = Lion(...)
    
    scala> val ev: =:=[A, B] = implicitly[A =:= B]
    val ev: A =:= B = generalized constraint
    
    scala> ev.apply(a)
    val res44: B = Lion(...)
    

    Note how ev.apply(a) types to B. The reason we can apply =:= in this way is because it is actually a function

    scala> implicitly[(A =:= B) <:< Function1[A, B]]
    val res43: A =:= B <:< A => B = generalized constraint
    

    so the implicit parameter list

    (implicit ev: A =:= B)
    

    is in effect specifying an implicit conversion function

    (implicit ev: A => B)
    

    so now compiler is able to inject automatically implicit conversion wherever it is needed so the following

    def f[A <: B, B](a: A, b: B)(implicit ev: A =:= B): B = {
      val x: B = a
      x
    }
    

    is automatically expanded to

    def f[A <: B, B](a: A, b: B)(implicit ev: A =:= B): B = {
      val x: B = ev.apply(a)
      x
    }
    

    In summary, just like type bounds, the generalised type constraints are an additional way of asking the compiler to do further checks at compile-time on our codebase.