The ABCs Of OOP - Part 3

by Dave Harms

Published 1999-06-28    Printer-friendly version

Read Part 2

Virtual methods are one of the most useful and powerful features of object-oriented programming. I don't think it's understating the case to say that understanding virtual methods opens up whole new vistas of software development. In fact, without virtual methods, the ABC templates and class library simply couldn't exist, at least not in their present form.

I've had several opportunities to present object-oriented programming basics in articles and in seminars, and I remain convinced that while OOP concepts often look intimidating, they're really not that difficult to grasp. If anything, it's the appearance of difficulty that is the real difficulty. But if there is an aspect of OOP that can be a bit tricky to get, it's virtual methods.

In this installment I'll build on the information I presented in the previous articles in this series. As you'll recall, the first article  described some basic OOP concepts, and the second article  elaborated on inheritance and encapsulation.

The DebugClass Example

Both of those articles discuss a small example class which can be used to store debugging messages in a log. Listing 1 shows the declaration for the class, and Listing 2 shows the implementation.

Listing 1. The DebugClass declaration.
TraceQueue     QUEUE,TYPE
Text              STRING(200)
               END


DebugClass     CLASS,TYPE,MODULE('DEBUG.CLW')
NextLineToWrite   long(1)
TraceQ            &TraceQueue
Construct         PROCEDURE
Destruct          PROCEDURE
ShowTrace         PROCEDURE
Trace             PROCEDURE(STRING Text)
WriteTrace        PROCEDURE
               END
Listing 2. The DebugClass implementation.
   MEMBER

   MAP
   END

   INCLUDE('DEBUG.INC')

TraceLog          FILE,DRIVER('ASCII'),NAME('TRACE.LOG')cr.gif (850 bytes)
    ,CREATE,PRE(TRACE)
Record               RECORD,PRE()
Text                     STRING(1000)
                     END
                  END

DebugClass.Construct         PROCEDURE
   CODE
   SELF.TraceQ &= NEW(TraceQueue)


DebugClass.Destruct          PROCEDURE
   CODE
   FREE(SELF.TraceQ)
   DISPOSE(SELF.TraceQ)

DebugClass.ShowTrace         PROCEDURE

window WINDOW('Debug Messages'),AT(,,331,206),|
     FONT('MS Sans Serif',8,,,CHARSET:ANSI),|
     SYSTEM,GRAY,DOUBLE
       LIST,AT(5,5,320,180),USE(?List1),HVSCROLL,|
     FONT('Courier New',8,,FONT:regular,CHARSET:ANSI)|
     ,FROM(self.TraceQ)
       BUTTON('Close'),AT(150,190,,14),USE(?Close)
     END

   CODE
   OPEN(WINDOW)
   ACCEPT
      IF FIELD() = ?Close AND EVENT() = EVENT:Accepted
         BREAK
      END
   END

DebugClass.Trace             PROCEDURE(STRING Text)
   CODE
   SELF.TraceQ.Text = Text
   ADD(SELF.TraceQ)
   IF RECORDS(SELF.TraceQ) % 10 = 0 THEN SELF.WriteTrace().

DebugClass.WriteTrace        PROCEDURE
X                 LONG
   CODE
   OPEN(TraceLog)
   IF ERRORCODE()
      CREATE(TraceLog)
      OPEN(TraceLog)
      IF ERRORCODE()
        MESSAGE('Unable to open error log: ' & ERROR())
        RETURN
      END
   END
   LOOP X = SELF.NextLineToWrite TO RECORDS(SELF.TraceQ)
      GET(SELF.TraceQ,X)
      TraceLog.Text = self.TraceQ.Text
      ADD(TraceLog)
   END
   SELF.NextLineToWrite = RECORDS(self.TraceQ) + 1
   CLOSE(TraceLog)

You will notice at least one difference between the class discussed in the previous article and the one shown in Listings 1 and 2. This version of the class has a WriteTrace method which figures heavily in this discussion of virtual methods.

The WriteTrace Method

The WriteTrace method isn't a virtual method - it's just a normal method like the others. The purpose of WriteTrace is to write the debug messages out to a text file. You could call this method periodically yourself (as when exiting a procedure) but it's much easier to have the debug class handle the call automatically.

The Trace method (see Listing 3) accomplishes this by calling the WriteTrace method on every tenth trace call. The modulus (%) operator simply tests for the remainder of division, in this case by 10. This effectively creates a cache of up to ten records, which is a much more efficient approach than opening the file, writing out one record, and closing the file again. (You could use a property in place of "10" and have an adjustable cache size, if you wished.)

Listing 3. The Trace method calls WriteTrace.
DebugClass.Trace             PROCEDURE(STRING Text)
   CODE
   SELF.TraceQ.Text = Text
   ADD(SELF.TraceQ)
   IF RECORDS(SELF.TraceQ) % 10 = 0 THEN SELF.WriteTrace().

Accept for the moment that you've decided you'd like DebugClass to store its log of information in one of your application's data files rather than in a text file. Remembering the principle that it's usually better to derive a new class than modify an existing class, you decide to create a derived class that will work with your application. Can you simply rewrite the WriteTrace method so it uses your application's data file?

You can try, but the compiler won't be impressed. As you can tell by the empty MEMBER statement in Listing 2, this class is generic, which means it can be compiled with any application. Now in Clarion 2.1, modules with empty MEMBER statements automatically gained access to the global data of the application in which they were compiled. That's not the case any more. Modules can now only "see" declarations to which they're given explicit access, either by a member statement that points to an application source file, or by use of INCLUDE statements.

DebugClass doesn't have any knowledge of your application's data, and that's the way you want it, because this class needs to work with any application. If you use an application-specific INCLUDE or MEMBER statement that points to an application, you've effectively prevented yourself from using that class in any other application.

Two-Tiered Development

The answer to the problem is to use a two-tiered development strategy, which is what ABC does. In the top tier are generic classes with empty MEMBER statements. In the bottom tier are the application-specific classes. All the common code (or as much of it as possible) goes in the top tier, thereby maximizing code reuse. Figure 1 shows the two tiers.

Figure 1. A two-tiered class design.

abc_fig1.gif (5124 bytes)

This two-tiered approach is an ideal way to solve the problem of making DebugClass work with a specific application's data (and code).

To test this, create a TPS file in the application's data dictionary (or follow along with the example application). This file, called Trace, only needs one field (Text STRING(1000)) and doesn't need any keys, although if you wanted to jazz it up you could add the date and time the trace record was added.

In the application, create a main menu item to call a browse procedure which will display the trace log records. Again, you don't have to specify a key, since if no key exists the browse will display the records in the order they were added. You don't need to bother with an update procedure for this browse since all the records will be created by the derived debug class. Use the Browse wizard to create a procedure to browse the Trace TPS file.

The Derived Debug Class

When I create generic classes I usually put them in my LIBSRC directory along with all of the ABC classes. When I create an application-specific class I normally put it in the application directory, as I won't be wanting to use it with any other apps.

The derived AppDebugClass declaration is shown in Listing 4. There are several differences between the way this class is declared and the way its parent class (DebugClass) is declared.

Listing 4. The derived AppDebugClass declaration (AppDebug.INC).
OMIT('_EndOfInclude_',_APPDEBUGPRESENT_)
_APPDEBUGPRESENT_   EQUATE(1)


   INCLUDE('DEBUG.INC')

AppDebugClass        CLASS(DebugClass),TYPE,cr.gif (850 bytes)
    MODULE('appdebug.clw'),LINK('appdebug.clw')
WriteTrace              PROCEDURE
                     END
 _EndOfInclude_

As with last article's DebugTimedClass AppDebugClass has a CLASS(DebugClass) attribute which tells the compiler which class to use it comes across methods and properties which aren't declared within AppDebugClass. The INCLUDE statement points to the source file which contains the parent class's declaration.

The LINK attribute tells the compiler which source file contains the class implementation. If you have a LINK attribute on the class you don't need to explicitly add the source file to the project.

NOTE: Although it's usually best to use the LINK attribute, you may on occasion wish to add the source to the project manually, as this lets you set compiler pragmas on just that file. You will also need the DLL attribute if using classes exported from a DLL. See the ABC class declarations for examples.

Another curious aspect of this include file is the use of an OMIT statement. The OMIT prevents the declaration from being referenced by the compiler more than once. This is a way of preventing duplicate symbol warnings which can happen if you have a number of nested includes. The first time the compiler references this code the AppDebugPresent flag is false, by default, and immediately after the OMIT it's set to true, so on all subsequent passes the compiler ignores the OMITted code.

There is only one method declared in AppDebugClass, and that is WriteTrace. The implementation, shown in Listing 5, replaces the original WriteTrace method with code that puts the trace statements in the application's Trace file.

Listing 5. The AppDebugClass implementation (APPDEBUG.CLW).
MEMBER('TEST.CLW')

   MAP
   END


AppDebugClass.WriteTrace              PROCEDURE
X        LONG
   CODE
   ACCESS:Trace.OPEN()
   ACCESS:Trace.UseFile()
   LOOP X = SELF.NextLineToWrite TO RECORDS(self.TraceQ)
      GET(SELF.TraceQ,x)
      TRA:Text = SELF.TraceQ.Text
      ACCESS:Trace.Insert()
   END
   SELF.NextLineToWrite = RECORDS(self.TraceQ) + 1
   ACCESS:Trace.CLOSE()

At the top of the source listing is the MEMBER('TEST.CLW') statement which, following the two-tier model, ties this class to the application's data and allows the use of all global data, classes, and procedures. If you compare this code with the base class code, you'll see that one of the benefits of using ABC is a lot of tedious code is taken care of in the libraries. Instead of the original's somewhat hacked attempt to make sure that the trace file can be opened, you have a single call to ACCESS:Trace.Open which takes care of all of the error checking. Similarly ACCESS:Trace.Insert checks for any problems with the add. Other than this, the replacement method simply takes the same approach as the original but adds the records to the application's TPS Trace file rather than to the ASCII trace file.

To implement the class put the following in a global embed point, such as After Global Includes:

   INCLUDE('APPDEBUG.INC')
db AppDebugClass

If you've been following along with the previous articles you'll be working with an application that already has a db object, but which is declared as an instance of DebugClass not AppDebugClass. You will also have an INCLUDE statement that points to 'DEBUG.INC' rather than 'APPDEBUG.INC'. Because AppDebugClass is derived from DebugClass, you can leave all of the calls to the db object's methods as they were, and simply make db an instance of the derived class. Derived classes don't lose any data or methods, although they may change the implementation of some of those methods.

Testing AppDebugClass

The apps from the previous articles, and the example application for this article demonstrate a simple use of the debug classes. The each have the code:

db.Trace('Event ' & EVENT())

in the TakeEvent method of the Names browse object in the BrowseNames procedure. If you call BrowseNames, the Trace method records all of the events that the browse receives.

This application uses AppDebugClass which has a replacement WriteTrace method which writes messages to the TPS Trace file. So what happens if you run the application using AppDebugClass?

Well, not what you might expect. If you call the BrowseNames procedure, it turns out that AppDebugClass functions exactly the same way as DebugClass. The trace messages are still logged to the ASCII file, and the TPS Trace file remains empty.

What went wrong?

As Figure 2 shows, only the WriteTrace method is declared in both classes. The actual object or instance of the class which is used in your application contains both methods, and there are rules which govern which of the two methods will be called.

Figure 2. The DebugClass/AppDebugClass class diagram.

abc_fig2.gif (3835 bytes)

The problem is that although the object is an instance of AppDebugClass, in the example application code the WriteTrace method is never directly called by the application, as is the Trace method. WriteTrace is always called by the Trace method itself, which is declared only in DebugClass.

To understand what's happening you need to be clear on several concepts. One is that the db object is a combination of AppDebugClass and DebugClass. The other is that within that object are (in this case) two levels at which code exists: the upper (parent) and lower (child) levels.

When your code calls the Trace method it's making a call to the upper parent part of the object. The rule is that by default, an upper level cannot call down to a lower level. This makes sense, since the upper part (DebugClass) doesn't have an INCLUDE statement that points to the lower level (AppDebugClass), although the reverse is true. Another way to say this is that the child class always knows about the parent class, but the parent doesn't normally know about the child.

If you call a lower level method, that method can call a higher level method, implicitly (if the method doesn't exist at the lower level) or explicitly (by using the PARENT keyword). But under normal circumstances a higher level method can never call a lower level method. So once control passes to the higher level, that's where it's going to stay.

It would be possible to get around this problem by copying the Trace method to AppDebugClass since that would bring it down to the same level as the replacement WriteTrace method, but that would defeat the whole purpose of reusing code. What you really want is a way to tell the upper part of the object that you've created a replacement for one of its methods, and you want it to call your replacement instead of its own method.

Thankfully there is a way to do this, and it's called the Virtual Method.

Virtual Methods

Virtual methods are ridiculously easy to implement. Simply add the VIRTUAL attribute to the WriteTrace method in both classes, as shown in Listings 6 and 7.

Listing 6. DebugClass with the VIRTUAL attribute on WriteTrace.
DebugClass     CLASS,TYPE,MODULE('DEBUG.CLW')
NextLineToWrite   long(1)
TraceQ            &TraceQueue
Construct         PROCEDURE
Destruct          PROCEDURE
ShowTrace         PROCEDURE
Trace             PROCEDURE(STRING Text)
WriteTrace        PROCEDURE,VIRTUAL
               END
Listing 7. AppDebugClass with the VIRTUAL attribute on WriteTrace.
AppDebugClass        CLASS(DebugClass),TYPE,cr.gif (850 bytes)
    MODULE('APPDEBUG.CLW'),LINK('APPDEBUG.CLW')
WriteTrace              PROCEDURE,VIRTUAL
                     END

The VIRTUAL attribute reverses the natural direction in which the program, at runtime, looks for methods. By default, methods are always checked for on the current level. If they can't be found, the program looks in the parent, and then that class's parent if present, and so on up until it finds a method.

Virtual methods work the other way around. If a virtual method exists at a particular level in an object, the program looks in the object to see if a replacement virtual method has been declared at a lower level.

Now if you compile the test application, and you create more than ten trace messages (or whatever the cache size has been set to) and bring up the Trace browse, you'll see that trace records have in fact been added to the TPS file.

NOTE: You need to put the VIRTUAL attribute on both the parent and child WriteTrace methods.

This is an incredibly powerful feature of object-oriented programming. With virtual methods you can plug and play with the methods of a class without having to know how or when those particular methods are called. In fact, once you understand how virtual methods work, you understand how ABC works!

Virtual Methods and ABC

It's no understatement to say that without virtual methods there would be no ABC templates and class library. It's not that difficult to create a block of code, class or procedure, that can display and browse records. But how do you allow someone to plug their own code into that code? The legacy templates handle this problem by generating all of the code and allowing you to insert your code in as needed, but this results in excessive code generation and mixes templates and source code.

In ABC, almost all of the logic is in top-tier generic classes, which makes testing and debugging (by Topspeed) much easier. In most cases when you put code in an embed point, the templates create a derived method which is automatically called by the parent class (such as BrowseClass) at the appropriate time.

The derived classes are located in your modules, so they have access to all of your application's information. By means of virtual methods, the base classes "call down" into your created code whenever appropriate.

If you wish to format a browse box field for display, for instance, your code is generated into the virtual SetQueueRecord method which is called whenever the browse object needs to copy the data from the file to the queue used for display. (And this derived, generated SetQueueRecord also contains a call to PARENT.SetQueueRecord to ensure that the default behaviour still happens as well.)

I'm sometimes asked if, since virtual methods are so powerful, all methods should be made virtual. There is a small performance penalty associated with using virtual methods, since the system must maintain a virtual method table (VMT), or list of methods, so it knows at runtime which methods to call under which situation (this is an example of "late" binding). VMT lookups do take some time, though it's debatable whether in most situations you'd notice.

Under The Hood

At the start of this series of articles I commented that most ABC procedures begin with one line of code:

GlobalResponse = ThisWindow.Run()

and that this code appeared to not call any other code generated as part of the procedure. As you can now see, the answer to this puzzle is (drum roll) virtual methods. The call to ThisWindow.Run passes control up to the top tier base class, and the code rambles about at that level for a while until it comes across a virtual method (such as ThisWindow.Init), at which time control passes down to the lower derived level.

Virtual methods really do make working with the ABC class library "plug and play." You can also use this mechanism in your own classes. It's a good exercise to look at your class designs and ensure they're structured in such a way that someone else could selectively replace methods to change the behaviour without breaking the code. And by going to a two-tiered approach you help to maximize code reuse and maintainability, two key benefits of object-oriented programming.

Download the source code


David Harms is an independent software developer and the editor and publisher of Clarion Magazine. He is also co-author with Ross Santos of Developing Clarion for Windows Applications, published by SAMS (1995), and has written or co-written several Java books. David is a member of the American Society of Journalists and Authors (ASJA).

Printer-friendly version

Reader Comments

To add a comment to this article you must log in.

 
 

Search

 

Advanced Search
Topical Index

Related Articles

Subscribe to
ClarionMag

One year: $169

(includes all back issues since '99)

Renewals from $119

Two years: $269

Renewals from $219

More Info

Subscribe Now!

ClarionMag Blog

RSS Feeds

Updates via Email

Enter your Email


Powered by FeedBlitz

Quick Links