Re: LSP and subtype



H. S. Lahman wrote:
Um, why is the "happiness" of some *particular* client code
important? As long as all foo overrides respect the invariants, pre-
&
postconditions that Base documents/relies on then *any* imaginable
client code will run just fine with *any* subclass object of Base,
won't it? IOW, the client just needs to be happy with the documented
behavior (again invariants, pre- & postconditions) of Base and never
needs to know that there are in fact subclasses and overrides.

The relationship between the Client and the member of the tree needs to be defined for each client. When evaluating the collaboration the developer must determine where in the tree the Client will access objects. That decision must be made on a client-by-client basis.

For the tree above it is entirely possible that for a given class of
Client objects the only behavior that is acceptable is Sub2.foo().  If
that is the case the developer must establish a relationship between
the Client class and only the Sub1 class.  That is the only way ot
capture the constraint on the collaboration that the Base.foo()
behavior is not acceptable to the Client.

Do you have a real-world example for such a case?

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.


Same disagreement as above, I think you can leave client access
contexts out of the discussion.

If Base.foo() and Sub1.foo() are acceptable to the existing clients one still has to make sure that Sub3.foo() is also acceptable. This is a really basic maintenance issue anytime one updates a subclassing tree. If there are overrides it is possible to break the existing clients when an instance of the new subclass is presented to them. To put it another way, validating the clients' access is the only /practical/ way to ensure one is not creating an LSP problem when adding the new behavior to the tree.

That is contrary to my experience. With the usually flat inheritance trees I design and use I very rarely run into an LSP problem anyway. In the few cases when I did in fact run into such a problem it always turned out to be either:

1. a doc problem in the base class
2. The method granularity wasn't right (one method tried to do too much
or too little), i.e. the base class interface wasn't thought out well to
begin with
3. a violation of the LSP in the newly added derived class

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


This just shows that run() was wrong to begin with. I'm suspicious
whether even respondToAttack is right, because technically a predator
doesn't/shouldn't really tell its prey to respond to its attack. I
think the attack should more adequately be seen as an event to which
Prey subclasses will react. That is, in a real-world design there's
usually one more level of indirection. But I guess that's what
you're hinting at below anyway.

run() is only wrong when one does maintenance and adds new subclasses; in the first situation it was Just Fine.

I don't think so. If I really ever had to develop a game with predators and prey I'm sure I personally would never have thought that run() is a good interface (at least not after going through the minimal experience you get during CS studies).

To ensure that the Base
postconditions will /always/ be valid for LSP one would have to be
prescient about every possible requirements change that might lead to
providing new behaviors.

Well, it totally depends on how much the requirements change. If they really change so much that you can't add a new subclass without breaking the existing Prey interface then yes there's no way around checking every client.

Ok, I guess I'm starting to see why you keep saying that one needs to
check existing clients when one adds new subclasses. I agree that you
need to do that in the run --> respondToAttack scenario, because you
have effectively changed the behavior/interface of Prey. There's no
way around that.

I believe that is generally true in OO subclassing because we do routinely provide different behaviors. Base behaviors like "sort" where one can truly just substitute different implementations without affecting the semantics tend to be quite rare. [In fact, I have a hard time thinking of a practical example where such a pure implementation substitution isn't a response to just nonfunctional requirements at the OOD/P level.

What about Streams? The location where a byte-stream goes or comes from is certainly a functional requirement.

[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.


I don't think that the problems evaporate just because you define
messages instead of methods. Even in a message scenario "run" is
simply the wrong message. Sending an attackImminent message would
much better convey what is happening. In a more complex scenario one
would probably more adequately send appropriate sound & vision
events, which Prey subclass objects use to establish that an attack
is imminent. As mentioned above, Predator should probably not
interact with Prey directly.
I also think that this has only little to do with procedural vs.
message-oriented. In a procedural language there's "only" more work
to do. I.e. you have to implement a message-oriented system yourself
or use a library.

I think they evaporate because if one truly separates message and response, then the message is just an announcement of something the sender did. That means the sender implementation does not to know about the response, much less depend in any way on what the responder does, so LSP becomes academic.

Right, but as I said, you'd never think of sending a "run" message in such an environment. So "message-basedness" alone doesn't do the job, the problems evaporate because the prey interface was improved. You no longer impratively say "run". If you had chosen respondToAttack() as Prey interface right in the beginning then there wouldn't have been as many problems with adding additional subclasses.

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


Right, but that's just an artifact of the rather special relationship
Predator <--> Prey. Procedural interaction is usually just fine
between everyday objects like FileStream and Whatever, because
Whatever can (and must be able to) rely on the documented behavior
of FileStream. Predator and Prey are much more loosely coupled.

I could argue that FileStream is a computing space artifact and it is unlikely to change significantly because it is really mathematically defined. Customer problems spaces aren't though, which is what makes constructing software interesting. That is, the OO paradigm is focused on properly abstracting customer problem spaces in a manner that is maintainable in the face of volatile requirements. One technique for doing that is implementation independence, which is what drives the conceptual separation of message and method. One only gets into trouble at the OOP level where they aren't separated.

You keep saying that. You only get into trouble when you don't see that the problem at hand requires messages-based interaction instead of method-based interaction. An architect not seeing that is a bad architect. Surely, a OOPL makes it easy for newbies to fall into this trap but an experienced architect won't.

Then it becomes must easier to make caller implementations dependent
on callee responses because of expectations arising from encoding
procedural-style Do This calls.

Which are just fine most of the time. It is only for the rather special and rare Predator <--> Prey style relationships where you need and benefit from message-based interaction. Message-based interaction has problems of its own you only want to deal with when you must.

                  [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.


Right, that fixes the problem by effectively not calling the buggy
code. I'm not sure whether that's very helpful in practice because
someone somewhere implemented Sub3 for a reason.

The OOP implementation can then ensure the relationship

But it shouldn't be "buggy" code if that is the way the problem space taxonomy works. The fact is that different subsets of Base objects have three different behaviors (four if Base is not abstract) and not all of those behaviors may be appropriate for particular clients. Going back to the Prey example, consider:

             [Prey]
                A
                |
      +---------+---------------+
      |                         |
   [Bird]                   [Antelope]
   + takeflight()           + run()
                                A
                                |
                      +---------+-------+---...
                      |                 |
                  [Gazelle]         [Impala]

Now let's say I have two flavors of [Predator], [Hawk] and [Lion] and
assume lions can't catch birds that take flight.  If the goal of the
collaborations is for the predator to satisfy its hunger, then hawks
are not going to attack antelopes and lions are not going to attack
birds. (It's just an example so let's not pick on the exceptions.) In
that case the behavior of takeFlight for [Lion] and run for [Hawk]
are inappropriate and could lead to LSP problems.  So neither predator
should have a direct relationship with [Prey]; instead their
relationships should be with [Bird] or [Antelope].

This is a totally different situation than the original one. Please have a look at the quoted context to refresh your memory. We started with a hierarchy with *one* method "foo", where you claimed that some clients calling foo() might only be "happy" with the implementations of Sub1 and Sub2 but not with the one of Sub3. You then went on that rearranging the tree and having the client reference SubA fixes the problem. I said it doesn't because the Sub3.foo() implementation is buggy (violates the LSP). Now you present a totally unrelated example with *two* different methods to back up your original claim. I therefore maintain my position that there must be *some* bug in the software when you have the original hierarchy:

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

and a client is only happy with Sub1.foo() and Sub2.foo() but not with
Sub3.foo(). Note that the crucial point is that we're only talking about
1 method that is overridden in subclasses.

Bottom line: the fact that certain clients should not access certain
members of the taxonomy does not invalidate the taxonomy.

That is true in general but not in the context of the original example. Again: We've only talked about one method.

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.


Agreed, but let's be clear that this is not because something is
wrong with subclassing non-abstract classes or overriding methods
per se.

I never said there was anything wrong with subclassing; it is one of

Note that I said "subclassing *non-abstract* classes", doesn't that imply implementation inheritance?

the most important tools in the OO arsenal.  What I do contend is that
implementation inheritance in general and implementation overrides in
particular are not good OO practice (in most situations).  That's
because they open the door to maintainability problems later.

So what's a language that's not poorly formed?

I must have missed that answer...

But OOPLs are crippled by being 3GLS and are
necessarily bound closely to hardware computational models.

Oh, are they? How is the Java language bound to a hardware computational model? Or would you say Java is 4GL?

--
Andreas Huber

When replying by private email, please remove the words spam and trap
from the address shown in the header.

.


Quantcast