12

One of the rules that Liskov Substitution Principle imposes on method signature in derived class is:

Contravariance of method arguments in the subtype.

If I understood correctly, it is saying that the derived class's overridding function should allow contravariant arguments(Supertype arguments). But, I could'nt understand the reason behind this rule. Since LSP talks mostly about dynamically binding the types with there subtypes(rather than supertypes) in order to achieve abstraction, so allowing supertypes as the method arguments in derived class is quite confusing to me. My questions are :

  • Why LSP allows/requires Contravariant arguments in derived class's overridding funtion?
  • How Contravariance rule is helpful in achieving data/procedure abstraction?
  • Is there any real world example where we need to pass contravariant parameter to the derived class's overridden method?
Mac
  • 1,711
  • 3
  • 12
  • 26

3 Answers3

9

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, ..).

mastercat
  • 55
  • 7
quetzalcoatl
  • 32,194
  • 8
  • 68
  • 107
  • Thanks for the detailed description. However, `Anything res = adder.Add( integer1, float2 ); // WIN` this could be true if Add method has `Number` as the argument in within `SupersetComplexAdder` class. Considering the fact that `BasicAdder` clearly indicates that it is not expecting anything other than Number type or its subtype as the argument in `Add` method, providing supertype as the argument within the derived class gives no extra facility to the caller. – Mac Oct 31 '16 at 16:02
  • Even if we allow the subtype to have covariant arguments , in that case the caller code (Client) looses the provision of replacing the object of 'SupersetComplexAdder' with any other subtype of `BasicAdder` as now the code is more specific to `SupersetComplexAdder`. And this itself breaks the LSP for `BasicAdder`. Although I would agree that the LSP still holds true for `SupersetComplexAdder`. – Mac Oct 31 '16 at 16:37
  • After re-reading your answers, I have got a pretty clear picture about the importance of supporting covariance in LSP :) . Thanks again. – Mac Nov 06 '16 at 07:10
5

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.

Marko Topolnik
  • 195,646
  • 29
  • 319
  • 436
  • Thanks for your valuable input.But, I still stand on my question that If the base class is itself not bothering about the supertype of argument that it has in its overriding method then it is the clear indication that the base class wants its clients (callers) to deal only with the type/subtype of arguments. In such cases, providing supertype as the argument in derived class isn't going to give any new provisions to the callers who are calling those methods on derived class object with the superclass reference . – Mac Oct 31 '16 at 15:50
  • For example a caller is always going to call `base.callMe(Number num);` or `base.callMe(Integer int) ;` but never `base.callMe(Object obj);` – Mac Oct 31 '16 at 15:50
  • Just as covariant return types, this only serves the clients of the subtype. – Marko Topolnik Oct 31 '16 at 16:04
  • But the thing is that , in that case, this caller code looses the provision to replace the object with any other subtype of base class as now it is more specific to that particular subtype with covariant argument.! – Mac Oct 31 '16 at 16:26
  • That doesn't break LSP, though. You changed the code to work with an unrelated type, on a different branch of the hierarchy. – Marko Topolnik Oct 31 '16 at 16:28
  • But it breaks LSP for the base class though. Right? – Mac Oct 31 '16 at 17:32
  • Why? How? I don't think I follow you anymore. We had a `Base` type and a `Derived` type. Then you said let's imagine some code written against `Derived` type. Then you said we can't pass an instance of an unrelated `Derived2` to it, whichis both true and not a violation of LSP. Finally, you said it "breaks the LSP for the base class", but there is no `Base` in this picture anymore. The code is written against `Derived`. – Marko Topolnik Oct 31 '16 at 17:58
  • 1
    My apologies for the confusion. Looks to be Monday's blue effect on me :). While writing my last message I was thinking in terms of Base class. But it was not the case, I have myself mentioned that the client code is written specifically for the Derived class and LSP obedience will start from that starting point in the inheritance hierarchy. Thanks once again for the clear cut explanation. – Mac Oct 31 '16 at 18:27
  • this is not what it does for you. it is not helping to achieve specialized (extras) in subtypes. because your mentioned extra cases require a more specific implementation not a less specifc one. as with a supertype you will not extend with extra cases. this becomes apparent, when you try to call the (more specific) parent method with your supertype object in the subtype and then add extra. you then needed to check and cast. static code analysis e.g. enables covariance for parameters, which has many use cases. no violation of liskovs substitution principle, it guarantees correct call types. – Lemonade Feb 08 '22 at 03:41
  • @Lemonade Contravariance of argument types in the subtype is a plain and simple _requirement_ for LSP to work. It is the least restrictive rule you can have and not break the idea of substitutability. You seem to talk about some _usages_ and _utility_ of LSP ("helping you to achieve" something, "has many use cases"), which is a totally different discussion. Your comments on "check and cast" are misplaced, I think, there's no need anywhere to do that. – Marko Topolnik Feb 08 '22 at 07:29
  • thing is, i was referring to your statement "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. " and do not agree with its implications regarding the "usage scenario" that you seem to infer as it is misleading at least and obfuscates the real necessities in system designs, often being : you need covariance in your parameters to achieve sustainable extra case realization while allowing structural generalisation in real life scenarios ... just my cents – Lemonade Feb 08 '22 at 17:47
  • You seem to keep discussing what you think someone _needs_ while this answer is about what LSP _is_ and why it makes perfect sense just the way it is defined. Furthermore, saying "you need covariance in parameters" probably reveals a misunderstanding of the concepts involved. Allowing function parameters to be covariant with the owning type leads to broken code which doesn't conform to the contract of its supertype. – Marko Topolnik Feb 09 '22 at 08:02
2

I know it is a pretty old question, but I think a more real life use could help:

 class BasicTester
    {
       TestDrive(Car f)

    }

    class ExpensiveTester:BasicTester
    {
       TestDrive(Vehicle v)
    }

The old class can only work with Car type, while the derived one is better and can handle any Vehicle. In addition, those who use the new class with the "old" Car type will be served too.

However, you cannot override like that in C#. You could implement that indirectly using delegates:

protected delegate void TestDrive(Car c)

which can be then assigned a method that accepts Vehicle. THanks to contravariance it will work.

John V
  • 4,855
  • 15
  • 39
  • 63