C/C++ Contributing Editors


The Journeyman's Shop: When "Retro" Is Better

Pete Becker

The old ways aren't always the best ways, but they often are pretty darned good.


In 1981, when the IBM PC was first introduced, it used a processor that ran at 4.77 MHz. Today, for less than you would have paid for that original IBM model, you can buy a PC with a processor that runs at 750 MHz. That's more than 150 times faster than the original version. And clock speed doesn't tell the full story. Improved processor designs make many of the most commonly used CPU instructions run in fewer clock cycles than they used to, producing harder to measure, but nonetheless real, additional speedups.

Shortsighted software designers see this ongoing speed increase as the solution to software that runs too slowly. Just get a faster computer. If there aren't any fast enough, wait a while. There will be.

When that faster hardware comes along, though, we immediately add new features to our applications, using up most of the added capabilities of the new hardware. When Borland C 1.0 first shipped, back in 1988, it filled four 5-1/4 inch floppy disks, holding 360 KB each. The last version of Borland C++ that was available on floppy disks filled 55 3-1/2 inch floppies, at 1.44 MB each [1]. The latest version of Borland C++, in a fairly small configuration, requires about 300 MB of hard disk space. That's over 200 times as large as the original version.

A new feature can be something that the application could not do before, or it can be a more sophisticated version of something that it could do before. Either way, the code that implements the new feature must interact with the pre-existing code from the earlier version. Sometimes that interaction requires very few changes in the old code. For example, adding a new menu item that invokes new code is often fairly simple. Usually, though, the new code is tightly interwoven into the old code. Making this sort of a code change requires a thorough understanding of the old code and all its quirks. As an application grows larger, the interactions within its code tend to grow even faster, and if the application's developers aren't careful they'll end up with something that is so complicated that they no longer understand how it works. If that happens it becomes impossible to add new features and impossible to fix things that don't work right, and customers add the application to their list of software that used to be good but couldn't keep up with the changing market.

The history of the discipline of software engineering is largely the history of attempts to manage complexity. As the things we expect software to be able to do become more complex, the tools that we use to develop software must become more sophisticated. So, to sketch only one branch of the evolutionary tree, we move from assembly language to FORTRAN II, from FORTRAN II to C, from C to C++, increasing the expressive power of our language with each step [2]. Further, the latter two changes reflect not only changes to more powerful programming languages, but changes in how we approach programming, moving first to structured programming and then to object-oriented programming. In fact, these changes in our fundamental programming paradigms have driven changes in programming languages. Part of the power of newer programming languages comes from their ability to express the underlying concepts of the newer programming paradigms in simple code. You can write object-oriented code in C, but it is much easier to do it in C++.

But part of the power of newer programming languages also comes from their acceptance of things that worked well in the past. Languages that have attempted to incorporate what was viewed as a "pure" version of a new paradigm, rejecting legacies that were inconsistent with the new paradigm, have not been broadly successful [3]. That's because the newer paradigms make some things harder to express, defeating their purpose of reducing complexity. That's not to say that we shouldn't use these new paradigms. The gains in expressive power have been tremendous. There are times, though, when the new paradigms simply don't work well, and the response of programmers has been to fall back to techniques that are generally less powerful, but better able to manage some details of a particular computation.

One construct from FORTRAN II that I sometimes miss is the three-way IF statement:

      IF (J - 3) 10,20,30
   10 PRINT 11,J
   11 FORMAT(16HJ IS LESS THAN 3, I4)
      GOTO 999
   30 PRINT 31,J
   31 FORMAT(19HJ IS GREATER THAN 3, I4)
      GOTO 999
   20 PRINT 21
      FORMAT(10HJ EQUALS 3)
  999 CONTINUE

The first line branches to statement 10 if J - 3 is less than zero, 20 if it equals zero, and 30 if it is greater than zero. To do the same thing with a logical IF statement requires two IFs:

      IF (J.EQ.3) GOTO 20
      IF (J.GT.3) GOTO 30
      PRINT 11,J
   11 FORMAT(16HJ IS LESS THAN 3, I4)
      GOTO 999
   30 PRINT 31,J
   31 FORMAT(19HJ IS GREATER THAN 3, I4)
      GOTO 999
   20 PRINT 21
      FORMAT(10HJ EQUALS 3)
  999 CONTINUE

Now, this probably doesn't look much worse than the original, especially if you don't read FORTRAN and are lost in the details. But if C had a three-way if statement we could, when necessary, write code like this:

ifn (j - 3)
   printf("j is less than 3\n");
elsez
   printf("j equals 3\n");
elsep
   printf("j is greater than 3\n");

where today we would write something that looks more like this:

if (j < 3)
   printf("j is less than 3\n");
else if (j == 3)
   printf("j equals 3\n");
else
   printf("j is greater then 3\n");

The problem in the second version is that it does the same comparison twice. In the first if statement it compares j to 3, performing some action if j is less, and in the second if it again compares j to 3, performing some action if j is equal. In the underlying processor these two comparisons usually translate to the same instruction, an instruction that sets the processor's control bits to indicate the result of the comparison. So in the three-way if statement the test can be done once, and the subsequent branching decisions can all be based on that single result. Not so with the logical if, the use of which requires that the test itself must be repeated.

Now, obviously, the cost of a single compare instruction is so small that it isn't worth considering. But the fact that the test has to be repeated can sometimes require that we write additional code, both to insure consistency and to improve performance when the test is not as simple as this one. To insure consistency, we would replace the 3s in the two tests with a manifest constant, so that if we later decided that we needed to compare j with 4 instead of 3 we would need to change the code in only one place. (With the three-way if this use of manifest constants wouldn't be necessary [4].) To improve performance, we'd move a lengthy test outside of the if statement, and add a variable to hold the result:

int res = compare(j, 3);
if (res < 0)
   printf("j is less than 3\n");
else if (res == 0)
   printf("j equals 3\n");
else
   printf("j is greater then 3\n");

None of this is important enough to justify choosing FORTRAN over C, although for some applications there are good reasons for such a choice. I've presented this example in part to point out that older languages often have constructs that could be useful in newer languages, even though they don't satisfy our more recently developed sense of elegance.

The other reason I've presented this example is to illustrate why goto is considered harmful. It's not because every possible use of a goto statement is bad, but because the flow of control in a program that uses if/goto as its primary control mechanism can be very hard to follow. The two earlier examples of FORTRAN code are fairly well structured: a C programmer ought to be able to recognize the skeleton of the C examples in the FORTRAN code. When the IF statements become more deeply nested, though, you have to start drawing arrows to understand the flow of control, and the printout starts to look like spaghetti. Add to this the fact that line numbers in FORTRAN are merely labels — their values do not have to increase as you move through the code — and poorly structured code can easily become nearly incomprehensible. We usually tried to stave off this sort of problem with what we today call coding standards: write line numbers in increasing order; use ranges of line numbers to indicate code that works on the same task; indent code to reflect control flow. In the latter two standards you can see the beginnings of the notion of block structuring, and, more generally, structured programming.

I was a bystander during the structured programming revolution. It was pretty much over when I decided to get back into programming after ten years of other pursuits. Fortunately, I decided to emphasize structured programming as part of my reentry into programming. I bought a copy of Turbo Pascal, version 3.01, and learned the religion: exactly one entry and one exit from every block. However, I eventually found programming in Pascal to be so frustrating [5] that when I switched to C it felt like the first day of Spring after a long, cold Winter. I had been reborn.

Suppose you have to write some code that initializes a data structure by making four system calls to get various types of thread control objects [6]. If any of the calls fails you shouldn't bother with the rest of the calls, and you must release all the objects obtained so far. If you insist on writing fully structured code, you end up with something like this:

mthrd *create_mth(void)
{
mthrd *res = calloc(sizeof(mthrd));
if (res)
   {
   res->obj1 = system_get();
   if (res->obj1)
      {
      res->obj2 = system_get();
      if (res->obj2)
         {
         res->obj3 = system_get();
         if (res->obj3)
            res->obj4 = system_get();
         }
      }
   if (!res->obj4)
      {
      system_release(res->obj3);
      res->obj3 = NULL;
      }
   if (!res->obj3)
      {
      system_release(res->obj2);
      res->obj2 = NULL;
      }
   if (!res->obj2)
      {
      system_release(res->obj1);
      res->obj1 = NULL;
      }
   if (!res->obj1)
      {
      free(res);
      res = NULL;
      }
   }
return res;
}

With all those nested ifs it's hard to see what's really going on. On the other hand, if we are willing to suspend the discipline of structured programming we can write the function this way:

mthrd *create_mth(void)
{
mthrd *res = calloc(sizeof mthrd);
if (!res)
   return NULL;
res->obj1 = system_get();
if (!res->obj1)
   goto obj1_failed;
res->obj2 = system_get();
if (!res->obj2)
   goto obj2_failed;
res->obj3 = system_get();
if (!res->obj3)
   goto obj3_failed;
res->obj4 = system_get();
if (!res->obj4)
   goto obj4_failed;
return res;

obj4_failed:
   system_release(res->obj3);
obj3_failed:
   system_release(res->obj2);
obj2_failed:
   system_release(res->obj1);
obj1_failed:
   free(res);
return NULL;
}

The second version is much simpler, both to write and to read. The first has too many levels of nested if statements — it requires a careful examination of the braces to understand what it does. And having to set all those NULLs so the succeeding code can test them is just busywork. I have absolutely no qualms about using goto in cases like this. There are times when the control structures of structured programming can impede comprehension.

Using C++, we can write a helper class to release individual objects, and rewrite the preceding code to handle cleanup when an error occurs, like this:

class manager
{
public:
   manager(resource *r) : 
     rsrc(system_get()) {}
   ~manager() { if (rsrc) 
     system_release(rsrc); }
   resource *get() { return rsrc; }
   resource *release()
      { resource *res = rsrc; 
        rsrc = 0; return res; }
};

mthrd *create_mth()
{
manager r1;
if (!r1.get())
   return NULL;
manager r2;
if (!r2.get())
   return NULL;
manager r3;
if (!r3.get())
   return NULL;
manager r4);
if (!r4.get())
   return NULL;
mthrd *res = calloc(sizeof(mthrd));
res->obj1 = r1.release();
res->obj2 = r2.release();
res->obj3 = r3.release();
res->obj4 = r4.release();
return res;
}

In this version, create_mth contains no code that explicitly deals with cleaning up these objects. Instead, the compiler generates code to call the destructor for each manager object, and the destructor takes care of cleaning up. By itself this code isn't much of an improvement over the C version. Its big advantage is its reusability: I can tuck the manager class away in my archives, and use it again in some other application. The ad hoc solution in the C version doesn't lend itself so easily to reuse.

The fundamental idea behind object-oriented programming is combining data and operations on that data into a single entity that the compiler can understand. In a sense there's nothing new about this idea: compiler writers have always recognized that a data type consists of a region of storage and a set of operations that are valid for that type. What the object-oriented revolution gave us was the ability for programmers to create their own types, each with its own exclusive set of allowed operations; and with that, the ability to translate concepts, drawn from the problem a program is supposed to solve, into code defined by classes, with less effort and less risk of error.

Unlike structured programming, however, object-oriented programming is not a monolithic concept. There are many variations of the definitions for object and class, so when we talk about object-oriented programming we must often consider the particular language that will be used.

The preceding code example relies on C++'s notion of the lifetime of an object. In particular, the lifetime of every C++ object ends at a definite time. The lifetime of an auto object ends when that object goes out of scope; that of a heap-allocated object when that object is deleted. When any of the return statements in the code example is executed, the objects that have been constructed are destroyed, and their destructors, in turn, release the resources that the objects control. Java objects, in contrast, do not have a well-defined end to their life. There is no way, in Java, to say "this object is no longer in use, so it should go away." Rather, what Java code can say is "I'm no longer interested in this object," and at some later time, since there are no more references to the object, its finalize method is run and its memory is reclaimed. A direct consequence of this difference is that Java code cannot rely on object lifetimes to manage resources. That can make the transition difficult for C++ programmers, who have grown up with the notion that resource allocation is initialization, and its mirror image, resource release is destruction.

Once we have understood that Java's finalize method is not appropriate for the release of resources, we might try to go back to a C-like version of create_mth:

public class Mthrd
{
public Mthrd create_mth()
   {
   int handle1 = -1;
   int handle2 = -1;
   int handle3 = -1;
   int handle4 = -1;
   Mthrd res = new Mthrd();
   try {
      handle1 = system_get();
      if (handle1 == -1)
         return null;
      handle2 = system_get();
      if (handle2 == -1)
         return null;
      handle3 = system_get();
      if (handle3 == -1)
         return null;
      handle4 = system_get();
      if (handle4 == -1)
         return null;
      }
   finally
      {
      if (handle4 != -1)
         system_release(handle4);
      if (handle3 != -1)
         system_release(handle3);
      if (handle2 != -1)
         system_release(handle2);
      if (handle1 != -1)
         system_release(handle1);
      }
   res.init(handle1, handle2, handle3, 
      handle4);
   return res;
   }
}

This is obviously more verbose than the C version. We can improve matters a bit if we encapsulate the system_get calls in a function and throw exceptions when the call fails:

class Manager
{
public Manager() { handle = -1; }
public allocate() {
   handle = system_get();
   if (handle == -1)
      throw new NoResourceException();
   }
public int get()
   { int res = handle; handle = -1; 
     return res; }
void release()
   { if (handle != -1) 
        system_release(handle)); }
}

public class Mthrd
{
public Mthrd create_mth()
   {
   Manager m1 = new Manager();
   Manager m2 = new Manager();
   Manager m3 = new Manager();
   Manager m4 = new Manager();
   try {
      Mthrd res = new Mthrd();
      m1.allocate();
      m2.allocate();
      m3.allocate();
      m4.allocate();
      res.init(m1.get(), m2.get(),
         m3.get(), m4.get());
      }
   finally
      {
      m4.release();
      m3.release();
      m2.release();
      m1.release();
      }
   }
}

If any of the calls to system_get fails, the code throws an exception. In that event, the code in the finally block is executed, cleaning up the resources.

This code isn't much longer than the C-like version, but I find it much less satisfactory. In part that's because the class Manager doesn't really encapsulate the properties of a Manager object — it provides no way to guarantee that the underlying resource that a Manager object controls will be released when it is no longer in use. To provide that guarantee we had to write a finally block in the code that uses our class. Details that ought to be handled by the Manager class have leaked out into the user's code, in this case, the code that implements Mthrd.create_mth. Unfortunately, Java seems to offer no good way to encapsulate complex object management schemes such as we have seen here.

On the other hand, in some cases we can use Java's finally clause to write code that is simpler than the corresponding C++ code. Suppose that, while debugging a program, we decide that we need to display a message on the screen whenever a particular function returns for any reason. The Java code for this is simple:

public class Monitored
{
public void test()
   {
   try {
      // code goes here
      }
   finally
      {
      System.out.println("Monitored.test exiting");
      }
   }
}

Now whenever test exits, either by a normal return or by throwing an exception, the message will be displayed on the screen.

In C++ we would create an object whose destructor displays the message:

class message
{
public:
   ~message()
      { std::cout << "monitored.test exiting\n"; }
};

class monitored
{
public:
   void test()
      {
      message m;
      // code goes here
      }
};

As with the Java code, whenever test exits, by a normal return or by throwing an exception, the message will be displayed on the screen. To make this work, though, we've had to create a new class whose sole purpose is to display the message. This is added complexity, driven by the absence of a finally clause in C++. An alternative would be to eliminate the object, and write the code that displays the message in two places:

class monitored
{
public:
   void test()
      {
      std::string m("monitored.test exiting\n";
      try {
         // code goes here
         }
      catch(...)
         {
         std::cout << m;
         throw;
         }
      std::cout << m;
      }
};

To me this decision looks like a close call. Creating classes that exist only to provide destructors is carrying object-oriented programming too far. On the other hand, adding an otherwise unnecessary try block and catch clause, as in the last example, isn't a good solution either.

In the absence of exceptions, object-oriented code can sometimes be simplified by eliminating some of the objects and dropping back to ordinary structured programming. Structured code can sometimes be simplified by eliminating some of the structure and dropping back to ordinary spaghetti code. Such cases are uncommon, but don't ever let the attitude that object-oriented programming or structured programming is inherently better keep you from considering a more primitive alternative. Conditions before the revolution weren't all that bad. They're just described that way.

Notes

[1] CD-ROMs were beginning to become widely available, and were the primary distribution medium for that release. We considered offering people who asked for a floppy disk distribution a free CD-ROM drive if they would take the CD version instead. It would have cost us less than the floppy disks. We decided not to do it, though, because we didn't want to tell customers they had to add hardware to use our product.

[2] I don't intend to suggest that FORTRAN is an evolutionary dead end. Rather, this list reflects some of the major languages that I have used for significant programming projects. I haven't written FORTRAN code for many years, so anything I tried to say about FORTRAN as it exists today would be at best naive. FORTRAN is widely used, and, as far as I know, has kept up fairly well with the changing demands of programmers.

[3] For example, Pascal was designed to force programmers to use structured techniques, and Smalltalk turned everything into an object. Both helped to popularize their respective paradigms, by teaching programmers how to use the new techniques that these paradigms brought with them, but neither is a significant force in the market today.

[4] We still might want to use a manifest constant in order to provide a descriptive name, but that's a separate issue.

[5] Pascal is best written by teams of two programmers, one to write the code and one to write the semicolons.

[6] This example is based on actual code in the thread support package of Dinkumware's Java library.

Pete Becker is Technical Project Leader for Dinkumware, Ltd. He spent eight years in the C++ group at Borland International, both as a developer and manager. He is a member of the ANSI/ISO C++ standardization committee. He can be reached by email at petebecker@acm.org.