Variance

Variance is another aspect related to parameterized types. To understand why it is needed and how it works, let's have a drink. First, we will define a glass that can be (half) full or empty:

sealed trait Glass[Contents]
case class Full[Contents](contents: Contents) extends Glass[Contents]
case object Empty extends Glass[Nothing]

There can only be one empty glass filled with Nothing, and we model this case with a case object. A full glass can be filled with different contents. Nothing is a subclass of any class in Scala, so in our case it should be able to substitute any contents. We will now create the contents and we would like to be able to drink it. The implementation is not important in this case:

case class Water(purity: Int)
def drink(glass: Glass[Water]): Unit = ???

We now are able to drink from the full glass and are unable to do so from an empty one:

drink(Full(Water(100)))
drink(Empty) // compile error, wrong type of contents

But what if instead of drinking, we'd like to define drinkAndRefill, which should refill an empty glass?

def drinkAndRefill(glass: Glass[Water]): Unit = ???
drinkAndRefill(Empty) // same compile error

We would like our implementation to accept not only Glass[Water], but also Glass[Nothing], or more generally any Glass[B] if B <: Water. We can change our implementation accordingly:

def drinkAndRefill[B <: Water](glass: Glass[B]): Unit = ???

But what if we would like our Glass to work like this with any method, not only drinkAndRefill? Then we need to define how the relation between parameterizing types should affect the way the parameterized type works. This is done with variance. Our definition, sealed trait Glass[Contents], is called invariant, and it means that the relation in the type that parameterizes Glass does not affect how glasses with different contents are related—they are not related at all. The covariance means that, in regards to the compiler, if type parameters are in a subclass relation, then the main types should be too. It is expressed with a + (plus) before the type constraint. Therefore, our definition of the glass becomes the following:

sealed trait Glass[+Contents]

And the rest of the code remains unchanged. Now, if we have contents that are related, we can drink them without facing the same problems we had before:

drink(Empty) // compiles fine

A typical use of covariance is with different kinds of immutable containers, where it is safe to have a more specific element in the container, like the one that is declared by the type.

It is not safe to do so with mutable containers, though. The compiler will not allow us to do this, but if it would, we might end up passing a container, C, with some subclass, B, to the method, expecting a container with superclass A. This method would then be able to replace the contents of C with A (as it is not even supposed to know about the existence of B), hence making future uses of C[B] impossible.

Now, let's imagine that our glass is supposed to interact with a drinker. We'll create a Drinker class for this, and the drinker is supposed to be able to drink the contents of Glass:

class Drinker[T] { def drink(contents: T): Unit = ??? }

sealed trait
Glass[Contents] {
def contents: Contents
def knockBack(drinker: Drinker[Contents]): Unit = drinker.drink(contents)
}
case class Full[C](contents: C) extends Glass[C]

Now, let's inspect what happens if we have two different kinds of Water:

class Water(purity: Int)
class PureWater(purity: Int) extends Water(purity) {
def shine: Unit = ???
}

val glass = Full(new PureWater(100))
glass.knockBack(new Drinker[PureWater])

PureWater is Water with some additional properties. We can create a glass full of it and let it fill a drinker. Obviously, if somebody can drink just water, they should be able to drink pure water as well:

glass.knockBack(new Drinker[Water]) // compile error

To fix this, we need to use contravariance, which is denoted by the - (minus sign) before the type parameter. We fix our Drinker like so, and our example starts to compile:

class Drinker[-T] { def drink(contents: T): Unit = ??? }
glass.knockBack(new Drinker[Water]) // compiles

It is important to notice that co- and contravariance do not define the type itself, but only the type parameters. This is very important for functional programming in Scala because it allows defining functions as first-class citizens. We will look at function definition in more detail in the next chapter, but to give you some indication, here is what it is about.

If we want to pass over to the caller a function, that is, f(water: Water): Water, what kind of substitute would be safe to pass instead? It would not be safe to pass a function that accepts PureWater because the caller won't be able to call it with such an argument. But it will be safe for the function to accept Water and any superclass of it that describes contravariance. For the result, it would be unacceptable for our replacement function to return anything higher in the hierarchy than f because the caller expects the result to be at least as specific as f. It would be no problem if our substitute was more specific, though. Therefore, we end up with covariance. Hence, we can define f as f[-Parameter,+Result].

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

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