The const
Keyword
The const
keyword is a type qualifier in C++ that allows us to
prevent an object from being modified. As an example, consider the
following erroneous definition of strcpy()
:
void strcpy(char *dst, const char *src) {
while (*src != '\0') {
*src = *dst;
++src;
++dst;
}
*src = *dst;
}
In this definition, the assignments are backwards – the code is
attempting to modify the source string rather than the destination.
However, because the src
parameter was declared with type
const char *
, the compiler will detect this error:
$ g++ --std=c++17 strcpy.cpp
strcpy.cpp:3:10: error: read-only variable is not assignable
*src = *dst;
~~~~ ^
strcpy.cpp:7:8: error: read-only variable is not assignable
*src = *dst;
~~~~ ^
2 errors generated.
A variable declared as const
can be initialized, but its value
cannot be later modified through an assignment. For a simple type, the
const
keyword may appear on either side of the type:
const int x = 3; // initialization is OK
int const y = 4; // const can go on the right as well
x = y; // ERROR: attempt to assign to const object
Only types with values may be declared as const
. The following
types do not have values:
References; they alias objects that may have values but do not have values of their own.
Arrays; as we saw previously, arrays are objects that do not have values of their own.
Functions; we will see function types later in the course.
References and const
We saw previously that the binding between a reference and the object it aliases is established at initialization, and it cannot be broken as long as the reference exists. Thus, it is not meaningful for a reference itself to be const.
While a reference does not have a value and thus cannot be declared as
const
itself, it can refer to an object with const
type. The
const
keyword can appear to the left of the &
in a reference
type, as in const int &
– this is read “inside out” as “reference
to a constant int”. The object the reference is aliasing is not
modifiable through the reference:
int x = 3;
const int &ref1 = x; // reference to const int
int const &ref2 = x; // const can go on the right of int as well
ref1 = 4; // ERROR -- attempt to assign to const object
However, the original object is still modifiable if there is a non-const way to refer to it:
int x = 3;
int &ref1 = x;
const int &ref2 = x;
x = 4; // OK -- x not declared as const
ref1 = 5; // OK -- ref1 a reference to a non-const int
ref2 = -6; // ERROR -- ref2 a reference to a const int
The example above has three names that refer to the same object.
However, the object is only modifiable through the names that do not
include the const
keyword.
In the two previous examples, we have created reference-to-const aliases for a non-const object. We will see shortly that non-const to const conversions are allowed, but the other direction is prohibited.
Arrays and const
Since an array does not have a value of its own, it cannot be assigned to as a whole – we saw previously that a compile error would result, since we cannot obtain an array value to place on the right-hand side of the assignment. Thus, it is also not meaningful for an array itself to be const either.
Similar to a reference, an array may not be const itself, but its elements may be:
const double arr[4] = { 1.1, 2.3, -4.5, 8 };
arr[2] = 3.1; // ERROR -- attempt to assign to const object
The declaration const double arr[4]
is read inside out as “arr
is an array of four constant doubles.” The elements can be initialized
through an initializer list, but they may not be modified later
through assignment.
Pointers and const
Pointers do have a value, so they can be declared as const
. To do
so, the const
keyword is placed to the right of the *
:
int x = 3;
int y = 4;
int * const ptr = &x;
*ptr = -1; // OK -- ptr is pointing to a non-const int
ptr = &y; // ERROR -- attempt to assign to const object
Reading the declaration of ptr
inside out, we get “ptr
is a
constant pointer to an int.” Thus, we cannot modify the value of
ptr
itself. However, since the type that ptr
is pointing to
(what is to the left of the *
) is not const, we can modify the
object that ptr
is pointing to.
Similar to a reference, we can declare that the object that a pointer
is pointing to is const by placing the const
keyword to the left
of the *
:
int x = 3;
int y = 4;
const int * ptr = &x; // equivalent: int const *ptr = &x;
ptr = &y ; // OK -- ptr is not const, so we can change its value
*ptr = -1; // ERROR -- attempt to assign to const object
Finally, we can declare that both the pointer itself and the object it is pointing to are const:
int x = 3;
int y = 4;
const int * const ptr = &x;
ptr = &y ; // ERROR -- attempt to assign to const object
*ptr = -1; // ERROR -- attempt to assign to const object
const
Conversions
We’ve seen examples above where we’ve constructed references-to-const
and pointers-to-const objects from objects that were not declared as
const
. The general rule for converting between const and non-const
is that the conversion must not enable modifications that are
prohibited without the conversion.
A const object can appear on the right-hand side of an assignment, since an assignment has value semantics – it copies the value from the right-hand side to the left-hand side object. Thus, it does not permit the const object to be modified:
const int x = 3;
int y = x; // OK -- copies value from x; does not enable x to be modified
On the other hand, if we initialize a reference-to-non-const with a const object, we would enable the object to be modified through the reference. Thus, such a conversion is prohibited:
const int x = 3;
int &ref = x; // ERROR -- enables const object to be modified through ref
ref = 4; // would modify the x object if the above were allowed
The same goes for placing the address of a const object in a pointer-to-non-const:
const int x = 3;
int *ptr = &x; // ERROR -- enables const object to be modified through ptr
*ptr = 4; // would modify the x object if the above were allowed
The other direction is allowed, however: creating a reference-to-const or pointer-to-const from a non-const object does not enable any new modifications:
int x = 3;
int const &ref = x; // OK -- does not permit const object to be modified
const int *ptr = &x; // OK -- does not permit const object to be modified
The compiler only reasons about each conversion in isolation. This means that it does not allow a conversion from const to non-const even if we have some other means of modifying the underlying object:
int x = 3;
int *ptr1 = &x; // OK -- does not permit const object to be modified
const int *ptr2 = ptr1; // OK -- does not permit const object to be modified
ptr1 = ptr2; // ERROR -- compiler sees that ptr2 is pointing to a
// a const object, but ptr1 is not a pointer
// to const
In the example above, even though ptr2
was originally initialized
from ptr1
, we cannot later assign the value of ptr2
to
ptr1
, since it would be converting a pointer-to-const to a
pointer-to-non-const. This is actually useful for us as programmers:
if we pass a pointer to another function, and the function guarantees
that it won’t modify the pointed-to object by declaring its parameter
as pointer-to-const, then we would be unhappy if the function could
convert it back to a pointer-to-non-const and modify the pointed-to
object. [1]
Compound Objects
As we mentioned previously, C++ has several different categories of objects, including atomic, array, and class-type objects. Class-type objects are objects composed of member subobjects, each which may be of a different type. They are also often called compound objects.
In C++, a class type is introduced through the struct
or class
keyword. We will use the two keywords for different conventions when
introducing our own data types. Later, we will see that the actual
distinction between how C++ treats the two keywords is minimal.
In order to introduce a new data type, we use the struct
keyword,
followed by the name of the type we are introducing, followed by a
body with member declarations:
struct Person {
string name;
int age;
bool is_ninja;
};
Here, we are introducing a Person
type. The body contains
declarations for three member variables, each with their own type
and name. The semicolon after the struct definition is mandatory,
unlike in some other languages.
After the struct definition, we can create objects that have
Person
type. The following program creates two local variables of
type Person
:
int main() {
Person elise;
Person tali;
}
Each Person
object has its own subobjects name
, age
, and
is_ninja
located within the memory for the Person
object, as
shown in Figure 29.
Since we did not explicitly initialize the Person
objects, they
are default initialized by in turn default initializing their member
subobjects. The age
and is_ninja
members are of atomic type,
so they are default initialized to indeterminate values. The name
member is of class type, and it is default initialized to an empty
string. In the future, we will see that class types can specify
how they are initialized.
We can explicitly initialize a Person
object with an initializer
list, similar to how we can initialize the elements of an array:
Person elise = { "Elise", 22, true };
This initializes the struct member-by-member from the initializer list:
the name
member is initialized with "Elise"
, the age
member is initialized with 22
, and the is_ninja
member is
initialized with true
. As with arrays, if fewer initializers are
provided than members, the remaining members are implicitly
initialized (with zeros for atomic types).
Unlike an array, structs do have a value, so they do not turn into pointers. This also means we can copy structs when initializing a new struct or assigning to an existing one:
Person tali = elise;
By default, copying a struct copies the members one by one. [2] The result in memory is illustrated in Figure 30.
We will see in the future that we can customize how class types are copied by overloading the copy constructor and assignment operator.
We can access individual members of a struct with the dot (.
)
operator. The struct object goes on the left-hand side, and the member
name on the right:
tali.name = "Tali";
Here, we have assigned the string "Tali"
to the name
member of
the tali
object. Figure 31 shows the
result in memory.
As the figure shows, tali
and elise
each have their own
name
members, so that modifying one does not affect the other.
Unlike an array, we can use an initializer list for a struct in contexts other than initialization. The following uses an initializer list in an assignment (which is different from an initialization, since the target object already exists), as well as an argument to a function:
void Person_print_name(Person person) {
cout << person.name << endl;
}
int main() {
Person tali;
tali = { "Tali", 21, true }; // in an assignment
Person_print_name({ "Elise", 22, true }); // as argument to function
}
When passing a struct to a function, we have our usual default of
value semantics, meaning that a copy is made. For instance, the
following erroneous definition of Person_birthday()
does not
modify the argument object, since it receives a copy of the argument:
// MODIFIES: person
// EFFECTS: Increases the person's age by one. If they are now older
// than 70, they are no longer a ninja.
void Person_birthday(Person person) {
++person.age;
if (person.age > 70) {
person.is_ninja = false;
}
}
Figure 32 illustrates what happens when we
call Person_birthday()
on a Person
object:
The modification happens on a Person
object that lives in the
activation record for Person_birthday()
. This copy will die when
Person_birthday()
returns, and the object that lives in main()
is unchanged.
Instead, we need to pass the struct indirectly, either using a reference or a pointer. The following uses a pointer, and its execution is shown in Figure 33:
// REQUIRES: ptr points to a valid Person object
// MODIFIES: *ptr
// EFFECTS: Increases the person's age by one. If they are now older
// than 70, they are no longer a ninja.
void Person_birthday(Person *ptr) {
++(*ptr).age;
if ((*ptr).age > 70) {
(*ptr).is_ninja = false;
}
}
The code uses the *
operator to dereference the pointer, then uses
the .
operator to access a member of the resulting Person
object. The parentheses around the dereference are required because
the postfix .
has higher precedence than the prefix *
.
C++ provides the ->
operator as a shorthand for dereference
followed by member access. The following definition of
Person_birthday()
is equivalent to the one above:
void Person_birthday(Person *ptr) {
++ptr->age;
if (ptr->age > 70) {
ptr->is_ninja = false;
}
}
Of course, this one is nicer to read and to write, so we should make
use of the ->
operator when possible.
The Person_birthday()
function needs to modify the underlying
Person
object, so we cannot declare the Person
as const
.
If a function does not need to modify the underlying object, then it
should be declared as const
. Declaring a struct as const
prevents any of its members from being modified.
// REQUIRES: ptr points to a valid Person object
// MODIFIES: nothing
// EFFECTS: Prints a one-sentence description of the person
void Person_describe(const Person *ptr) {
cout << ptr->name << " is " << ptr->age << " years old and ";
if (ptr->is_ninja) {
cout << "is a ninja!" << endl;
} else {
cout << "is not a ninja." << endl;
}
}
Except for very small structs, we generally do not pass structs by value, since creating a copy of a large struct can be expensive. Instead, we pass them by pointer or by reference. If the original object needs to be modified, we use a pointer or reference to non-const. Otherwise, we use a pointer or reference to const:
void func_ptr(const Person *p); // const can go on either side of
void func_ref(Person const &p); // Person, but must be to the left
// of * or & for the Person itself
// to be non-modifiable
Structs can be composed with other kinds of objects, such as arrays.
The following is an array of three Person
elements:
Person people[3];
Figure 34 shows the layout of this array in memory.
The following is a struct that contains an array as a member, and its layout is show in Figure 35:
struct Matrix {
int width;
int height;
int data[6];
};
int main() {
Matrix matrix;
...
}
Compound Objects and const
Since a class-type object has a value, it can be declared as
const
, which prevents any of its members from being modified.
As an example, consider the following struct definition:
struct Foo {
int num;
int *ptr;
int arr[4];
};
Like any const
object, a const Foo
must be initialized upon
creation:
int main() {
int x = 3;
const Foo foo = { 4, &x, { 1, 2, 3, 4 } };
...
}
The array member can be initialized using its own initializer list, which is the same syntax for initializing a local array variable.
With foo
declared as const
, attempting to modify any of its
members results in a compile error:
foo.num = -1; // ERROR
++foo.ptr; // ERROR
foo.arr[0] = 2; // ERROR
As the last example shows, it is an error to modify an element of an
array that resides within a const object; modifications cannot be made
to any of the subobjects that live within the memory of an object
declared const
. [3]
A member can be declared mutable, which allows it to be modified even if the object that contains it is const.
On the other hand, it is possible to modify the object that
foo.ptr
points to:
*foo.ptr = -1;
cout << x; // prints -1
Since foo
is const
, foo.ptr
is a const pointer, and the
expression has type int * const
. This means it is not a pointer to
const, so modifying the value of the object it is pointing at is
allowed. Looking at it another way, the object x
lives outside the
memory for foo
, so the fact that foo
is const
has no
effect on whether or not x
can be modified through a pointer that
lives within foo
.