# const in C++
In this article I'm going to go over all the cases of const
in C++ I see regularly.
# Why const
?
First I want to briefly touch on why we should bother having to qualify as many variables, member functions, etc. whenever we can.
The topic of compiler optimizations have been written many times before. Please take a look at this GotW article for when const
is effective and also when it may be ineffective at optimizing code. Another practical example can be found here by Matt Godbolt, specifically when talking about const
in testFunc
.
Besides compiler optimizations, const
is also important for developers, arguably more so. When you something is qualified as const
, it's effectively shrinking the set of runtime state you need to manage in your mental model of the code you're looking at. In other words, the fewer variables that are changing in a function, the more you can focus on the code that does change state.
void foo(int a, int b) {
bool b = ...;
T t = ...;
U u = ...;
...
}
In foo
, because nothing is qualified as const
, you need to examine every single local variable and determine if you need to keep track of their state throughout the body of this function. However, with bar
:
void bar(const int a, const int b) {
bool b = ...;
const T t = ...;
U u = ...;
...
}
In this fictitious function, I qualified three local variables with const
. Now for a future developer who needs to understand what this function does, step through in a debugger, and so on, we only need to observe the values of a
, b
, and t
once, and focus on the runtime state of u
and b
only.
# When const
?
I like to make it a habit to proactively use const
, and while I continue working on code if I find I have to change the state of something and it makes sense to do so, I'll remove it. Due to having to write out an extra few keystrokes, I think it's more cumbersome than using a language like Rust which took the opposite direction (const
by default rather than opt-in). Although it's annoying, the benefits are worth it.
# const
with variables
Use const
when you want to essentially mark the value of this variable immutable. When used with objects, you can only call special member functions (such as constructors, destructors) and const
member functions.
const int& x = 123;
const bool b = true;
const T t;
# Lifetime extension
An interesting feature of using const
references like in the first line above is you get lifetime extension: the literal value 123
can be bound to a reference because the compiler extends the lifetime of this value simply because we use const
to tell the compiler we aren't changing the value. This would not compile if it was a mutable reference. In practice, don't worry about this too much.
# const
with functions
Consider const
in the return type when you're returning a pointer or reference that you want don't intend to modify. Don't use const
for value types, like int
, some T
type you create, etc. as this doesn't provide any practical benefit in modern C++.
// BAD:
const int get() { ... }
// GOOD:
int get() { ... }
Consider const
for parameters that you don't intend to modify. This advice also includes value types.
// BAD: `n` doesn't change, so it could be `const`.
int square(int n) {
return n * n;
}
// GOOD: no intention to change `n`.
int square(const int n) {
return n * n;
}
Do not add const
qualifiers to value types in the parameter list of function declarations (such as those in header files), as these are redundant.
For example:
void foo(const int x); // BAD: no need for `const` here.
void foo(const int x) {
// do something with x
}
void foo(int x); // GOOD
void foo(const int x) {
// do something with x
}
Same applies to member functions.
But why?
const
matters for things like references and pointers, but for value types these are copied anyway. It doesn't matter if the thing passed in isconst
from a declaration perspective, what matters when we useconst
is how we're using it in the definition (i.e. the body of a function). From the interface, a value type that gets copied doesn't need to advertise its "const-ness"; it's irrelevant to the caller.- The two declarations cannot be overloaded together, as they're the same declaration:
Using GCC 14.2:struct myfoo { void foo(int x); void foo(const int x); }; void myfoo::foo(const int x) {}
<source>:10:6: error: 'void myfoo::foo(int)' cannot be overloaded with 'void myfoo::foo(int)' 10 | void foo(const int x); | ^~~ <source>:9:6: note: previous declaration 'void myfoo::foo(int)' 9 | void foo(int x); | ^~~
# const
with member functions
const
can be applied to member functions like so:
class MyClass {
int x = ...;
public:
int get() const {
return x;
}
};
The advice for using const
in regular functions apply here too.
Consider const
when the member function doesn't change the state of the class. In the above example, since we simply read the value of x
, we're not modifying the class in any way so it's appropriate to mark get
as const
-qualified.
The this
pointer also becomes const
-qualified by the compiler in the body of a const
member function.
# mutable
There is one edge-case to be remindful of when it comes to const
member functions. I'll illustrate with a common example:
class MyClass {
int x = ...;
mutable mutex mx = ...;
public:
int get() const {
int xValue;
mx.lock();
xValue = x;
mx.unlock();
return xValue;
}
};
This is a simplified example where we use a mutex to safely get the value of x
since there is concurrency involved. Notice that we call lock
and unlock
which changes the state of mx
-- also changing the state of MyClass
. We can use the mutable
keyword to allow these calls in a const
-qualified member function.
But why is this useful? The idea is if we have an interface that semantically makes sense to be const
(think of this as read-only), we can use mutable
as an escape hatch to keep our interface sensible. The only reason we needed to use mutable
was due to an implementation detail hidden from the user (i.e. locking/unlocking the mutex). Remember, we only did this because the mutex is a side-effect of safe concurrency -- it is not part of the semantic state of our class and so it's acceptable to keep it logically separated from our interface.
# const
with member variables
Member variables const
-qualified are generally not useful except for view types (classes which are used as read-only containers for data -- think of std::string_view
semantically).
Additionally, const
-qualified members may prevent implicitly-defined special member functions such as default constructors.
For class constants, consider making them static
.
# const
with raw pointers
const
has a few forms with raw pointers, so use the form most appropriate for your use case:
int*
- pointer toint
;int const *
- pointer toconst int
;int * const
-const
pointer toint
;int const * const
-const
pointer toconst int
.
# const
with smart pointers
const
also shares a few forms with smart pointers, so use the form most appropriate for your use case:
std::unique_ptr<int>
- exclusive pointer toint
;std::unique_ptr<const int>
- exclusive pointer toconst int
;const std::unique_ptr<int>
-const
exclusive pointer toint
;const std::unique_ptr<const int>
-const
exclusive pointer toconst int
.
# const
with containers
Using const
prevents reassignment of a container and also keeps immutable elements:
const std::vector v {1, 2, 3};
v = std::vector{3, 2, 1}; // ERROR
const std::vector v {1, 2, 3};
v[0] = 123; // ERROR
Some containers also propagate const
because the operator[]
or at
member functions return const
references:
struct Foo {
void f() {}
};
const std::vector<Foo> v {Foo{}, Foo{}, Foo{}};
v[0].f(); // ERROR: f is not `const`-qualified
However, not all containers do this!
int arr[] = {1, 2, 3};
const std::span s {arr};
s[0] = 3; // OK
With std::span
you'd need a span of const int
to make this work.
# const
with iterators
Most people are familiar with begin
and end
on containers to express the begin and end iterators respectively. The standard library also provides their const
equivalents: cbegin
and cend
. These return the const
-equivalent iterators into the container.
These are useful when you want to express algorithms or ranges where you don't intend to mutate container elements.
std::vector v {1, 2, 3};
const auto sum = std::accumulate(v.cbegin(), v.cend(), 0, std::plus{});
// sum == 6
# Lambda parameters
Lambdas don't work differently when it comes to parameters than with functions, but I often see confusion when they are passed to algorithms. Here's an example:
std::vector v {1, 2, 3};
const auto sum = std::accumulate(v.cbegin(), v.cend(), 0,
[](auto x, auto y) { return x + y; });
This function returns the same sum, but while this example compiles, x
and y
are not const
. Since we're taking a copy of a simple type like an int
, this works just fine, but these parameters should be const
for the reasons in the opening section (see: "Why const
?"). We would get a compiler error if we tried to take a mutable reference:
const auto sum = std::accumulate(v.cbegin(), v.cend(), 0,
[](auto& x, auto& y) { return x + y; }); // ERROR: int& is not `const`
So in general, match up the iterator qualification with the lambda parameters unless you're doing non-trivial work (in that case, it probably makes sense to move this out into a free/member function).
# const
with ranges
Ranges with const
is messy and complicated. In C++20: In Detail by Nicolai Josuttis, he goes into this topic in much more depth than I can here. Based on my own conclusion from reading his book, const
is probably too dangerous to use safely with ranges until a bunch of problems get fixed in versions later than C++20, which is a shame because of the benefits of const
.