Previous | Table of Contents | Next |
Aside from making this class a template, and changing char to T where appropriate, the only change was to the constructor that did not take any argument. The reason for the change was to remove the dependency on the type of the string literal , because it does not necessarily make sense to use , which has type char*, to initialize a string of characters of arbitrary type. Instead, we defined a static data member, which is a member that has one instance for the entire class rather than one instance for each object.
In the case of a template class, there is one instance of the data member for each instantiation of the class. That is, String<char>, String<unsigned char>, and String<wchar_t> each has its own null member. In this example, the member is a single-element array, which we must define at one single point in the program as shown. This definition as a global object causes the element of that array to be initialized automatically by a default that is zero for all integral types, including character types. This array is therefore what we use to represent a single-element array, of unknown type, whose element is zero.
To use a template type that represents a container, such as our revised String, we must explicitly mention the element type. So, for example, we can write
String<char> sc; String<unsigned char> uc; String<wchar_t> kanji_name;
and so on.
When we rewrote strlen, we did so to make it work with arrays of elements of any type that could be compared to zero. However, a further rewrite makes strlen even more general:
template<class T> int strlen(T p) // instead of const T* p { int n = 0; while (*p++) ++n; return n; }
Now, we do not even require p to be a pointer. We just require it to be an object of a type that gives a meaningful value to *p++. Because of operator overloading, p could be any class object that behaves enough like a pointer; all that strlen would require would be suitable definitions for operator* and operator++.
Indeed, the notion of defining a function that works with any type that supports the requisite operations is the source of a great deal of power in the C++ standard library, and offers more possibilities than can be described in detail in a single article. We can, however, offer a sample.
Consider how we might go about reversing the elements of an array. A fairly general way of describing an array is by a pair of pointers; typically one pointer will point to the first element and the other to one past the last element. We might write a function to reverse such an array as follows:
template<class T> void reverse(T* begin, T* end) { while (begin < end) swap(*begin++, *--end); }
where swap might be defined as follows:
template<class T> void swap(T& x, T& y) { const T temp = x; x = y; y = temp; }
The asymmetry between *begin++ and *--end is necessary because end starts out pointing one element past the end of the array. We must therefore decrement end before we dereference it.
This definition of reverse is overspecified in two important respects: It assumes that begin and end are pointers, and that there is an order relation < defined on them. Imagine if begin and end were class objects that had ++, --, and * defined appropriately, but did not have < defined. Such objects might, for example, represent locations in a doubly-linked list, whose elements might be arbitrarily scattered throughout memory. It would therefore make sense to use == and != to compare such objects, even if < might not make sense.
It is possible to reverse a data structure defined by such objects, although it costs a small amount of extra overhead to do so:
template<class T> void reverse(T begin, T end) { while (begin != end) if (begin != --end) swap(*begin++, *end); }
Here, as before, we loop as long as there is work to do. However, this time, we decrement end and compare it against begin a second time before swapping *begin and *end and then incrementing begin. This double test is necessary to ensure that begin and end do not cross.
What we have gained for this small overhead is the ability to use a single algorithm to reverse any data structure for which there exists a type that acts sufficiently like a pointer. Moreover, there exist much more sophisticated techniques that can remove even this small amount of extra overhead.
Despite the benefits of determining types during compilation, rather than when the program is written or run, there are times when it can be useful to defer knowledge of the type of an object until the program looks at that particular object. The most common context for deferring such knowledge is when we have a collection of data structures that are similar to each other in some way. Then we may wish sometimes to consider only those properties that they have in common, and other times to take their differences into account.
For example, if we are writing a system that displays a variety of graphic symbols on a screen, we often might care only that an object represents something that we can potentially display. However, when it comes time to display it, we will care what particular kind of symbol it is, because different symbols will require different display algorithms.
C++ uses two separate mechanisms to make object-oriented programming possible. One is inheritance, which lets us describe a class by describing only the members that it has in addition to those provided by some other class. The other is the notion of a virtual function, which is a function that is selected at run time based on the type of an object.
Previous | Table of Contents | Next |