8
\$\begingroup\$

I made an object tracker for debugging and testing purposes called ccm_counter (construction, copy, move counter). It counts constructor, copy and move calls. It can be used to detect inefficient forwarding and unnecessary (or unintended) copies. The behavior is as follows:

What it does

Let vector<A> be the type you want to test and A its element type (A must be move and copy constructable and must not be final). Drop in the counter like vector<ccm_counter<A>>. ccm_counter behaves like A except that it also counts:

  1. constructor calls
  2. copy constructor calls
  3. move constructor calls
  4. copy assignment calls
  5. move assignment calls
  6. copies
  7. moves

Point 1 to 5 are bound to the object (or variable) while point 6 and 7 are bound to the value of the object. For example:

struct A{}; 
ccm_counter<A> a, b;  // a and b increment constructor call count by 1
b = a;  // a +1 copies -> import copies and moves from a -> b +1 copy assignment 
a = b;  // b +1 copies -> import copies and moves from b -> a +1 copy assignment 
b = a; 

This example results in (shortened)

a:
constructor calls:      1
copy assignment calls:  1
total copies made:      3

b:
constructor calls:      1
copy assignment calls:  2
total copies made:      3

Now we add the vector<A> (the examples uses std::vector):

struct A{}; 
vector<ccm_counter<A>> vec;
ccm_counter<A> a, b;
vec.shrink_to_fit();
vec.push_back(a);
vec.push_back(a);
vec.push_back(a);
vec.push_back(b);

for (int i = 0; i < vec.size(); i++)
    cout << "element " << i << ":\n" << vec[i].get_object_stats() << "\n" << *vec[i].get_value_stats() << "\n\n";

which prints:

element 0:
constructor calls:      0
copy constructor calls: 0
move constructor calls: 1
copy assignment calls:  0
move assignment calls:  0
total copies made:      3
total moves made:       6

element 1:
constructor calls:      0
copy constructor calls: 0
move constructor calls: 1
copy assignment calls:  0
move assignment calls:  0
total copies made:      3
total moves made:       6

element 2:
constructor calls:      0
copy constructor calls: 0
move constructor calls: 1
copy assignment calls:  0
move assignment calls:  0
total copies made:      3
total moves made:       6

element 3:
constructor calls:      0
copy constructor calls: 1
move constructor calls: 0
copy assignment calls:  0
move assignment calls:  0
total copies made:      1
total moves made:       0

The constructor and assignment call counts are not very useful in this example, because they are bound to the object itself, which, in this case, is just an element in the internal buffer of the vector (which also changes as soon as the buffer is reallocated). However, the total copies and moves were tracked by the value. Element 0 gets copied when we call vec.push_back(a) the first time. Element 1 gets copied when we call vec.push_back(a) the second time. Because the vector exceeds his size each time we call vec.push_back(...), its internal buffer has to be reallocated each time. This results in element 0 beeing moved into the new buffer which is reflected in the output and so on.

Implementation

#include <algorithm>
#include <cmath>
#include <iomanip>
#include <memory>
#include <ostream>
#include <type_traits>

struct ccm_object_stats
{
    std::size_t ctor = 0;        // number of (any, except copy and move) constructor calls
    std::size_t copy_ctor = 0;   // number of copy constructor calls
    std::size_t copy_assign = 0; // number of copy assignment calls
    std::size_t move_ctor = 0;   // number of move constructor calls
    std::size_t move_assign = 0; // number of move assignment calls

    constexpr ccm_object_stats& operator+=(const ccm_object_stats& rhs)
    {
        ctor += rhs.ctor;
        copy_ctor += rhs.copy_ctor;
        move_ctor += rhs.move_ctor;
        copy_assign += rhs.copy_assign;
        move_assign += rhs.move_assign;
        return *this;
    }

    constexpr ccm_object_stats operator+(const ccm_object_stats& rhs)
    {
        ccm_object_stats ret(*this);
        return ret += rhs;
    }
};

std::ostream& operator<<(std::ostream& os, const ccm_object_stats& stats)
{
    using namespace std;
    constexpr size_t sw = 24;
    const size_t nw = static_cast<size_t>(
        log10(max({ stats.ctor, stats.copy_ctor, stats.copy_assign, stats.move_ctor, stats.move_assign }))) + 1;

    os
        << setw(sw) << left << "constructor calls: " << setw(nw) << right << stats.ctor << "\n"
        << setw(sw) << left << "copy constructor calls: " << setw(nw) << right << stats.copy_ctor << "\n"
        << setw(sw) << left << "move constructor calls: " << setw(nw) << right << stats.move_ctor << "\n"
        << setw(sw) << left << "copy assignment calls: " << setw(nw) << right << stats.copy_assign << "\n"
        << setw(sw) << left << "move assignment calls: " << setw(nw) << right << stats.move_assign;
    return os;
}

struct ccm_value_stats
{
    std::size_t copies = 0; // number of copies made
    std::size_t moves = 0;  // number of moves made

    constexpr ccm_value_stats& operator+=(const ccm_value_stats& rhs)
    {
        copies += rhs.copies;
        moves += rhs.moves;

        return *this;
    }

    constexpr ccm_value_stats operator+(const ccm_value_stats& rhs)
    {
        ccm_value_stats ret(*this);
        return ret += rhs;
    }

};

std::ostream& operator<<(std::ostream& os, const ccm_value_stats& stats)
{
    using namespace std;
    constexpr size_t sw = 24;
    const size_t nw = static_cast<size_t>(
        log10(max({ stats.copies, stats.moves }))) + 1;

    os
        << setw(sw) << left << "total copies made: " << setw(nw) << right << stats.copies << "\n"
        << setw(sw) << left << "total moves made: " << setw(nw) << right << stats.moves;
    return os;
}

// A wrapper object that inherits from `T` and counts construction, copy and move operations of `T`.        
template <typename T>
class ccm_counter : public T
{
public:
    template <typename ...Args, typename = std::enable_if_t<std::is_constructible_v<T, Args...>>>
    constexpr explicit ccm_counter(Args... args) noexcept(std::is_nothrow_constructible_v<T, Args...>)
        : T(std::forward<Args>(args)...), _val_stats{ std::make_shared<ccm_value_stats>() }
    {
        _obj_stats.ctor++;
    }

    constexpr ccm_counter(const ccm_counter& other) noexcept(std::is_nothrow_copy_constructible_v<T>)
        : T(other), _val_stats(other._val_stats)
    {
        static_assert(std::is_copy_constructible_v<T>, "T must be copy constructible.");
        _val_stats->copies++;
        _obj_stats.copy_ctor++;
    }

    constexpr ccm_counter(ccm_counter&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
        : T(other), _val_stats(other._val_stats)
    {
        static_assert(std::is_move_constructible_v<T>, "T must be move constructible.");
        _val_stats->moves++;
        _obj_stats.move_ctor++;
    }

    constexpr auto operator=(const ccm_counter& other) noexcept(std::is_nothrow_copy_assignable_v<T>)
        -> std::enable_if_t<std::is_copy_assignable_v<T>, ccm_counter&>
    {
        T::operator=(other);
        _val_stats = other._val_stats;
        _val_stats->copies++;
        _obj_stats.copy_assign++;
        return *this;
    }

    constexpr auto operator=(ccm_counter&& other) noexcept(std::is_nothrow_move_assignable_v<T>)
        -> std::enable_if_t<std::is_move_assignable_v<T>, ccm_counter&>
    {
        T::operator=(other);
        _val_stats = other._val_stats;
        _val_stats->moves++;
        _obj_stats.move_assign++;
        return *this;
    }

    [[nodiscard]] constexpr ccm_object_stats get_object_stats() const noexcept
    {
        return _obj_stats;
    }

    constexpr void set_object_stats(ccm_object_stats stats)
    {
        _obj_stats = std::move(stats);
    }

    [[nodiscard]] constexpr std::shared_ptr<ccm_value_stats> get_value_stats() const noexcept
    {
        return _val_stats;
    }

    constexpr void set_value_stats(std::shared_ptr<ccm_value_stats> stats)
    {
        _val_stats = std::move(stats);
    }

private:
    ccm_object_stats _obj_stats{};
    std::shared_ptr<ccm_value_stats> _val_stats;
};

template <typename T>
std::ostream& operator<<(std::ostream& os, const ccm_counter<T>& counter)
{
    return os << counter.get_ccmd_stats();
}
\$\endgroup\$
1
  • \$\begingroup\$ On a related note, you might find my Track & Trace allocators useful for testing container implementations. \$\endgroup\$ Commented Feb 12 at 17:10

1 Answer 1

3
\$\begingroup\$

One obvious metric we could be gathering is the number of times the destructor is called, which should add up to the same number as the total of constructor calls if the container is behaving correctly. But that's only useful if we have a way to access the stats after the object is destroyed!


The += operator is just on the verge of being easier to write as a loop:

    constexpr ccm_object_stats& operator+=(const ccm_object_stats& rhs)
    {
        for (auto mem: { &ccm_object_stats::ctor,
                         &ccm_object_stats::copy_ctor, &ccm_object_stats::copy_assign,
                         &ccm_object_stats::move_ctor, &ccm_object_stats::move_assign}) {
            this->*mem += rhs.*mem;
        }
        return *this;
    }

This loop will become the easy option once we have the reflection support from C++26, I think.


We make nw a std::size_t, but use it only as argument to std::nw - which takes an int. Probably better to just cast the std::log10 result directly to int.


This constructor is incorrect:

constexpr ccm_counter(ccm_counter&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
    : T(other), _val_stats(other._val_stats)

That should be T(std::move(other)) (which requires us to include <utility>, of course).


\$\endgroup\$
2
  • 1
    \$\begingroup\$ You could have the destructor do a sanity check at least: it could assert() or throw when the sum of the constructors is not equal to 1. You could also count the number of destructor calls in case someone directly calls the destructor multiple times, although that is already UB so the compiler could legally optimize that away. In fact, the same goes for having the constructor called more than once on the same object. \$\endgroup\$
    – G. Sliepen
    Commented Mar 13 at 20:49
  • 1
    \$\begingroup\$ Throwing from a destructor will result in program termination, which prevents gathering any subsequent errors; that's what I meant by needing a way to access the stats after destruction (perhaps we could append the error to a global collection of diagnostics). But as you say, mismatched construction/destruction is already UB, so all bets are off after that point, and giving up immediately might be the best action. \$\endgroup\$ Commented Mar 14 at 7:31

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.