In this article, I will focus on the concept of invariance. I will cover covariance and contravariance in upcoming articles.
Type Systems play a big role in programming languages. In computations, we deal with values. Every value has some type. Who assigns types to the values? The answer is the type system. Wrongly assigned types to values may crash the whole program at run-time. Therefore, we need to make sure that all assigned types are ok and no type-related issues will happen at run-time. This job is done by the type checker (part of the type system).
That said, I want to emphasize one important fact that Java is strongly-typed language. Java is also statically -typed language. By strongly-typed, we mean that the Java compiler ensures that there are no type errors in the written Java program. If any type errors are found, the compiler will provide us with the compilation errors with the reasons. If any program is passed through the compilation process successfully without any errors or warnings, that program will not face any type-errors in run-time. It ensures type-safety. By statically typed, we mean Java does the type-checking of variables and expressions at the compile-time. Types of the variables and expressions must be known at compile-time.
With the above understanding, I am going to discuss a few important points related to objects and references. We will use the below classes and interfaces for that purpose.
The code for the above classes and interfaces are given below.
public class EaterA {
public void eat() {
System.out.println("I am eating apple.");
}
public void eatPizza() {
System.out.println("I am eating Italian pizza.");
}
}
public class EaterB extends EaterA {
@Override
public void eat() {
System.out.println("I am eating banana.");
}
public void eatChocolate() {
System.out.println("I am eating dark chocolate.");
}
}
public class EaterC extends EaterB {
@Override
public void eat() {
System.out.println("I am eating Cowberry.");
}
@Override
public void eatChocolate() {
System.out.println("I am eating milk chocolate.");
}
}
public class EaterG extends EaterB {
@Override
public void eat() {
System.out.println("I am eating grapefruits.");
}
}
public interface Eater {
public void eat();
}
public class EaterD implements Eater {
@Override
public void eat() {
System.out.println("I am eating dragon fruit.");
}
public void eatBurger() {
System.out.println("I am eating veggie burger.");
}
}
public class EaterE implements Eater {
@Override
public void eat() {
System.out.println("I am eating elephant apple fruit.");
}
public void eatBurger() {
System.out.println("I am eating veggie burger.");
}
}
public class EaterF extends EaterE {
public void eatSushi() {
System.out.println("I am eating nigiri sushi.");
}
}
Point #1
We access an object through reference variable. When a Java program runs, it creates different objects and attach reference variables to them for accessing their behaviors.
EaterA refVar1 = new EaterA();
Eater refVar2 = new EaterF();
We can access object Obj1‘s behavior through reference variable refVar1. Similarly, we can access object Obj2‘s behvaior through reference variable refVar2.
Point #2
Each reference variable has a type; we get this type name from the variable declaration. Similarly, an object has an object type, and we get the name of the object type from the class used to create the object.
Eater refVar3 = new EaterE();
The reference variable refVar3 has type Eater. The object Obj3 has type EaterE.
Point #3
Each object offers a fixed set of behaviors. By behaviors, we mean the public methods provided by an object. We access those methods through a reference variable to get something done.
Point #4
We can attach different reference variables with the same object. Each reference variable allows us to access some of the behaviors of the same object.
EaterF refVar4 = new EaterF();
EaterE refVar5 = refVar4;
Eater refVar6 = refVar4;
Point #5
One reference variable’s type provides a specification of behaviors (set of public methods) that you can access from the attached object. Basically, a type variable’s type conceptually defines an interface to the object. One object may have multiple interfaces based on the reference variables attached to it. In other words, an object’s interface depends on the attached reference variable’s type.
Point #6
In Part 1 of the series Solid Code Design, we mentioned that we could define supertypes using both Java interfaces and classes. In Figure 7 (below), EaterA is a supertype of EaterB, EaterC, and EaterG subtypes. In Java, every subclass is a Java subtype. Similarly, when a class implements a Java Interface, that class becomes a subtype of the interface supertype. Hence, EaterD, EaterE, and EaterF are subtypes of Eater supertype.
Point #7
We understand that type of reference variable provides a specification of behaviors. In run-time, we can attach an object with the reference variable to access the implementation of that specification. It doesn’t matter which object offers the implementation. We only care the behaviors must be accessible in run-time.
Consider the below reference variable.
Eater refVar7;
The reference variable refVar7 has type Eater. Hence, it provides a specification with only one public method as shown in the Figure 6.
If we look at the Figure 7, we can see that objects of classes EaterD, EaterE, and EaterF can provide the implementation of the specification perceived through the reference variable refVar7.
Now consider the below code snippet. Here, we are attaching subtype objects to the supertype reference variable. It is possible because EaterD, EaterE, and EaterF are all subtypes of supertype Eater. The code is written in terms of supertype specification. Here, refVar7 has type Eater. We are attaching to it objects of different subtypes. This is called subtype polymorphism (aka inclusion polymorphism). [Important Note: Subtyping and inheritance are not the same thing in Java. But subtyping happens automatically in Java when we use inheritance mechanism (a class extends another class). It also true that when a class implements an interface, it also designates a subtyping relation.]
refVar7 = new EaterD();
refVar7.eat();
refVar7 = new EaterE();
refVar7.eat();
refVar7 = new EaterF();
refVar7.eat();
The line number 2 is accessing the implementation of eat() method provided by an EaterD object. The output for this line is “I am eating dragon fruit.“
The line number 5 is accessing the implementation of eat method provided by an EaterE object. The output for this line is “I am eating elephant apple fruit.“
The line number 8 is accessing the implementation of eat method provided by an EaterF object (Actual implementation is provided by class EaterE. EaterF object gain access to that implementation through inheritance.). Therefore, the output for line 8 is the same as that of line 5: “I am eating elephant apple fruit.“
Keeping the above seven points in mind, we will discuss invariance, co-variance, and contravariance.
Invariance, Covariance, and Contravariance
So far, we have seen subtyping relationships between normal Java classes and interfaces. We have also learned about subtype polymorphism. Now we will discuss subtyping, parametric polymorphism, and generics..
The parametric polymorphism is known as generics in Java. For a gentle introduction of generics, you can look at Part 3 of the series Solid Code Design.
In programming languages and type theory, parametric polymorphism allows a single piece of code to be given a “generic” type, using variables in place of actual types, and then instantiated with particular types as needed. – Wikipedia (Types and Programming Languages, Benjamin C. Pierce)
We are comfortable writing code taking advantage of subtype polymorphism. Programming becomes challenging when generics (parametric polymorphism) comes into the picture. The rest of our discussion (in this article and subsequent articles) will solve that challenge by explaining essential aspects of Java generics.
We know that ArrayList<E> is a generic type available in Java library. We can instantiate this generic type by replacing the type parameter E with a type argument. For example, ArrayList<EaterA>. Here, we say ArrayList<EaterA> is a parameterized type, and EaterA is the type argument. Similarly, ArrayList<EaterB> and ArrayList<EaterC> are parameterized types with type arguments EaterB and EaterC respectively.
In Figure 7, we have seen that EaterB is a subtype of EaterA, EaterC is a subtype of EaterB. When a type S is a subtype of T, we write as S <: T. Using this notation, we can write EaterC <: EaterB and EaterB <: EaterA. Since subtyping relationship is transitive, we can write EaterC <: EaterA. Moreover, subtyping relationship is reflexive. Hence, we can write EaterA <: EaterA, EaterB <: EaterB, and EaterC <: EaterC.
Subtyping helps us write reusable code. Generics also help us write reusable code. We aim to write more sophisticated reusable code using subtyping and generics.
That said, we will investigate the below piece of code.
EaterB refVar8 = new EaterC();
ArrayList<EaterB> refVar9 = new ArrayList<EaterB>();
ArrayList<EaterB> refVar10 = new ArrayList<EaterC>();
Line 1 and 2 are absolutely OK. Line 3 throws compilation error saying “ArrayList<EaterB> cannot be converted to ArrayList<EaterA>.”
The subtyping relationship EaterC <: EaterB does not imply ArrayList<EaterC> <: ArrayList<EaterB>. Why? Because, in Java, generics are invariant in their type parameters. More accurately, in Java, the subtyping of generic types is invariant. If you don’t change the type argument, generic subtyping works (“ArrayList is a subtype of List” implies “ArrayList<EaterB> is a subtype of List<EaterB>”). But ArrayList<EaterC> has no relationship with ArrayList<EaterB>. In fact, ArrayList<X> has no relationship with ArrayList<Y> even if any X and Y may be in subtyping relationship, where X and Y are normal Java classes or interfaces. Every instantiation of generic class ArrayList<E> is different – there is no relationship with each other. It ensures type safety for each instantiation (parameterized type).
But without subtype relationships between different parameterized types of the same generic type (e.g., ArrayList<E>), programming in generics won’t be much interesting. We need that somehow. Here comes variance. This is the focus of my next article (Part 5 of the series Solid Code Design).
I have shared knowledge based on my understanding and experiences. If you find any significant errors or want to give me some feedback, feel free to contact me at maliksanjoykumar[@]gmail.com.
Sanjoy Kumar Malik is an experienced software architect and technologist. He is passionate about Cloud Computing, Software Architecture, and System Design. Apart from technology and software, he is an avid LinkedIn networker. You can join his 5.5+ lacs supporters on LinkedIn.