Author : Ian Joyner
Page : << Previous 3 Next >>
it the
decision of the designer of the parent or descendant class? Cases can be
made for both. They are not mutually exclusive and can be catered for
quite easily in an object-oriented language.
There are three options, corresponding to 'must not', 'can', and
'must' be redefined:
1) The redefinition of a routine is prohibited; descendant classes
must use the routine as is.
2) A routine could be redefined. Descendant classes can use the
routine as provided, or provide their own implementation as long as it
conforms to the original interface definition and accomplishes at least
as much.
3) A routine is abstract. No implementation is provided and each
non-abstract descendent class must provide its own implementation. This
is polymorphism.
The base class designer must decide options 1 and 3. Descendant
class designers must decide option 2. A language should provide direct
syntax for these options.
Option 1
C++ does not cater for the first option. Not using a virtual
function is the closest. But in that case the routine can be completely
replaced. This causes two problems. Firstly, a routine can be
unintentionally replaced in a descendent. The compiler should report a
syntax error due to 'duplicate declaration'. This is logical as
descendant classes are part of the same name space as classes they
inherit from. The redeclaration of a name within the same scope should
cause a name clash. Allowing two entities to have the same name within
one scope causes ambiguity and other problems. (See the section on name
overloading.)
The following example illustrates the second problem:
class A
{
public:
void nonvirt ();
virtual void virt ();
}
class B : public A
{
public:
void nonvirt ();
void virt ();
}
A a;
B b;
A *ap = &b;
B *bp = &b;
bp->nonvirt (); // calls B::nonvirt as you would expect
ap->nonvirt (); // calls A::nonvirt, even though this object is
// of type B.
ap->virt (); // calls B::virt, the correct version of the
// routine for B objects.
In this example, class B has extended or replaced routines in class
A. B::nonvirt is the routine that should be called for objects of type
B. It could be pointed out that C++ gives the client programmer
flexibility to call either A::nonvirt or B::nonvirt. But this can be
provided in a simpler more direct way. A::nonvirt and B::nonvirt should
be given different names. That way the programmer calls the correct
routine explicitly, not by an obscure and error prone trick of the
language, as follows:
class B : public A
{
public:
void b_nonvirt ();
void virt ();
}
B b;
B *bp = &b;
bp->nonvirt (); // calls A::nonvirt
bp->b_nonvirt (); // calls B::b_nonvirt
Now the designer of class B has direct control over B's interface.
The application requires that clients of B can call both A::nonvirt, and
B::b_nonvirt. B's designer has explicitly provided for this. This is
good object-oriented design, which provides strongly defined interfaces.
C++ allows client programmers to play tricks with the class interfaces,
external to the class, and B's designer cannot prevent A::nonvirt from
being called. This is opposite to good modular design. This shows the
unsafeness C++'s virtual mechanism. Objects of class B have their own
specialised 'nonvirt'. But B's designer does not have control over B's
interface to ensure that the correct version of nonvirt is called.
C++ also does not protect class B from other changes in the system.
Suppose we need to write a class C that needs 'nonvirt' to be virtual.
Then 'nonvirt' in A will be changed to virtual. But this breaks the
B::nonvirt trick. The requirement of class C to have a virtual routine
forces a change in the base class. This has an effect on all other
descendants of the base class, instead of the specific new requirement
being localised to the new class. This is opposite to the reason for
OOP having loosely coupled classes, so that new requirements, and
modifications will have localised effects, and not require changes
elsewhere which can potentially break other existing parts of the
system.
Rumbaugh et al, put their criticism of C++'s virtual as follows: "C++
contains facilities for inheritance and run-time method resolution, but
a C++ data structure is not automatically object-oriented. Method
resolution and the ability to override an operation in a subclass are
only available if the operation is declared virtual in the superclass.
Thus, the need to override a method must be anticipated and written into
the origin class definition. Unfortunately, the writer of a class may
not expect the need to define specialized subclasses or may not know
what operations will have to be redefined by a subclass. This means
that the superclass often must be modified when a subclass is defined
and places a serious restriction on the ability to reuse library classes
by creating subclasses, especially if the source code library is not
available. (Of course, you could declare all operations as virtual, at
a slight cost in memory and function-calling overhead.)" [RBPEL91]
A further argument is that any statement should consistently have the
same semantics. The object-oriented interpretation of a statement like
a->f () is that the most suitable implementation of f() is invoked for
the object referred to by 'a', whether the object is of type A, or a
descendent of A. In C++, however, the programmer must know whether the
function f() is defined virtual or non-virtual in order to interpret
exactly what a->f () means. Therefore, the statement a->f () is not
implementation independent. A change in the declaration of f () will
change the semantics of the invocation. Implementation independence
means that a change in the implementation DOES NOT change the semantics,
of executable statements.
If a change in the declaration changes the semantics, this should
generate a compiler detected error. The programmer should make the
statement semantically consistent with the changed declaration. This
reflects the dynamic nature of software development, where the program
text is subject to perpetual change.
For yet another case of the inconsistent semantics of the statement
a->f () vs constructors, consult section 10.9c, p 232 of the C++ ARM.
[Sakkinen 92] points out that a descendant class can redefine a private
virtual function even though it cannot access that function in other
ways. When the ancestor class calls the function it instead invokes the
function in the descendant class.
Option 2
The second option should be left open for the programmers of
descendant classes. In C++, however, the decision must be made in the
base class. In object-oriented design, the decisions you decide not to
make are as important as the decisions you make. Decisions should be
made as late as possible. This strategy prevents mistakes being built
into the system at early stages. By making early decisions, you are
often stuck with assumptions that later prove to be incorrect. C++
requires the parent class to specify potential polymorphism by virtual
(although an intermediate class in the inheritance chain can introduce
virtual). This prejudges that a routine might be redefined in
descendants. This can be a problem because routines that aren't
actually polymorphic are accessed via the slightly less efficient
virtual table technique instead of a straight procedure call. (This is
never a large overhead but object-oriented programs tend to use more and
smaller routines making routine invocation a more significant overhead.)
The policy in C++ should be that routines that might be redefined should
be declared virtual.
Virtual, however, is the wrong mechanism for the programmer to deal
with. A compilation system can detect polymorphism, and generate the
underlying virtual code, where and only where necessary. Having to
specify virtual burdens the programmer with another bookkeeping task.
This is the main reason why C++ is a weak object-oriented language as
the programmer must constantly be concerned with low level details. The
compiler should take care of such detail and so relieve the programmer.
Another problem in C++ is mistaken redefinition. The base class
routine can be redefined unwittingly. The compiler should report an
erroneous name redefinition within the same name space unless the
descendant class programmer specifies that the routine redefinition is
really intended. The same name can be used, but the programmer must be
conscious of this, and state this explicitly, especially in
Page : << Previous 3 Next >>