Generic Traits and Classes With Type Parameters And Variance

Knoldus Inc.
Dec 27, 2019 · 4 min read

By using type parameterization we can write polymorphic methods and generic classes and traits. For example, sets are generic and take a type parameter: they are defined as Set[T]. As a result, any particular set instance might be a Set[String], a Set[Int], etc., but it must be a set of something.

Implementing a generic trait:

trait Container[T] {
def get: T
def insert(x: T): Container[T]
...
}

So this is how we can implement a trait queue with parameterized type, which declares the methods head, tail, and enqueue. All three methods are implemented in a subclass.

Variance

Variance is the correlation of subtyping relationships of complex types and the subtyping relationships of their component types. Scala supports variance annotations of the type parameters of generic classes.

Types of Variance:

  1. Covariance
  2. Contravariance
  3. Invariance
class Stack[+A] // A covariant class
class Stack[-A] // A contravariant class
class Stack[A] // An invariant class

Covariance:

A type parameter A of a generic class can be made covariant by using the annotation +A. If S is a subtype of type T, then should Queue[S] be considered a subtype of Queue[T]? If so, we could say that trait Queue is covariant.

class Container[+T](val animal: T)

class Animal

class Pet extends Animal

class Cat extends Pet{
override def toString: String = "Cat"
}

class Dog extends Pet{
override def toString: String = "Dog"
}

val dogContainer: Container[Dog] = new Container[Dog](new Dog)

val animalContainer: Container[Animal] = new Container[Cat](new Cat)

animalContainer.animal

> O/P: res0: Animal = Cat

Contravariance:

A type parameter A of a generic class can be made contravariant by using the annotation -A.

class Animal

class Pet extends Animal

class Cat extends Pet{
override def toString: String = "Cat"
}

class Dog extends Pet{
override def toString: String = "Dog"
}

class Container[-A](val animal: Animal)

val dogContainer: Container[Dog] = new Container[Animal](new Dog)

dogContainer.animal
>O/P: res0: Animal = Dog

That is, for some class Container[-A], making A contravariant implies that for two types A and B where A is a subtype of B, Container[B] is a subtype of Container[A].

Invariance:

Generic classes in Scala are invariant by default. This means that they are neither covariant nor contravariant.

class Container[T](val pet: T)

class Animal

class Pet extends Animal

class Cat extends Pet{
override def toString: String = "Cat"
}

class Dog extends Pet{
override def toString: String = "Dog"
}

// The below line of code will give an compile time error
val animalContainer: Container[Animal] = new Container[Cat](new Cat)

We cannot substitute Container[T] for Container[U] unless T is U.

Covariant and contravariant positions:

A type can be in covariant or contravariant position depending on where it is specified.

  • “Covariant Position” — method return type.
  • “Contravariant Position” — method argument.
  • Covariant parameters cannot appears in contravariant position.
  • Contravariant parameters cannot appears in covariant position.

Some of good examples are the following:

The problem with contravariant position

class Container[+T](val pet: T){

// won't compile-covariant T occurs in contravariant position
def swap(newPet : T) = new Container[T](newPet)

}

val animalContainer: Container[Animal] = new Container[Cat](new Cat("Doremon"))

// Seems okay so far
animalContainer.swap(new Cat("kitty"))

// Oops, we can't store a Dog in a Container[Cat]
animalContainer.swap(new Dog("BullDog"))

We can overcome this problem by using lower bound, the example is shown below.

Lower Bound

The lower statement can be written as ‘[T >: S]’. Here T is a type parameter and S is a type. The above lower bound statement means that the type parameter T must be either the same as S or a supertype of S.

class Container[+T](val pet: T){

def swap[U >: T](newPet : U) = new Container[U](newPet)

}

val catContainer: Container[Cat] = new Container[Cat](new Cat("Doremon"))

// Now we can pass a Dog instance
catContainer.swap(new Dog("BullDog"))

So the compiler will find the new type i.e., the lowest upper bound. Now the type of the new instance is Pet.

If we specified the type parameter of the polymorphic method(swap) as Dog.

catContainer.swap[Dog](new Dog("BullDog"))

The above line of code compiled successfully but at run time it will generate an error.

error: type arguments [Dog] do not conform to method swap's type parameter bounds [U >: Cat]

So type parameter U must be either same as T or a supertype of T.

The problem with covariant position:

class Container[-T](val pet: T){

// won't compile-contravariant T occurs in covariant position
def getOut : T = ???

}

val animalContainer: Container[Cat] = new Container[Pet](new Cat("Doremon"))

// Seems okay so far
val pet1 : Pet = animalContainer.getOut

// if getOut returns a Cat, everything is ok
// if it returns a Dog, we have a problems.
// if it returns a Pet, we have a problem.
val pet2 : Cat = animalContainer.getOut

Upper Bound

Upper bound statement can be written as ‘[T <: S]’. Here T is a type parameter and S is a type. The above Upper bound statement means that the type parameter T must be either the same as S or a lower type of S.

def getElement[T <: S](element : T) : T =
{
...
element

}

So compiler will allow the type i.e., the lower type of S.

Conclusion:

  • Type parameterization allows us to write generic classes and traits.
  • Generic classes are classes that take a type as a parameter i.e., one class can be used with different types without actually writing down it multiple times.
  • Variance defines an inheritance relationship between parameterized classes.
  • Various types of variance are covariance, contravariance, invariance.
  • Covariance: If S is a subtype of type T, then Queue[S] be considered a subtype of Queue[T].
  • Contravariance: If S is a subtype of type T, then Queue[T] be considered as a subtype of Queue[S].
  • Invariance: There is no inheritance relationship between parameterized classes or traits.
  • The main idea behind the use of bounds is to overcome the run time error at compile time itself.
  • We can use bounds to overcome the variance problem by widening or narrowing.
  • So lower bound changes from the same type to potentially a wider type.

To explore more about variance examples here is the link of Github repo.

References:

Knoldus Inc.

Written by

Group of smart Engineers with a Product mindset who partner with your business to drive competitive advantage | www.knoldus.com

More From Medium

More from Knoldus Inc.

More from Knoldus Inc.

Functional Aspect of Rust

140

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade