Contra-who? Covari-wha?
With PHP's recent focus on stricter type handling comes some new terminology that might be intimidating to some and confusing to others.
Two new terms, "contravariant parameters" and "covariant returns", describe how derived classes and interface implementations can widen and narrow the declared types of their parent interface or class.
Back in the code-slinging, devil-may-care olden days of PHP (up to version 4), we didn't have to worry about this stuff, because the language's loosey-goosey typing "solved" it for us. We could come up with any horrendously tangled mess we wanted to.
But now, PHP is growing up. It has Opinions™. And those opinions mean that more care needs to be taken when smashing rocks together to craft our classes.
Introducing our Example
Say we have some Company
classes and an interface that look like this:
<?php
declare(strict_types=1);
class Company {}
class MotorboatInsuranceCo extends Company {}
interface InsuredBoat {
public function setInsurer(MotorboatInsuranceCo $company): void;
public function getInsurer(): MotorboatInsuranceCo;
}
Now we want to create a concrete implementation:
<?php
declare(strict_types=1);
class Boat implements InsuredBoat {
public $insurer;
public function setInsurer(MotorboatInsuranceCo $company): void {
$this->insurer = $company;
}
public function getInsurer(): MotorboatInsuranceCo {
return $this->insurer;
}
}
In the beginning .... Invariant Types
All the way up to PHP 7.3, only "invariant" types were supported. If we created a Sailboat
class that extended our Boat
class or implemented the InsuredBoat
interface, the type declarations on its setInsurer()
and getInsurer()
methods would have to be a 100% match to the parent definition.
However, beginning in PHP 7.4, we have another way to approach things.
Contravariant Parameters
Drawing from our example, what if we want to make a specific Boat
implementation for Sailboats, and we want to pass in some kind of HappySailboatInsuranceLtd
object?
Our first instinct might be to rewrite the class like this:
<?php
declare(strict_types=1);
class HappySailboatInsuranceLtd extends MotorboatInsuranceCo {}
class Sailboat extends Boat {
// ...
// this example does not work (see below for more info)
public function setInsurer(HappySailboatInsuranceLtd $company): void {
$this->insurer = $company;
}
// ...
}
So now, when we call setInsurer()
on one of these Sailboat
classes, we can only pass in a HappySailboatInsuranceLtd
. Right?
Actually, no. PHP won't let us declare this change from the InsuredBoat
Interface and the Boat
class, because it improperly narrows the accepted parameters. An object of Sailboat
that implements InsuredBoat
cannot accept a narrower input parameter than MotorboatInsuranceCo
. It breaks the interface's contract.
Fatal error: Declaration of Sailboat::setInsurer(HappySailboatInsuranceLtd $company): void must be compatible with Boat::setInsurer(MotorboatInsuranceCo $company): void in /tmp/example2.php on line 28
Contravariant Parameters mean that the parameters can only "go wider"; that is, if MotorboatInsuranceCo
is the declared type, only a parent class or interface of Company
would be accepted; sub-types or sub-interfaces, like our hypothetical HappySailboatInsuranceLtd
, would be rejected.
A working example would look more like this:
<?php
declare(strict_types=1);
class HappySailboatInsuranceLtd extends Company {}
class Sailboat implements InsuredBoat {
public $insurer;
public function setInsurer(Company $company): void {
if (!$company instanceof HappySailboatInsuranceLtd) {
throw new \Exception('avast me hearties');
}
$this->insurer = $company;
}
public function getInsurer(): MotorboatInsuranceCo {
return $this->insurer;
}
}
This wider parameter (Company $company
) follows the contract of the interface and everything is copasetic ... er, happy.
Well, almost. The return type on getInsurer()
is not going to be happy, not one bit.
Covariant Returns
Now we come to the other confusing bit of terminology. Let's say we want to change the return type of the getInsurance()
method to make more sense in our new Sailboat
class:
<?php
declare(strict_types=1);
class HappySailboatInsuranceLtd extends Company {}
class Sailboat implements InsuredBoat {
public $insurer;
public function setInsurer(Company $company): void {
if (!$company instanceof HappySailboatInsuranceLtd) {
throw new \Exception('avast me hearties');
}
$this->insurer = $company;
}
public function getInsurer(): HappySailboatInsuranceLtd {
return $this->insurer;
}
}
Would this be OK? Actually, again, no. Because HappySailboatInsuranceLtd
extends Company
and not MotorboatInsuranceCo
, PHP views it as an inappropriate widening of the return type.
Fatal error: Declaration of Sailboat::getInsurer(): HappySailboatInsuranceLtd must be compatible with InsuredBoat::getInsurer(): MotorboatInsuranceCo in /tmp/example4.php on line 35
Here we have two or three options for how to solve the issue.
Option 1:
We could modify the original interface to say that getInsurer()
returns a Company
. Then, we could modify the Boat->getInsurer()
method's return type to return MotorboatInsuranceCo
, and allow the Sailboat->getInsurer()
method to return HappySailboatInsuranceLtd
.
Option 2:
We could modify the HappySailboatInsuranceLtd
class to extend from the MotorboatInsuranceCo
class. This is less than ideal in some architectures, because the resulting hierarchy might be misleading.
Option 3:
We could create a semi-generic InsuranceCompany
class that both HappySailboatInsuranceLtd
and MotorboatInsuranceCo
extend.
Options 1 and 3 seem the most likely to achieve our goal, so let's see what that implementation of Option 3 would look like:
<?php
declare(strict_types=1);
class Company {}
class InsuranceCompany extends Company {}
class MotorboatInsuranceCo extends InsuranceCompany {}
class HappySailboatInsuranceLtd extends InsuranceCompany {}
interface InsuredBoat {
public function setInsurer(MotorboatInsuranceCo $company): void;
public function getInsurer(): InsuranceCompany;
}
class Boat implements InsuredBoat {
public $insurer;
public function setInsurer(MotorboatInsuranceCo $company): void {
$this->insurer = $company;
}
public function getInsurer(): MotorboatInsuranceCo {
return $this->insurer;
}
}
class Sailboat implements InsuredBoat {
public $insurer;
// this definition widens the parameter using contravariance
public function setInsurer(InsuranceCompany $company): void {
if (!$company instanceof HappySailboatInsuranceLtd) {
throw new \Exception('avast me hearties');
}
$this->insurer = $company;
}
// this definition narrows the return type using covariance
public function getInsurer(): HappySailboatInsuranceLtd {
return $this->insurer;
}
}
Looking at this, there seems to be an indication that the setInsurer
method on the InsuredBoat
interface could have its $company
parameter type be widened to InsuranceCompany
, but that's kind of just a quirk of the example.
Navigating the Variance
What might be handy is a memorable way to think of the subject. There are all kinds of examples that could be drawn from, such as plumbing, the digestive system, London fatbergs, rivers, TARDISes, and so on.
Since I'm rather obsessed with boats, I feel like charting a nautical tack.
For "Contravariant Parameters", think of arriving at a marina in your boat. If it's a regular powerboat, you can take it to your boathouse and it'll fit right in. But if you happened to swap that boat out with a sailboat while you are motoring around (you piratey sea-dog, you!), and you try to put that sailboat in your boathouse, it's not going to fit. While you'll be able to find a berth for it in the open part of the marina, the boathouse's roof blocks the ship's mast from entering. That's a covariant parameter: it can go wider (going somewhere else in the marina), but it can't go narrower (fitting a sailboat into a boathouse).
For "Covariant Returns", consider a sailboat in a storm. In calm weather, the mainsail can be fully up. But when the wind hits, you "reef" it down. There are set reefing points that you can tie off in order to reduce the amount of sail area and sail more safely. It can go narrower (second reef, third reef), but as long as the wind is howling, it can't (or shouldn't, for safety) go wider.
Let's Get Real
Okay, maybe my contrived examples aren't the best way of conveying the idea. Luckily, there is a real-world use case of these concepts that we can look to.
The PHP Framework Interoperability Group (FIG) has released a number of standardized interfaces over the years. As the language has evolved, the need has grown to revisit those interfaces and allow them to adapt as well.
Breaking backwards compatibility is frowned on, especially for critical interoperability interfaces. So they needed a plan to upgrade the interfaces that wouldn't break the world out of the gate.
What they came up with was a two-step dance of "Version 2" and "Version 3". In this plan, "Version 3" would be the final or ideal form of the new interface, and "Version 2" would be an intermediate step that introduces part of the upgrade in a way that allows older non-updated libraries to continue to work.
Version 2 of a PSR will use Contravariant Parameters to bring an element of typedness to the interface. At the same time, Version 3 of the PSR will be released, which follows on to fill in the blanks by adding Covariant Returns.
Any projects that code against the original Version 1 of the PSR would automatically be compatible with Version 2, because their methods would have "wider" (i.e., untyped) parameters. This is where Contravariants shine.
Then, the implementing library/project can release an updated version that is compatible with both Version 2 and Version 3. Adding return types, which uses covariance to narrow the contract, still allows Version 2 of the PSR to be used.
Libraries would also have the option of following the same two-step process, if they would prefer.
For more information about FIG's proposal, check out their blog post on upgrading PSRs.
Edging Forward
I hope this helps folks wrap their head around how and when to apply Contravariants/Covariants to their own projects. For additional reading, the PHP manual has a section that covers the different types of variance.
Let's embrace the future of PHP -- and bring our codebases along for the ride.
Thanks for reading!
Published: March 12, 2020
Tags: dev, development, coding, php, maintenance