Checkmate: Cornering C++ Dynamic Memory Errors With Checked Pointers

Scott M. Pike and Bruce W. Weide (The Ohio State University)

Proceedings of the thirty-first SIGCSE technical symposium on Computer Science Education (March 7-12, 2000, Austin, TX USA)

1. Abstract

Pointer errors are stumbling blocks for student and veteran programmers alike. Although languages such as Java use references to protect programmers from pointer pitfalls, the use of garbage collection dictates that languages like C++ will still be used for real-time mission-critical applications. Pointers will stay in the classroom as long as they're used in industry, so as educators, we must find better ways to teach them. This paper presents checked pointers, a simple wrapper for C++ pointers that prevents pointer arithmetic and other common sources of pointer errors, and detects all dereferencing and deallocation errors, including memory leaks. The syntax of checked pointers is highly faithful to raw C++ pointers, but provides run-time error detection and debugging information. After debugging, changing one #include is all that is required to substitute a non-checking implementation that is as fast as raw C++.

2. Introduction

This is not a paper about chess. But it is a paper about strategy and complexity. The complexity is how to develop and debug programs that use pointers and dynamically allocated memory. The strategy employs a simple wrapper for C++ pointers, which we call checked pointers, that detects common bugs such as illegal dereferences. Typically, this kind of programming mistake surfaces as a cryptic memory error such as segmentation fault or its kissing cousin bus error. Such mistakes ordinarily go undetected unless they happen to ramify into unexpected run-time behavior. In either case, it is frequently difficult to identify and/or reproduce the root cause of the resulting core dump or snowball behavior. Checked pointers also detect memory leaks, which often remain latent bugs until the most inopportune moments.

War stories of pointer errors are legion. You've probably never committed one personally, but maybe a friend of yours has, or perhaps you've just heard of someone who has. Most of us are among the unwashed masses, but in case you're not a card-carrying member, a couple anecdotes should suffice.

This paper explains what we should teach students about how to use C++ pointers, and how teaching a disciplined approach to using C++ pointers can be supported with simple and free software.

3. Background

The problem of denotation is ubiquitous. In the general case, it is a widespread philosophical problem pertaining to any language and its domain of discourse. In our special case, the domain of discourse is computation, and the problem is how programs attach to the world of hardware computing devices (e.g., memory locations).

It is a folk theorem of computer science that most problems can be solved by introducing a level of indirection. Fortran programmers achieve indirection by using arrays and integer subscripts to model memory locations and their addresses. Other languages (in our case C++) support indirection by providing dynamic memory management and special language constructs that help compilers detect certain type-related errors. Of course, the C++ programmer using pointers can accomplish the same byzantine manipulations as the Fortran programmer using arrays and integers: namely, doing arithmetic with pointers and treating a pointer as the base address of an array segment. C++ also leaves the programmer responsible for allocating and deallocating storage, but unlike Fortran, it provides built-in implementations of operators new and delete to help with this.

Java distills the notion of indirection without opening the same can of worms as Fortran or C++.  To the Java programmer, nearly everything is a reference and pointers are nowhere to be seen. A reference in Java is not the base address of an array; it acts like a pointer whose value cannot be changed using pointer arithmetic. Moreover, a reference is automatically dereferenced without explicit syntax for doing so. The Java programmer is still responsible for making a reference refer to something (by using new or assignment), but is no longer responsible for deallocating the storage it refers to because Java has a garbage collector.

To be sure, then, C++ pointers are a disturbing ilk because they are so easy to misuse, especially when compared to Java references. Here are some common problems that can arise in both student and professional C++ programs with pointers, some of which cannot arise in Java programs with references:
 
Problem Problem with
C++ pointers?
Problem with
Java references?
1. Difficulty reasoning about side effects due to aliasing YES YES
2. Failure to initialize a pointer/reference before using it YES YES
3. Treating a pointer/reference as if it were an integer YES NO
4. Treating a pointer/reference as if it were an array base YES NO
5. Dereferencing a null pointer/reference YES NO*
6. Dereferencing a dangling pointer/reference YES NO
7. Deleting a dangling pointer/reference YES NO
8. Failure to reclaim storage no longer needed YES NO

    *   Not detected by compiler, but detected at run-time upon occurrence

Problem #1 is an inherent consequence of using indirection to solve the denotation problem. Problem #2 cannot be solved without language changes to C++ or Java (e.g., it can be solved with the swapping paradigm [Har91]). On the other hand, problem #2 doesn't seem so bad because the actual misuse of an uninitialized pointer can be caught at run-time in C++ (see the next section), and dereferencing a null reference is caught at run-time in Java.

So why not just move everything and everyone to Java? The downside -- to be sure, one that some people hope to remedy someday -- is that one of the improvements offered by Java ("solving" problem #8, memory leaks) comes at the cost of garbage collection. This effectively rules out Java for real-time mission-critical applications. The license agreement for Sun's Java Developer's Kit includes the following disclaimer [JDK99]:

Software [JDK] is not designed or licensed for use in on-line control of aircraft, air traffic, aircraft navigation or aircraft communications; or in the design, construction, operation or maintenance of any nuclear facility. You warrant that you will not use Software for these purposes.
For obvious reasons, you'd hate to have garbage collection kick in when your 747 is about to land. There are incremental garbage collection algorithms that attempt to amortize the cost of reclaiming unused storage, but explicit memory allocation and deallocation will continue be the responsibility of real-time programmers for many years to come. So there is still some point in teaching C++. But as educators teaching C++ we must find better ways to show students how to use C++ pointers wisely and safely.

The remainder of this paper explains how checked pointers can support teaching a disciplined use of C++ pointers. The following table shows that the C++ pointer problems listed above are addressed -- not quite up to Java standards, but perhaps as well as can be expected if the programmer still has explicit control over storage allocation and deallocation:
 
Problem Problem with
C++ checked
pointers?
Problem with
Java references?
1. Difficulty reasoning about side effects due to aliasing YES YES
2. Failure to initialize a pointer/reference before using it YES YES
3. Treating a pointer/reference as if it were an integer NO NO
4. Treating a pointer/reference as if it were an array base NO NO
5. Dereferencing a null pointer/reference NO* NO*
6. Dereferencing a dangling pointer/reference NO* NO
7. Deleting a dangling pointer/reference NO* NO
8. Failure to reclaim storage no longer needed NO** NO

    *   Not detected by compiler, but detected at run-time upon occurrence
    ** Not detected by compiler or at run-time upon occurrence, but reported on request

4. Design Desiderata for Checked Pointers

Others have developed commercially-available and prototype tools to help C++ programmers detect pointer errors. Some (e.g., Purify [HJ92]) are intended to deal with legacy code, i.e., C++ source code as it is, not as we think it should be. Purify, in fact, works by instrumenting object code; C++ source is not even required. Other approaches (e.g., LCLint [Eva96]) use static analysis to check programmer-provided annotations regarding the intended uses of pointer variables. Finally, there have been various proposals for "smart" or "safe" pointers (e.g., [Gin91]) that involve programmers living with various degrees of change to pointer syntax. C++ designer Bjarne Stroustrup explains how to code some smart-pointer techniques in The C++ Programming Language [Str97].

In contrast to tools like Purify and LCLint, our approach is not designed for legacy code, and no annotation or static analysis is required. We limit what programmers can do with C++  pointers to essentially what they can do with Java references, plus explicit control of storage management. Our approach is similar to "smart" pointers, but with a few twists, as outlined below.

Our desiderata for checked pointers included the following:

It might be possible to have no syntactic differences at all by using just a little more macro magic than we used, but this seemingly would require replacing the built-in new and delete operators, about which Stroustrup [Str97, p. 421] warns:
... replacing the global operator new () and operator delete () is not for the fainthearted.
Not being particularly fainthearted, but also not being C++ gurus, we opted for syntax that is only slightly different (except in declarations of pointer variables) from the syntax of raw C++ pointers.  We created a template class, Pointer, that is parameterized by the type pointed to. In particular, the only syntactic differences are listed below:
 
C++ raw pointer example C++ checked pointer example
  int* p;   Pointer<int> p;
  p = new int;   New (p);
  delete p;   Delete (p);

One advantage of slightly different syntax for new/New, is that we can log, and later report, important information to facilitate debugging memory leaks (see below). This would not be possible without introducing some syntactic difference for new.

Compile-time prevention of programmer errors is the best strategy, of course. We are able to eliminate problems #3 and #4 simply by not defining arithmetic operators or operator [] for Pointer variables. When the internal state of a program becomes invalid at run-time, perhaps the best thing it can do is terminate execution immediately and output a meaningful error message. Programmers make mistakes; detecting such mistakes can prevent bugs from propagating through the program execution into compounded or undetected errors. We are able to report problems #5, #6, and #7 at run-time, upon occurrence, by keeping track of the set of memory addresses that have been allocated but not yet deallocated. We detect dereferences of null and dangling pointers by recognizing that requested memory addresses are not in this set. This turns out to be a bit trickier than it sounds, as explained in the next section, although it is conceptually straightforward. If all else fails, an error report at program termination -- "the program had an error, and here's some information about where it was" -- is better than nothing. We approach problem #8 in this way by providing a procedure that reports, for each memory address that has been allocated but not deallocated, the file name and line number where its allocation (e.g., "New (p)") occurred. If this procedure, Report_Storage_Allocation(), is called at the end of the program, then every memory address it reports is a memory leak in the program. We achieve this by having two implementations of Pointer, one that does checking and one that doesn't. The performance of the unchecked implementation -- just as Stroustrup claims [Str97, p. 290] -- is very efficient; with inlining, it is essentially indistinguishable from the performance of raw C++ pointers. Our software, including both the checked and unchecked implementations, is small and easy to understand. It is free for anyone who wants it. (A URL will appear in the published paper, but not here in order to maintain anonymous review. The code is available in the appendix only for review purposes.)

5. Implementation Considerations

For starters, below is the header file Pointer.h as seen by a client programmer. Private members for our two different implementations -- checked and unchecked -- have been elided.
#ifndef POINTER_H
#define POINTER_H

#define New(p)    p.Allocate (__LINE__, __FILE__)
#define Delete(p) p.Deallocate (__LINE__, __FILE__)

template <class T>
class Pointer
{
public:
    Pointer<T> ()
    Pointer<T> (void* a)
    Pointer<T>& operator= (const void* a)
    bool operator== (const Pointer<T>& a)
    bool operator== (const void* a)
    bool operator!= (const Pointer<T>& a)
    bool operator!= (const void* a)
    void Allocate (long l, char* f);
    void Deallocate (long l, char* f);
    T* operator-> ();
    T& operator* ();
};

void Report_Storage_Allocation ();

#endif // POINTER_H
Pointer does not supply member functions for arithmetic manipulations or array access. Consequently, any implementation of the Pointer class rules out problems #3 and #4 above by design. There are a copy constructor and an assignment operator from void*, which allows initialization of a Pointer to 0 (null) and assignment of 0 to a Pointer. By the rules of C++ [Str97, p. 835], a constant expression that evaluates to 0 -- but not to any other integral value -- can be implicitly converted to void*, so attempting to set a Pointer equal to a non-zero value will result in a compile-time error. Similarly, equality and inequality testing is permitted only between two Pointer variables of the same type or between a Pointer and 0.

Problems #5, #6, #7 are concerned with deleting and dereferencing null and dangling pointers. As a side note, it is legal to delete null pointers in C++, so that is not an "error" we need to catch. To detect the other three cases, we need to keep track of where each pointer is pointing, and which memory locations are currently allocated. We check at run-time that a pointer points to a legal address before deleting or dereferencing it. If the check fails, execution halts and the error is reported immeadiately. We accomplish this by implementing a simple memory-location naming scheme along with some run-time allocation accounting.

To motivate the naming scheme, consider the following code example:

(1)    Pointer<int> p1, p2;
(2)    New (p1);
(3)    p2 = p1;
(4)    Delete (p1);
(5)    New (p1);
(6)    Delete (p2);
Line (1) declares two int pointers p1 and p2. After line (3), an int has been allocated and p1 and p2 both point to it. On line (4), the int is deallocated so that p1 and p2 both become dangling references. Everything is fine so far, but line (5) becomes a potential source of problems. Typically, a new int will be allocated so that p1 will point to a legal address and p2 will still be a dangling reference. But it may turn out that the int allocated on line (5) is the same address that was just deallocated on line (4). From reasoning about the source code alone, p2 is still a dangling reference, but in this case, p2 will point to a legal address by chance! Consequently, in line (6) it is not sufficient just to check that p2 points to a legal address; we need to detect that this is an illegal deletion of a dangling reference even though p2 happens to point to a legal address on this particular run of execution.

Addresses alone are insufficient because the same memory location can be allocated and deallocated several times during a given execution. The key insight to solving this problem is simple: at any given point in time, every memory location is either allocated or deallocated, so we timestamp legal addresses with the unique time they were allocated. All memory is allocated with some address and at some time, so our naming scheme can use addresses for unique spatial names and timestamps for unique temporal names. The result is that the representation for a checked pointer contains two data members: a C++ pointer to the memory it points to, and a timestamp of when that address was allocated. In the case of null pointers, the value of the timestamp is meaningless.

Returning to the example above, even if the same memory address gets allocated twice, the second allocation will have a different timestamp. Consequently, we can detect in line (6) that p2 points to the right address, but at the wrong time -- that is, for the wrong allocation timestamp. Problem solved.

The last piece of machinery we need is a pointer table to store debugging information about allocated memory. Fortunately, we can use macro magic to gain access to the variables __LINE__ and __FILE__ defined by the C++ preprocessor. These variables indicate the line number and file name of each macro expansion. When a programmer allocates memory using New (p), the call is automatically replaced with a call to p.Allocate(__LINE__, __FILE__). In the implementation, the Allocate operation obtains the required memory and enters the address, timestamp, line number, and file name into the pointer table.

Similarly, a call to Delete (p) is automatically replaced with a call to p.Deallocate (__LINE__, __FILE__). The implementation checks whether the address and timestamp of the pointer are defined in the pointer table. If so, the memory is deallocated and the entry gets removed. Otherwise, execution stops and an error message indicates the file name and line number on which the programmer attempted to delete a dangling reference. As mentioned above, it is ok to delete null pointers in C++, so they are quietly ignored as a special case.

The checked pointer operators -> and * are implemented to detect illegal dereferences, including null pointers. The check is performed just as it is in Deallocate, but the resulting error messages are different. Both report that an illegal dereference has occurred, including whether it was a null or dangling pointer, and which dereference operator was used. The drawback, however, is that information about the file name and line number of the error is not available. Macro magic can be used to get the file and line of an operation, but the price of this information is significantly different pointer syntax. This is simply a design trade-off that any smart-pointer implementation has to live with.

Finally, we come to problem #8: detecting storage leaks. The header file Pointer.h declares the global operation Report_Storage_Allocation. This operation prints out the current contents of the pointer table. Thus, a call to this operation at the end of any client program reports every address that was allocated but never reclaimed, along with the file name and line number of each allocation for debugging purposes. Note that a single call to Report_Storage_Allocation reports all memory allocated for Pointer variables of all types.

In this section, we have showed how macro magic can be used with a memory-location naming scheme and an accounting table to detect pointer errors. Clearly there are several ways these key ideas can be implemented in practice. We chose to implement the table by hashing pointer addresses with chaining for conflict resolution. Obviously, the hash table size is a parameter that affects performance.

As for the naming scheme, we implemented the timestamp mechanism by incrementing a long int variable every time an entry is made into the pointer table. This implementation is not failsafe, of course. There are approximately 4 billion unique timestamps, so eventually they may have to be reused. Our implementation will miss an error if the same address gets allocated more than once with the same timestamp and some dangling reference still points to it that later gets dereferenced or deleted. This was also a design trade-off where the odds seemed suitably low. Using two long int variables for timestamping would be enough to ensure centuries of execution time before any timestamps were ever reused. Alternatively, one could make system calls to the OS to get unique timestamps.

6. Performance Analysis

As a performance check, we compared timing profiles of the checked and unchecked implementations of Pointer against built-in C++ pointers. A sample main program executed two iterations of building a singly-linked list of 1,000,000 integers, searching for each of the integers using linear search, and then reclaiming all of the nodes in the list. The results from our timing experiments indicated that: Although the checked pointers are slow relative to the unchecked pointers, they serve their purpose well in code development. After you're done debugging your pointer program, if you replace the checked version by the unchecked version, then your resulting code will be essentially just as fast as built-in C++ pointers. Moreover, replacing the checked implementation with the unchecked implementation of Pointer is as easy as changing one include file; no other source code needs to be edited.

7. Conclusions

This paper contributes ideas that can be instrumental to the improvement of pointer education. On the concrete level, we have presented a real component that is freely available for immediate use as well as for potential refinement. More importantly, however, we have focussed on key insights that are easily accessible, and on design trade-offs and implementation considerations that can be realized in many ways. The outcome is an approach to checkable pointers that is faithful to C++ syntax without loss of performance.

8. Acknowledgements

We gratefully acknowledge financial support from our own institutions, from the National Science Foundation under grants DUE-9555062 and CDA-9634425, and from the Fund for the Improvement of Post-Secondary Education under project number P116B60717.

9. References

[Eva96] Evans, David. Static detection of dynamic memory errors. Proceedings PLDI '96, ACM, 1996, 44-53.

[Gin91] Ginter, Andrew. Cooperative Garbage Collectors Using Smart Pointers in the C++ Programming Language. Technical report 1991-461-45, Department of Computer Science, University of Calgary, 1991.

[Har91] Harms, Douglas E., and Weide, Bruce W. Copying and swapping: influences on the design of reusable software components. IEEE Transactions on Software Engineering 17, 5 (May 1991), 424-435.

[HJ92] Hastings, Reed and Joyce, Bob. Purify: fast detection of memory leaks and access errors. Proceedings of the Winter Usenix Conference, Usenix Association, 1992.

[JDK99] Java JDK License Agreement (http://java.sun.com/products/jdk/1.1/LICENSE), downloaded 10 September 1999.

[Str97] Stroustrup, Bjarne. The C++ Programming Language. Addison-Wesley, 3rd edition, 1997.

[TGM97] "Why Bre-X crashed the Toronto Stock Exchange" by Geoffrey Rowan in Toronto Globe and Mail, 12 Apr 1997 (http://www.globeandmail.ca/) (Also referenced from http://catless.ncl.ac.uk/Risks/19.09.html#subj1.1).

[WSJ98] 17 Jun 1998 Wall Street Journal section (B1) (Also referenced from http://catless.ncl.ac.uk/Risks/19.82.html#subj2.1).

10. Appendix: Code

Test Program Unchecked implementation Checked implementation