4
\$\begingroup\$

I wasn't sure about how to name it, maybe "follow_ptr", "self_updating_ptr", or "stalking_ptr" or something on those lines. For now it's called Identifier.

What I'm trying to achieve is a pointer wrapper which will always refer to the same object even when that object is moved in memory (vector resizes is a quite frequent example, also algorithms like std::remove_if that can move elements around).


EDIT: One requirement is to allow storing objects in sequential containers (like vector and deque) without losing sequential storage as one would by using unique_ptr or shared_ptr. This whole system is not meant to take care about ownership.

It's my bad for using the term "smart pointer in the original title", it's smart in the sense that it follows the pointed object as opposed to an observer pointer which wouldn't do that.


A requirement is that the object is stored within an "Identified" class. That class is necessary to keep all the Identifiers updated.

The trick is having a double indirection, where a raw pointer living in the heap will point to the object to be stalked:

#include <memory>
#include <stdexcept>

template <typename T>
class Identifier;
template <typename T>
class Identified;

// A pointer to an identified object. This object lives in the heap and is used to share information with all identifiers about the object moving in memory.
template <typename T>
class Inner_identifier
    {
    public:
        Inner_identifier() = default;
        Inner_identifier(T* identified) noexcept : identified{identified} {}

        Inner_identifier(const Inner_identifier& copy) = delete;
        Inner_identifier& operator=(const Inner_identifier& copy) = delete;

        Inner_identifier(Inner_identifier&& move) = delete;
        Inner_identifier& operator=(Inner_identifier&& move) = delete;

        T* identified{nullptr};
    };

The Identifier, or stalker, acts as an in-between a smart pointer and an optional. The idea is that if Identifiers outlive an object, they're still valid (assuming the user checks with has_value before using them, like with an optional).

I'm unsure if I should just delete the default constructor, so that it's always certain that an Identifier's pointer to the Inner_identifier is always valid, and I can get rid of some checks. For now I've left it just to make writing the example simpler.

template <typename T>
class Identifier
    {
    public:
        Identifier() = default;
        Identifier(Identified<T>& identified) : inner_identifier{identified.inner_identifier} {}
        Identifier& operator=(Identified<T>& identified) { inner_identifier = identified.inner_identifier; return *this; }

        Identifier(const Identifier& copy) = default;
        Identifier& operator=(const Identifier& copy) = default;

        Identifier(Identifier&& move) = default;
        Identifier& operator=(Identifier&& move) = default;


        const T& operator* () const { check_all(); return *inner_identifier->identified; }
              T& operator* ()       { check_all(); return *inner_identifier->identified; }
        const T* operator->() const { check_all(); return  inner_identifier->identified; }
              T* operator->()       { check_all(); return  inner_identifier->identified; }

        const T* get() const { check_initialized(); return inner_identifier->identified; }
              T* get()       { check_initialized(); return inner_identifier->identified; }

        bool has_value() const noexcept { return inner_identifier && inner_identifier->identified != nullptr; }
        explicit operator bool() const noexcept { return has_value(); }

    private:
        std::shared_ptr<Inner_identifier<T>> inner_identifier{nullptr};

        void check_initialized() const
            {
#ifndef NDEBUG
            if (!inner_identifier) { throw std::runtime_error{"Trying to use an uninitialized Identifier."}; }
#endif
            }

        void check_has_value() const
            {
#ifndef NDEBUG
            if (inner_identifier->identified == nullptr) { throw std::runtime_error{"Trying to retrive object from an identifier which identified object had already been destroyed."}; }
#endif
            }

        void check_all() const { check_initialized(); check_has_value(); }
    };

Finally the Identified class, which holds the instance of the object to be pointed to by one or more Identifiers. It is responsible for updating the Inner_identifier whenever it is moved around in memory with either move constructor or move assignment. On the opposite the copy constructor makes sure that the new copy has its own new Inner_identifier and all the existing Identifiers still work with the instance being copied from. Upon destruction, the Inner_identifier is nullified but it will keep existing for reference as long as at least one Identifier to the now defunct object still exists (hence the internal shared_ptrs)

template <typename T>
class Identified
    {
    friend class Identifier<T>;
    public:
        template <typename ...Args>
        Identified(Args&&... args) : object{std::forward<Args>(args)...}, inner_identifier{std::make_shared<Inner_identifier<T>>(&object)} {}
        
        Identified(Identified& copy) : Identified{static_cast<const Identified&>(copy)} {}

        Identified(const Identified& copy) : object{copy.object}, inner_identifier{std::make_shared<Inner_identifier<T>>(&object)} {}
        Identified& operator=(const Identified& copy) { object = copy.object; return *this; } //Note: no need to reassign the pointer, already points to current instance

        Identified(Identified&& move) noexcept : object{std::move(move.object)}, inner_identifier{std::move(move.inner_identifier)} { inner_identifier->identified = &object; }
        Identified& operator=(Identified&& move) noexcept { object = std::move(move.object); inner_identifier = std::move(move.inner_identifier); inner_identifier->identified = &object; return *this; }

        ~Identified() { if (inner_identifier) { inner_identifier->identified = nullptr; } }
        
        const T& operator* () const { return *get(); }
              T& operator* ()       { return *get(); }
        const T* operator->() const { return  get(); }
              T* operator->()       { return  get(); }

        const T* get() const
            {
#ifndef NDEBUG
            if (!inner_identifier || inner_identifier->identified == nullptr) { throw std::runtime_error{"Attempting to retrive object from an identifier which identified object had already been destroyed."}; }
#endif
            return &object;
            }

        T* get()
            {
#ifndef NDEBUG
            if (!inner_identifier || inner_identifier->identified == nullptr) { throw std::runtime_error{"Attempting to retrive object from an identifier which identified object had already been destroyed."}; }
#endif
            return &object;
            }

        T object;
    private:
        std::shared_ptr<Inner_identifier<T>> inner_identifier;
    };

On top of criticisms, I'd like some advice on naming. If I were to call the Identifier "follow_ptr", "self_updating_ptr", or "stalking_ptr", I've no idea how to call the other two classes.

Aside for the first capital letter of the classes, does the interface feel "standard" enough?

Here is an usage example, compile in debug mode for the exceptions:

#include <stdexcept>
#include <iostream>
#include <vector>
#include <algorithm>

struct Base
    {
    int tmp; bool enabled = true; bool alive = true;
    Base(int tmp) : tmp(tmp) {}
    virtual volatile void f() { std::cout << "Base::f" << tmp << std::endl; };
    void g() { std::cout << "Base::g" << tmp << std::endl; };
    };
struct TmpA : public Base
    {
    TmpA(int tmp) : Base(tmp) {}
    virtual volatile void f() override { std::cout << "TmpA::f" << tmp << std::endl; };
    void g() { std::cout << "TmpA::g" << tmp << std::endl;/**/ };
    };
 
int main()
    {
    //Create empty identifiers
    Identifier<TmpA> idn;
    Identifier<TmpA> id1;
    Identifier<TmpA> id5;

    std::vector<Identified<TmpA>> vec;

    if (true)
        {
        //Create some data and assign iit to identifiers
        Identified<TmpA> identified_a1{1};
        Identified<TmpA> identified_will_die{0};

        idn = identified_will_die;
        id1 = identified_a1;
        id5 = vec.emplace_back(5);

        //Move some identified objects around, this also causes the vector to grow, moving the object Identified by id5.
        vec.emplace_back(std::move(identified_a1));
        }

    std::cout << " _______________________________________________ " << std::endl;
    std::cout << "vec[0]: " << " "; try { vec[0]->f(); } catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "vec[1]: " << " "; try { vec[1]->f(); } catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "id1:    " << " "; try { id1->f(); }    catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "id5:    " << " "; try { id5->f(); }    catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "null:   " << " "; try { idn->f(); }    catch (std::exception& e) { std::cout << e.what() << std::endl; }

    //Move some identified objects around
    std::partition(vec.begin(), vec.end(), [](Identified<TmpA>& idobj) { return idobj->tmp > 2; });
    
    std::cout << " _______________________________________________ " << std::endl;
    std::cout << "vec[0]: " << " "; try { vec[0]->f(); } catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "vec[1]: " << " "; try { vec[1]->f(); } catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "id1:    " << " "; try { id1->f(); }    catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "id5:    " << " "; try { id5->f(); }    catch (std::exception& e) { std::cout << e.what() << std::endl; }
    std::cout << "null:   " << " "; try { idn->f(); }    catch (std::exception& e) { std::cout << e.what() << std::endl; }
    }
\$\endgroup\$
16
  • 1
    \$\begingroup\$ @G.Sliepen for my understanding it has nothing to do with weak_ptr. Weak_ptr doesn't follow the object when it's moved in memory, which is the only thing my code is meant to do. Objects here aren't stored dynamically, they're stored however the user decides to store them. No need to store them in shared pointers. They can be stored sequentially in a vector for instance. \$\endgroup\$
    – Barnack
    Commented Apr 1, 2021 at 18:37
  • 1
    \$\begingroup\$ @Barnack Correct me if I am wrong, but you don't actually want to keep track of all the objects but only several of them, right? I'd rather use some other methods to keep track of a few objects you need. Say, keep a copy in a separate shared_ptr and update it each time the tracked object is modified. \$\endgroup\$
    – ALX23z
    Commented Apr 1, 2021 at 19:55
  • 1
    \$\begingroup\$ yeah, something like that. I originally thought about coupling the shared_ptr with variables but how you described is more or less the same - only that you suggest that an external class manages the instances update which is probably better. \$\endgroup\$
    – ALX23z
    Commented Apr 1, 2021 at 20:55
  • 1
    \$\begingroup\$ Have you heard of the concept of a "Handle". Basically a pointer to a pointer. User holds the handle that points at a pointer in a table. When an object is moved the table pointer is updated with the new location. The user does not need to know as their pointer simply points to the location of the pointer. \$\endgroup\$ Commented Apr 2, 2021 at 16:12
  • 1
    \$\begingroup\$ @Barnack: It's simply the name of the concept you were missing "Handle". They don't need to be in tables (that is just a common implementation) (or even pointers). It's good to use the common name (pattern) to describe what you are trying to achieve. This is a common concept that is used by a lot of OS's to handle resource management. So if you use "Handle_ptr" rather than "self_updating_ptr" everybody that understands the concept of patterns will automatically have an idea of what you are building. \$\endgroup\$ Commented Apr 2, 2021 at 18:44

2 Answers 2

4
\$\begingroup\$

Keeping objects as sequential as possible

After reading the comments, it seems the most important use case is for tracking moves of objects in containers, but we want to keep those objects sequential, and be as cache-friendly as possible. It is also likely that you don't want to track all the objects in a container, but just a few. In that case, your implementation has some drawbacks. The main one is that you store a std::unique_ptr along with every object, so they are no longer sequential. Consider that you had a:

std::vector<T> vec;

Then in memory you have:

T0 T1 T2 T3 ...

But now you want to track some of the Ts, then you'd write:

std::vector<Identified<T>> vec;

Then in memory you would have:

T0 std::shared_ptr<Inner_identifier<T>> T1 std::shared_ptr<...> T2 ...

Can we do better? Ideally we want to get the T's packed back-to-back like in the original vector. We can get that if we move the tracking to a separate, global registry:

template<typename T>
std::unordered_map<T *, std::shared_ptr<T *>> registry;

Now when you create an instance of Identified<T>, you want it to put the address of the object that it constructed into that map. When the object is moved, you have to update the map and the inner identifier accordingly. However, if no one is tracking a given object, it doesn't even have to be stored in the registry, so we can delay adding an object to the registry until someone wants to create an Identifier<T> from it.

Here is an example of what it could look like:

template<typename T>
class Identifier {
    std::shared_ptr<T *> object;
public:
    Identifier(Identified<T> &identified) {
        // Check if this object is already in the registry
        if (auto it = registry<T>.find(&identified.object); it != registry<T>.end()) {
            // Yes, we also want a reference to it
            object = *it;
        } else {
            // No, make a new entry in the registry
            object.reset(&identified.object);
            registry<T>[&object] = object;
        }
    }

    T &operator*() {
        if (!*object)
            throw ...;
        return **object;
    }

    ...
};

template<typename T>
class Identified {
    T object;

public:
    // Constructor just constructs the object
    template <typename ...Args>
    Identified(Args&&... args): object{std::forward<Args>(args)...} {}

    // Move constructor has to update the registry
    Identified(Identified &&other) {
        // Check if this object is already in the registry
        if (auto it = registry<T>.find(&other.object); it != registry<T>.end()) {
            // Yes, update the value
            *it.reset(&object);

            // And also update the key
            auto nh = registry<T>.extract(it);
            nh.key() = &object;
            registry<T>.insert(std::move(nh));
        }

        // Now move the actual contents of the object
        object = std::move(other.object);
    }

    ...            
};

Naming things

Consider renaming the classes to better convey their purpose:

  • Identified -> Trackable
  • Identifier -> Tracker

I don't think there is a need for an Inner_identifier if you have the registry.

\$\endgroup\$
2
  • \$\begingroup\$ uhm would you mind further elaborating how the move operation should interact with that registry? If T* is the key wouldn't you lose the key once the object is moved? \$\endgroup\$
    – Barnack
    Commented Apr 1, 2021 at 21:11
  • 1
    \$\begingroup\$ I added an example. The trick is to update both the key and value in the map when an object is moved. \$\endgroup\$
    – G. Sliepen
    Commented Apr 1, 2021 at 21:48
3
\$\begingroup\$

The fundamental problem is that the mechanism as it is very non-thread-safe. If you hold identifier in one thread and move it in another thread then it results in data racing as you don't use any memory synchronisation routines.

Furthermore, you cannot move the object when another thread uses it. And currently there is no way to even test it if it is being used or anything of the sort. To fix it you've gotta lock a mutex each time you use it or move it. Meaning a move might take a lot of time due to long wait and if you have many of those then you'll have to lock that many mutexes - which is a good source of deadlocks if you don't apply special algorithms that ensure that mutexes will be locked in a deadlock safe way.

Furthermore, whatever cache friendliness you hoped to achieve with this object is going to get ruined as soon as any memory fencing is triggered (only relaxed memory operation might not ruin it but you will need acquire and release which require re caching up to shared memory).

\$\endgroup\$

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.