A practical example of using std::shared_ptr, std::weak_ptr, and a custom deleter to implement a thread-safe object cache. Multiple independent components can share the same object by retrieving it by name. When the last owner releases its shared_ptr, the custom deleter automatically removes the corresponding entry from the cache and destroys the object.
This example demonstrates how shared ownership simplifies object lifetime management without requiring centralized tracking of individual users.

#include <atomic>
#include <cassert>
#include <cstdint>
#include <iostream>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <utility>
// An object shared by multiple independent components.
class Element
{
public:
explicit Element(std::string name)
: _name(std::move(name))
{
std::cout << "Created: " << _name << '\n';
}
~Element()
{
std::cout << "Destroyed: " << _name << '\n';
}
void doSomething() const
{
std::cout << "Using: " << _name << '\n';
}
private:
std::string _name;
};
// A thread-safe cache that does not own its cached elements.
//
// Elements are owned by external shared_ptr instances.
// The cache stores weak_ptr instances to avoid extending
// their lifetime.
//
// When the last shared_ptr is destroyed, its custom deleter
// removes the corresponding entry from the cache.
class Cache
{
private:
struct State
{
struct Entry
{
std::weak_ptr<Element> object;
std::uint64_t generation;
};
std::mutex _mutex;
std::unordered_map<std::string, Entry> _objects;
std::atomic<std::uint64_t> _nextGeneration{0};
// Called by the custom shared_ptr deleter.
void onLastOwnerGone(
const std::string& name,
std::uint64_t generation)
{
std::lock_guard lock(_mutex);
auto it = _objects.find(name);
if (it == _objects.end())
return;
// Do not remove a newer element with the same name.
if (it->second.generation != generation)
return;
_objects.erase(it);
std::cout
<< "Removed from cache: "
<< name
<< '\n';
}
};
public:
// Returns an existing element or creates a new one.
//
// Multiple callers requesting the same name receive
// shared ownership of the same element.
std::shared_ptr<Element> get(const std::string& name)
{
auto state = _state;
// Try to find an existing element.
{
std::lock_guard lock(state->_mutex);
auto it = state->_objects.find(name);
if (it != state->_objects.end())
{
if (auto existing = it->second.object.lock())
return existing;
}
}
const auto generation =
++state->_nextGeneration;
// The deleter must not capture a raw Cache pointer
// because the element may outlive the cache.
auto weak_state = std::weak_ptr<State>(state);
// Create an element with a custom shared_ptr deleter.
auto result = std::shared_ptr<Element>(
new Element(name),
[weak_state, name, generation](Element* element)
{
// Notify the cache when the last owner
// releases the element.
if (auto state = weak_state.lock())
{
state->onLastOwnerGone(
name,
generation
);
}
// Destroy the actual element.
delete element;
}
);
{
std::lock_guard lock(state->_mutex);
// Another thread may have created the same element
// while we were constructing our candidate.
auto it = state->_objects.find(name);
if (it != state->_objects.end())
{
if (auto existing = it->second.object.lock())
{
// The unused candidate is destroyed after
// the mutex has been released.
return existing;
}
}
// The cache stores only a weak reference.
state->_objects[name] = {
result,
generation
};
}
return result;
}
std::size_t size() const
{
auto state = _state;
std::lock_guard lock(state->_mutex);
return state->_objects.size();
}
private:
// Separate state allows the custom deleter to safely
// detect whether the cache still exists.
std::shared_ptr<State> _state =
std::make_shared<State>();
};
// An independent component that retains shared ownership
// of an element obtained from the cache.
class Component
{
public:
Component(
Cache& cache,
const std::string& name)
: _element(cache.get(name))
{
}
void run() const
{
_element->doSomething();
}
const std::shared_ptr<Element>& getElement() const
{
return _element;
}
private:
std::shared_ptr<Element> _element;
};
int main()
{
Cache cache;
{
Component component1(cache, "element-1");
Component component2(cache, "element-1");
Component component3(cache, "element-1");
// All three components share the same element.
assert(
component1.getElement().get() ==
component2.getElement().get()
);
assert(
component2.getElement().get() ==
component3.getElement().get()
);
// The cache contains only one entry.
assert(cache.size() == 1);
// Three independent owners.
assert(
component1.getElement().use_count() == 3
);
component1.run();
component2.run();
component3.run();
// All three components are destroyed here.
}
// The last shared_ptr has been destroyed.
//
// Its custom deleter has:
// 1. Notified the cache.
// 2. Removed the cache entry.
// 3. Destroyed the Element.
assert(cache.size() == 0);
// Requesting the same name creates a new element.
auto element = cache.get("element-1");
assert(cache.size() == 1);
// Release the last owner.
element.reset();
// The custom deleter removes the entry again.
assert(cache.size() == 0);
// Verify that an element can outlive the cache.
std::shared_ptr<Element> external_element;
{
Cache temporary_cache;
external_element =
temporary_cache.get("element-2");
assert(temporary_cache.size() == 1);
// The cache is destroyed here,
// but element-2 remains alive.
}
external_element->doSomething();
// The custom deleter detects that the cache
// no longer exists and simply deletes the element.
external_element.reset();
return 0;
}


An interesting difference between C++ and C# is what happens when we require **immediate cache eviction after the last independent user releases an object**.
In C#, ordinary strong references keep the object alive, and `WeakReference` allows the cache to avoid extending its lifetime. However, the garbage collector does not notify the cache at the exact moment the last strong reference disappears. A weak-reference cache therefore needs lazy or periodic cleanup, and eviction is not deterministic.
To reproduce the behavior of this C++ example precisely, we would need an explicit lease mechanism in C#: each component acquires a lease, releases it through `IDisposable`, and the cache maintains its own reference count. When the last lease is disposed, the cache removes the entry immediately.
In other words, we would be implementing reference counting ourselves on top of a garbage-collected runtime. In C++, `std::shared_ptr`, `std::weak_ptr`, and a custom deleter already provide exactly this lifetime-management mechanism.