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 that are 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 Kbytes each. The last version of Borland C++ that was available on floppy disks filled 55 3-1/2 inch floppies, at 1.44 Mbytes each1. The latest version of Borland C++, in a fairly small configuration, requires about 300 Mbytes 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 step2. 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 successful3. That’s because the newer paradigms make some things harder to express, defeating their purpose of reducing the complexity of the code we write. 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 they 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 it is less, and in the second if it again
compares j
to 3, performing some action if it is equal. In
the underlying processor these two comparisons are usually the same
instruction: it sets the processor’s control bits to indicate what the
result was. 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
, where 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 3’s 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 there would only
be one place that needed to be changed. With the three-way
if
this wouldn’t be necessary4. 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
and 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 that the fact that line numbers in FORTRAN are merely labels, i.e.
they do not have to increase as you move forward in 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 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 re-entry 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 frustrating5 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 objects6. If any of the calls fails you shouldn’t bother with the rest of the calls, and must release all of the objects that have been obtained so far. If you insist on writing fully structured code to do this, 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 if
s 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 NULL
s so that 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
doesn’t have any 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 operation in 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: compiler writers have always recognized that a data type consists of a region of storage and a set of operations that are legal 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 that 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 definition of an object and of a class, so when talking about object oriented programming it is often necessary to 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, every C++ object’s lifetime 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 will be destroyed, and
their destructors, in turn, will 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
time after there are no more references to an object its finalize method
will be run and its memory reclaimed. A direct consequence of this
difference is that Java code cannot rely on object lifetimes to manage
resources. That can be a difficult transition 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 could 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. If we encapsulate
the system_get
calls in a constructor and throw exceptions
when the call fails we get a bit of improvement:
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
will be 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 doesn’t provide a way to guarantee that
the underlying resource that a Manager
object controls will
be released when it is no longer in use. In order to do that we had to
write a finally
block in the code that uses our class.
Details that ought to be handled by the code in the Manager
class have leaked out into the user’s code, in this case, the code that
implements Mthrd.create_mth
. Unfortunately, Java doesn’t
seem to offer a good way to encapsulate complex object management like
what we’ve been looking at.
On the other hand, Java’s finally
clause can be used in
some cases 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, by a normal return or by
throwing an exception, the message will be displayed on the screen.
In C++ we’d 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. In order to do that, 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 looks like a close call. Creating classes whose
destructors are their only reason to exist 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.
1. CD’s were beginning to become widely available, and were the primary distribution medium for that release. For people who asked for a version on floppy disks we considered offering them a free CD-ROM drive if they’d 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 have to tell customers that they had to add hardware in order 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 have 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.
Copyright © 2000-2006 by Pete Becker. All rights reserved.