Declaring a Class Member Constant in C++ Can Backfire
When designing a new class in C++
, you may declare some of its members or
methods as constants by using the const
keyword. I.e. you may want your
compiler to guarantee immutability properties.
-
A
const
member should not mutate during the object’s lifetime:struct Person{ const std::string name = "Nobody"; }; auto hero = Person{"Odysseus"}; // this will fail at compile time hero.name = "Achilles";
-
A
const
method should not mutate any class member:struct Person{ const std::string name = "Nobody"; uint age = 20; // this will fail at compile time void birthday() const{ // the const method tries to mutate a member age++; } };
Unfortunately, this is not all the story. The decision to declare something
inside class const
or not requires more considerations (as with everything in
C++
).
So const
method can mutate mutable
class members
Already, you should be aware of one exception to the const
-method rule. It
consists in declaring a class member mutable
. Note that this is quite rare,
although I employed this keyword in the past in the context of concurrent
access, in order to avoid creating an additional wrapper in an already complex
codebase.
struct Person{
const std::string name = "Nobody";
const uint age = 20;
mutable std::mutex mutex;
uint getAgeSafely() const{
// this works despite it being a const method and mutating the mutex
std::lock_guard<std::mutex> lock(mutex);
return age;
}
};
In the remainder of this post, we will dive into the implications of declaring a
class member const
.
A const
member can be a liability
A small scenario to make my point
Let’s assume that an object of class Person
has a field name
. We are making
an application to manage a large set of Person
(e.g. managing a database of
employees in a company or something similar).
- At no point, it is asked of us to edit the
name
- We may traverse & access the list of
Person
, - We want to be able to add/remove some
Person
from the list.
struct Person{
const std::string name = "Nobody";
};
The question is then: is that good practice to declare name
as const
?
Reading the specs, it seems fine to just go for it and use const
. We also need
to pick a container type to hold all the Person
. Our initial number of
Person
is not large. Given that we know we need to add/remove Person
objects, it seems easier to use a container with efficient find and remove
methods. However, we don’t know beforehand which type is going to be most
efficient as the number of Person
grows larger. We will have that information
from the profiler after the fact.
We will start by using std::set
from standard library. If it turns out that this
container is not fit for the job, we might just easily switch to another one,
say std::list
or std::vector
, without changing the rest of our code. Indeed,
thanks to the standard library, we produce generic code: the performance
might be affected because the underlying container type changed, but the
functions to add/remove/traverse are still valid, no further edits would be needed.
...Or so we think, we will revisit that statement later
Our Person
is declared as such:
struct Person
{
const std::string name;
// needed for hashing
bool operator<(const Person& p) const { return name < p.name; }
bool operator==(const Person& p) const { return name == p.name; }
};
To follow up on our generic approach, we use a function from the stl
to remove
a Person
by name
: std::erase_if()
is in the C++20 standard and works
across multiple containers.
// the persons
auto persons = std::set<Person> { Person {"Agamemnon"}
, Person {"Hector"}
, Person {"Priam"}
, Person {"Achilles"}
};
// traverse
std::for_each(persons.begin(),persons.end(),
[](auto & person){
// do something ...
}
);
// we remove Hector
std::string personToRemove= "Hector";
// being too generic !
std::erase_if(persons
,[&personToRemove]<typename T>
(const T& e)
{
return e.name == static_cast<decltype(e.name)>(personToRemove);
}
);
We compile the code with g++
:
# compile successfully
g++ main.cpp -std=c++20
Modifying the container type
When the number of Person
goes larger, the traverse feature becomes the
bottleneck of terms of performance. Maybe using std::vector
instead of
std::set
is more relevant. Let’s test it:
// the persons, was std::unordered_set before
auto persons = std::vector<Person> { Person {"Agamemnon"}
, Person {"Hector"}
, Person {"Priam"}
// ...
, Person {"Achilles"}
};
And horror… We have this disaster as a compile error message…
In file included from /usr/include/c++/12.2.0/algorithm:60,
from main.cpp:1:
/usr/include/c++/12.2.0/bits/stl_algobase.h: In instantiation of ‘constexpr _ForwardIterator std::__remove_if(_ForwardIterator, _ForwardIterator, _Predicate) [with _ForwardIterator = __gnu_cxx::__normal_iterator >; _Predicate = __gnu_cxx::__ops::_Iter_pred > >]’:
/usr/include/c++/12.2.0/vector:113:40: required from ‘constexpr typename std::vector<_Tp, _Alloc>::size_type std::erase_if(vector<_Tp, _Alloc>&, _Predicate) [with _Tp = Person; _Alloc = allocator; _Predicate = main(int, char**)::; typename vector<_Tp, _Alloc>::size_type = long unsigned int]’
main.cpp:33:16: required from here
/usr/include/c++/12.2.0/bits/stl_algobase.h:2142:23: error: use of deleted function ‘Person& Person::operator=(Person&&)’
2142 | *__result = _GLIBCXX_MOVE(*__first);
| ^
main.cpp:11:8: note: ‘Person& Person::operator=(Person&&)’ is implicitly deleted because the default definition would be ill-formed:
11 | struct Person
| ^~~~~~
main.cpp:11:8: error: no matching function for call to ‘std::__cxx11::basic_string::operator=(const std::string) const’
In file included from /usr/include/c++/12.2.0/string:53,
from /usr/include/c++/12.2.0/bits/locale_classes.h:40,
from /usr/include/c++/12.2.0/bits/ios_base.h:41,
from /usr/include/c++/12.2.0/ios:42,
from /usr/include/c++/12.2.0/ostream:38,
from /usr/include/c++/12.2.0/iostream:39,
from main.cpp:3:
/usr/include/c++/12.2.0/bits/basic_string.h:844:7: note: candidate: ‘constexpr std::__cxx11::basic_string<_CharT, _Traits, _Alloc>& std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::operator=(std::__cxx11::basic_string<_CharT, _Traits, _Alloc>&&) [with _CharT = char; _Traits = std::char_traits; _Alloc = std::allocator]’ (near match)
844 | operator=(basic_string&& __str)
| ^~~~~~~~
/usr/include/c++/12.2.0/bits/basic_string.h:844:7: note: conversion of argument 1 would be ill-formed:
main.cpp:11:8: error: binding reference of type ‘std::__cxx11::basic_string&&’ to ‘const std::string’ {aka ‘const std::__cxx11::basic_string’} discards qualifiers
11 | struct Person
| ^~~~~~
/usr/include/c++/12.2.0/bits/basic_string.h:803:7: note: candidate: ‘constexpr std::__cxx11::basic_string<_CharT, _Traits, _Alloc>& std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::operator=(const std::__cxx11::basic_string<_CharT, _Traits, _Alloc>&) [with _CharT = char; _Traits = std::char_traits; _Alloc = std::allocator]’ (near match)
803 | operator=(const basic_string& __str)
| ^~~~~~~~
/usr/include/c++/12.2.0/bits/basic_string.h:803:7: note: passing ‘const std::string*’ {aka ‘const std::__cxx11::basic_string*’} as ‘this’ argument discards qualifiers
/usr/include/c++/12.2.0/bits/basic_string.h:928:8: note: candidate: ‘template constexpr std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::_If_sv<_Tp, std::__cxx11::basic_string<_CharT, _Traits, _Alloc>&> std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::operator=(const _Tp&) [with _CharT = char; _Traits = std::char_traits; _Alloc = std::allocator]’
928 | operator=(const _Tp& __svt)
| ^~~~~~~~
/usr/include/c++/12.2.0/bits/basic_string.h:928:8: note: template argument deduction/substitution failed:
In file included from /usr/include/c++/12.2.0/bits/stl_pair.h:60,
from /usr/include/c++/12.2.0/bits/stl_algobase.h:64:
/usr/include/c++/12.2.0/type_traits: In substitution of ‘template using enable_if_t = typename std::enable_if::type [with bool _Cond = false; _Tp = std::__cxx11::basic_string&]’:
/usr/include/c++/12.2.0/bits/basic_string.h:155:8: required by substitution of ‘template template using _If_sv = std::enable_if_t >, std::__not_*> >, std::__not_ > >::value, _Res> [with _Tp = std::__cxx11::basic_string; _Res = std::__cxx11::basic_string&; _CharT = char; _Traits = std::char_traits; _Alloc = std::allocator]’
/usr/include/c++/12.2.0/bits/basic_string.h:928:8: required by substitution of ‘template constexpr std::__cxx11::basic_string::_If_sv<_Tp, std::__cxx11::basic_string&> std::__cxx11::basic_string::operator=(const _Tp&) [with _Tp = std::__cxx11::basic_string]’
main.cpp:11:8: required from ‘constexpr _ForwardIterator std::__remove_if(_ForwardIterator, _ForwardIterator, _Predicate) [with _ForwardIterator = __gnu_cxx::__normal_iterator >; _Predicate = __gnu_cxx::__ops::_Iter_pred > >]’
/usr/include/c++/12.2.0/vector:113:40: required from ‘constexpr typename std::vector<_Tp, _Alloc>::size_type std::erase_if(vector<_Tp, _Alloc>&, _Predicate) [with _Tp = Person; _Alloc = allocator; _Predicate = main(int, char**)::; typename vector<_Tp, _Alloc>::size_type = long unsigned int]’
main.cpp:33:16: required from here
/usr/include/c++/12.2.0/type_traits:2614:11: error: no type named ‘type’ in ‘struct std::enable_if&>’
2614 | using enable_if_t = typename enable_if<_Cond, _Tp>::type;
| ^~~~~~~~~~~
main.cpp: In instantiation of ‘constexpr _ForwardIterator std::__remove_if(_ForwardIterator, _ForwardIterator, _Predicate) [with _ForwardIterator = __gnu_cxx::__normal_iterator >; _Predicate = __gnu_cxx::__ops::_Iter_pred > >]’:
/usr/include/c++/12.2.0/vector:113:40: required from ‘constexpr typename std::vector<_Tp, _Alloc>::size_type std::erase_if(vector<_Tp, _Alloc>&, _Predicate) [with _Tp = Person; _Alloc = allocator; _Predicate = main(int, char**)::; typename vector<_Tp, _Alloc>::size_type = long unsigned int]’
main.cpp:33:16: required from here
/usr/include/c++/12.2.0/bits/basic_string.h:814:7: note: candidate: ‘constexpr std::__cxx11::basic_string<_CharT, _Traits, _Alloc>& std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::operator=(const _CharT*) [with _CharT = char; _Traits = std::char_traits; _Alloc = std::allocator]’
814 | operator=(const _CharT* __s)
| ^~~~~~~~
/usr/include/c++/12.2.0/bits/basic_string.h:826:7: note: candidate: ‘constexpr std::__cxx11::basic_string<_CharT, _Traits, _Alloc>& std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::operator=(_CharT) [with _CharT = char; _Traits = std::char_traits; _Alloc = std::allocator]’
826 | operator=(_CharT __c)
| ^~~~~~~~
/usr/include/c++/12.2.0/bits/basic_string.h:913:7: note: candidate: ‘constexpr std::__cxx11::basic_string<_CharT, _Traits, _Alloc>& std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::operator=(std::initializer_list<_Tp>) [with _CharT = char; _Traits = std::char_traits; _Alloc = std::allocator]’
913 | operator=(initializer_list<_CharT> __l)
| ^~~~~~~~
/usr/include/c++/12.2.0/bits/stl_algobase.h: In instantiation of ‘static constexpr _OI std::__copy_move::__copy_m(_II, _II, _OI) [with _II = Person*; _OI = Person*]’:
/usr/include/c++/12.2.0/bits/stl_algobase.h:492:12: required from ‘constexpr _OI std::__copy_move_a2(_II, _II, _OI) [with bool _IsMove = true; _II = Person*; _OI = Person*]’
/usr/include/c++/12.2.0/bits/stl_algobase.h:522:42: required from ‘constexpr _OI std::__copy_move_a1(_II, _II, _OI) [with bool _IsMove = true; _II = Person*; _OI = Person*]’
/usr/include/c++/12.2.0/bits/stl_algobase.h:530:31: required from ‘constexpr _OI std::__copy_move_a(_II, _II, _OI) [with bool _IsMove = true; _II = __gnu_cxx::__normal_iterator >; _OI = __gnu_cxx::__normal_iterator >]’
/usr/include/c++/12.2.0/bits/stl_algobase.h:652:38: required from ‘constexpr _OI std::move(_II, _II, _OI) [with _II = __gnu_cxx::__normal_iterator >; _OI = __gnu_cxx::__normal_iterator >]’
/usr/include/c++/12.2.0/bits/vector.tcc:195:6: required from ‘constexpr std::vector<_Tp, _Alloc>::iterator std::vector<_Tp, _Alloc>::_M_erase(iterator, iterator) [with _Tp = Person; _Alloc = std::allocator; iterator = std::vector::iterator]’
/usr/include/c++/12.2.0/bits/stl_vector.h:1561:17: required from ‘constexpr std::vector<_Tp, _Alloc>::iterator std::vector<_Tp, _Alloc>::erase(const_iterator, const_iterator) [with _Tp = Person; _Alloc = std::allocator; iterator = std::vector::iterator; const_iterator = std::vector::const_iterator]’
/usr/include/c++/12.2.0/vector:117:16: required from ‘constexpr typename std::vector<_Tp, _Alloc>::size_type std::erase_if(vector<_Tp, _Alloc>&, _Predicate) [with _Tp = Person; _Alloc = allocator; _Predicate = main(int, char**)::; typename vector<_Tp, _Alloc>::size_type = long unsigned int]’
main.cpp:33:16: required from here
/usr/include/c++/12.2.0/bits/stl_algobase.h:405:25: error: use of deleted function ‘Person& Person::operator=(Person&&)’
405 | *__result = std::move(*__first);
| ~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~
Why do we end up with this cryptic1 garbage2 ? All we did is replace set
by vector
.
Additionally, if we instead substitute std::set
by std::list
for instance, it works just fine, so there must be something
wrong with std::vector
.
What Went Wrong
Two conflicting restrictions are triggered behind the scenes, one when we use
const
, the other when we use vector
.
Firstly, declaring a const
member in a class prevent the compiler from
implicitly defining the copy-assignment operator (because you cannot assign
the copied member value into the const
member established value, what do you
do ? Do you keep the const
value as it is ? The compiler does not want to make
that decision for you).
But so far in our code, no copy assignment occurred, until removing an element
from std::vector
.
This is our second restriction: when we erase an element from a std::vector
,
since std::vector<T>
hold its items T
contiguously in memory, the element
after the one to be erased gets copy-assigned to the previous spot in memory
(and this happens recursively for every item after the erased one).
In a large codebase, with many layers between the erase_if
method and the
error culprit, identifying and fixing the issue is more challenging. In our
case, we just have to remove the const
keyword.
Conclusion
So the correct strategy for our purpose was actually to not declare
name
asconst
. We also made assumption that our code is rock-solid generic, so that when we change a type, we know it won’t break. In my experience, this is very difficult to achieve.
Furthermore, as our Person
type is not copy-assignable in the presence of a
const
member, you could argue that it goes against the isocpp guidelines on
cleanest semantics
(see the
”rule of three/five/zero”).
Footnotes
-
If we use the
clang++
compiler instead ofg++
, the causes of the error appear more clearly, although the message is still very long. ↩ -
Using a
clangd
based LSP in our IDE, the diagnostic highlights the error cause immediately, before compilation. I recommend coding with an LSP, provided you know how to configure it with the correct flags. ↩