Lab 9. Covariance and contravariance
In this lab we will be presenting covariance and contravariance for generic types in Scala.
Objectives:
- get yourself familiar with covariance and contravariance
- see the benefits of using these concepts
*Introduction*
When we define a generic type, for example Shelter[T], and we have a types Animal and Dog and Dog extends Animal, the type Shelter[Dog] does not extend type Shelter[Animal]. However, sometimes we would like to have a conversion in one direction or the other. For example, consider the following code:
trait Shelter[+T] { def sheltered: T }
In this case we can assign a value of type Shelter[Dog] to a variable of type Shelter[Animal] because the trait only outputs sheltered so when we cast Shelter[Dog] to Shelter[Animal] we will expect sheltered to be Animal which Shelter[Dog] provides. +T means that the generic parameter is covariant, meaning it preserves the cast direction, if Dog → Animal then Shelter[Dog] → Shelter[Animal]. Remember, if your trait/interface always uses a generic parameter as an output type in the methods it can be made covariant.
Contravariance works in the other direction:
trait Predicate[-T] { def test(value: T): Boolean }
We can transform a Predicate[Animal] into a Predicate[Dog] because the test method for Predicate[Animal] requires us to provide a value of type Animal which can be restricted to just values of type Dog. So, -T means that the generic parameter is contravariant, it reverses the cast direction, if Dog → Animal then Predicate[Animal] → Predicate[Dog]. Remember, if your trait/interface always uses a generic parameter as an input type in the methods it can be made contravariant.
Note that when we don't specify + or - the generic parameter is invariant.
We can also do restrictions on the generic parameter like T <: Animal, which means that the generic parameter is can be only a subtype of Animal, something that extends it. The other way around T >: Corgi means that T can be a supertype of Corgi, meaning if Corgi extends Dog, T can be Corgi, Dog or Animal.
9.1. Define the following types:
Start by creating a new Scala project and use the following code:
trait Animal { def name: String } class Dog(val name: String) extends Animal { def makesSound: String = "Woof!" } class Cat(val name: String) extends Animal { def makesSound: String = "Mew!" } trait Shelter[+T] { def name: String def residents: List[T] } trait Predicate[-T] { def test(value: T): Boolean }
9.2. Modify this type to use the correct variance to the generic parameter:
case class AnimalShelter[T](name: String, residents: List[T]) extends Shelter[T]
9.3. Using these values create a list from them, use the correct type:
val dogShelter = AnimalShelter("Barking Buddies", List(new Dog("Rex"), new Dog("Bo"))) val catShelter = AnimalShelter("Whiskers Haven", List(new Cat("Garfield"), new Cat("Tom"))) val generalShelter = AnimalShelter("City Zoo Rescue", List(new Dog("Rover"), new Cat("Luna"))) val allShelters: List[???] = List(dogShelter, catShelter, generalShelter)
9.4. Using these values create a list from them, use the correct type:
val generalNameFilter: Predicate[Animal] = new Predicate[Animal] { def test(a: Animal): Boolean = a.name.length > 3 } val dogSoundFilter: Predicate[Dog] = new Predicate[Dog] { def test(d: Dog): Boolean = d.makesSound == "Woof!" } val allFilters: List[???] = List(generalNameFilter, dogSoundFilter)
9.5. Considering the code from the previous exercises complete the following code and make it such that for a shelter we get all the animals that match the predicate:
def crossJoinAndFilter[T](shelter: Shelter[T], predicate: Predicate[T]): Unit = { println(s"Processing '${shelter.name}' using a filter...") val matches = ??? if (matches.isEmpty) { println(" -> No animals matched this criteria.") } else { println(s" -> There were ${matches.length} matches.") } }
Notice the invariance of the generic parameter, it will come up in the last exercise.
9.6. Apply the crossJoinAndFilter method on every possible shelter-predicate combination from the previous exercises, note which ones compile and which none don't.
9.7. Complete the following code and use the proper bound and use it on some shelter:
def inspectShelter[???T???](shelter: Shelter[T]): Unit = { println(s"Inspecting animal Shelter: ${shelter.name}") //Print all the residents }
9.8. Complete the following code and use the proper bound to implement it, start from a shelter for Corgi and then expand it by adding new types of residents:
class Corgi(name: String) extends Dog(name) def saveAnimal[???T???](newResident: T): Shelter[T] = { // Returns a new Shelter[T] containing the old residents + the new resident }
9.9. Apply the proper generic types to this function and test it, S is the type of residents in the shelter and P is the type to test in the predicate, chose the proper bound:
def safeCrossJoin[???](shelter: Shelter[S], predicate: Predicate[P]): Unit = { println(s"Safely filtering ${shelter.name}:") shelter.residents.foreach { animal => if (predicate.test(animal)) { println(s" -> Passed check: ${animal.name}") } } }