ICS 45C Spring 2022
Notes and Examples: Constness
The need for specifying constness
We've seen many times this quarter that C++ is a language that takes the notion of types very seriously, and that it requires the same of you. By encoding your intent — what type of value can be stored in a variable, what type of parameter can be passed to a function, and so on — the compiler is able to verify that intent statically, before the program is ever allowed to run. If you write code that violates your own stated intent, what you've got is a program bug, so rather than allowing that bug to persist in a running program, C++ will require that you fix it before the program successfully compiles.
C++ actually provides a much richer notation for describing types than you might first imagine. While there is complexity in the notation, the benefit is that you're able to encode a lot of useful information about your intent, which helps not only a human reader, but also the compiler.
One very useful concept included in C++'s type system is the ability to express your intent not to change a value. Any attempt to change a value in places where you've said you wouldn't can then be reported as a compile-time error, as just another violation of your own stated intent; there's a really good chance that these are program bugs, so it's better to find out about them sooner rather than later. As it turns out, when you promise not to change something, you can also enable the compiler to perform optimizations it might not otherwise be able to perform. Since the compiler, too, is sure that a value won't change, it might be able to make profitable changes to the code it generates based on that assumption.
There is a single keyword in C++ that embodies the concept of your intent not to change a value: const. The const keyword can appear in a variety of contexts and it means subtly different things in each of them, but its meaning always centers around your intent not to introduce change, and your desire for protection against violating that intent.
Constant variables
Declaring a variable whose value can't be changed is as simple as prepending the word const to its type. Note that const is actually part of the variable's type. For example, consider these two declarations:
const int x = 3; const std::string name = "Boo";
Like many of the more complex type declarations in C++, it's easiest to read these from right to left, so we would say that "x is an integer constant" and that "name is a string constant." In both cases, we've given these variables a value at initialization, and it's important to note that this is a requirement for constant variables of the built-in types (like int, double, and bool). Either way, once a constant variable has been initialized, its value can never be changed again, and any attempt to do so will result in a compile-time error, so these statements would be erroneous:
x = 9; name = "Thornton";
What you are and aren't allowed to do with a const variable depends partly on its type. For a const int, the promise you're making seems clear enough: You can read its value but not assign to it. But for a const std::string, it's a bit murkier, because there are not only assignment operators, but other operators like +=, as well as member functions that you can call on them. For example, consider the following examples; some of them are legal and some aren't.
std::cout << name << std::endl; // legal, because it only reads the value std::cout << name.length() << std::endl; // legal, because it only obtains the value's length name += "Thornton"; // illegal, because it changes the value name.clear(); // illegal, because it changes the value std::string copy = name; // legal, because it doesn't change the value, but only makes a copy of it copy.clear(); // legal, because it only changes the copy
In all of these cases, the legality or lack thereof is driven by a single underlying factor: The value of name is not allowed to change. So, of course, printing that value is legal because it doesn't change it. Asking for its length doesn't change it, so that's fine, too. Appending a string to it is problematic, because this modifies name. The clear member function removes all characters from name, so that's a change and is, thus, illegal. Making a copy of name into a separate variable called copy is fine, because name doesn't change, and we don't even have to promise not to change copy; any change to copy will affect only copy, but not name, so even clearing copy is fine.
We'll see that there are some challenging details here, but, suffice it to say, the compiler is aware of the distinction between what operations are safe on constant values of properly-designed types. One of the hallmarks of a properly-designed type is making that distinction, so we'll need to be aware of this when we design our own types, as well.
On the viral nature of constness
The bargain you're making with a const variable is not just about the variable; it's about the variable's value. Let's make sure we understand exactly what bargain we're making with the compiler, because that's vital to being able to use const correctly. When you include const in the type of a variable x, what you're saying to the compiler is this:
The bit about introducing "even the possibility of x's value changing" is important, because it limits things more than you might first consider. There are ways to change the value of a variable x without assigning directly to it, particularly when you consider the effect of pointers and references, which allow you to do things like this:
So the compiler has to be more aggressive about enforcing the bargain than you might initially think. Let's consider some of the following declarations and decide whether they're legal or illegal.
const int x = 3; const int y = x; int z = x; const int w = z; int& r = x; int* p = &x;
What these examples show us is that const is difficult to introduce into a program in a halfway manner. You either have to embrace the concept fully throughout your program — as we'll do in this course — or leave it aside altogether, but trying to mix code that is const-correct with code that isn't can be very difficult indeed, because you'll find yourself unable to pass const-protected arguments to functions that do not make the same promises about their parameters.
Using const with references
Just as you can include const in the declaration of a type such as an int, you can also include const in the declaration of a reference type. Syntactically, you do it the same way: by prepending the word const to the type.
int x = 3; const int& y = x;
The first thing to consider is what we've stated is constant. Reading the type declaration of y from right to left, we see that "y is a reference to an integer constant." It's the integer that's constant here, not the reference. (In truth, it's actually both, since references are effectively constant by definition; once you've referred a reference to an object, it's not permitted to later refer to another.)
The second thing to consider is what we haven't stated is constant. The declaration of x says that it's an integer, but makes no guarantees about its constness. That means it'll be legal to modify x if we want, making it legal to assign it a new value, even though that will cause y to have a new value, too.
The key thing to understand is that a reference to a constant doesn't necessarily guarantee that the value it refers to will never change; it simply guarantees that the reference itself will never be used to change the value.
The ability to declare a reference to a constant solves a problem we had earlier: how to point a reference to a variable that's already const. The solution is to use a reference with const in its type also.
const int x = 3; const int& y = x;
It's legal to refer y to x. Let's think about why:
Parameters with const reference types
As with many features in C++, when first confronted with the idea of a const reference type, you may wonder what practical usefulness it has. It turns out that these are incredibly useful, because they allow us to bridge the gap between two things that we wanted, but couldn't yet achieve simultaneously.
Declaring a parameter's type to be a reference to a constant provides precisely this middle ground: The object is passed by reference, meaning that it's not copied, but the function guarantees that the object won't be changed. For parameters of small, cheaply-copied types like int, this is not a step you would take. But for a potentially large string, or a large or complex data structure, this is a hugely important technique.
Consider, for example, a function that prints the characters of a string in reverse. One way to implement that function might look like this:
void printInReverse(const std::string& s) { for (int i = s.length() - 1; i >= 0; --i) { std::cout << s[i]; } }
There's no reason why this function should need to operate on a copy of the string passed into it, which might be quite large. But there's also no reason why the function would ever make any change to the string that's passed to it. The type const std::string& captures this idea perfectly.
There's one more useful wrinkle. As we saw before, one thing you give up when you use pass-by-reference parameter passing is the ability to pass an rvalue as an argument. This restriction is lifted for parameters with const reference types, since the problematic operation — changing an rvalue, which has no storage associated with it — is not an issue if the value can never be changed.
Using const with pointers
The notion of constness becomes more complicated for pointers. Recall that a pointer variable p gives you the ability to access two different things:
When you consider the effect that const might have on a pointer's type, you realize that there are four possibilities for what you might like to hold constant:
Given that we might like to say any of these things, the question is simply one of syntax. How do we say these things? The answer is that we have to put the word const in the correct places, and the trick lies in what we've learned about reading type declarations from right to left. Consider these five type declarations:
int* p; const int* q; int const* r; int* const s; const int* const t;
Reading these declarations from right to left, we see that these type declarations represent five possibilities of what we might like to specify.
None of these is definitely better than the others; aside from the declarations of q and r (which are two different syntaxes with the same meaning), each represents a different constraint that we might reasonably want to express, and we'll likely see useful examples of all of those constraints as we progress this quarter.