I l@ve RuBoard Previous Section Next Section

5.4 Defining an Abstract Base Class

In this section we redesign the num_sequence class of the preceding section into an abstract base class from which we inherit each of the numeric sequence classes. How do we go about that?

The first step in defining an abstract base class is to identify the set of operations common to its children. For example, what are the operations common to all numeric sequence classes? These operations represent the public interface of the num_sequence base class. Here is a first iteration:



class num_sequence { 


public: 


    // elem( pos ): return element at pos 


    // gen_elems( pos ): generate the elements up to pos 


    // what_am_i() : identify the actual sequence 


    // print( os ) : write the elements to os 


    //check_integrity( pos ) : is pos a valid value? 


    // max_elems() : returns maximum position supported 


    int         elem( int pos ); 


    void        gen_elems( int pos ); 


    const char* what_am_i() const; 


    ostream&    print( ostream &os = cout ) const; 


    bool        check_integrity( int pos ); 


    static int  max_elems(); 


    // ... 


}; 

elem() returns the element at the user-requested position. max_elems() returns the maximum number of elements supported by our implementation. check_integrity() determines whether pos is a valid position. print() displays the elements. gen_elems() generates the elements for the sequence. what_am_i() returns a character string identifying the sequence.

The next step in the design of an abstract base class is to identify which operations are type-dependent ?that is, which operations require separate implementations based on the derived class type. These operations become the virtual functions of the class hierarchy. For example, each numeric sequence class must provide a separate implementation of gen_elems(). check_integrity(), on the other hand, is type-invariant. It must determine whether pos is a valid element position. Its algorithm is independent of the numeric sequence. Similarly, max_elems() is type-invariant. All the numeric sequences hold the same maximum number of elements.

Not every function is this easy to distinguish. what_am_i() may or may not be type-dependent depending on how we choose to implement our inheritance hierarchy. The same is true of elem() and print(). For now, we'll presume that they are type-dependent. Later, we'll see an alternative design that turns them into type-invariant functions. A static member function cannot be declared as virtual.

The third step in designing an abstract base class is to identify the access level of each operation. If the operation is to be available to the general program, we declare it as public. For example, elem(), max_elems(), and what_am_i() are public operations.

If the operation is not meant to be invoked outside the base class, we declare it as private. A private member of the base class cannot be accessed by the classes that inherit from the base class. In this example, all the operations must be available to the inheriting classes, so we do not declare any of them as private.

IA third access level, protected, identifies operations that are available to the inheriting classes but not to the general program. check_integrity() and gen_elems(), for example, are operations that the inheriting classes, but not the general program, must invoke. Here is our revised num_sequence class definition:



class num_sequence { 


public: 


    virtual ~num_sequence(){}; 





    virtual int         elem( int pos ) const = 0; 


    virtual const char* what_am_i() const = 0; 


    static  int         max_elems(){ return _max_elems; } 


    virtual ostream&    print( ostream &os = cout ) const = 0; 





protected: 


     virtual void        gen_elems( int pos ) const = 0; 


     bool                check_integrity( int pos ) const; 





     const static int    _max_elems = 1024; 


}; 

Each virtual function either must be defined for the class that declares it or, if there is no meaningful implementation of that function for that class (such as gen_elems()), must be declared as a pure virtual function. The assignment of 0 indicates that the virtual function is pure:



virtual void gen_elems( int pos ) = 0; 

Because its interface is incomplete, a class that declares one or more pure virtual functions cannot have independent class objects defined in the program. It can serve only as the subobject of a derived class, which, in effect, completes it by providing concrete implementations for each of its pure virtual functions.

What data, if any, should the num_sequence class declare? There is no hard and fast rule. In this class design, num_sequence does not declare any class data members. This design provides an interface for the numeric sequence hierarchy but defers the implementation to its derived classes.

What about constructors and a destructor? Because there are no nonstatic data members within the class to initialize, there is no real benefit to providing a constructor. We will, however, provide a destructor. As a general rule, a base class that defines one or more virtual functions should always define a virtual destructor. For example,



class num_sequence { 


public: 


    virtual ~num_sequence(); 


    // ... 


}; 

Why? Consider the following code sequence:



num_sequence *ps = new Fibonacci( 12 ); 


// ... use the sequence 


delete ps; 

ps is a num_sequence base class pointer, but it addresses a Fibonacci-derived class object. When the delete expression is applied to a pointer to a class object, the destructor is first applied to the object addressed by the pointer; then the memory associated with the class object is returned to the program's free store. A nonvirtual function is resolved at compile-time based on the type of the object through which it is invoked.

In this case, the destructor invoked through ps must be the Fibonacci class destructor and not the destructor of the num_sequence class. That is, which destructor to invoke must be resolved at run-time based on the object actually addressed by the base class pointer. To have this occur, we must declare the destructor virtual.

However, I don't recommend having the destructor declared as a pure virtual function in the base class ?even if there is no meaningful implementation. For the destructor, it is better to provide an empty definition, such as the following: [2]

[2] See the introduction to Chapter 5 of [LIPPMAN96a] for the explanation of why a virtual destructor is best not declared as a pure virtual function.



inline num_sequence::~num_sequence(){} 

For completeness, here are the implementations of the num_sequence instance of the output operator and of check_integrity():



bool num_sequence:: 


check_integrity( int pos ) const 


{ 


   if ( pos <= 0 || pos > _max_elems ) 


   { 


        cerr << "!! invalid position: " << pos 


             << " Cannot honor request\n"; 


        return false; 


   } 





   return true; 


} 





ostream& operator<<( ostream &os, const num_sequence &ns ) 


      {  return ns.print( os ); } 

Although this completes the definition of the abstract num_sequence base class, the class itself is incomplete. It provides an interface for the subsequently derived classes. IEach derived class provides the implementation that completes the num_sequence base class definition.

    I l@ve RuBoard Previous Section Next Section