PROJECTS
Share Article
Written by: Kim Roar Stenvard Dehli (Senior Development Engineer)
PROJECTS
Share Article
Written by: Kim Roar Stenvard Dehli (Senior Development Engineer)
The keyword const
is likely familiar to most C++ developers. On its face it is a simple thing used to declare constant values in C++ source code, but it’s easy to take for granted the actual benefits of doing so. Additionally, const
shows up in places other than variable declarations and can be deceptively complicated to use under certain circumstances.
In this article we will explore what const
means in the C++ language, how it benefits our code and our programming experience, and establish some rules of thumb around the usage of const
.
In the C++ standard (and in this article), the term “object” is used not only for instances of classes, but also for variables of fundamental types like int and double. This differs from how it is commonly used in the context of other object-oriented programming languages, where variables of primitive types are not considered objects.
const
, really?Put simply, const
declares that a variable is constant, i.e. that its value will never change. In fact, trying to modify the value of a variable declared as const
will result in a compile-time error.
const int c = 299'792'458;
std::println("The speed of light is {} m/s", c); // OK: reading the value of c.
c += 1; // Error: modifying c is not allowed.
c = 300'000'000; // Error: reassigning c is not allowed.
Software developers and computer scientist like to call such variables immutable, and conversely call variables that can change mutable.
There are ways to circumvent the compilers enforcement of immutability, such as const_cast
. However, any attempt to modify an object that was initially declared as immutable results in undefined behavior and as such must be avoided.
Saying that const
turns variables immutable is only a half-truth, however. In reality, const
is something we add to a type, not an object. Adding const
to a type is known as const-qualifying the type. It is the type system of C++ that prevents any possibly modifying operation from being invoked on objects of const-qualified types.
A side effect of the immutability of const types, is that variables of const-qualified type must be initialized. Since immutable objects cannot be reassigned to new values, this should hardly come as a surprise.
const std::string name; // Error: uninitialized constant.
name = "Albert"; // Error: reassigning "name" is not allowed.
const
So, what is the purpose of const-qualifying our variables’ types, other than marking clearly constant values as such? Well, declaring a variable as immutable is a guarantee to both the developer and the compiler that the variable will definitely not change. Such a guarantee can be taken advantage of by both parties.
From the compilers side, immutable values are subject to optimizations. The compiler can — among other things — store the object in read-only memory, or not store it at all, if it can deduce the values and resulting code paths that the object is interacting with at compile time.
Compilers are very sophisticated, however, and can typically make these kind of optimizations regardless of whether or not a variable is declared const
. The more interesting benefits of const
are the ones for the developer.
From the developers’ side, it is both an indication of intent and a safety net that prevents accidental modification of variables that should not be modified. Furthermore, it promotes writing code with less side effects, which can be easier to reason about.
Consider the following example:
std::vector<int> foo = get_foos();
std::vector<int> bar = get_bars();
for (std::size_t idx = 0; idx < std::size(foo); ++idx) {
if (foo.at(idx) % 3 == 0) {
foo.at(idx) = -1;
}
}
use_foo_and_bar(foo, bar);
Here we get two vectors of integers, foo
and bar
, and modify foo
before passing both to the function use_foo_and_bar
. Assuming that get_foos
, get_bars
and use_foo_and_bar
are defined elsewhere, this code both compiles and executes just fine. However, for the sake of this example, let’s assume that we were actually supposed to modify bar
and not foo
. In other words, there's a bug in the code. The correct code would be:
std::vector<int> foo = get_foos();
std::vector<int> bar = get_bars();
for (std::size_t idx = 0; idx < std::size(foo); ++idx) {
if (foo.at(idx) % 3 == 0) {
bar.at(idx) = -1; // Note: bar being modified here, not foo.
}
}
use_foo_and_bar(foo, bar);
It turns out in this case that foo
was never supposed to be modified, but the similarities between foo
and bar
made them easy to mix up. If foo
had been declared const
, not only would it have been made clear that foo
is immutable, but the compiler would prevent the incorrect code from compiling in the first place.
const std::vector<int> foo = get_foos();
std::vector<int> bar = get_bars();
for (std::size_t idx = 0; idx < std::size(foo); ++idx) {
if (foo.at(idx) % 3 == 0) {
foo.at(idx) = -1; // Error: modifying foo is not allowed
}
}
use_foo_and_bar(foo, bar);
Always declare variables in block-scope const
by default if possible. This makes code easier to read and can protect variables from accidental modification.
const
vs east-const
In this article, const
has so far only appeared on the left side of the type specifier, but the C++ language grammar allows for const
to appear on the right side as well. Placing it to the left is often called “west-const
” and placing it to the right is often called “east-const
”.
const int x = 0; // west-const
int const y = 0; // east-const
Whether we use west- or east-const
makes no difference to the compiler, but C++ developers are known to have very strong opinions about which style should be preferred. Whichever style we choose, we should take care to be consistent.
Use whichever you prefer of west-const
or east-const
, but make sure to be consistent within the same code base.
const
pointersPointers are just variables in the end, and as such can be declared const
as well. Pointers make things a little bit more complicated however, as there is a distinction of whether the pointer itself is immutable, the pointed-to object is immutable, or both. Take the following declaration:
const int* p = /* ... */;
Since C++ source code is read left to right, it would be natural to conclude that p
is a “constant pointer to int” (that is, an immutable pointer to a mutable integer), but it is in fact a “pointer to constant int” (a mutable pointer to an immutable integer). To actually get an immutable pointer to an int, you’d write:
int* const p = /* ... */;
This is a result of the pointer syntax that C++ inherited from C, which is rather unintuitive. To correctly read the const-qualifiers of pointers, you can think about this way: The *
separates the type into “levels”, and const
only applies to the part of the type on its own level. With multiple levels of pointer indirection, it can become tricky to wrap your head around it.
const char* const* p = /* ... */;
In such cases, using
-declaration can help us make the type more readable.
using Czstring = const char*; // Null-terminated string of immutable characters.
using CzstringSpan = const Czstring*; // Pointer to Immutable Czstring
CzstringSpan p = /* ... */;
Use using
-declarations to make const-qualified multilevel pointers easier to read.
Note that you can have a pointer to an immutable object point to a mutable object. This is essentially getting a read-only view of an object.
std::string address = "221B Baker Street";
const std::string* p = &address; // OK: immutable access to a mutable object.
std::println("{}", *p); // Prints "221B Baker Street";
address = "155 Country Lane";
std::println("{}", *p); // Prints "155 Country Lane";
The other way around is not permitted:
const std::string address = "221B Baker Street";
std::string* p = &address; // Error: mutable access to immutable object.
When declaring a pointer to a mutable object, const-qualify the pointed-to type if you don’t need mutable access to the pointed-to object.
const
referencesConst-qualifiers work the same way for references as they do for pointers, with the notable exception that references cannot be const-qualified, as references are always immutable. There is also no way to have a reference to a reference, which avoids the possibly confusing syntax of multi-level pointers.
References to const-qualified types are most typically used for passing input parameters to functions. With the exception of small and cheap to copy types, like int
and char
, it is desirable to pass parameters as references to avoid unnecessary copies, and unless the function is writing to or otherwise modifying the original object being passed as a parameter, it should be const-qualified to avoid accidental modification and make the function easier to work with.
A simple example demonstrates the benefits of const-ref parameters:
auto count_digits_by_copy(std::string str) {
const std::locale loc;
return std::ranges::count_if(str, [&](char ch) { return std::isdigit(ch, loc) ;});
}
auto count_digits_by_ref(std::string& str) {
const std::locale loc;
return std::ranges::count_if(str, [&](char ch) { return std::isdigit(ch, loc) ;});
}
auto count_digits_by_const_ref(const std::string& str) {
const std::locale loc;
return std::ranges::count_if(str, [&](char ch) { return std::isdigit(ch, loc) ;});
}
auto main() -> int {
std::string msg = "She ran 100 meters in 13 seconds";
const std::string date = "May 2, 1624";
// Works, but unnecessarily copies the entire string.
std::println("{}", count_digits_by_copy(msg));
std::println("{}", count_digits_by_copy(date));
std::println("{}", count_digits_by_copy("#12B2FF"));
// Doesn't copy, but only works for msg.
// date is const-qualified and so cannot be passed as non const-qualified reference.
// "#12B2FF" is a temporary value, and cannot be passed as non const-qualified reference.
std::println("{}", count_digits_by_ref(msg)); // OK
std::println("{}", count_digits_by_ref(date)); // Error
std::println("{}", count_digits_by_ref("#12B2FF")); // Error
// Works, without copying the string.
std::println("{}", count_digits_by_const_ref(msg));
std::println("{}", count_digits_by_const_ref(date));
std::println("{}", count_digits_by_const_ref("#12B2FF"));
}
Pass parameters to functions as references to const
unless they are fundamental types, or the function is supposed to modify or write to the parameter.
const
, classes, and youThe types of class member variables can also be const-qualified. If we follow the earlier advice of this article, we might be led to believe that we should declare all our member variables const
if we never plan on modifying them. Doing so however, will cause problems as soon as an attempt is made to copy-assign an instance of the class in question.
Let's say we have the following class:
class Entity {
public:
Entity(int id, const std::string& name) :
id{id},
name{name}
{}
auto get_id() -> int {
return id;
}
auto get_name() -> const std::string& {
return name;
}
void set_name(const std::string& new_name) {
name = new_name;
}
private:
int id;
std::string name;
};
auto main() -> int {
Entity entity_a{1, "foo"};
Entity entity_b{2, "bar"};
std::println("{}", entity_a.get_name()); // prints "foo"
}
Being diligent, we realize that id
will never change for a given instance of Entity
, so we decide to const-qualify the type of id
.
class Entity {
public:
Entity(int id, const std::string& name) :
id{id},
name{name}
{}
auto get_id() -> int {
return id;
}
auto get_name() -> const std::string& {
return name;
}
void set_name(const std::string& new_name) {
name = new_name;
}
private:
const int id;
std::string name;
};
This seems fine to begin with, as id
is properly being initialized in the constructors’ initializer-list. The problem reveals itself when we try to assign entity_a
to entity_b
.
auto main() -> int {
Entity entity_a{1, "foo"};
Entity entity_b{2, "bar"};
entity_a = entity_b // Compile error
std::println("{}", entity_a.get_name());
}
The compiler spits out an error about a deleted assignment operator. We are aware that C++ implicitly defines a copy assignment operator, but it seems that this didn't happen here. Pressing on, we define an assignment operator explicitly.
//...
auto operator=(const Entity& other) -> Entity& {
this->id = other.id;
this->name = other.name;
}
// ...
Which the compiler will reject as well. But at this point the problem is made clear. The statement this->name = other.name;
is trying to assign a new value to an immutable object. This is why the compiler didn't implicitly define a copy assignment operator for us, and why making one ourself isn't possible. Thus, in order to keep our types copyable and movable, we should not const-qualify the types of non-static member-variables.
Do not declare non-static class member variables as const
.
const
isn’t only used to const-qualify types of objects, but it’s also used to const-qualify member functions. To understand what it means to const-qualify a member function, we must look at what const-qualifying a class type does to its members.
Let’s look at the class Entity
from earlier.
auto main() -> int {
const Entity entity{47, "baz"};
std::println("{}", entity.get_id()); // Compile error
std::println("{}", entity.get_name()); // Compile error
}
Here we have const-qualified entity
since we don’t plan on modifying it, but our compiler complains about our accessor functions. What’s going on here?
The problem is that member functions are allowed to change the member variables in their class. This means that — as far as the compiler is concerned — all the member functions of entity
could potentially modify it. Thus, to honor the const-qualifier of entity
’s type, the compiler disallows us from calling its member functions.
This is where const-qualifying member functions comes to the rescue. This is done by adding const
after a member functions parameter list like so:
//...
auto get_id() const -> int {
return id;
}
//...
By doing this, we tell the compiler that the member function will not change any member variables and will therefore be available to use even on const-qualified instanced of the class. In fact, this will also be enforced by the compiler.
//...
auto get_id() const -> int {
id = 101; // Error: const-qualified member functions cannot modify member variables.
return id;
}
//...
So, by changing our accessor functions to be const-qualified, our Entity
class behaves as expected.
class Entity {
//...
auto get_id() const -> int {
return id;
}
auto get_name() const -> const std::string& {
return name;
}
//...
};
auto main() -> int {
const Entity entity{47, "baz"};
std::println("{}", entity.get_id()); // OK
std::println("{}", entity.get_name()); // OK
}
Const-qualify non-static class member functions that do not alter the externally visible state of the class.
mutable
The keyword const
has a lesser-known sibling mutable
. Despite what the name suggests, it is not simply the opposite of const
. Instead, it is used to exempt a member variable from the enforcement of const-qualification of a class type. It is useful for when you have a mutex or some cache variable which must be modified even when performing immutable operations on an object.
Without mutable
this version of Thread_safe
fails to compile:
class Thread_safe {
public:
void set_data(std::size_t idx, int value) {
std::scoped_lock lock{data_mutex};
data.at(idx) = value;
}
auto get_data(std::size_t idx) const -> int {
std::scoped_lock lock{data_mutex}; // Compile error
return data.at(idx);
}
private:
std::mutex data_mutex;
std::vector<int> data;
};
But this version works like we would want:
class Thread_safe {
public:
void set_data(std::size_t idx, int value) {
std::scoped_lock lock{data_mutex};
data.at(idx) = value;
}
auto get_data(std::size_t idx) const -> int {
std::scoped_lock lock{data_mutex}; // OK
return data.at(idx);
}
private:
mutable std::mutex data_mutex;
std::vector<int> data;
};
This is a relatively niche keyword, but it is indispensable when we want to write code that is both const-correct and thread-safe.
Declare member variables like mutexes and cache as mutable
so they do not prevent you from const-qualifying non-modifying class member functions.
Sometimes we have to use APIs which don't allow us to directly assign the result of a function to a variable, or we have to initialize our variable in multiple steps. In such cases we cannot declare our variable const
because we have to modify it after it’s declaration. In many cases, however, we don’t want to modify the variable after its initialization. Take std::cin
from the C++ standard library, for instance:
int user_input;
std::cin >> user_input;
If we were to declare user_input
as const
here, std::cin
’s >>
operator won’t be able to write the result to it. In cases like this, we can use a technique known as the immediately invoking lambda expression to make sure our variable is only mutable for as long as it needs to be:
const int user_input = [] {
int value;
std::cin >> value;
return value;
}();
All the brackets may seem confusing at first, but all we have done is define a lambda expression taking no parameters, [] {}
and invoking it right away in the same expression with the function call operator ()
, producing the somewhat funny looking sequence of brackets. The result is that we managed to keep the scope of user_input
’s mutability as small as possible, without having to go out of our way to define an entire function for it.
Use immediately invoking lambda expressions to const-qualify variables that should be immutable after initialization, but needs to be initialized in multiple steps.
In this article we have looked at what the keyword const
means in C++, why we should care about it, and established some techniques and best practices for using const
in practice. In summary const
helps us write code that's easier to read and understand, and helps us catch bugs during compile time.
R&D Manager