Siegfried Heintze is a Software Engineer involved in research, software development and training. He consults in all areas of software engineering and specializes in object oriented techniques. He has developed and taught courses on object oriented design and analysis and object oriented programming.
Introduction
Our ability to express our ideas is often constrained by the syntax and the semantics of a programming language. The difference between what we want to write and what we are constrained to write is spoken of as the "semantic gap." The better a language is able to support abstraction, the less prominent is the semantic gap. Facilities to support abstraction include abstract data types and object-oriented programming.This article identifies some semantic gaps in the C programming language and discusses bridging the semantic gap using C++. I had always regretted the fact the C programming language had no explicit support for values sometimes best represented as single bits, such as TRUE or FALSE. C programmers often use
typedef unsigned char Boolean;as a substitute when space is not an issue. If, however, it is evident that packing our bits into an unsigned long is appropriate, then we have to use macros. So, when we wanted to say:
BitArray x, b; x[5] = b[6];we were constrained to using some macros that might make the above statements look like this in C:
typedef unsigned long BitArray; BitArray x, b; int t; t = bitx(b,6); /* extract bit position 6, length 1 */ bitd(x,5,t); /* deposit into bit position 5, length 1 */Class BitArray
As you can see, there is quite a difference between what we wanted to write (or could write if we were writing in a language such as Pascal) and what the compiler constrained us to write. By incorporating the features of C++ we can overload the subscript operator (operator[]) to do what ever we want correct? Well maybe. Even C++ places some constraints on what we do. Let's start with a naive (but functional) implementation of class BitArray.
class BitArray { public: BitArray(); int operator[](int); void set(int value, int pos); private: unsigned long _data; };With this class definition, we can now write:
BitArray x, b; x.set(b[6],5);This is an improvement over the previous fragment in C because we can use operator[] to extract a bit. Unfortunately, we cannot use operator[] and operator= (the assignment operator) to deposit a value into an array of bits. As a substitute, we introduce the member function set to deposit new values into the array.If we want to abandon member function set and allow operator[] to be a modifiable 1value (an expression that can receive a value by being placed on the left hand side of an assignment statement), we typically overload operator[] to return a reference to an element of the array. Then we let the operator= actually perform the transfer of the value. We typically see operator[] overloaded as follows:
class IntArray { public: int& operator[](int sub){ {...} ... };The logic here is that we use a single function called operator[] to both deposit and extract values from the container object. The & indicates that we return a reference to a value instead of a value. Returning a reference permits operator[] to appear on the left side of an assignment statement, as in:
IntArray x, b; x[6] = b[5];There are two problems with this approach, however:1) We cannot return a reference to a single bit.
2) The function for fetching an element out of the array is identical to the function for storing an element. (In both cases we merely return a reference to the element).
These constraints are clearly not a problem when implementing an array of int because our function return type is a reference to an int. The problem, then, is how to access (fetch and deposit) the elements of an array of bits using operator[] and operator=.
The focus of the remainder of this article will be to examine some important features of the C++ language and develop a technique to solve the problem of returning a reference to a bit. Later, we will note that this is not the only place where type constraints make the naive use of operator[] difficult, so we can apply this technique elsewhere.
const Function Arguments
The const type qualifier allows us to tell the compiler that we promise not to modify an object. The object might be a function value, a function argument, or the secret (or implied) argument of a member function. This is done with a const type qualifier that becomes part of the signature of the function. Consider the following fragment of code:
void func(const BitArray & x) { if (x[0] == 1) ... }Here we are passing the argument by reference (which sometimes indicates that we plan to modify the argument). But then we declare the argument to be const, which indicates that the body of code is not permitted to do so. This may seem confusing. Why did not we just pass the argument by value (the default passing mechanism in C and C++ that automatically makes a copy of the object being passed)? Often the answer is that we could have, but passing by reference is more efficient because it is not necessary to invoke the constructor and destructor for the parameter x.Sometimes, in copy constructors for example, we are not permitted to pass by value. The problem with the above fragment of code is that the compiler will complain that we are modifying the object x. This is not actually true, of course, we are just examining it. Nevertheless, we can observe that we will sometimes be using operator[] to modify a BitArray object and other times we will need to explicitly state that operator[] will not modify the object. We can use the const feature to overload operator[]. We now modify the definition of class BitArray:
class BitArray { public: int& operator[](int sub) {...} int operator[](int sub) const {...} ... };The const in the second function is part of the signature and consequently becomes a criterion for selecting the correct function among several overloaded functions of the same name. We have solved one problem: we can now use operator[] for read-only access inside functions that don't permit us to modify BitArray objects. However, we are not finished yet because we don't know what the value is that we are going to deposit! Recall that operator[] has only two arguments: the implied argument (which is the array of bits) and the subscript.
Class BitField
Given the code:
BitArray x, b; x[6] = b[5];How does the operator[] function on the left know that we want to deposit a value of one and not of some other value, perhaps zero? The answer is that it cannot. Only the operator= function knows this. In the last code fragment, operator= receives two integer values. But the operator= function needs to know:1) the address of the representation of the array object (here the address of an unsigned long)
2) the subscript
3) the length of the bit field (in this example it is assumed to be one)
4) the value to be deposited in the array of bits.
The problem, then, is to communicate these four pieces of information to the operator= function.
To communicate the first three pieces of information we introduce a new class whose declaration looks something like this:
class BitField { public: BitField(unsigned long *data, unsigned int pos, unsigned width); void operator=(unsigned long rhs); private: unsigned long *_data; int _pos; int _width; };We also change BitArray to accommodate this new change:
class BitArray { public: BitField operator[](int) { {...} int operator[](int) const { {...} ... };Now we have solved another problem. We have a function BitField::operator= which will be called by the compiler on our behalf when the following code is encountered:
BitArray x, b; x[6] = b[5];BitField::operator= has access to all four of the necessary pieces of information to perform the deposit into the array of bits. When the compiler sees x[6] it notes that it is a modifiable lvalue and consequently calls function BitField::operator[](int), which returns a BitField object.The compiler then looks for an appropriate assignment function and notes that class BitField has such a member function. This is then used to perform the actual masking to deposit the new value into the unsigned long which holds the actual representation of the array of bits. We might consider ourselves finished at this point. However, there is still another potential problem. If we code in the tradition of many C programmers, we may want to use a feature whereby we are allowed to have multiple assignments within a single statement, like the following:
BitArray x; x[3] = x[2] = x[1] = x[0] = 1;This presents a problem because we declared our assignment operator function to be of type void, which means it is really a procedure that has no return value. So after we assign the value one to x[0] we have no value to assign to x[1].How can we make our new class BitField compatible with other built-in types in C? The key to this problem is to consider something counterintuitive. We normally don't think of operator= as a function that returns a value. But this exactly why we are permitted to write expressions such as a = b = c in C and C++. We now have a definition of BitField that looks like this:
class BitField { public: BitField(unsigned long *data, unsigned int pos, unsigned int width); BitField& operator=(unsigned long rhs); private: unsigned long *_data; int _pos; int _width; };We could have declared the assignment operator as:
BitField operator=(unsigned long)which would have returned a value of type BitField instead of a reference to a BitField. This would have caused constructors and destructors for the BitField class to execute. Since C++ allows us to return references as function values we added the & for efficiency at no cost. We still have not solved our problem however. operator= only accepts values of type unsigned long. Recall the fragment of code causing the problem:
BitArray x; x[3] = x[2] = x[1] = x[0] = 1;While we now have the rightmost operator= returning a reference for use by the next rightmost operator=, we need a value of type unsigned long when in fact we have a reference to an object of type BitField. There are a couple of ways to resolve this problem. We shall now examine one of them.
Conversion Operators
C++ has yet another feature called conversion operators, which convert from a user-defined type to a different type (often a built-in type). We may define such a function as:
class BitField { public: operator unsigned long() { {...} };Since this is a member function, we have a single implied argument and a function return value of type unsigned long. The compiler automatically inserts calls to this function to perform the conversions we need for statements containing multiple assignments. We now reexamine the example of multiple assignment operators one last time:
BitArray x; x[3] = x[2] = x[1] = x[0] = 1;Progressing from right to left: The rightmost operator[] returns a reference to a BitField object for which its member function operator[] is invoked. The function returns a reference to a BitField object. This is a problem because the next operator[] wants an integer value, not a reference to a BitField object. The conflict is resolved by invoking the coversion operator which converts the reference to a BitField object to an integer value which is then used by the next operator[].There is one scenario in which the conversion operator does not work as we would like. Consider some arbitrary constructor that accepts an integer, and a function that accepts a const argument of that type:
struct X{ X(unsigned int x){cout << "Create X =" << x << endl; } }; void f(const X& x){cout << "Function f"; }We expect the conversion operator for class X to perform the conversion, and indeed it does when the X object is declared to be const. Unfortunately, the conversion will not accomodate the conversion to a constant object. The workaround, shown below, is to explicitly declare a temporary constant object to pass to the function.
main(){ X a; f(a[1]); //error const X temp = a; f(temp[1]); //OK }Other Applications
Stepping back a bit, we had a scenario where it was intuitive to use the subscript notation (operator[]) to access elements of a container object. The problem was that, unlike arrays of integers, the method for depositing into the object was quite different from extracting a value. Where else might this technique be useful?The most obvious extension is to accomodate array elements represented by multiple bits. We can specify the number of bits to be used for each element in the constructor for BitArray. Bitfield objects will then contain the address, bit position, and length (i.e., number of bits). This is demonstrated in Listing 1, showing the file bitarr.h. The first argument to the constructor gives the width in bits. Listing 2 shows the accompanying file bitarr.cpp.
Interested readers will want to modify this code to accomodate arrays whose representation requires more than a single long word of bits. We have a similar situation for a video display composed of pixels, where the plot and readPixel functions mentioned earlier are used for writing and reading pixels respectively. If we have previously programmed in Pascal, we might be tempted to pursue implementing a syntax to accommodate code like this:
VideoDisplay v; v[5,6] = v[20,10];While this was possible in some older C++ compilers, it is not permitted in the current C++ language, because the underlying C language does not permit multiple arguments for operator[]. This is unfortunate, but there is a reasonable substitute:
VideoDisplay v; v(5,6) = v(20,10);We can use the same technique of overloading operator[] twice, the second time with a const type qualifier, then defining a temporary class of objects (call it class Pixel for this example) for which the appropriate conversion operators and assignment operators are defined. Yet another application of this technique might be ISAM (indexed sequential access method) files to implement persistent objects. We may find it intuitive to access indexed records with the subscript or brackets notation when directly using separate functions to read and write indexed records.