Lightweight C++ Allocation Tracking
A lightweight inheritance-based allocation tracker for targeted leak detection without false positives from external tools.
A drop-in C++ base class that tracks live instances per type. Pinpoints leaks at class granularity with zero overhead when disabled, cutting through allocator noise and integrating cleanly into tests and CI.
Introduction
Memory leaks in C++ are one of those bugs that show up at the worst possible time. Long after the first tests pass. Tools like Valgrind or AddressSanitizer are excellent for full coverage, but they often bury you in noise when you just want to know: is this class leaking right now?
In legacy codebases, or when debugging a specific subsystem, a minimal, selective tracker can be more effective than heavyweight external tooling. This post explores a small inheritance-based pattern for leak detection, its design trade-offs, and useful extensions for real-world use.
Design Goals
The tracker should be invasive enough to be useful, but not so invasive that it changes how you write code. A single inheritance line gets you tracking without rewriting APIs or changing call sites.
When tracking is disabled, the overhead should disappear completely. No virtual calls, no memory overhead, no runtime branching. A compile-time switch makes the tracker vanish from release builds.
Output should focus on what matters: which classes are leaking, how many instances, and ideally where they were created. No noise from global allocators, STL internals, or framework code you don't control.
The pattern should drop into existing classes easily. Inherit from the tracker, define the static counter, and you're tracking. No API changes, no refactoring, no coordination with existing inheritance hierarchies.
The Basic Pattern
The core idea is to track object lifetimes through a simple counting scheme. Every constructor increments a counter, every destructor decrements it. If the counter isn't zero when you expect it to be, you have a leak.
The challenge is making this work automatically and reliably with all constructor and destructor combinations, including copy and move operations.
template <class Tag>
struct AllocationTracker
{
#if ENABLE_TRACKING
AllocationTracker() { counter++; }
AllocationTracker(AllocationTracker const&) { counter++; }
AllocationTracker(AllocationTracker&&) noexcept { counter++; }
AllocationTracker& operator=(AllocationTracker const&) = default;
AllocationTracker& operator=(AllocationTracker&&) noexcept = default;
~AllocationTracker() { counter--; }
#endif
static size_t get_live_count() { return counter; }
private:
static std::atomic<size_t> counter;
};
// Usage: inherit with your class as the tag
struct ParticleSystem : AllocationTracker<ParticleSystem>
{
std::vector<Particle> particles;
// normal implementation
};
// Define the counter in a .cc file
template<>
std::atomic<size_t> AllocationTracker<ParticleSystem>::counter{0};
The Tag template parameter creates a separate counter for each tracked type. Using the class itself as the tag (CRTP style) is natural, but you could use any unique type. Each tag gets its own static storage, so ParticleSystem and AudioBuffer would have independent counters.
Multiple inheritance works without issues. The tracker is empty, so it doesn't affect object size or layout. You can add it to classes that already inherit from other bases.
The pattern handles copies and moves correctly. Each constructor call increments the counter, each destructor decrements it. This guarantees that properly managed objects maintain the 1-to-1 balance between construction and destruction.
Assignment operators are defaulted rather than custom-implemented because they don't involve object creation or destruction. Copy assignment and move assignment transfer state between existing objects, so the live object count stays the same. The operators must be explicitly defaulted; otherwise the compiler deletes them when the parent class declares custom constructors.
Thread safety comes from std::atomic. Multiple threads can construct and destroy objects safely without additional synchronization. For single-threaded code, a plain size_t is sufficient.
Testing integration is natural. Save the counter value, run your test, and assert that the counter returned to the same value:
void test_particle_cleanup()
{
auto initial = ParticleSystem::get_live_count();
{
ParticleSystem system;
system.add_explosion(/*...*/);
// system destructor runs here
}
REQUIRE(ParticleSystem::get_live_count() == initial);
}
Where the Tracker Fits
Precision vs. Coverage
Valgrind and ASan see everything, including allocator churn in libraries you don't control. That breadth is powerful but noisy. The tracker does the opposite: you annotate classes, it tracks lifetimes, and the output stays sharp. No false positives from STL internals, no allocator noise.
Practical Use
The mechanics are predictable: one increment on construction, one decrement on destruction. Works with pools, placement new, or stack allocation. CI integration is trivial: snapshot, run test, assert zero. In legacy code you can run it in "delta mode," proving a function is allocation-neutral even if the system still leaks elsewhere.
Limits and Costs
Manual effort is the trade-off: only annotated classes are tracked, and you see that an object survived, not why. Extensions (live sets, stack traces) add insight but also cost: hash lookups, mutexes, and stack walking. High-frequency types should stick to counters; heavyweight resources may justify deeper tracking.
Takeaway
This pattern is a scalpel. It won't replace global tools, but it finds subtle lifetime bugs with minimal ceremony. For many projects, the plain counter is enough.
The rest of this post is optional depth for when you need more. Treat it as a gallery.
Useful Extensions
Live Set Tracking
Counters tell you how many objects are leaked, but not which specific instances. For debugging, you often need to iterate over the actual leaked objects to inspect their state or print diagnostic information.
template <class Tag>
struct AllocationTracker
{
AllocationTracker()
{
std::lock_guard lock{mutex};
live_objects.insert(this);
}
~AllocationTracker()
{
std::lock_guard lock{mutex};
live_objects.erase(this);
}
static std::vector<void const*> get_live_objects()
{
std::lock_guard lock{mutex};
return {live_objects.begin(), live_objects.end()};
}
static size_t get_live_count()
{
std::lock_guard lock{mutex};
return live_objects.size();
}
private:
static std::mutex mutex;
static std::unordered_set<void const*> live_objects;
};
This extension stores pointers to all live objects. The pointers are stored as void const* because the tracker base class doesn't know the exact derived type.
When leaks are detected, you can iterate over the live set and cast back to the tracked type for inspection:
if (ParticleSystem::get_live_count() > 0)
{
std::cout << "Leaked particle systems:\n";
for (void const* ptr : ParticleSystem::get_live_objects())
{
auto const* system = static_cast<ParticleSystem const*>(ptr);
std::cout << " System with " << system->particles.size() << " particles\n";
}
}
The mutex provides thread safety at the cost of serializing all construction and destruction. For single-threaded code, you can remove the locking.
Stack Trace Capture
Knowing which objects are leaked is helpful. Knowing where they were created is invaluable. C++23's std::stacktrace makes this practically hassle-free for the first time in standard C++.
#include <stacktrace>
template <class Tag>
struct AllocationTracker
{
AllocationTracker()
{
std::lock_guard lock{mutex};
allocation_sites[this] = std::stacktrace::current();
}
~AllocationTracker()
{
std::lock_guard lock{mutex};
allocation_sites.erase(this);
}
static void print_leaks()
{
std::lock_guard lock{mutex};
if (allocation_sites.empty()) return;
std::cout << "Leaked " << allocation_sites.size()
<< " objects of type " << typeid(Tag).name() << ":\n";
for (auto const& [ptr, trace] : allocation_sites)
{
std::cout << " Object at " << ptr << " created at:\n";
for (auto const& frame : trace)
{
std::cout << " " << frame << "\n";
}
std::cout << "\n";
}
}
static size_t get_live_count()
{
std::lock_guard lock{mutex};
return allocation_sites.size();
}
private:
static std::mutex mutex;
static std::unordered_map<void const*, std::stacktrace> allocation_sites;
};
Stack trace capture is expensive. Each constructor call must walk the call stack and symbolize function names. Use this extension judiciously: enable it only when hunting specific leaks, not in production or performance-sensitive tests.
Before C++23, platform-specific APIs like backtrace() on Linux or CaptureStackBackTrace() on Windows provide similar functionality. The interfaces are less clean, but the concept remains the same.
The payoff is significant when debugging complex object lifetimes. Instead of guessing where a leaked object came from, you get exact call stack information pointing to the allocation site.
Dependency Hiding
Heavy tracking extensions can pollute headers with includes like <unordered_map>, <mutex>, or <stacktrace>. When the tracker is used in widely-included headers, this becomes a build time problem.
A solution is to move the tracking infrastructure behind an opaque interface:
// allocation_tracker.hh - minimal header
template <class Tag>
struct AllocationTracker
{
AllocationTracker();
AllocationTracker(AllocationTracker const&);
AllocationTracker(AllocationTracker&&) noexcept;
AllocationTracker& operator=(AllocationTracker const&) = default;
AllocationTracker& operator=(AllocationTracker&&) noexcept = default;
~AllocationTracker();
};
// your_class.hh - clean interface
struct ParticleSystem : private AllocationTracker<ParticleSystem>
{
void add_explosion(/*...*/);
// normal API, tracking hidden
};
Implementation details move to a separate implementation header and .cc files:
// allocation_tracker_impl.hh - implementation header
#include "allocation_tracker.hh"
#include <mutex>
#include <unordered_map>
#include <stacktrace>
template <class Tag>
struct AllocationTrackerImpl
{
static std::mutex mutex;
static std::unordered_map<void const*, std::stacktrace> allocation_sites;
static size_t get_live_count();
static void print_leaks();
};
// Implementation of AllocationTracker methods
template <class Tag>
AllocationTracker<Tag>::AllocationTracker()
{
std::lock_guard lock{AllocationTrackerImpl<Tag>::mutex};
AllocationTrackerImpl<Tag>::allocation_sites[this] = std::stacktrace::current();
}
template <class Tag>
AllocationTracker<Tag>::~AllocationTracker()
{
std::lock_guard lock{AllocationTrackerImpl<Tag>::mutex};
AllocationTrackerImpl<Tag>::allocation_sites.erase(this);
}
#define DECLARE_ALLOCATION_TRACKER(Tag) \
template struct AllocationTracker<Tag>; \
template<> std::mutex AllocationTrackerImpl<Tag>::mutex; \
template<> std::unordered_map<void const*, std::stacktrace> AllocationTrackerImpl<Tag>::allocation_sites
Then in your implementation file:
// ParticleSystem.cc
#include "ParticleSystem.hh"
#include "allocation_tracker_impl.hh"
DECLARE_ALLOCATION_TRACKER(ParticleSystem);
// rest of ParticleSystem ...
This approach keeps headers lightweight while supporting arbitrarily complex tracking behind the scenes. The macro simplifies static member declarations, and template instantiation overhead becomes negligible since only implementation files that actually use tracking pay the compile-time cost.
DLL Considerations
Shared libraries complicate static storage. If the tracker lives in a static library that gets linked into multiple DLLs, each DLL gets its own copy of the static counter. Tracking becomes unreliable because creation in one DLL and destruction in another won't affect the same counter.
The fix is careful placement of static storage. Define the tracking data in exactly one shared library:
// In core.dll header
struct ParticleSystemTag { };
// In static library code
struct ParticleSystem : AllocationTracker<ParticleSystemTag>
{
// implementation
};
// In core.dll implementation file
template<>
std::atomic<size_t> AllocationTracker<ParticleSystemTag>::counter{0};
The tag doesn't need to match the class name. It just needs to resolve to storage in a single, shared location. Both the tag declaration and the static counter definition must live in the same shared library (typically core.dll, not main.exe) to avoid duplication across DLL boundaries.
Delta Tracking
Large legacy codebases often can't achieve zero global leaks quickly. But you can still verify that specific functions or tests are allocation-neutral by comparing before and after snapshots.
struct AllocationSnapshot
{
std::unordered_set<void const*> live_objects;
template <class Tag>
static AllocationSnapshot capture()
{
return {AllocationTracker<Tag>::get_live_objects()};
}
AllocationSnapshot operator-(AllocationSnapshot const& earlier) const
{
AllocationSnapshot diff;
std::set_difference(
live_objects.begin(), live_objects.end(),
earlier.live_objects.begin(), earlier.live_objects.end(),
std::inserter(diff.live_objects, diff.live_objects.begin()));
return diff;
}
};
// Usage
void test_level_loading()
{
auto before = AllocationSnapshot::capture<ParticleSystem>();
load_level("forest.dat");
unload_level();
auto after = AllocationSnapshot::capture<ParticleSystem>();
auto leaked = after - before;
assert(leaked.live_objects.empty()); // level loading should be neutral
}
This enables incremental leak fixing. You can ensure new code doesn't introduce leaks even while existing leaks remain unfixed.
The operator-() is cute but not essential. You could just as easily implement bool are_new_objects_live(AllocationSnapshot const& earlier) const or similar comparison functions depending on your preferred style.
Compared to a simple counter, you can still inspect stack traces in this version (filtered to those objects that were created between snapshots).
Performance Considerations
The basic counter is essentially free: just an atomic increment and decrement per object lifetime. But as you add extensions, performance characteristics change.
Counter vs. Sets/Maps: Live object tracking with std::unordered_set or std::unordered_map adds hash table overhead. The mutex serializes all construction and destruction. For single-threaded code, you can remove the mutex entirely and use plain containers.
Stack trace capture dominates: If you're recording stack traces, that cost overwhelms everything else. Walking and symbolizing the call stack is expensive. Use this extension only when actively hunting leaks.
High-frequency objects: Classes with very frequent creation/destruction should stick to counter-only tracking. The extensions can be mixed and matched per class based on need.
An intrusive linked list can provide better performance than hash tables but trades memory safety for speed: object corruption can break the list. In our experience, the mutex-protected approach is fast enough and much simpler to reason about.
Event Logging
For complex debugging scenarios, record a complete timeline of object lifetimes:
struct AllocationEvent
{
enum Type { Create, Copy, Move, Destroy };
Type type;
void const* object;
void const* source; // for copy/move
std::chrono::steady_clock::time_point timestamp;
std::stacktrace trace;
};
template <class Tag>
struct AllocationTracker
{
AllocationTracker()
{
log_event(AllocationEvent::Create, this);
}
AllocationTracker(AllocationTracker const& other)
{
log_event(AllocationEvent::Copy, this, &other);
}
AllocationTracker(AllocationTracker&& other) noexcept
{
log_event(AllocationEvent::Move, this, &other);
}
// add move/copy assign if desired
~AllocationTracker()
{
log_event(AllocationEvent::Destroy, this);
}
static std::vector<AllocationEvent> get_event_log()
{
std::lock_guard lock{mutex};
return event_log;
}
private:
static void log_event(AllocationEvent::Type type, void const* obj, void const* src = nullptr)
{
std::lock_guard lock{mutex};
event_log.push_back({
type, obj, src,
std::chrono::steady_clock::now(),
std::stacktrace::current()
});
}
static std::mutex mutex;
static std::vector<AllocationEvent> event_log;
};
Event logging provides the richest debugging information: not just what's leaked, but the complete timeline of how objects were created, copied, moved, and destroyed. You can replay the log to reconstruct the live object set at any point in time.
The cost is memory growth proportional to total object activity, so this extension works best for time-bounded debugging sessions or CI runs with limited scope.
Conclusion
This tracker is not a silver bullet. It won't replace Valgrind or ASan. But as a drop-in, class-level leak detector it's highly effective for targeted leak hunting.
Best practice is to keep at least the counter version always-on in CI, so tests can assert zero leaks in subsystems that matter. From there, extensions like stacktrace storage or delta mode can add depth as needed.
Think of it as a scalpel, not an MRI: precise, simple, and invaluable when you know where to cut.