With experience of how to overload common math operators gained from the previous articles, in this article we’re going to look at how to overload some “special” operators (unary * and ->) as well as increment and decrement (++ and --). Since none of these could be usefully applied to our Vec3d class, we’ll be creating a new class from scratch. We’ll also discuss three operators which should not normally be overloaded, even though the language permits it.
A class representing a circular buffer could start off looking like this:
template<typename T,std::size_t N>
class CircularArray final {
T data[N];
std::bitset<N> inUse;
std::size_t next{};
// ...
The template type parameter T and template non-type parameter N are exactly as for std::array, and the data[N] member is contained within the class (meaning that no heap memory is allocated). We wish to keep track of which elements are in use, and we can use a std::bitset for this, also of size N. (If the specification of the size of the buffer were instead required at runtime, we could use a std::vector<T> and a std::vector<bool> here instead.) The next member holds the index of the next element of data to use to store a value of type T, being default-initialized to zero.
Reading into the buffer has to allow for more data being supplied than the size of the array, meaning that the writes will “wrap around”. This is catered for by the private member function doNext():
void doNext() {
++next;
next %= N;
}
public:
CircularArray() = default;
CircularArray(const T* ptr, std::size_t n) {
for (auto *p = ptr; p != ptr + n; ++p) {
data[next] = *p;
inUse[next] = true;
doNext();
}
}
The special member functions are supplemented with three overloads for operator=, these having parameter lists (const T&), (T&&) and (nullptr_t). The first two allow for setting the value indexed by next, while the third allows assigning to nullptr which will delete the value:
CircularArray(const CircularArray&) = default;
CircularArray(CircularArray&&) = default;
CircularArray& operator=(const CircularArray&) = default;
CircularArray& operator=(CircularArray&&) = default;
CircularArray& operator=(const T& rhs) {
data[next] = rhs;
inUse[next] = true;
return *this;
}
CircularArray& operator=(T&& rhs) {
data[next] = std::move(rhs);
inUse[next] = true;
return *this;
}
CircularArray& operator=(nullptr_t) {
data[next] = T{};
inUse[next] = false;
return *this;
}
Dereferencing (reading) the data should only happen if the inUse field is true. To enable this a public member function hasData() is defined. The dereferencing operator* function returns a const-reference-to-T, while operator-> simply returns the address of this reference. (If T were of type std::string, the use of operator-> to access member functions such as c_str() would follow conventional C++ syntax, ie. buffer->c_str()) would return a pointer to a NTMBS for the current item in the circular buffer). All three functions can be declared const:
bool hasData() const {
return inUse[next];
}
const T& operator*() const {
if (!hasData()) {
throw std::runtime_error("No data");
}
return data[next];
}
const T* operator->() const {
return &(operator*());
}
Finally, the operator++ and operator-- member functions are defined. The variants with empty parameter lists return the modified value of next (prefix ++ and --), while the variants with a dummy (int) parameter return the value of next before modification (postfix ++ and --). The use of std::size_t for the buffer position allows us to make use of unsigned arithmetic laws for operator--():
std::size_t operator++() {
doNext();
return next;
}
std::size_t operator++(int) {
auto prev = next;
doNext();
return prev;
}
std::size_t operator--() {
--next;
if (next >= N) {
next = N - 1;
}
return next;
}
std::size_t operator--(int) {
auto prev = next;
operator--();
return prev;
}
};
Testing this class with the following main program allows for the command dmesg | ./tail10 under Linux, which prints out the last ten kernel log messages:
int main() {
CircularArray<std::string,10> buffer;
std::string str;
getline(std::cin, str);
while (!std::cin.eof() && !str.empty()) {
buffer = str;
++buffer;
getline(std::cin, str);
}
while (!buffer.hasData()) {
++buffer;
}
for (int i = 0; i != 10; ++i) {
if (buffer.hasData()) {
std::cout << *buffer << '\n';
++buffer;
}
}
}
Note that due to the presence of the second while-loop, even with fewer than ten input lines they are always output correctly. Also, note that in the for-loop, std::cout << buffer->c_str() << '\n'; could have been used as demonstration of operator->.
There is some general advice which has been the same for about 30 years, and that is to not to try to overload operator&&, operator|| or operator,. The reason at first is simple: whether global or member, it is not possible to guarantee evaluation order of its operands, and there are other, more technical reasons. Recall that && and || should “short-circuit,” that is, the second operand is not always evaluated. Also, be aware that operator, has uses in TMP (Template Meta-Programming) as it returns the type of the last expression in a list.
To allow for && and || should you really need it, it is possible to write an operator bool() member function, which allows your class to implicitly convert to bool and thus be permissible in such expressions. Even better, this will ensure that short-circuit evaluation will happen correctly, and this compromise should be sufficient for most use cases.
That wraps things up for this mini-series. We have covered assignment and operator-assignment, various math operators (+, -, * and /), operations without using an operator symbol (dot() and cross()), special operators (unary * and ->) and increment and decrement (++ and --). It should be possible to adapt the code provided to many different use cases, as applicable to your class.
Source code for this mini-series is available on GitHub.