by Joel Tari

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).

copyassignvector

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 as const. 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

  1. If we use the clang++ compiler instead of g++, the causes of the error appear more clearly, although the message is still very long.

  2. 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.