Part I: Fundamental Concepts
Chapter 3: Software Components
Last updated 10 April 1998 |
Generic programming is based on the idea that we can break programs down into components, and write many of the components in a general way that allows them to be mixed and matched with a variety of other components. We implement this composition by defining the set of interfaces (i.e. operators and functions) assumed by a component, and then providing those interfaces in other components. In the preceding chapter, we discussed our assumptions about the standard operators and their interfaces. However, few interesting components can be written practically using only standard operators. Rather, we must often specify interfaces at a higher level.
Using higher level interfaces provides us with a corresponding increase in the power available from our components, but it also makes it easy to define interfaces which are so specialized that it becomes difficult to use them in a variety of contexts. We can minimize this effect by clearly understanding the purposes served by components, and by designing component interfaces to cleanly satisfy one of a small set of such purposes. This perspective is the core of this chapter, which will discuss a taxonomy of software components, and describe effective techniques for developing and using them in C++.
Before we proceed with the presentation of this taxonomy, it is important to point out what we are not trying to do. The past 25 years have seen attempts to revolutionize programming by reducing all programs to a single conceptual primitive. Functional programming, for example, made everything into a function; the notions of states, addresses, and side effects were taboo. Then, with the advent of object-oriented programming (OOP), functions became taboo; everything became an object (with a state and associated methods).
Far from solving the problems of programming, these reductionist approaches have been distracting and ultimately harmful. Effective programming uses a variety of paradigms, because different parts of the problem have different requirements. For example, an array and a binary search should not be reduced to a single, fundamental notion. The two are quite different. An array is a data structure -- a component that holds data. A binary search is an algorithm -- a component that performs a computation on data stored in a data structure. As long as a data structure provides an adequate access method, you can use the binary-search algorithm on it. Only by respecting the fundamental differences between arrays and binary searches can efficiency and elegance be simultaneously achieved.
This observation leads directly to the orthogonal decomposition of component space which is at the core of generic programming. We want to understand the differences between components. Only by understanding these can we identify the meaningful similarities which are the basis of our taxonomy. This is the fundamental philosophical failure of the reductionists. By starting with the claim that everything is a function, or an object, and ignoring their differences, they discard all knowledge. A definition that fits everything provides no interesting information. The attempt to reduce everything to a single category is also methodologically wrong. It is as if mathematicians were to start with axioms, and then see if any of the resulting theorems were interesting. Instead, mathematicians start with likely theorems, attempt to prove them, and finally identify their axioms based on the requirements of of their proofs. Similarly, programming methodologies should begin with interesting algorithms, understand them well, and only then attempt to define interfaces that will make them work. (I don't actually like this analogy. It's pretty weak.)
Another important failing of the language design community has resulted from a fundamental misunderstanding of addresses and pointers. Many brilliant computer scientists have observed problems with pointers, and come to the conclusion that they should be avoided. Functional programming was motivated partially by the desire to work with values instead of addresses. Object-oriented programming attempts to minimize pointer use by providing access only to the object and not directly to its members. The Ada programming language limited pointer use to objects in the heap, forbidding their use with global (static) and stack objects.
But in fact, pointers are very good. We don't mean dangling pointers. We don't mean pointers to the stack. But the general notion of pointer is a powerful tool, universally used. In programming, as in the real world, we are usually interested in identifying specific objects rather than identifying an object with a particular value. The address of the object is how we identify it. Sometimes we can use the notion of a variable name in lieu of an explicit address, but complex data structures and algorithms to do not lend themselves to explicit naming of each component, depending on the use of anonymous pointers or indices to navigate a uniform space like a vector or a tree.
It is incorrectly believed that pointers make our thinking sequential. That is not so. Without some kind of address we cannot describe any parallel algorithm. If we attempt to describe an addition of n numbers in parallel, we need to be able to talk about the first number being added to the second number, while the third number is added to the fourth number. Some kind of indexing is required. Addresses are fundamental to our conceptualization of any algorithm, sequential or parallel.
Rather than attempt to avoid pointers, we will abstract their useful properties, developing them carefully in Part III. By abstracting them, we can actually provide a more disciplined usage, which helps to avoid some of the problems which led to the misguided attempts to eliminate pointers.
Writing and using general-purpose algorithms depends on our ability to take the operations defined on an arbitrary type and map them to the specific operations required by a generic algorithm. For example, consider a sort algorithm. Its most fundamental requirement is an operation which compares two elements of the data structure being sorted, and orders them. Although the default "<" operator is often appropriate for this purpose, it is by no means the only interesting ordering operator. Thus, we need a way to provide a specific function with the name expected by the generic algorithm.
Even in C, we can pass a pointer to a function as a parameter to another function, but this is not as general as we would like. It is useful to be able to specify not only the code to be used for a function, but also some state. For example, if we are passing a function which adds two numbers modulo some base, we need to associate the base value with the function. We call this construct a function object, because it is an object which incorporates a function's code along with the state data which it requires. The alternatives, i.e. putting the state in global variables referenced by the function, or writing a distinct version of the function for each state of interest, are clearly less desirable.
In C++, our preferred technique for defining and using function objects is derived from the treatment of template functions. When C++ sees a function call which matches a template, it automatically instantiates the template with the required template parameters. However, it must be able to derive those parameters' types from the types of the function call parameters. Consider the following code fragment:
|
However, if the application calls this template function for sort, the compiler cannot deduce the desired Compare type from the parameter list, which specifies only the first and last positions to be sorted. Instead, then, we use a declaration like the following, passing the function object as an explicit parameter:
|
With this context in mind, let us consider the appropriate interface for the Compare function object itself. We could require that the Compare class have a comparison function as a member function with a particular name. But C++ gives us a means to avoid adding another name to the specification -- we can define an application operator, which allows us to simply treat the function object's name as the function name:
|
Next, consider the declaration of the formal parameters to operator() as const T&. While we could pass them by value, this could be much more expensive for large types T, or for types T with expensive copy constructors. This is particularly true in cases where the "<" operator for T ends up referencing a small part of the object. On the other hand, if the type T is a simple type without copy constructors, the compiler is free to actually pass it by copying the value if that is less expensive. With an ideal compiler, therefore, we get the best of both worlds.
To deal with less than ideal compilers, in important cases, it may still be desirable to provide specializations of the function object for common simple cases. For example, we might specialize for int as follows:
|
The examples we have considered so far associate no state with the function object -- the function code requires none. This is not always the case, however. In fact, much of the power of function objects comes from the ability to include state with the function, without passing additional parameters and thereby changing the interface.
As a demonstration, let us consider a different sorting problem. Some algorithms in graphics, for instance convex hulls, start by sorting a list of points in the order of the angle of a line connecting a particular point with each of them in turn. This sorting problem involves a comparison operator with state in the form of the distinguished point. If points are represented as pairs of floats, we might start with the following definition:
|
|
Now consider how to sort a set of these points in order of their angles from a central point. First, we define a function which returns the angle between the line connecting them and the x-axis:
|
|
Next, present an example of a function object with state. One possibility is a less than function defined on points in a plane, comparing the distance of two points from a fixed third point; This fits well into the sort framework we have already introduced. A second possibility is a function which tests whether its parameter value is less than some fixed value. This could be used with the find function. It provides the basis for a discussion of the binder function object adapters, and could lead into, or fit into, the next section.
The following are points to be integrated somewhere:
Any specialization must satisfy the constraint that removing it from a program, if the program remains valid, does not change the results of the program, although it may become less efficient (slower, more resource consumption). That is, a specialization may be richer than the more general component it specializes, but it may not be semantically different.
(Specializing classes is not supported in C++.)
Send comments about this page to Jim Dehnert or Alex Stepanov. |