Why Liskov Substitution Principle needs the argument to be contravariant?
The phrase "contravariance of method arguments" may be concise, but it's ambiguous. Let's use this as an example:
class Base {
abstract void add(Banana b);
}
class Derived {
abstract void add(Xxx? x);
}
Now, "contravariance of method argument" could mean that Derived.add
must accept any object that has the type Banana
or a supertype, something like ? super Banana
. This is an incorrect interpretation of the LSP rule.
The actual interpretation is: "Derived.add
must be declared either with the type Banana
, just as in Base
, or some supertype of Banana
such as Fruit
." Which supertype you choose is up to you.
I believe that using this interpretation it is not hard to see that the rule makes perfect sense. Your subclass is compatible with the parent API, but it also, optionally, covers extra cases which the base class doesn't. Therefore it's LSP-substitutable for the base class.
In practice there aren't many examples where this widening of type in the subclass is useful. I assume this is why most languages don't bother to implement it. Requiring strictly the same type preserves LSP as well, just doesn't give you the full flexibility you could have while still achieving LSP.
Here, following what LSP says, a "derived object" should be usable as a replacement of the "base object".
Let's say your base object has a method:
class BasicAdder
{
Anything Add(Number x, Number y);
}
// example of usage
adder = new BasicAdder
// elsewhere
Anything res = adder.Add( integer1, float2 );
Here, "Number" is an idea of base type for a number-like data types, integers, floats, doubles, etc. No such thing exists in i.e. C++, but then, we're not discussing a specific language here. Similarly, just for the purpose of example, "Anything" depicts an unrestricted value of any type.
Let's consider a derived object that is "specialized" to use Complex:
class ComplexAdder
{
Complex Add(Complex x, Complex y);
}
// example of usage
adder = new ComplexAdder
// elsewhere
Anything res = adder.Add( integer1, float2 ); // FAIL
hence, we just broke LSP: it is NOT usable as a replacement for original object, because it is not able to accept integer1, float2
parameters, because it actually requires complex parameters.
On the other hand, please note that covariant return type is OK: Complex as return type will fit Anything
.
Now, let's consider the other case:
class SupersetComplexAdder
{
Anything Add(ComplexOrNumberOrShoes x, ComplexOrNumberOrShoes y);
}
// example of usage
adder = new SupersetComplexAdder
// elsewhere
Anything res = adder.Add( integer1, float2 ); // WIN
now everything is OK, because whoever was using the old object, now is also able to use the new object as well, with no change impact on the point of use.
Of course, it is not always possible to create such "union" or "superset" type, especially in terms of numbers, or in terms of some automatic type conversions. But then, we are not talking about specific programming language. The overall idea matters.
It's also worth noting that you can adhere or break LSP at various "levels"
class SmartAdder
{
Anything Add(Anything x, Anything y)
{
if(x is not really Complex) throw error;
if(y is not really Complex) throw error;
return complex-add(x,y)
}
}
It surely looks like conforming to LSP at the class/method signature level. But is it? Often not, but that depends on many things.
How Contravariance rule is helpful in achieving data/procedure abstraction?
it is well.. obvious for me. If you create say, components, that are meant to be exchangeable/swappable/replaceable:
- BASE: compute sum of invoices naively
- DER-1: compute sum of invoices on multiple cores in parallel
- DER-2: compute sum of invoices with detailed logging
and then add a new one:
- compute sum of invoices in different currency
and lets say it handles EUR and GBP input values. What about inputs in old currency, say USD? If you omit that, then new component is not a replacement of old ones. You cannot just take out the old component and plug the new one in and hope everything is fine. All other things in the system may still send a USD values as inputs.
If we create the new component as derived from BASE, then everyone should be safe to assume that they can use it wherever a BASE was required earlier. If some place required a BASE, but a DER-2 was used, then we should be able to plug the new compoenent there. This is LSP. If we cannot, then something is broken:
- either place of use did't require just BASE but in fact required more
- or our component indeed is not a BASE (please note the is-a wording)
Now, if nothing is broken, we can take one and replace with another, regardless of whether USDs or GBPs or single core or multicore is out there. Now, looking at the big picture at one-level-above, if no longer need to care about specific types of currency, then we successfully abstracted it away the big picture will be simpler, while of course, components will need to internally handle that somehow.
If that does not feel like helping in data/procedure abstraction then look at opposite case:
If component derived from BASE didn't adhere to LSP, then it may raise errors when values legitimate in USDs arrive. Or worse, it will not notice and will process them as GBP. We have a problem. To fix that we need to either fix the new component (to adhere to all requirements from BASE), or change other neighbour components to follow new rules like "now use EUR not USD, or the Adder will throw exceptions", or we need to add things to the big picture to work it around i.e. add some branches that will detect old-style data and redirect them to old components. We just "leaked" the complexity to neighbours (and maybe we forced them to break SRP) or we made the "big picture" more complex (more adapters, conditions, branches, ..).