Passing By Reference-to-const
The rules for initializing references make passing by reference-to-const an efficient and attractive alternative to passing by value.
As in C, function calls in C++ normally pass arguments by value. For example, given:
int abs(int i);
calling abs(n) passes argument n to function abs by value. That is, the program copies the value of n to abs's parameter i each time it makes this call.
C++ uses pass-by-value even for objects of class types. (In C++, class types include struct and union types.) For example, given some struct type:
// a whole bunch of fields
int test(gadget g);
calling test(x) passes argument x by value. Much as before, the program copies x's value to parameter g each time it makes this call.
For large struct objects, passing by value can be very slow because it copies each byte of the argument to the corresponding byte of the parameter, as if by calling the Standard C memcpy function. The program does not necessarily call memcpy to do the job, but the argument passing mechanism behaves as if it did. Strictly speaking, passing a class object by value in C++ conceptually calls a special function associated with the class called the copy constructor. However, when the class is just a struct as in C, the copy constructor behaves essentially like memcpy.
You can avoid the cost of copying a large argument by passing it by address rather than by value. You simply change the function declaration to:
int test(gadget *g);
and then change the function call to test(&x). Passing &x (the address of x) is often a lot less work than passing an entire copy of x.
Unfortunately, when you change the parameter type from gadget to "pointer to gadget," you introduce a change in the function's behavior, or at least the possibility of a change. When passing by value, the function only sees a copy of the original gadget, so it cannot change that gadget. In my example, when passing by value, calling test(x) cannot change x. When passing by address, the function can dereference its pointer parameter and store new values into the gadget argument. That is, calling test(&x) might change x. Of course, you can prevent the function from tampering with its argument by using the const qualifier, as in:
int test(gadget const *g);
For very large objects, passing by address (with const) is almost always faster than passing by value. For objects of modest size (on the order of eight to 16 bytes), it isn't always clear at the outset whether passing by address will actually be faster than passing by value. It depends on the target machine's architecture and what the function does with the parameter. Sometimes you just have to make your best guess and wait to measure the performance of the running program. If it turns out that you guessed wrong, you may have to rewrite the function.
Unfortunately, rewriting a function call so that it uses pass-by-address instead of pass-by-value, or vice versa, can be a bona fide maintenance headache. Changing the function declaration from:
int test(gadget g);
int test(gadget const *g);
isn't much fuss, nor is changing the corresponding function definition. But numerous function calls might be scattered throughout the code, and you have to rewrite all those calls. In some cases, a call such as text(x) becomes test(&x). In others, test(*p) becomes test(p) (where p is a "pointer to gadget").
In C++, passing by reference offers an alternative to passing by address. You can declare the test function as:
int test(gadget const &g);
In this case, parameter g has type "reference to const gadget." This lets you write the call as test(x), as if it were passing by value, but it yields the exact same performance as if it were passing by address. Again, the const qualifier prevents the function from changing the value of its actual gadget argument.
Changing a function parameter from passing by value to passing by reference-to-const is a fairly minor change. You must change the function's definition and corresponding declaration, if any, appearing in a header. Although you must recompile any code that calls the function, you need not rewrite the calls.
In short, passing by reference-to-const is a potentially efficient alternative to passing by value. To the calling function, an argument passed by reference-to-const looks and acts just like one passed by value. The argument expression in the call looks the same either way-you need not add or remove *s or &s when switching from one form to the other.
When passing by value, the called function can't store into the actual argument because it only has access to a copy. When passing by reference-to-const, the called function can't store into the actual argument because it's const.
Last month, I explored the similarities between pointer and reference initialization. (See "Reference Initialization," September 2001) I also described a noteworthy exception to the general behavior of reference initialization. That difference is there so that passing by reference-to-const consistently behaves like passing by value. Allow me to recap.
Any discussion of pointer or reference initialization hinges on the concept of cv-qualified types. A cv-qualified type has the form "cv T" where, cv is a sequence of cv-qualifiers (const and volatile) and T is a type (without cv-qualifiers). The sequence cv can be empty, just const by itself, just volatile by itself, or const volatile (in either order). For any two sequences of cv-qualifiers cv1 and cv2, we say that cv1 has the same or greater cv-qualification than cv2, and write cv1 >= cv2, if every cv-qualifier in cv2 also appears in cv1. If cv1 >= cv2 is false, then we say that cv1 has less cv-qualification than cv2.
As a general (but somewhat oversimplified) rule, a variable of type "pointer to cv1 T" can be initialized to point only to an lvalue of type "cv2 T," where cv1 >= cv2. For example, given:
double *pd = &i;// error
is an error because a pointer to double can't point to an object of type int. Although 4.2 is a double, a declaration such as:
double *pd = &4.2;// error
is an error because 4.2 is an rvalue, not an lvalue, and you can't take the address of an rvalue.
As yet another example, given:
double const v = 4.2;
double *pd = &v;// error
is also an error. Although v is an lvalue of type double, it's const-qualified. So the declaration attempts to initialize a "pointer to double" so that it points to an lvalue of type "const double." It's an error because the pointer has fewer cv-qualifiers than the object it tries to point to.
The corresponding (and also somewhat oversimplified) rule for reference initialization is that a variable of type "reference to cv1 T" can be initialized to refer only to an lvalue of type "cv2 T," where cv1 >= cv2. For example:
double &rd = i;// error
is an error because a reference to double can't bind to an object of type int. The declaration:
double &rd = 4.2;// error
is also an error because you can bind a reference to an rvalue such as 4.2. And once again, given:
double const v = 4.2;
double &rd = v;// error
is an error. The declaration attempts to initialize a "reference to double" so that it refers to an lvalue of type "const double." It's an error because the reference type has fewer cv-qualifiers than the object it tries to refer to. On the other hand, either of:
double const &rd = v;
double const volatile &rd = v;
will compile without error.
As I noted last month, the above rules for pointer and reference initialization are a bit simpler than the rules observed by actual C++ compilers. For example, they don't cover a declaration such as:
void *p = &i;// OK
Nor do they cover initializing a pointer or reference using a derived-class-to-base-class conversion. However, these rules are sufficiently accurate for the following discussion.
The exception to the general rule for initializing references is that a variable of type "reference to const T" can be initialized with an expression e that is not an lvalue of type T, provided there's a conversion from e's type to T. In other words, when initializing a "reference to const T," the initializing expression e might be an rvalue of type T, or an expression (lvalue or rvalue) of type U different from T, where there's a conversion from U to T. In all cases, the program initializes the reference by:
1. Creating storage for a temporary object of type T (so that the reference has something to bind to)
2. Converting e to T, if necessary, and placing the result in the temporary storage
3. Binding the reference to the temporary storage
The lifetime of the temporary is the same as that of the reference. When the program no longer needs the reference (such as when the reference goes out of scope), the program discards the temporary as well.
Whereas a pointer initialization such as:
double const *pcd = &1;
is an error, a reference initialization such as:
double const &rcd = 1;
is valid. In this case, the compiler generates code to create storage for a temporary object of type double, convert 1 from int to double, place the result in the temporary, and bind rcd to the temporary.
Since 1 is a constant, any decent optimizing compiler can convert 1 (an int) to 1.0 (a double) at compile time. However, that optimization is not available when the initializing expression is not constant, as in:
double const &rcd = n;
where n is a variable of type int. In this case, the compiler has no choice but to generate code that will convert n to double at run time.
The program does not generate a temporary object during reference binding unless it has to. For example, in:
int const &rci = n;// OK
the program binds rci directly to int variable n. There's no need to create a temporary to hold a copy of n.
This special behavior for initializing a reference-to-const enables passing by reference-to-const to have the same outward behavior as passing by value. For example, given:
void f(double d);
then calling f(x) simply passes x by copying it to f's parameter d. Calling f(n) converts n from int to double and copies the result of the conversion to f's parameter d.
Now, suppose you change f so that it its parameter by reference-to const, as in:
void f(double const &d);
Calling f(x) looks just like it did when passing by value, except it passes a reference to x rather than a copy of x. Calling f(n) also looks as it did when passing by value, except now it creates a temporary double object containing the value of n converted to double, and passes a reference to that temporary.
If C++ stuck rigorously to the general rule that you can initialize a reference to T only to refer to an lvalue of type T, this call to f(n) would not compile. It would mean that passing by reference-to-const would not be a viable alternative to passing by value. Fortunately, the special rule that allows initializing a "reference to const T" with any expression that's convertible to T makes passing by reference-to-const a potentially efficient and attractive alternative to passing by value.
Dan Saks is the president of Saks & Associates, a C/C++ training and consulting company. He is also a consulting editor for the C/C++ Users Journal. You can write to him at firstname.lastname@example.org.