Re: LSP and subtype



Responding to Huber...

However, there are some potential pitfalls here. For example, some poorly formed OOPLs like C++ allow one to instantiate a superclass without specifying a subclass.


These "poorly formed" OOPLS almost invariably provide means to disallow the instantiation of a superclass without specifying a subclass.

To achieve this in C++, one usually makes all constructors protected, so that they are only accessible from a deriving class ...

<snip example>

Sure. But that is just enshrining a Good Practice to avoid shooting oneself in the foot -- much like making attributes private and forcing the use of public getters/setters to hide the data implementation.


There is the abstract keyword in C# (to my knowledge the same works in Java) ...

abstract class Base
{
 // ...
}

... with similar effects as in C++.

So if a designer feels that he'd rather not deal with any of the "problems" you describe then he can easily enforce the proper use of his base class.

I think it is less about the original developers than the maintainers who follow. If it is done once for one tree, then the maintainer can never trust the design and must inspect every access and instantiation context in detail.


[BTW, there is a more aesthetic reason for not instantiating base classes w/o specifying a subclass. The members of the superclass set that are not in any subclass are defined by a negative (NOT Sub1 AND NOT sub2 AND NOT...). Just on general principles that lacks a certain crispness of definition.]

BTW, I'm not disputing that base classes should *usually* be abstract (most of mine are). The problem arises with libraries that provide classes that can be used out-of-the box and as base classes. If you follow the base-classes-must-be-abstract rule then you end up with essentially double the amount of classes, see below.

External libraries like FileStream are a kind of special situation because they do not modify application state variables or invoke application behaviors directly. IOW, because their results are manifested outside the application, it is highly unlikely that there could be LSP side effects for the examples you provided.


I regard this as the same sort of exception as processing a stream of arbitrary objects from an external source in C++. There one is essentially forced to used dynamic_cast because it is the only game in town for reflection. But just because it is there does not justify using it for routine navigation of the application's own subclassing trees.

IOW, I agree with you that external libraries can be an exception to the admonition of avoiding overrides just as object streams are an exception to the admonition to never use dynamic_cast in C++.

That segues to the notion that the client has to understand _the whole tree_ to access it properly. For example, suppose we can't instantiate Base on a standalone basis and we have:

                  [Base]
                  + foo()
                    A
                    |
          +---------+--------+
          |                  |
        [Sub1]            [Sub2]
        + foo()

where [Sub2] gets the Base.foo() implementation. Suppose a Client invoking Base.foo() is happy with either implementation so all is well.


Why would the client ever care about what implementation he gets? All Sub1.foo() must respect are the invariants, pre- & postconditions that were established by Base. If Sub1.foo() does so then the client will never notice a difference. If Sub1.foo() fails to do so then the LSP is violated and Sub1 is faulty.

As I said, the stipulation is the Client is happy with either implementation, so it can invoke Base.foo(). [If it would not be happy with, say, Sub1.foo() then it would have to have a direct relationship with [Sub2] and it would invoke Sub2.foo().]



Now suppose somebody comes along doing maintenance and decides we need a third implementation:

                  [Base]
                  + foo()
                    A
                    |
          +---------+--------+--------------+
          |                  |              |
        [Sub1]            [Sub2]          [Sub3]
        + foo()                           + foo()

This may break the original Client if the Sub3.Foo() implementation is unacceptable (i.e., an LSP violation from the Client perspective) even though the original Client and its context was not touched.


This is just an example that derived classes must observe the LSP or else there's a bug in the software. I don't see how this backs your assertion that one should not be able to instantiate Base classes.

That was pretty much my point. The pitfall from an LSP perspective is that one cannot arbitrarily add new subclass implementations without validating them against the existing client access contexts.


Apropos of your point above about DbC conditions, there is really no way to write such conditions in practice to deal with OO inclusion polymorphism. That's because one is not really substituting different implementations of the same semantics; one is substituting different behavior semantics. So the post conditions between subclasses will usually be different. IOW, adding a new subclass behavior almost always changes the base class' ORed postcondition specification.

The only way to effectively deal with that is by raising the level of abstraction of the base class condition specification. So if we have:

           1        attacks 1
[Predator] ------------------ [Prey]
                                A
                                |
                    +-----------+
                    |           |
                [Gazelle]  [Impala]
                + run()    + run ()

we can implement a Prey.run behavior for the base class and things will Just Work from an LSP perspective. But now suppose we add:

           1        attacks 1
[Predator] ------------------ [Prey]
                                A
                                |
                    +-----------+----------+
                    |           |          |
                [Gazelle]  [Impala]   [Pheasant]
                + run()    + run ()   + takeFlight()

Now Prey.run() doesn't cut it. We can fix that by abstracting the postcondition and providing a Prey.flee() behavior. However, it we add:

           1        attacks 1
[Predator] ------------------ [Prey]
                                A
                                |
                    +-----------+----------+-----------------+
                    |           |          |                 |
                [Gazelle]  [Impala]   [Pheasant]       [Brontosaurus]
                + run()    + run ()   + takeFlight()   + stomp()

we have a new problem. We can raise the level of abstraction again and upgrade the [Prey] postcondition to Prey.respondToAttack(). However, the reality is that so long as we keep adding new behaviors we have to keep upgrading the notion of the [Prey] behavior. More important, we have to check every existing client to make sure the new postcondition is still acceptable.

[BTW, this problem exists in no small part because the OOPLs do not separate message and method because they are all implemented with type systems and they all employ procedural message passing. If one only needed to define the messages that a [Prey] would accept, most of the LSP problems evaporate. That's because separation of message and method decouples the contexts so there is no expectation of specific behavior on the part of the client. Thus there is no contract with the client per se. The message simply becomes an announcement of something the client did. The developer could then map that announcement to the proper response in the interface. That does involve contract enforcement but it is at a quite different level of abstraction (e.g., a UML Interaction Diagram).]


To fix this problem we would need to do further surgery on the tree:

                  [Base]
                  + foo()
                    A
                    |
          +---------+--------+
          |                  |
        [SubA]            [Sub3]
          A               + foo()
          |
    +-----+------+
    |            |
  [Sub1]       [Sub2]
  + foo()      + foo()

Not only do we have to perform more surgery on the tree, we have to change the calling context in Client to invoke SubA.foo() rather than Base.foo(). Thus the Client has to understand the tree to invoke the correct property implementations


How could this ever fix the problem above? Sub3.foo() is buggy, no amount of rearranging the inheritance tree will fix that.

It fixes the problem because the Client can unambiguously access the tree at the [SubA] level so it never gets the unacceptable [Sub3] behavior. The OOP implementation can then ensure the the relationship is instantiated only with [SubA] members of [Base]. The downside is that the Client must understand where to access the tree so if the tree changes the Client may become broken.



Bottom line: overriding implementations in subclassing was just one of those things that seemed like a good idea at the time but turned out to be a real Bad Idea.


Reality disagrees with this statement. Most of the real work is done with languages that allow exactly that (C++, Java, C#, VB, etc.). BTW, would you care to entertain us with a name of a language that, in your view, is not poorly formed?

Languages do not define OOA/D methodologies or developer discipline. Quite often they make compromises with practicality because they are 3GLs (e.g., no OOPL fully separates message and method). I suspect that designers leave in features like implementation overrides and dynamic_cast because it is easier than providing an explicit way to deal with very specific situations like external libraries and external object streams. But that doesn't mean the feature should be used routinely. (Note that you indicated above that you make your base classes abstract except for libraries, so you aren't using that feature even though it is there.)


Also note that the OOPLs still do a horrendous job of managing physical coupling even after three decades. Entire books are devoted to refactoring techniques just to solve the developer's problem of having more maintainable OOPL code. The amount of time developers have to waste on dependency management at the OOP level boggles the mind. If they haven't addressed that problem yet, it shouldn't be surprising that they haven't addressed the override problem.

Note that it took nearly thirty years before modern OOPLs recognized the attribute access problem and started to support getters/setters properly by substituting a developer-supplied getter/setter for the direct <implementation-dependent> attribute access syntax if it is supplied.

All things considered, I don't think the track record of OOPL designers has been all that good over the years. (The introduction of interface inheritance is a major exception.)

*************
There is nothing wrong with me that could
not be cured by a capful of Drano.

H. S. Lahman
hsl@xxxxxxxxxxxxxxxxx
Pathfinder Solutions  -- Put MDA to Work
http://www.pathfindermda.com
blog: http://pathfinderpeople.blogs.com/hslahman
(888)OOA-PATH



.



Relevant Pages

  • Re: Regarding IO, OO-design and Encapsulation
    ... Thus even though a client is accessing a generalized property, ... Question-types from an XML file and be able to output these to an XML ... The problem is that one needs to know the specific subclass the instance must belong to in order to create it. ... If the Client passes the Bystander instance to Service when Client invokes the Service's behavior, Client is essentially instantiating the R2 relationship temporarily. ...
    (comp.object)
  • Re: Text terminal rendering design
    ... The client wants to later change the color and provides ... If the client is doing a multiple part conversion then the client ... On separating concerns of instantiation and data acquisition: ... A SymbolFactory always produces a Symbol of the same subclass. ...
    (comp.object)
  • Re: LSP and subtype
    ... The problem arises with libraries that provide classes that can be used out-of-the box and as base classes. ... Why would the client ever care about what implementation he gets? ... client code will run just fine with *any* subclass object of Base, ... doesn't/shouldn't really tell its prey to respond to its attack. ...
    (comp.object)
  • Re: LSP and subtype
    ... whole tree_ to access it properly. ... If Sub1.foodoes so then the client will never notice a difference. ... client code will run just fine with *any* subclass object of Base, ... doesn't/shouldn't really tell its prey to respond to its attack. ...
    (comp.object)
  • Re: inheritance question
    ... Whoever originally had the client access ... aggravated when there are overrides of implementation inheritance. ... >>the availability of a response. ... > bad motive for implementing a subclass relationship... ...
    (comp.object)