2

I am designing a memory allocator that is able to move objects during their lifetime. To support this it requires use of IndirectPointer that points to a control block. This control block points to the true location of the object and is updated if the memory allocator wants to move an objects.

This creates a problem when you want an IndirectPointer to a base class of the allocated type, as the pointer value in the memory block points to the derived class. I've come up with the following solution, by checking the offset between the base and derived pointers, storing it, and adjusting the memory block pointer by this offset.

Is this approach defined behavior? Specifically, is storing an offset along with a pointer defined behavior including any potential edge cases?

struct ControlBlock
{
    void* ptr;
};

template<typename T>
class IndirectPointer
{
public:

    // Constructor used immediately after object allocation
    IndirectPointer(ControlBlock* control_block) :
        control_block(control_block),
        offset(0)
    {
    }

    // Allow indirect pointers to base clases
    template<typename Derived>
    IndirectPointer(const IndirectPointer<Derived>& r) requires std::is_base_of_v<T, Derived> :
        control_block(r.control_block)
    {
        if (const Derived* ptr = r.try_get())
        {
            offset = r.offset + reinterpret_cast<const char*>(static_cast<const T*>(ptr)) - reinterpret_cast<const char*>(ptr);
        }
    }

    // Access value
    T* try_get() const 
    {
        if (control_block && control_block->ptr)
        {
            return reinterpret_cast<T*>(static_cast<char*>(control_block->ptr) + offset);
        }
        return nullptr;
    }
    
private:

    template<typename T>
    friend class IndirectPointer;

    ControlBlock* control_block;
    std::ptrdiff_t offset;
};

int main()
{
    struct X { 
        ~X() = default;
        virtual void foo() = 0;
    };
    struct Y : X {
        void foo() override {}
    };
    
    // Minimal code to show intented behaviour:

    // Create object
    Y object;
    ControlBlock cb { &object };
    IndirectPointer<Y> pointer{ &cb };
    IndirectPointer<X> base_pointer{pointer};

    // Move object
    Y object_new_location{ std::move(object) };
    
    // Update control block pointer
    cb.ptr = &object_new_location;

    // Pointer now points to new object
    assert(pointer.try_get() == &object_new_location);
    assert(base_pointer.try_get() == &object_new_location);
}
9
  • 2
    You may want to limit your question to exact operations you are not sure defined or not. Reviewing entire solution is not really what SO is for Commented Jul 14 at 13:01
  • Rather than messing around with offsets between base and derived classes (which are inherently implementation dependent, since the standard specifies nothing about the) why not have a base class with a so-called "virtual copy constructor" (e.g. a virtual Clone() function) which your IndirectPointer() can use to create a clone of the actual object as needed? Commented Jul 14 at 13:22
  • 1
    With multiple inheritance & virtual inheritance, that's certainly not a correct approach: demo. Commented Jul 14 at 13:52
  • 1
    @VincentSaulue-Laborde I've fixed the issue by adding the offset of the previous pointer, now your demo does work. godbolt.org/z/xEcKK7W1j Commented Jul 14 at 14:21
  • @Peter This seems like a rather large potential overhead? Possibly requiring a deep copy? Commented Jul 14 at 14:22

2 Answers 2

3

Yes, storing an offset to the base class (as you do) will work correctly.

But remember that to move objects in memory legally you have to call their move constructors (and destructors on the original copies), or use C++26 trivial relocatability.

Sign up to request clarification or add additional context in comments.

Comments

0

Is it "defined behavior" to mess with integer offsets and reinterpret_cast'ing char pointers as you're doing? Of course not. It's 100% UB. But will it work? Yes... except that the precise strategy you're using here (Godbolt) won't quite work, in the presence of virtual bases. Here's an example (Godbolt):

// Create object
struct VB { int vb_ = 42; };
struct B : virtual VB { int b_ = 0; };
struct D1 : B { int d1_[10] = {}; };
D1 object;  // [vptr, b_, d1_, vb_]
ControlBlock cb = { &(B&)object };
auto pointer = IndirectPointer<B>(&cb);
auto base_pointer = IndirectPointer<VB>(pointer);

// Move object
B object_new_location = object;  // [vptr, b_, vb_]

// Update control block pointer
cb.ptr = &(B&)object_new_location;

// Pointer now points to new object
assert(pointer.try_get() == &object_new_location);
assert(base_pointer.try_get() == &object_new_location); // boom goes the dynamite
assert(pointer.try_get()->vb_ == 42);
assert(base_pointer.try_get()->vb_ == 42);

What you're doing here, with an indirect "control block" and a fairly explicit conversion mechanism from Ptr<T> to Ptr<U>, looks a lot like shared_ptr. shared_ptr solves its problem by having each shared_ptr<X> object store both a pointer to the control block and a raw X*. So static_pointer_cast<Y>(sptr_to_x) operates by leaving the control-block pointer alone, and resetting the new shared_ptr<Y>'s stored Y* to static_cast<Y*>(sptr_to_x.get()).

This works, though, only because the "convert from X* to Y*" operation is done once, inside static_pointer_cast<Y>, at a point where we statically know both X and Y. Your idea requires that we be able to perform that operation again, much later in program execution, after the X object has gone somewhere else in memory — i.e., at a time when we (that is, IndirectPointer<Y>) don't statically know X. In general (e.g. in the presence of virtual bases) that simply can't be done, physically.

The easiest way to solve this would be to parameterize IndirectPointer on X as well as Y, like this (Godbolt):

template<class MDT>
struct ControlBlock;

template<class MDT, class T>
struct IndirectPointer {
    explicit IndirectPointer(const ControlBlock<MDT> *p) : cb_(p) {}

    template<class U>
    IndirectPointer(const IndirectPointer<MDT, U>& r) : cb_(r.cb_) {}

    T* try_get() const {
        if (cb_ && cb_->ptr_) {
            return static_cast<T*>(cb_->ptr_);
        }
        return nullptr;
    }
private:
    template<class, class> friend struct IndirectPointer;
    const ControlBlock<MDT> *cb_;
};

template<class MDT>
struct ControlBlock {
    IndirectPointer<MDT, MDT> ptr() const {
        return IndirectPointer<MDT, MDT>(this);
    }

    MDT *ptr_;
};

template<class U, class MDT, class T>
    requires std::is_convertible_v<MDT*, U*>
auto pointer_cast(const IndirectPointer<MDT, T>& r)
{
    return IndirectPointer<MDT, U>(r);
}

~~~~
    D1 object;  // [vptr, b_, d1_, vb_]
    ControlBlock<B> cb;
    cb.ptr_ = &object;
    auto pointer = cb.ptr();
    auto base_pointer = pointer_cast<VB>(pointer);

    // Move object
    B object_new_location = object;  // [vptr, b_, vb_]

    // Update control block pointer
    cb.ptr_ = &object_new_location;

    // Pointer now points to new object
    assert(pointer.try_get() == &object_new_location);
    assert(base_pointer.try_get() == &object_new_location);

This fails to give you a single-argument template like IndirectPointer<Cat>; instead you'll have IndirectPointer<Cat, Cat> and IndirectPointer<Cat, Animal>, the latter being a completely different type from IndirectPointer<Dog, Animal>. But (unless I've made a mistake) it will achieve your goal of letting a single update to cb.ptr_ magically fix all your pointers.

Whether this fits into your (misguided, IMHO) memory-allocator design, I don't know. I doubt it.

Comments

Your Answer

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

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.