This week, I spent two days debugging an issue at work. I am documenting it here, hoping it might be helpful to someone facing a similar situation in the future. This article also aims to deepen the understanding of unique_ptr
.
Specifically, the issue involved the deleter of std::unique_ptr
. In polymorphic scenarios, when performing a dynamic_cast
on a unique_ptr
, if the memory for the unique_ptr
is allocated using a custom allocator, especially one with a custom deleter, the deleter may not automatically cast.
The incident started with a task at work where I needed to write a custom allocator to limit the memory allocation of certain data structures, ensuring their total memory usage did not exceed 100MB. These data structures included standard std::vector
and std::map
. Close to the deadline, a colleague, in haste, replaced one of the std::vector<T>
with std::unique_ptr<T>
, where T
was a baseClass
, and what was actually stored were unique_ptr
of T
‘s derivedClass
objects. This caused me quite a headache.
I had a simple custom allocator:
#include <iostream>
#include <memory>
#include <limits>
#include <vector>
#include <functional>
// Custom allocator that tracks memory usage and enforces a limit.
template <typename T>
class LimitedAllocator
{
public:
using value_type = T;
using pointer = T*;
using const_pointer = const T*;
using void_pointer = void*;
using const_void_pointer = const void*;
using size_type = size_t;
using difference_type = ptrdiff_t;
static const size_t limit = 100 * 1024 * 1024; // memory limit of 100MB
static size_t used_memory;
LimitedAllocator() noexcept = default;
template <typename U>
LimitedAllocator(const LimitedAllocator<U>&) noexcept {}
pointer allocate(size_type n, const_void_pointer hint = 0)
{
size_type bytes = n * sizeof(T);
if (bytes + used_memory > limit)
{
throw std::bad_alloc();
}
used_memory += bytes;
return static_cast<pointer>(::operator new(n * sizeof(T)));
}
void deallocate(pointer p, size_type n) noexcept
{
used_memory -= n * sizeof(T);
::operator delete(p);
}
size_type max_size() const noexcept
{
return std::numeric_limits<size_type>::max() / sizeof(T);
}
template <typename U, typename... Args>
void construct(U* p, Args&&... args) {
new(p) U(std::forward<Args>(args)...);
}
template <typename U>
void destroy(U* p) {
p->~U();
}
bool operator==(const LimitedAllocator&) const noexcept {
return true;
}
bool operator!=(const LimitedAllocator&) const noexcept {
return false;
}
};
template <typename T>
typename LimitedAllocator<T>::size_type LimitedAllocator<T>::used_memory = 0;
However, abstracting my situation, I had two classes:
class MyClassA {
public:
virtual ~MyClassA() = default; // Ensure we have a virtual destructor for base class
virtual void doSomething() const {
std::cout << "MyClassA doing something." << std::endl;
}
};
class MyClassB : public MyClassA {
public:
void doSomething() const override {
std::cout << "MyClassB doing something different." << std::endl;
}
};
void testLimitedAllocatorWithPolymorphism()
{
LimitedAllocator<MyClassB> allocator;
std::vector<LimitedUniquePtr<MyClassA> > m_Horizontals;
auto myClassBPtr = make_unique_limited<MyClassB>(allocator);
// Since we're using PolymorphicDeleter, we can directly assign myClassBPtr to myClassAPtr
LimitedUniquePtr<MyClassA> myClassAPtr = std::move(myClassBPtr);
m_Horizontals.emplace_back(std::move(myClassAPtr));
for (const auto& item : m_Horizontals)
{
item->doSomething();
}
m_Horizontals.clear();
}
When using them, I had a std::vector<LimitedUniquePtr<MyClassA>>
which, in practice, was emplaced back with LimitedUniquePtr<MyClassB>
objects.
Naively, I thought I could simply release the ownership from ClassBUniquePtr
and construct a MyClassAUniquePtr
from it, assuming derivedClass -> baseClass conversion would work flawlessly in C++’s object-oriented capabilities.
However, I encountered a std::bad_function_call
error. Upon debugging with gdb, I discovered that the deleter in make_unique_limited
, which captured the allocator, was causing issues because the allocator went out of scope by the program’s end.
To solve this, I introduced a PolymorphicDeleter
, explicitly overriding the std::unique_ptr
‘s deleter with a function pointer that does not depend on the allocator. This resolved the issue.
The complete solution is as follows:
// Polymorphic deleter
struct PolymorphicDeleter {
template<typename T>
void operator()(T* ptr) const {
delete ptr; // Correctly calls the destructor for T, handling polymorphism
}
};
template<typename T>
using LimitedUniquePtr = std::unique_ptr<T, PolymorphicDeleter>;
template<typename T, typename... Args>
std::unique_ptr<T, PolymorphicDeleter> make_unique_limited(LimitedAllocator<T>& allocator, Args&&... args) {
T* raw_ptr = allocator.allocate(1); // Allocate space for one T
new (raw_ptr) T(std::forward<Args>(args)...); // Use placement new with forwarded arguments
return std::unique_ptr<T, PolymorphicDeleter>(raw_ptr, PolymorphicDeleter{});
}
This approach successfully resolved the `std::bad_function_call` issue. In summary, if you’re using a static, singleton allocator, there’s not much to worry about since the allocator remains in memory, and there’s no concern about the deleter capturing it failing. Polymorphism is also not a significant issue. However, if your allocator has a lifecycle, you must consider the relationship between the deleter’s function pointer and the allocator’s lifecycle.
发表回复