Whenever I hear “covariant return type”, I have to pause and engage my System 2 thoroughly in order to understand what I have just heard. And even then, I cannot bet I will answer properly what it means. So this serves as a memo for me of the concept of variance in programming.
The notion of variance is related to the topic of subtyping in programming language theory. It deals with rules of what is allowed or not with regards to function arguments and return types.
Variance comes in four forms:
- invariance
- covariance
- contravariance
- bivariance (will skip that)
Before we dive into explanations, let us agree on pseudo code that I am going
to use. The >
operator shows subtyping. In the example
Vehicle > Bus
Bus
is a subtype of Vehicle
. Functions are defined with the following syntax:
func foo(T): R
where T
is a type of an argument, and R
is a return type of a function foo
.
Functions can also override another functions (think “override of a method
in Java”). Here, bar
overrides foo
:
func foo(T): R > func bar(T): R
Throughout the example, I will be using this hierarchy of objects.
Vehicle > MotorVehicle > Bus
Invariance
Invariance is the easiest to understand: it does not allow anything - neither supertype nor subtype - to be used instead of a defined function argument or return type in inherited functions. For instance, if we have a function:
func drive(MotorVehicle)
Then the only possible way to define an inherited function is with MotorVehicle
argument, but not Vehicle
or Bus
.
func drive(MotorVehicle) > func overrideDrive(MotorVehicle)
Same goes for return types.
func produce(): MotorVehicle > func overrideProduce(): MotorVehicle
This way, the type system of a language doesn’t allow you much flexibility, but protects you from many possible type errors.
Covariance
Covariance allows subtypes or, in other words, more specific types to be used instead of a defined function argument or return type. Let’s start with return types. Return types are covariant. Let’s look at these two functions:
func produce(): MotorVehicle > fn overrideProduce(): Bus
Is it OK that overrideProduce
returns more concrete Bus
instead of
MotorVehicle
? Yes, it is! Since Bus
is a type of MotorVehicle
, it meets
the contract, because it supports everything a MotorVehicle
can do. So this
is allowed:
motorVehicle = product()
motorVehicle = overrideProduce()
In this case, for the calling code there is no difference whether motorVehicle
variable has a MotorVehicle
instance or a Bus
.
But what about function arguments? Is this definition fine?
func drive(MotorVehicle) > func overrideDrive(Bus)
This is actually not allowed by a safe type system, because overrideDrive
breaks parent’s contract. Users of drive
expect to be able to pass any type
of MotorVehicle
, not only Bus
. Indeed, imagine someone calls drive
with,
let’s say a Car
(where MotorVehicle > Car
), then the call to overrideDrive
will be overrideDrive(Car)
, but overrideDrive
works only with Bus
instances.
So function arguments are not covariant. And here we approach contravariance.
Contravariance
Contravariance allows supertypes or, in other words, more abstract types to be used instead of a defined type. Function arguments are contravariant. Let’s have a look at the example.
func drive(MotorVehicle) > func overrideDrive(Vehicle)
Though it looks counterintuitive, this is a perfectly valid case.
overrideDrive
meets parent’s contract: it supports any Vehicle
, and since
MotorVehicle
is a type of Vehicle
, users of drive
still can pass any
instance of MotorVehicle
.