C++ Conceptual Constness

Introduction

In modern C++, conceptual constness becomes more important than bitwise constness. With the mutable specifier, we could now create implementations that is consist with the interface with conceptual constness.

In this blog post, I would like to briefly discuss about bitwise constness, conceptual constness, and the C++ mutable specifier.

Bitwise Constness

Bitwise constness means none of the memory inside the object is modified inside the function. In C++, every object declared with const achieves bitwise constness. It can usually be easily enforced because any operation that tries to modify the const object will be prevented by the compiler.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class T1, class T2>
struct Pair
{
T1 first;
T2 second;
};

int main()
{
int const a{10};
Pair<int, double> const pair{1, 3.0};
// Cannot modify the object because of bitwise constness.
// pair.second = 6.0;
}

Conceptual Constness

Conceptual constness means the object’s abstract state isn’t modified inside the function, although its bits might be.

For example, if we want to create a thread-safe counter without using std::atomic, the counter could be implemented as follows.

counter.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <cassert>
#include <iostream>
#include <mutex>
#include <thread>

class ThreadSafeCounter
{
public:
unsigned int get()
{
std::lock_guard<std::mutex> locker(m);
return count;
}
void inc()
{
std::lock_guard<std::mutex> locker(m);
++count;
}

private:
std::mutex m;
unsigned int count = 0;
};

void count_n(unsigned int n, ThreadSafeCounter& counter)
{
for (unsigned int i = 0; i < n; i++)
{
counter.inc();
}
}

int main()
{
unsigned int n{1000};
ThreadSafeCounter counter{};
std::thread t1(count_n, n, std::ref(counter));
std::thread t2(count_n, n, std::ref(counter));
t1.join();
t2.join();
assert((counter.get() == n * 2) &&
"The thread-safe counter is problematic.");
std::cout << "Total Counts: " << counter.get() << std::endl;
}
1
2
3
$ g++ counter.cpp -o counter -lpthread -std=c++11
$ ./counter
Total Counts: 2000

However, we found the ThreadSafeCounter::get method look weird at the interface level because it is not a const member function. Why would we ever want to modify the object if we just want to know its count? If it were a single thread counter, definitely we will make the get method const. Adding const to the ThreadSafeCounter::get method will not work because locking the mutex will change the state of the object and the bitwise constness enforced by the compiler will prevent it.

The C++ mutable specifier comes to save the conceptual constness in this scenario. mutable permits modification of the class member declared mutable even if the containing object is declared const. By adding a mutable specifier to the mutex member variable, the const get method is allowed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ThreadSafeCounter
{
public:
unsigned int get() const
{
std::lock_guard<std::mutex> locker(m);
return count;
}
void inc()
{
std::lock_guard<std::mutex> locker(m);
++count;
}

private:
mutable std::mutex m;
unsigned int count = 0;
};

In addition to mutex, the mutable specifier is often used for the member cache variables in the object. After all, the cache should not affect the object conceptual constness.

Conclusions

Use mutable wisely for conceptual constness and its corresponding interface. In C++ const member functions implies that the member function is thread-safe and it can be called without synchronization. Conceptual constness extended bitwise constness, so the user should be more careful implementing the const member functions in a thread-safe fashion.

Author

Lei Mao

Posted on

04-14-2022

Updated on

04-14-2022

Licensed under


Comments