Rethinking How to Teach C++Part 9: What we learned from our students

During the more than 15 years we have been teaching C++, our notions of how to teach have changed dramatically. Our students are the most important factor in that change, because they let us know when something we try works—and when it does not.

Of course, it is our responsibility to interpret their messages. No teaching technique will work perfectly for everyone, and some students differ enough from their peers that the class as a whole presents a problem. For example, we remember one student—a mathematician—who wanted us not to explain anything, but simply to tell him the rules and let him work out the implications. We never did find out whether he would have been able to do so successfully, because in finding out, we surely would have lost the rest of the class. Another unique student was familiar with Smalltalk, but had never written a line of code in C++, C, Pascal, Fortran, or any other procedural language. We do not think it is especially difficult to teach C++ to Smalltalk programmers, but to do so in a way that C programmers could understand at the same time was more than we could manage. Accordingly, we spent a good deal of time outside the class helping our Smalltalk programmer get up to speed.

Fortunately, such exceptions are, we might say, exceptional. Ordinarily, a single course will attract students with similar backgrounds, because the students will learn about the course from the same sources. Our students' background has typically been some C programming (anywhere between a little and a lot), and some familiarity with another programming language (sometimes C++, sometimes not).

When we began teaching C++, most of our students were C programmers who were uncertain or skeptical about whether learning C++ was worth their while. For this reason, and because C++—and typical C++ programmers—had much more in common with C than they do today, we naturally based our original C++ curriculum on a C foundation. We emphasized the C++ features that were intended as improvements on C, such as stronger type checking, overloading, and type-safe input-output, explaining how those features could make programming easier and more reliable. Only later would we move on to the ideas of data abstraction and object-oriented programming. We believe that virtually everyone who taught C++ during this period did so in much the same way, and for much the same reasons.

As C++ evolved, we found that beginning with C was less important. More people than before came into the class knowing they wanted to use C++, and understanding at least a little about C++'s improvements on C. Moreover, as we taught—and used—C++, we gained a greater appreciation of what it was possible to do in C++.

Accordingly, a few years after we first began teaching C++, we overhauled the course to concentrate on what was now clearly the most important part of C++ from our students' viewpoint: the notion of a class. We began the course by defining classes immediately, and gave our students homework that required them to define their own classes.

This approach worked so well that it took us nearly eight years to realize it had a fundamental problem: It is hard to understand how to define classes without first understanding how to use them. To overcome this problem, we had to begin by showing how to use classes. Only then were students in a position to understand how to define and implement their own classes.

Of course, in order to show students how to use classes, we had to have classes available for them to use that they could continue to use after they had returned home. The first really useful such classes were the string and container classes in what ultimately became the C++ standard library. These classes, for the first time, made it possible to write programs that were as straightforward as if they were written in much higher-level languages such as Awk or Perl. Once the students understood how to use these classes to write simple programs, they were much better placed to understand why classes were useful, and how to write additional classes of their own.

Although it took us a long time to adopt this approach, it took much less time for us to convince ourselves it worked. The first time we tried the new approach in a classroom, our students learned so much more quickly that we resolved to make this approach more widely known. This experience is what ultimately led to our writing Accelerated C++.1

In short, we have approached the problem of teaching C++ in three different ways over the years. Each approach made sense at the time, but as the C++ language and its libraries grew, and the community's general state of knowledge changed, the first two approaches no longer served as well as they had in the past.

In the rest of this article, we'll compare these approaches in more detail. We hope you will find the comparison useful, not only in improving your own understanding of C++, but in appreciating how C++ programming styles and emphases have changed as the available facilities have grown.

C++ as a better C
The state of the C++ world in 1986 was very different than it is today. The only available compiler produced C as its output, a fact that implied that every prospective C++ programmer already had a C implementation available. There were few libraries available: Input-output and complex arithmetic were about it. In effect, C++ programmers did not have much more in the way of library facilities than the underlying C implementation offered. Nevertheless, there was great interest in C++. Of course, much of that interest took the form of "OK, we have heard about this C++ stuff. Why should we consider using it instead of C?"

We found a simple but convincing example: a program that tried to concatenate three strings together. The point is that the usual way of dealing with strings in C is through functions named strcpy and strcat, which deal with character arrays terminated by null characters, and which place all of the burden of memory allocation and safety checking on the user. Suppose, for example, we have two strings (i.e., null-terminated arrays of characters) named s1 and s2, and we wish to concatenate them with a period between them. A program fragment to do this might look like this:


   /* Define a (fixed-length) array to
      hold the result */
   char result[32];
   /* Concatenate the strings */
   strcpy(result, s1);
   strcat(result, ".");
   strcat(result, s2);
This example has several serious problems. Perhaps the most important is that there is no bounds checking of any kind on the result, so if the result of the concatenation exceeds 31 characters (not 32, because the size of result must include a null character at the end), the effect of the program is undefined.

The example we used in class showed five separate attempts to correct the problem, each of which introduced a new problem. Finally, we came up with a (much more complicated) version we believed to be correct.

Once we had shown how hard it was to get even a simple program such as this one right in C, we showed the C++ version:


   String result = s1 + "." + s2;
The point, of course, is that C++ makes it possible to write a String class that allows programs such as this one to work.

Note we said "C++ makes it possible." At the time we were using this approach, there was no widely available String class that would allow such programs to work. Some people had written such classes, but there was no single, generally agreed upon class that was the best to use.

Accordingly, we had ready-made material for the course: understanding the language features that made it possible to define such classes, and learning how to define them.

This approach was just what people seemed to need at the time. They mostly had substantial C applications that already existed, and they were not about to change their entire way of thinking just for the sake of using a new language. Instead, they wanted to be able to continue using the C programming techniques they had already learned, while using C++ in contexts they believed would be useful.

We remember one large C application from those days. The group that wrote it maintained three separate versions for small-, medium-, and large-sized computers, respectively. The versions differed in the sizes of a few dozen tables. The program defined these tables as fixed-size arrays, with each size given by a collection of preprocessor macros.

Because of the greater flexibility C++ classes offered, we were able to convince this group to use C++ instead of C for future development. They began by making the minimal changes needed to compile their system under a C++ compiler. As they made these changes, C++'s stricter type checking allowed them to discover and correct several serious bugs in their system that had gone undetected until that point.

Once the system was running as a C++ program, we were able to replace all of their fixed-size tables by instances of a small memory-management class we had defined for the purpose. As a result, they were able to replace the three versions of their system with one, greatly reducing their maintenance effort. A limited goal, to be sure, but a convincing one.

Moreover, this particular group would not have had the time to cope with a more ambitious goal at the time. A small improvement that works is more useful than a large one that fails.

Building abstractions
As C++ became more popular, we found we had to spend less time convincing people that they should be interested in using it. Instead, we started getting students whose attitude was something like "We are convinced that C++ is great stuff; what can it do for us?"

Class libraries were still not universally—or even widely—available. Accordingly, the answer to the question had to be "You can use C++ to build abstractions that will let you write programs at a higher level."

To teach students to build useful abstractions, we had to cover two substantial concepts. The first was the notion of a class, along with the idea that when you define a class, you define a collection of operations on objects of that class, and all of the operations must fit together into a coherent whole. The second concept was the need to understand the data structures to be used to implement a class.

It is easy to assume that students who are already experienced C programmers—as most of our students were—would understand enough about data structures that they would have no trouble implementing whatever they had designed. It took us quite a while to realize why this assumption was not true.

The problem is that students had too much to learn at once. Not only did they have to apply their knowledge about data structures and operations thereon, but they had to make those operations available by using syntax they had to learn at the same time. Accordingly, our students had much more trouble learning how to design and implement classes than we first thought they would.

We began by showing how to define a class that did not rely on complicated data structures. The simplest such class we could think of was one to represent complex numbers, so that's what we used. Complex numbers have the pedagogical advantage that almost everyone understands their representation, and they do not require any memory allocation or auxiliary data structures.

Once we explained how to define a class to represent complex numbers, we assigned the students the problem of defining a class that represents a variable-length array of integers. We thought this assignment would be simple. After all, if you understand how to define a complex-number class that works, and you understand enough about pointers, dynamic arrays, and memory allocation to be able to program in C successfully, there is nothing additional to learn. Solving the problem is just a matter of applying the knowledge you already have.

What we found in practice was that the students always had tremendous difficulty with that first assignment. Although in principle they knew everything they needed to know, they did not know how to apply that knowledge in practice. The combination of having to think about generalized class design and having to apply their data-structure knowledge in a new context proved to be too much to master at one time.

We would see the same kind of frustration each time we taught the course. If it was a one-week class, we could confidently predict that most of the students would be frustrated and discouraged when they tried to do the homework assignment at the end of the first day, and that the frustration would persist through the second day. When students came in on the third day, they would have been immersed in the kinds of abstractions we had been teaching for long enough that they would begin to make sense, and by the third evening, most of the students would understand the concept. After that, their frustration would vanish, and they would learn rapidly. We became so familiar with this pattern of initial frustration that we would begin the course by telling our students we knew they would be frustrated for the first two days, but after that they would have a much easier time of it.

You might think that if we knew what caused the frustration, we could have avoided it. Nevertheless, we could not not figure out how to do so. As C++ existed at the time, it was still mostly a tool for defining new abstractions. Therefore, an introductory course had little choice but to begin by talking about how to define abstractions. The tools the students already had available were primitive enough that there was little choice but to begin by teaching them to define abstractions immediately. But learning how to define abstractions was the source of the trouble: It is so much harder to define new facilities when one does not yet know how to use those facilities! What could we do?

Using abstractions first
The answer to our question came from a library package called Standard Template Library (STL), much of which eventually became part of the C++ standard library. For the first time, our students had a collection of abstractions available to them that they did not have to write for themselves. Instead, they could write programs in terms of those abstractions, and defer thinking about how the abstractions might work until they became comfortable with using them.

Earlier in this series of articles (Part 7: Payback time),2 we described a program that produces a permuted index: an index that can be used to find a phrase from any of its constituent words. That program makes good use of library facilities. It uses string objects to store words, vector objects to store sequences of words, and very simple user-defined data structures to implement the algorithms needed to produce the permuted index. As a result, although this program appears on the surface to be much more complicated than a simple array class, it does not require the kind of mental shift that designing and building the array class requires. The point is that instead of introducing new ideas and a new syntax at the same time, it is a familiar-looking program that just happens to use C++ standard-library facilities instead of the more primitive ones built into the language.

The last few times we taught the course, we used the cross-reference program as our first homework assignment. To our delight, students found this problem much easier to solve than the array-class problem, even though the array-class problem was conceptually simpler. We always knew that abstractions such as strings and flexible arrays make programming easier than lower-level abstractions such as pointers and explicit memory allocation, and we were gratified to find that our knowledge worked as well in practice as it did in theory.

Moreover, once the students understood how to use these abstractions, they had a much easier time learning how to define new ones. For example, along the way to understanding how to use the string class, you learn about the operations the class provides and the relationships between the operations. If you then set out to implement a similar class, you are in a much better position to understand the task before you, because you already understand how to use the abstractions you are building.

Discussion
Programming is about building and using abstractions. Examples of such abstractions are subroutines, data structures, classes, subsystems, and entire systems. An abstraction must exist before people can use it. Accordingly, if you set out to program in an environment in which few abstractions are available, you must learn how to create your own abstractions before you can get anywhere.

Experienced C programmers are adept at creating a particular kind of abstraction. These abstractions work well in the C world, particularly in the area of operating systems and system programming, but they are less well suited to higher-level applications. In the early days of C++, then, the first task in learning C++ had to be to learn how to build abstractions on the low-level C substrate that were useful for higher-level applications.

The big change in the C++ world came about when the language had advanced to where it could support generally useful abstractions—and when the C++ community had embraced a particular collection of abstractions as the standard library. At that point, it became possible for the first time to teach C++ as a high-level language, and to defer the low-level details until after the student had learned to program at a high level.

In short, it is the C++ standard library that made possible the teaching strategy we used in Accelerated C++. That strategy, in turn, has made it possible for future programmers to view C++ in a completely different way.

Farewell...for now
This is our last column in the Journal of Object-Oriented Programming for a while. We intend to continue contributing, but on an irregular schedule. This has not been an easy decision for us. This article is the 110th we have written for the Journal of Object-Oriented Programming; the column has appeared in every issue but two since the very first one in April 1988.

If you look through this article again, you will see that students' main interest in C++ has changed over time. Originally, it was "Why should we care about this new-fangled language?" Later, it was "What can C++ do for us?" Still later, it was "How can we get up to speed most effectively?" This change is the result both of the growth of the language and the increased sophisitication of its user community.

Another aspect of the growth of C++ is that there are many more people writing about it today than there have been in the past. Accordingly, it is much harder than it used to be to think of topics that other people have not covered.

We do not want to stop writing columns entirely, as we are sure that from time to time we will have something to say that will interest our readers. However, the success of Accelerated C++ suggests to us that it is a good time for us to focus our efforts in a different direction.

We thank all of our readers for their interest, and we hope to be back again before too long.

References

  1. Koenig, Andrew and Barbara Moo. Accelerated C++: Practical Programming by Example, Addison–Wesley, Reading, MA, 2000.
  2. Koenig, Andrew and Barbara Moo. "Rethinking How to Teach C++, Part 7: Payback time," Journal of Object-Oriented Programming, 14(1): May 2001.