The Scala language has been published in 2004 and is continuously developed by EPFL and Typesafe. These activities are funded on the one hand by the European Union and on the other hand by industrial investors. Scala has gained popularity in recent years, and is used more and more in production – also at codecentric. This blog series discusses one aspect of the Scala type system, namely co- and contravariant type parameters (i.e. these weird plus and minus signs in the header of a generic class, as e.g. in class Box[+A]
).
Introduction
This article is no introduction to Scala, since there are already many of those. Some cheat sheets and short references already exist as well. Instead, I will discuss how co- and contravariant type parameters work in Scala, and why the rules that govern them make sense. Co- and contravariance generically describes how one aspect of the language varies with an inheritance hierarchy. If it varies along the inheritance hierarchy, it is called covariant. If it varies against the inheritance hierarchy, it is called contravariant (the category theoretical origin of these names is well explained in this Atlassian blog post). An interesting aspect of this topic is that the rules that underly the co- and covariance of type parameters in Scala can be inferred from subtype polymorphism. That means that the quite unfamiliar rules of co- and contravariance that is applied to type parameters can be traced back to a widely known and accepted concept.
To get this far, I will first discuss the basics in this post by introducing co- and contravariant type parameters. How the type checking of these type parameters works and how these checks can be explained by subtype polymorphism is a topic I will cover in the next blog post. In the third part, I will then discuss how type parameters and variances can be used sensibly.
TL;DR
No time? The results are condensed at the end of this post.
Parameterized types
Let’s have a look at the following class hierarchy of boxes and fruits.
abstract class Fruit { def name: String } class Orange extends Fruit { def name = "Orange" } class Apple extends Fruit { def name = "Apple" } abstract class Box { def fruit: Fruit def contains(aFruit: Fruit) = fruit.name.equals(aFruit.name) } class OrangeBox(orange: Orange) extends Box { def fruit: Orange = orange } class AppleBox(apple: Apple) extends Box { def fruit: Apple = apple } |
The definition of the two classes OrangeBox
and AppleBox
give us additional type safety, since the return type of the method fruit
is additionally restricted to Orange
and Apple
, respectively. This class hierarchy quickly leads to the question whether the cost of maintaining the code is worth the gains on the side of type safety.
To avoid these kinds of trade-offs, Java and Scala allow to parameterize classes. That means that you can use a type parameter instead of using a real type. Those type parameters must be declared in the definition of a class, and must be bound to a real type when instantiating that class. This is very similar to using a method parameter instead of a concrete value in a method body: the parameter is just a name for a value that is passed when invoking the method. Similarly, the type parameter can be seen as a name for a type that is bound when instantiating the class. By the way: those concrete types can be passed from type parameter to type parameter, just as values can be passed from variables to variables. The syntactical means differ slightly, though.
Let’s replace the concrete return type Fruit
of Box.fruit
with a type parameter F
, and additionally restrict it a subtype of Fruit
oder Fruit
itself (by adding F <: Fruit
). The modified class Box
is then as follows.
class Box[F <: Fruit](aFruit: F) { def fruit: F = aFruit def contains(aFruit: Fruit) = fruit.name == aFruit.name } var appleBox = new Box[Apple](new Apple) var orangeBox = new Box[Orange](new Orange) |
By parameterizing Box
, we implicitly defined at least two new types: Box[Orange]
and Box[Apple]
. How those types relate to each other needs to be defined with variance annotations.
Variance Annotations
The two classes Box[Fruit]
and Box[Apple]
in the example above do not inherit from each other – that is the assumption the Scala compiler makes when there is no variance annotation. Therefore, you cannot assign an object of type Box[Apple]
to a Box[Fruit]
-typed variable:
// Illegal: Box[Apple] is no subtype of Box[Fruit]. var box: Box[Fruit] = new Box[Apple](new Apple) |
Variance annotations to type parameter declarations are added with a +
(meaning covariance) or a -
(meaning contravariance). Die class header of Box
can be modified to allow the above assignment:
abstract class Box[+F <: Fruit] { |
The assignment of a Box[Apple]
to a variable of type Box[Fruit]
is now possible, since the covariance annotation +F
made Box[Apple]
a subclass of Box[Fruit]
.
Parameterized types are invariant, if no variance annotation is given. A variance annotation creates a type hierarchy between parameterized types that is derived from the type hierarchy of the used types. The following class diagram illustrates the inheritance relations between Box[Fruit]
and Box[Apple]
when declaring F
invariant, covariant and contravariant.
With covariance, the type hierarchy of the injected types is used, and with contravariance, their hierarchy is inverted. With invariance, the type hierarchy is completely ignored.
What relationship makes sense between the instances of the parameterized type must be decided by the developer. In this decision however, she has to consider that type parameters with variance annotations cannot be used as deliberately as invariant type parameters. How variance annotations are checked in Scala is the topic I will cover in my next blog post.
Conclusion
From a bird’s eyes view, co- and contravariant type parameters can be seen as a tool to extend the reach of the type checker in generic classes. They offer additional type safety, which also means that this concept offers new possibilities for leveraging type hierarchies without having to give up on type safety. While developers have to fall back to using comments and conventions in other programming languages, since those languages aren’t able to guarantee type safety, you can achieve quite some mileage with the Scala type system. In the next post I will discuss how co- and contravariant type parameters are checked by the type checker, and how those rules can be inferred from subtype polymorphism.
Condensed Results
Assume that class Orange extends Fruit
holds. If class Box[A]
is declared, then A
can be prefixed with +
or -
.
A
without annotation is invariant, i.e.:Box[Orange]
has no inheritance relationship toBox[Fruit]
.
+A
is covariant, i.e.:Box[Orange]
is a subtype ofBox[Fruit]
.var f: Box[Fruit] = new Box[Orange]()
is allowed.
-A
is contravariant, i.e.:Box[Fruit]
is a subtype ofBox[Orange]
.var f: Box[Orange] = new Box[Fruit]()
is allowed.
The post The Scala Type System: Parameterized Types and Variances, Part 1 appeared first on codecentric Blog.