The Liskov Substitution Principle Design by Contract

 < Day Day Up > 



The Liskov Substitution Principle & Design by Contract

Dr. Barbara Liskov and Dr. Bertrand Meyer are both important figures in the object-oriented software research community. The two design principles and guidelines that bear their name are the Liskov substitution principle (LSP) and Bertrand Meyer's design by contract (DbC). These closely related object-oriented design concepts are covered together in this section and can be summarized in the following statement:

Subtype objects must be behaviorally substitutable for supertype objects. Programmers must be able to reason correctly about and rely upon the behavior of subtypes using only the supertype behavior specification.

Reasoning About the Behavior of Supertypes and Subtypes

Programmers must be able to reason correctly about the behavior of abstract data types and their derived subtypes. The LSP and DbC provide both theoretical and applied foundations upon which programmers can build well- behaved class inheritance hierarchies that facilitate the object-oriented architectural reasoning process.

Relationship Between the LSP and DbC

The LSP and DbC are closely related concepts primarily because they both draw from largely the same body of research in the formulation of their theories. They each address the question of how a programmer should be able to reason about the behavior of a subtype object when it is substituted for a supertype object, they each address the role of method preconditions and postconditions in the specification of desired object behavior, and they each discuss the role of class invariants and how method postconditions should ensure invariant state conditions are preserved. They both seek to provide a mechanism for programmers to create reliable object-oriented software.

Design by contract differs from the LSP in its emphasis on the notion of contracts between supertype and subtype. The base class (supertype) is a contractor that may, at runtime, have its interface functions performed by a subcontractor (subtype). Programmers should not need any apriori knowledge of the subtype's existence when they write the code that may come to rely on the subtype's behavior. The subtype, when substituted for the supertype, should fulfill the contract promised by the supertype. In other words, the subtype object should not pull any surprises.

Another difference between the LSP and DbC is that the LSP is more notional, while DbC is more practical. By this I mean no language, as of this writing, directly supports the LSP specifically, with perhaps the exception of the type checking facilities provided by a compiler. Design by contract, on the other hand, is directly supported by the Eiffel programming language.

The Common Goal of the LSP and DbC

The LSB and DbC share a common goal. They both aim to help software developers build correct software from the start. Given this common goal I will occasionally refer to both concepts collectively as the LSP/DbC.

C++ Support for the LSP and DbC

With the exception of type checking, C++ does not provide direct language support for either the LSP or DbC. However, there are techniques you can use to enforce preconditions and postconditions, and to ensure the state of class invariants. Regardless of the level of language support for either the LSP or DbC, programmers can realize significant improvements in their overall class hierarchy designs by simply keeping the LSP and DbC in mind during the design process.

Designing with the LSP/DbC in Mind

The LSP/DbC focuses on the correct specification of supertype and subtype behavioral relationships. By keeping the LSP/DbC in mind when designing class hierarchies programmers are much less likely to create subclasses that implement behavior incompatible with that specified by the base class.

The Power and Danger of C++

C++ gives programmers the powerful object-oriented language features of redeclaration, polymorphism, and dynamic binding. By declaring pointers to base class objects programmers can substitute derived class objects at runtime and thereby implement dynamic polymorphic behavior. It is this dynamic polymorphic behavior that programmers must be able to correctly reason about. Yet, what is powerful can also be dangerous.

The same language features of redeclaration, polymorphism, and dynamic binding that provide C++ programmers with enormous power and flexibility can cause significant problems if not wielded properly.

Class Declarations Viewed as Behavior Specifications

A class declaration introduces a new abstract data type into a programmer's environment. The class declaration is, by its very nature, a behavioral specification. The behavior is specified by the set of public interface functions made available to clients, by the set of possible states an object may assume, and by the side effects resulting from method execution.

A class declaration can specify behavior only, as is the case with an abstract base class containing only pure virtual functions, or, it can both specify and implement behavior, as is the case where class functions are implemented for a particular class.

An abstract data type can adopt the behavioral specification of another abstract data type. The former would be the subtype and the latter the supertype. When the supertype is an abstract base class the subtype inherits only a behavior specification. It must then either implement the specified behavior or further defer the implementation to yet another subtype. When a supertype provides behavior implementation, a subtype may adopt the supertype behavior outright or provide an overriding behavior. It is the correct implementation of this overriding behavior about which the LSP/DbC is most concerned. Programmers can create well-behaved subtypes by employing preconditions, postconditions, and class invariants.

Preconditions, Postconditions, and Class Invariants

Preconditions, postconditions, and class invariants are the three cornerstones of both the LSP and DbC. Their definitions and application are discussed in this section.

Class Invariant

A class invariant is an assertion about an object property that must hold true for all valid states an object can assume. For example, suppose an airplane object has a speed property that can be set to a range of integer values between 0 and 800. This rule should be enforced for all valid states an airplane object can assume. All methods that can be invoked on an airplane object must ensure they do not set the speed property to less than 0 or greater than 800.

Precondition

A precondition is an assertion about some condition that must be true before a function can be expected to perform its operation correctly. For example, suppose the airplane object's speed property can be incremented by some value and there exists in the set of airplane's public interface functions one that increments the speed property anywhere from 1 to 5 depending on the value of the argument supplied to the function. For this function to perform correctly, it must check that the argument is in fact a valid increment value of 1, 2, 3, 4, or 5. If the increment value tests valid then the precondition holds true and the increment function should perform correctly.

The precondition must be true before the function is called, therefore it is the responsibility of the caller to make the precondition true, and the responsibility of the called function to enforce the truth of the precondition.

Postcondition

A postcondition is an assertion that must hold true when a function completes its operations and returns to the caller. For example, the airplane's speed increment function should ensure that the class invariant speed property being 0 <= speed <= 800 holds true when the increment function completes its operations.

An Example

Example 19.1 gives the source code for a header file named incrementer.h. An incrementer object can simply be incremented by 1, 2, 3, 4, or 5, and maintain a state value between 0 and 100.

Listing 19.1: incrementer.h

start example
  1  #ifndef INCREMENTER_H  2  #define INCREMENTER_H  3  4  class Incrementer {  5    /*********************************************  6     Class invariant: 0 <= Incrementer::val <= 100  7     *********************************************/  8    public:  9      Incrementer(int i = 0); 10      virtual ~Incrementer(); 11    /******************************************** 12       function: void increment(int i); 13       precondition: 0 < i <= 5 14      postconditoin: 0 <= Incrementer::val <= 100 15    ********************************************/ 16      virtual void increment(int i); 17     18    private: 19      int val; 20      void checkInvariant(); 21  }; 22  #endif
end example

Besides a constructor and destructor, class Incrementer contains a private integer instance attribute named val, a public virtual function named increment(), and a private function named checkInvariant().

The class invariant for Incrementer states that the val attribute can be any integer value between and including 0 and 100. The increment() function is introduced by a comment block that details the function's precondition and postcondition. Example 19.2 gives the source code for the incrementer.cpp file.

Example 19.2: incrementer.cpp

start example

click to expand

end example

In this example, the class invariant, precondition, and postcondition are enforced using the assert function found in the standard C library. Include the assert.h header file to access the assert function. The assert function will cause the program to immediately stop execution should the assertion fail.

Example 19.3 gives the code for a main() function that uses an Incrementer object.

Listing 19.3: main.cpp

start example
  1  #include <iostream>  2  using namespace std;   3  #include "incrementer.h"  4  5  int main(){  6      Incrementer* inc_ptr = new Incrementer(95);  7      inc_ptr->increment(4);  8      inc_ptr->increment(5);  9      inc_ptr->increment(3); 10      delete inc_ptr; 11      return 0; 12  }
end example

On line 6 of example 19.3 an Incrementer pointer named inc_ptr is declared and a new Incrementer object is created with its val property initialized to 95. On lines 7 through 9 the increment() method is called via inc_ptr with integer arguments that satisfy the function's precondition. The results of running example 19.3 are shown in figure 19-1.

click to expand
Figure 19-1: Results of Running Example 19.3

A programmer using the Incrementer class will know how Incrementer objects will behave by reading the class invariant, precondition, and postcondition comments in the incrementer.h file. But what will happen if a programmer calls the increment() function with an argument that fails the precondition? Example 19.4 gives a slightly revised version of the main() function originally given in example 19.3.

Listing 19.4: main.cpp

start example
  1  #include <iostream>  2  using namespace std;   3  #include "incrementer.h"  4  5  int main(){  6      Incrementer* inc_ptr = new Incrementer(95);  7      inc_ptr->increment(4);  8      inc_ptr->increment(5);  9      inc_ptr->increment(6); 10      delete inc_ptr; 11      return 0; 12  }
end example

In this example the call to the increment() function on line 9 uses an integer argument value of 6 that fails the precondition. Figure 19-2 shows the results of running example 19.4.

click to expand
Figure 19-2: Results of Running Example 19.4

Using Incrementer as a Base Class

A programmer using Incrementer objects knows from reading the class invariant, precondition, and postcondition specification for the Incrementer class and its functions how those objects can be used in a program and how they should behave. And that is all they should have to know, even when an Incrementer pointer points to an object that belongs to a class that was derived from Incrementer.

There are several issues that demand the attention of the programmer who plans to extend the functionality of Incrementer. First, they must be aware of the point of view of the client program that will use the derived object. That code expects certain behavior from Incrementer objects. For example, a client program calling the increment() function on Incrementer objects can rely on proper behavior if the arguments to the function satisfy the precondition of being greater than zero or less than or equal to five. If an object derived from Incrementer is substituted at runtime for an Incrementer object, the derived object must not break the client code by behaving in a manner not anticipated by the client program.

Second, with the expectations of the client code in mind, what rules should a programmer follow when extending the functionality of a base class to ensure the derived object continues to live up to or meet the expectations of the client code? These issues are explored further in this section.

Example 19.5 gives the code for a class named Derived that extends the functionality of Incrementer.

Listing 19.5: derived.h

start example
  1  #ifndef DERIVED_CLASS  2  #define DERIVED_CLASS  3  #include "incrementer.h"  4  5  class Derived : public Incrementer {  6    /****************************************  7     Class Invariant: 0 <= derived_val <= 50  8    ****************************************/  9    public: 10      Derived(int i = 0); 11      virtual ~Derived(); 12      /*************************************** 13             function: void increment(int i) 14         precondition: 0 < i <=5 15        postcondition: 0 <= derived_val <= 50 16      ***************************************/ 17      virtual void increment(int i); 18 19    private: 20      int derived_val; 21      void checkInvariant(); 22  }; 23  #endif
end example

The Derived class does essentially the same thing that Incrementer does with two exceptions. First, the class invariant of Derived states that the derived_val property cannot exceed 50. Derived's version of the checkInvariant() function is used to enforce this class invariant property. Next, the postcondition of Derived's increment() function is tailored to ensure that derived_val satisfies Derived's class invariant property.

The precondition of Derived's increment() function is the same precondition for Incrementer's increment() function. Example 19.6 gives the source code for the derived.cpp file.

Listing 19.6: derived.cpp

start example
  1  #include "incrementer.h"  2  #include "derived.h"  3  #include <iostream>  4  using namespace std;  5  #include <assert.h>  6  7   Derived::Derived(int i):Incrementer(i),derived_val(i){  8       cout<<"Derived object created!"<<endl;  9       checkInvariant(); 10   } 11 12   Derived::~Derived(){ 13      cout<<"Derived object destroyed!"<<endl; 14   } 15 16  void Derived::increment(int i){ 17       // enforce precondition 0 < i <= 5 18       assert((i > 0) && (i <= 5)); 19       Incrementer::increment(i); 20 21      // change incrementer object state 22      if((derived_val+i) <= 50){ 23          derived_val += i; 24       }else{ 25          int temp = derived_val; 26          temp += i; 27          derived_val = (temp - 50); 28          } 29 30      // enforce class invariant 31      checkInvariant(); 32 33      cout<<"Derived value is: "<<derived_val<<endl; 34 35  } 36 37  void Derived::checkInvariant(){ 38        assert((derived_val >= 0) && (derived_val <= 50)); 39  }
end example

On line 7 the Derived class constructor calls the Incrementer constructor and initializes its private attribute derived_val using the parameter i. On line 9 the Derived constructor then calls its version of the checkInvariant() function.

Derived's version of the increment() function begins on line 16. The precondition is enforced on line 18 followed by a call to Incrementer's version of the increment() function on line 19. If the precondition assertion passes then Increment::increment() should perform flawlessly.

Example 19.7 gives a main() function that uses both Incrementer and Derived objects.

Listing 19.7: main.cpp

start example
  1  #include <iostream>  2  using namespace std;   3  #include "incrementer.h"  4  #include "derived.h"  5  6  int main(){  7      Incrementer* inc_ptr = new Incrementer(95);  8      inc_ptr->increment(4);  9      inc_ptr->increment(5); 10      inc_ptr->increment(3); 11      delete inc_ptr; 12 13      inc_ptr = new Derived(45); 14      inc_ptr->increment(4); 15      inc_ptr->increment(5); 16      inc_ptr->increment(3); 17      delete inc_ptr; 18 19      return 0; 20  }
end example

Figure 19-3 shows the results of running example 19.7.

click to expand
Figure 19-3: Results of Running Example 19.7

Refer to examples 19.6 and 19.7 above when examining figure 19-3. The Derived object was successfully used in place of the Incrementer object. However, things could have gone horribly wrong. How? What if the precondition for the Derived version of the increment() function was different from that of the Incrementer version? The next section explores this topic.

Changing the Preconditions of Derived Class Functions

The version of the increment() function in class Derived discussed above implemented the same precondition as the Incrementer class version, namely, that the integer argument passed to the function was in the range 1 through 5. However, it is possible to specify a different precondition for the Drived class version of increment().

In regards to derived class function preconditions you can go three ways: 1) adopt the same precondition(s), as was illustrated in the previous section, 2) weaken the precondition(s), or 3) strengthen the precondition(s).

Adopting the Same Preconditions

Derived class functions can adopt the same preconditions as the base class methods they override. The increment() function in class Derived, shown in the previous section, adopted the same precondition as the Incrementer class's version of increment(). When a derived class function adopts the same preconditions as its base class counterpart its behavior is predictable from the point of view of any client program using a base class pointer to a derived class object. In other words, you can safely reason about the behavior of a derived class object whose overriding functions adopt the same preconditions as their base class counterparts.

Weakening Preconditions

Derived class functions can weaken the preconditions specified in the base class methods they override. Weakening can also be thought of as loosening or relaxing. The increment() function in class Derived could have weakened the precondition specified in the base class version of increment() by allowing a wider range of increment values to be called as arguments.

Example 19.8 gives a modified version of the Derived class declaration that weakens the preconditions on the increment function. Example 19.9 gives the modified version of the derived.cpp file and example 19.10 shows the modified Derived class object being used in a main() function.

Listing 19.8: derived.h (weakened precondition)

start example
  1  #ifndef DERIVED_CLASS  2  #define DERIVED_CLASS  3  #include "incrementer.h"  4  5  6  7  class Derived : public Incrementer {  8    /****************************************  9     Class Invariant: 0 <= derived_val <= 50 10    ****************************************/ 11    public: 12     Derived(int i = 0); 13     virtual ~Derived(); 14     /*************************************** 15            function: void increment(int i) 16        precondition: 0 < i <=10 17       postcondition: 0 <= derived_val <= 50 18     ***************************************/ 19     virtual void increment(int i); 20  private: 21    int derived_val; 22 23    void checkInvariant();   24  }; 25  #endif
end example

Notice on line 16 of example 19.8 that the precondition specification for the Derived class version of increment() has been weakened to allow a greater range of increment values. The argument to the increment function now can be anywhere from 1 to 10. The change to the derived.cpp code to implement the weakened preconditions occurs on lines 17 and 18 of example 19.9 on the following page. The main() function shown in example 19.10 below is the same version as that given in example 19.7.

Example 19.9: derived.cpp (weakened precondition)

start example

click to expand

end example

Listing 19.10: main.cpp

start example
  1  #include <iostream>  2  using namespace std;   3  #include "incrementer.h"  4  #include "derived.h"  5  6  int main(){  7      Incrementer* inc_ptr = new Incrementer(95);  8      inc_ptr->increment(4);  9      inc_ptr->increment(5); 10      inc_ptr->increment(3); 11      delete inc_ptr; 12 13      inc_ptr = new Derived(45); 14      inc_ptr->increment(4); 15      inc_ptr->increment(5); 16      inc_ptr->increment(3); 17      delete inc_ptr; 18 19      return 0; 20  }
end example

The results of running this program will be exactly the same as those shown in figure 19.3. Remember, that from the point of view of the programmer writing the main() function, the Incrementer class specification is the one that is expected to perform correctly when the program is executed. The precondition for the Incrementer version of the increment() function remains the same, namely, that the range of increment values can be 1 through 5. The Derived class's version of the increment() function can take a wider range of values and therefore the Derived class object performs as expected when substituted for an Incrementer object.

Strengthening Preconditions

So far you have seen how a Derived class object can be substituted for an Incrementer class object when the Derived class's increment() function adopts the same precondition or weakens the precondition of the Incrementer class's increment() function. When preconditions are kept the same or weakened in the overriding functions of a derived class, objects of the derived class type can be substituted for base class objects with little problem. However, if you happen to strengthen the precondition of an overriding derived class function you will break the code that relies on the original preconditions specified for the base class function.

A strengthening precondition in a derived class function places limits on or restricts the original precondition specified in the base class function it is overriding. In the case of the Incrementer and Derived classes, the preconditions on the Derived version in increment() can be strengthened to limit the range of authorized increment values to, say, 1 through 3. This would effectively break any code that relies on the Incrementer's version of the increment() function that allows the increment values 1 through 5.

So, strengthening preconditions of derived class functions is a bad thing. But, I will give you an example to drive the point home. Example 19.11 gives another version of the Derived class declaration that strengthens the increment() function's precondition to the values 1 through 3 as discussed above.

Listing 19.11: derived.h (strengthened precondition)

start example
  1  #ifndef DERIVED_CLASS  2  #define DERIVED_CLASS  3  #include "incrementer.h"  4  5  6  7  class Derived : public Incrementer {  8    /****************************************  9     Class Invariant: 0 <= derived_val <= 50 10    ****************************************/ 11    public: 12     Derived(int i = 0); 13     virtual ~Derived(); 14    /*************************************** 15           function: void increment(int i) 16       precondition: 0 < i <=3 17      postcondition: 0 <= derived_val <= 50 18    ***************************************/ 19    virtual void increment(int i); 20  private: 21    int derived_val; 22    void checkInvariant(); 23 24  }; 25  #endif
end example

Example 19.12 gives the modified derived.cpp file.

Example 19.12: derived.cpp (strengthened precondition)

start example

click to expand

end example

Example 19.13 gives the main() function, which is the same version used in example 19.10.

Listing 19.13: main.cpp

start example
  1  #include <iostream>  2  using namespace std;   3  #include "incrementer.h"  4  #include "derived.h"  5  6  int main(){  7      Incrementer* inc_ptr = new Incrementer(95);  8      inc_ptr->increment(4);  9      inc_ptr->increment(5); 10      inc_ptr->increment(3); 11      delete inc_ptr; 12 13      inc_ptr = new Derived(45); 14      inc_ptr->increment(4); 15      inc_ptr->increment(5); 16      inc_ptr->increment(3); 17      delete inc_ptr; 18 19      return 0; 20  }
end example

The difference now, however, when running this program is shown in figure 19-4.

click to expand
Figure 19-4: Results of Running Example 19.13

Quick Review

The preconditions of a derived class function should either adopt the same or weaker preconditions as the base class function it is overriding. A derived class function should never strengthen the preconditions specified in a base class version of the function. Derived class functions that strengthen base class function preconditions will render it impossible for programmers to reason about the behavior of subtype objects and lead to broken code should the ill- behaved derived class object be substituted for a base class object.

Changing the Postconditions of Derived Class Functions

Derived class function postconditions can be adopted, weakened, or strengthened just like preconditions. However, unlike preconditions, where a weakening condition is preferred to a strengthening condition, the opposite is true for postconditions: A derived class function should specify and implement a stronger, rather than weaker, postcondition.

The Incrementer and Derived class examples shown previously each had their own private attribute that was part if each class's invariant. (Incrementer::val and Derived::derived_val) Each class's increment() function had a separate postcondition to preserve each class's invariant. The two postconditions did not conflict or contradict and were therefore compatible.

If, on the other hand, Incrementer::val had been declared protected, and was inherited and used by Derived, the Derived version of the increment() function would need a postcondition that either maintained the class invariant specified by the Incrementer class (adopting postcondition) or a postcondition that strengthened Incrementer's class invariant (strengthening postcondition).

A weakening postcondition will cause problems. Consider for a moment what would happen if the Derived class version of increment() allowed inherited Incrementer::val to assume values outside the range of those allowed by Incrementer's class invariant specification. Disaster would strike the code sooner than later.

Special Cases of Preconditions and Postconditions

Function preconditions can specify and enforce more than just the values of function parameters, and postconditions can specify and enforce more than just class invariant states.

A function precondition can, for example, specify that the class invariant must hold true or that a combination of conditions hold true before it can do its job properly. A function postcondition can, in addition to enforcing the class invariant, specify the state of the object or reference the function returns (if any), or it can specify any number of conditions hold true upon completion of the function call. The conditions or combination of conditions imposed by derived class overriding function preconditions and postconditions can be weakening or strengthening.

The weakening and strengthening effects of preconditions and postconditions can apply to more than just simple conditions. Function parameter types, return types, and function access rights all play a part and are discussed below.

Function Argument Types

Derived class function preconditions can be weakened or strengthened by their function parameter types. An overriding function must agree with the function it overrides in the type, number, and order of its function parameters. (see chapter 13) Function parameter types can belong to a type hierarchy. This means that a function parameter might be related to another class via a subtype or supertype relationship.


Figure 19-5: Inheritance Hierarchy Showing Weaker and Stronger Types

A derived class function specifying a parameter that is a base class to the matching parameter declared in its base class counterpart is an overriding function. If, however, the derived class function declares a parameter that is a subclass of the parameter type declared in the base class function then the derived class function hides the base class version of the function. This is due to the transitive nature of subtypes. (i.e., Given two classes, B and D, if D is derived from B, then D is a B but a B is not a D)

In other words, an overriding function can only provide a weakening precondition with regards to parameter types because to strengthen the parameter type required would result in the declaration of a new function (requiring a new type from the point of view of the base class version of the function) not the overriding of the virtual function. To illustrate this point assume there exists the class inheritance hierarchy shown in figure 19-5.

Each function f() in each class A, B, and C, requires a reference to an object of type A. Each function overrides the previous function. If an A type pointer is created and initialized to point to a C type object, then the C version of the function will be called as expected. This is demonstrated using the main() function shown in example 19.14. The results of running the program are shown in figure 19-6.

Listing 19.14: main.cpp

start example
  1  #include <iostream>  2  using namespace std;  3  #include "a.h"  4  #include "b.h"  5  #include "c.h"  6  7  int main(){  8     A a1;  9     B b1; 10     C c1; 11 12     A* a_ptr = new C(); 13 14     a_ptr->f(a1); 15     a_ptr->f(b1); 16     a_ptr->f(c1); 17 18     delete a_ptr; 19     return 0; 20  }
end example

click to expand
Figure 19-6: Results of Running Example 19.14

Referring to example 19.14, the main() function begins on line 7. On lines 8 through 10 three objects are created, one of each type A, B, and C. An A type pointer named a_ptr is declared on line 12 and initialized to point to a C object. The f() function is then called via a_ptr using the three objects of type A, B, and C as arguments. As you can see in figure 19-6 everything runs as expected.

Now, let us change one thing. Let us change the signature of the C class version of the f() method to take an argument of type B, then rerun example 19.14. Figure 19-7 gives the results.


Figure 19-7: Results of Running Example 19.14 with Modified C Class Function

As you can see, changing C's version of the f() function to take a different type (subtype of A) results in a hiding function (not called polymorphically) vs. an overriding function (one that will be called polymorphically).

In short, an overriding function can specify a weaker (supertype) parameter than originally called for in the base class version of the function. A stronger (subtype) parameter type will result in a hiding function.

Function Return Types

Function return types are considered special cases of postconditions. An object or reference to an object may be returned from a function as a result of its execution. Refer again to the inheritance hierarchy illustrated in figure 19-5. If a snippet of client code expects a return type from a function to be of a certain type, the function can strengthen that condition and return a subtype of the type expected. This strengthening of return types is in line with the strengthening usually required of postconditions.

Function Access Rights

Function access rights are a special case of preconditions. In C++, access to an object's functions are controlled via the access specifiers public, protected, and private. The general rule-of-thumb regarding access rights is that an overriding function should be made available to the same, or wider, audience as the base class function it overrides. Thus, a relaxing of the access rules for overriding functions is a weakening precondition, which is acceptable.

However, in C++, when designing for polymorphic behavior using virtual functions, the accessibility of an overriding derived class function is of no concern to client code accessing the function via a base class pointer. To illustrate this point let us make another change to the C class version of the f() function. I will change its parameter type back to A, and its visibility to private. The code for the modified c.h file appears in example 19.15.

Listing 19.15: c.h

start example
  1  #ifndef C_H  2  #define C_H  3  #include "b.h"  4  #include <iostream>  5  using namespace std;  6  7  class C : public B {  8   public:  9     C(){cout<<"Object C created!"<<endl;} 10     ~C(){cout<<"Object C destroyed!"<<endl;} 11   private: 12     virtual void f(A& a){ /* expects reference of type A */  13        cout<<"C::f() called."<<endl;}  14  }; 15  #endif
end example

This version of class C will be used in the same version of the main() function shown in example 19.14. Because the C class version of the f() function is accessed polymorphically via an A type base class pointer, the private C::f() is called because the A::f() version is publicly accessible. Users of A::f() need no knowledge of the accessibility of C::f(). Everything works as expected. However, if access to C::f() were attempted via a C object then an error would result since C::f() is private to clients of the C interface. Figure 19-8 shows the results of running example 19.14 with the modified C class shown above.

click to expand
Figure 19-8: Results of Running Example 19.14 Using Private C::f() Overriding Function

Quick Review

Function parameter types are considered special cases of preconditions. Preconditions should be weakened in the overriding function, therefore, parameter types should be the same or weaker than the parameter types of the function being overridden. A base class is considered a weaker type than one of its subclasses.

Function return types are considered special cases of postconditions. The return type of an overriding function should be stronger than the type expected by the client code. A subclass is considered a stronger type than its base class.

Function access rights are considered special cases of preconditions. Access rights should be kept the same or weakened for an overriding function. However, polymorphic behavior using base class pointers to derived class objects renders this a mute point in the C++ language. The accessibility of an overriding function may change in a derived class, but, from the point of view of client code using the interface published by a base class pointer type, the accessibility of the base class function is what counts.

Three Rules of the Substitution Principle

In their book Program Development in Java: Abstraction, Specification, and Object-Oriented Design, Barbara Liskov and John Guttag say that the substitution principle must support three properties: the signature rule, the methods rule, and the properties rule. Each of these rules are discussed below.

Signature Rule

The signature rule deals with the functions published or made public by a type specification. In C++ these functions would have public accessibility. For a subtype to obey the signature rule it must support all the functions published by its base class and that each overriding function is compatible with the function it overrides. C++ enforces this type compatibility, although, as was shown in the previous section, the accessibility of derived class overriding functions (public, protected, and private) can be effectively ignored when accessing derived class functions via a base class pointer.

Methods Rule

The methods rule says that calls to overriding functions should behave like the base class functions they override. A type may be substitutable from a strictly type perspective but the behavior may be all wrong. Correct behavior of overriding functions is the aim of LSP and DbC.

Properties Rule

The properties rule is concerned with the preservation of provable base class properties by subtype behavior. A subtype should preserve the base class invariant. If a subtype's behavior violates a base class invariant then it is breaking the properties rule.



 < Day Day Up > 



C++ for Artists. The Art, Philosophy, and Science of Object-Oriented Programming
C++ For Artists: The Art, Philosophy, And Science Of Object-Oriented Programming
ISBN: 1932504028
EAN: 2147483647
Year: 2003
Pages: 340
Authors: Rick Miller

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net