Andrew Koenig is a former AT&T researcher and programmer. He is author of C Traps and Pitfalls and, with Barbara, coauthor of Ruminations on C++ and Accelerated C++. Barbara Moo is an independent consultant with 20 years of experience in the software field.
Part of the experience of learning any new programming language is discovering that the language has quirks that get in the way of solving some problems and that don't seem to be there for any obvious reason. A natural reaction to such quirks is to wish that they would go away. Such wishes often appear in the Usenet C++ newsgroups as questions of the form, "Why doesn't C++ have feature X?" or "Why doesn't C++ do Y instead of Z?"
Rhetorical though these questions may be, they often have literal answers. The quirks are there for a reason, and even though the reasons are different for different languages, the quirks usually come from an effort to balance several forces that are competing for the language's attention. Moreover, if we understand the answers to these questionsif we know why the languages we use behave as they dowe can learn the language more easily, and we can program in a style that is more consonant with the language's goals.
For example, Fortran was originally designed to make it possible to write scientific and engineering programs that would run almost as quickly as if they were in assembly language. This desire permeated the language design. Early versions of Fortran had no facilities for manipulating characters at all, because character manipulation was not part of the problem that the language was trying to solve. If we are writing Fortran programs, and we know that Fortran compilers usually devote a good deal of effort to optimization, this knowledge will give us the confidence to write programs that are easier to understand, without having to worry so much about whether the compiler will generate appropriately fast machine code for us.
In this article, we present some examples of quirks in C++ and the tradeoffs that shaped them. Of course, there are too many such quirks, and corresponding tradeoffs, for us to be able to discuss them all. Accordingly, we focus on issues of speed, safety, and compatibility with C and past C++ usage. One of our examples shows a case where convenience trumped compatibility, resulting in an issue that still puzzles programmers a decade later.
Array and Vector Bounds
Just as Fortran needed to attract assembly-language programmers to succeed, C++ needed to attract C programmers. Accordingly, C++ had to evolve in ways that made it attractive to C programmers. One common question about C++ is whether it is as fast as C. This question makes sense in the first place only because C++ is close to being a superset of C. In particular, it is easy to translate any C program into a C++ program that works the same way. Therefore, it makes sense to answer the question about the relative speeds of C and C++ by comparing C programs with their straightforward C++ translations. For this reason, it has long been a design principle of C++ that the parts of C++ that correspond to C must be able to be implemented so that they run every bit as quickly as corresponding C programs.
What may be less obvious is that this speed requirement spills over into parts of C++such as the standard librarythat do not correspond directly to parts of C. For example, it should be clear that C++ cannot require built-in arrays to check whether indices are in bounds, because doing so would put C++ at a speed disadvantage compared to C. Similar reasoning argues that standard-library vectors cannot be required to check bounds by default, either. For if they did, there would be a speed difference between arrays and vectors that would cause some users to avoid vectors entirely. Instead, the authors of the standard library compromised by offering two ways of accessing vector elements: If v is a vector, then v[i] and v.at(i) both refer to element i of v; but v[i] is not required to check that i is in bounds. In contrast, v.at(i) is required to throw an exception if i is out of bounds.
Uninitialized Variables
Another way in which C++ shows its bias toward performance is in the way it deals with uninitialized variables. In general, if we define a local variable of built-in type, that variable starts out with an unspecified value unless we give it a value explicitly. Indeed, the C++ Standard goes so far as to say that any use of such a variable (other than giving it a value) is permitted to evoke undefined behaviorthat is, the implementation is permitted to do as it pleases with such a program. For example:
int x; std::cout << x << std::endl;
is permitted to print Meep! or, for that matter, to delete all of your files.
Why does C++ offer such latitude? The main reason is to ensure that C++ performance is competitive with C. C does not initialize local variables, so C++ cannot afford to do so in programs that have obvious C counterparts. It is unfortunate that the possible effects of using an uninitialized variable are so wide ranging. You might ask whether C++ might be able to compromise by saying that even when local variables are not initialized, compilers are required to generate machine code that causes programs that use such uninitialized variables to behave in reasonable ways. Unfortunately, if C++ were to require such programs to behave reasonably, it would have to define "reasonable" in such contexts. Instead of printing Meep!, for example, would it be reasonable to print (undefined)? Instead of deleting all of your files, would it be permissible to terminate the program's execution with a diagnostic message? What about without a diagnostic message?
The possibility that using an uninitialized variable can cause such a wide range of effects places a special burden on programmers, because there is no completely reliable way of verifying that a programmer has not forgotten to initialize a variable. Suppose, for example, we have this code fragment:
int sum; for (int i = 0; i != v.size(); ++i) sum += v[i];
and we determine, through testing, that after we have executed it, the value of sum is equal to the sum of the elements of v. All that this testing has shown us is that on this particular implementation, in this particular program fragment, the value of sum just happens to have started out as zero, even though we did not explicitly say so. In other words, this program fragment contains a bug. Instead of writing:
int sum;
we should have written:
int sum = 0;
but unfortunately, this bug escaped our testing.
Indeed, it is possible for this kind of bug to escape any testing, because it is possible for the implementation to happen to appear to do the right thing by accident. Nevertheless, performance trumps testability in this case, because otherwise some C++ programs might run significantly more slowly than their C counterparts.
Functions Are Not Virtual by Default
It is easy to understand C++'s performance bias in the context of programs with C counterparts. Where C++ departs from C, its bias toward speed shows up in the notion that programs should be fast by default: You should not pay for features that you do not use. For example, unlike other object-oriented programming languages, C++ lets you specify which of a class's member functions should be subject to dynamic binding. Such specifications are important for two reasons. One reason should be obvious: Calling a dynamically bound function takes longer than calling a statically bound one. In addition to the time overhead, there is space overhead that comes into play for the first virtual function in a class. The point is that once a class has one or more virtual functions, there must be a way to determine the dynamic type of every object of that class, which means that every object must include the extra information needed to determine its dynamic type. Accordingly, C++'s default behavior is the faster one, namely making member functions nonvirtual by default. By choosing this default, C++ makes it less likely that forgetting to turn off dynamic binding for every member function inadvertently imposes the corresponding space overhead.
A common case in which this default causes trouble is that destructors are not virtual by default. Indeed, there is often no need for destructors to be virtual. A virtual destructor is needed only when a pointer to a base class is used to delete an object of a derived class. Accordingly, every class author must think about whether to permit the class to be used in this way, and if the answer is yes, must ensure that the class has a virtual destructor. This extra thought is the price for avoiding the per-object space overhead that comes along with having virtual functions.
Another reason that functions are not virtual by default is to make it easier for C++ implementers to be highly compatible with C if they wish to do so. Indeed, part of why C++ lets you say explicitly whether member functions are virtual is to make it possible to define data structures in C++ that will have the same layout as the corresponding data structures in C. This ability makes it much easier to write systems that include a mix of C and C++ programs.
Dynamic Arrays Versus Scalars
Another aspect of C++ that comes from the desire for C compatibility is the curious notion of having two forms of the delete statement, and of making the programmer choose the appropriate form based on the type of the memory being freed. Specifically, you must use delete to deallocate an object that is not an array, and delete[] to deallocate an array. It is particularly curious that there is no need to specify the number of elements being deleted.
The reason for these requirements grows out of a desire for to make it possible for C++ to be implemented on top of previously existing C libraries in a way that lets C++ and C programs work together on such an implementation. The C library offers two functions for memory allocation and deallocation: malloc, which allocates a given number of bytes, and free, which deallocates a block of memory allocated by malloc. These functions have two idiosyncrasies. First, free does not require its user to specify how much memory to free; that information is taken from a hidden data structure that malloc maintains. Second, the user does not have access to malloc's data structure, so that even though free knows how much memory is being freed, the program that called free does not know.
Now imagine a C++ implementation built on top of a C library. When a C++ programmer uses this implementation to allocate an array, such as by executing:
string* sp = new string[n];
the implementation must call malloc to allocate enough memory for n objects of type string. After doing so, it must construct these n objects in the memory that it has allocated.
When it comes time to free this memory, the implementation must reverse the process: It must destroy the n objects, then call free to hand the memory back to the C implementation.
How does the C++ implementation know how many objects to free? If it knew how large the memory block was, it could divide that by the size of a stringbut we are assuming that the C++ implementation is built on top of a C implementation, and there is no assured way of getting this information from the C implementation. So how is the C++ implementation to figure it out?
Originally, C++ solved this problem by requiring users to supply it. In other words, users who wrote:
string* sp = new string[n];
would have to write:
delete [n] sp;
to free the memory. However, at one point during the early development of C++, Jonathan Shopiro observed that it is possible to store the size of each dynamically allocated array in a data structure that the C++ library could maintain. It is true that this data structure would duplicate information that the C library was already maintaining, but duplicating it would gain independence from any particular C implementation. Moreover, if the C and C++ implementations were merged in the future, this duplication would go away.
If we were concerned only about compatibility, we could now declare this problem solved. However, we must not forget that C++ is also biased toward runtime efficiency. In this case, there is a question of space overhead. If what is being allocated is an array, the overhead doesn't matter much. However, if a scalar is being allocated, the overhead could well exceed the size of the object itself. Accordingly, we decided to require users to say whether they were deallocating an array, but not how large that array was.
Scope of Variables In for Statements
C++ has tried to stay compatible not only with C but with earlier C++ usage. Only with great reluctance has C++ changed in ways that break older programs. One example of such a change is in the scope of variables defined in for statements. In early versions of C++, a variable defined in a for statement persisted until the end of the scope surrounding the for, rather than until the end of the for itself. This behavior allowed code such as:
for (int i = 0; i != n && a[i] != x; ++i) ; if (i != n) { /* ... */ }
Here, the variable i is assumed to persist after the end of the for statement, so that it is possible to test its value in the if statement that follows. Although this ability is convenient, it has its problems. This example shows the problem that is probably the most annoying in practice:
for (int i = 0; i != n; ++i) { /* Do something */ } for (int i = 0; i != n; ++i) { /* Do something else */ }
Here, the second for statement, which appears to be so similar to the first, would fail to compile because the variable i is defined twice.
There are definition problems as well. For example, suppose we were to change our example slightly:
if (n > 0) for (int i = 0; i != n; ++i) { /* Do something }
Now it is nonsensical for i to persist beyond the scope of the if statement, because otherwise, a declaration that might never be executed would still somehow have to define the variable i. So we need a rule that says that a variable defined inside an if statement persists only until the end of the if, even though a variable defined in a for statement persists beyond the end of the for.
Arguments such as these eventually convinced the C++ Standards committee to change the rules so that a variable defined in a for statement has a scope that extends only to the end of the for. The committee realized that it would face criticism no matter what it did. Ultimately, the committee reasoned that if it adopted the more logical behavior, the criticism would eventually subside. Yet even though approximately 10 years have elapsed since this change, there are still compilers in widespread use that interpret declarations in for statements according to the old rules, and the 10-year-old change is still causing trouble for programmers today. In retrospect, we feel it was the right thing to dobut we are surprised at how long the issue has remained alive.
Conclusion
Every programming language has its quirks, and those quirks are usually there for a reason. Understanding the reasons for those quirks can often show us how to use the language more effectively. Often the answers to such questions are that the language's designers chose a compromise between several conflicting goals. In the case of C++, those goals usually include performance and compatibility, both with C and with past C++ usage. Compatibility issues often turn out to be important for much longer than it might seem at first.
Because of such conflicts, it is hard to imagine how there can ever be such a thing as a perfect programming language. Understanding why perfection is impossible should increase our understanding of the tools we use.