c++generic-programmingc++20type-safety

How can I create a typesafe handle that behaves almost exactly like int64_t, but disallow implicit conversions between handle types?


Context:

I have created a generic container called "ComponentManager". It looks roughly like this:

typedef uint64_t handle;

template<typename T>
class ComponentManager {
    std::vector<T> components; // stores the elements themselves
    std::unordered_map<handle, unsigned int> handles; // maps handles to indices
    HandleGenerator handleGenerator; // class that generates unique handles
public:
    size_t size() const;

    handle add(T component); // Adds an element to the component manager, and returns the handle for that element

    T* find(handle key) const; // returns a pointer to the element refered to by this handle, or NULL if none exists

    void remove(handle key); // may invalidate all iterators. Does NOT invalidate any key (except the one to the element deleted)

    ComponentManagerIterator<T> begin();
    ComponentManagerIterator<T> end();
};

This class uses a hash map of handles -> indices to allow O(1) random access to the elements, while also allowing cache efficient iteration through the elements due to their spacial locality within the vector (Though no specific order is guaranteed, as elements may be shuffled around when elements are deleted)

The Problem:

As it stands, all handles within the program have the same type, so a user could mistakenly try to use a handle that corresponds to one handle manager to access an element in a completely unrelated handle manager.

I would like to mitigate this by giving the handles themselves a different type, depending on the type of the handlemanager. Something similar to this:

template<typename T>
class handle
{
public:
    uint64_t key;
    explicit handle(uint64_t key_) : key(key_) {}
}

So that if you try and use a handle for the wrong type of handlemanager, the code will not compile without an explicit cast.

However, the problem with this is that I still sometimes want to treat theses handles like integers. I would like all of the regular integer operations to be defined for this type (comparison, bitwise ops, etc), and I would like algorithms specialized for integers (such as std::hash) to function as if my type was an integer. Is there a way to do this without manually implementing each of these operations myself?

If there is a better way to get type-safety like this in a different way, I am open to other ways of achieving this.

EDIT: I should also mention, that within my program, there will also only ever be one component manager for any given type T, so the type alone will be enough to identify a specific component manager.

EDIT 2 (additional context): one benefit I see in in giving handles unique types, is that I could overload a single function to access different component managers depending on the type of the handle.


Solution

  • You can create a method to do implicit conversions from handle to a uint64_t.

    template<typename T>
    class handle
    {
    public:
        operator uint64_t() { return key_; }
        explicit handle(uint64_t key) : key_(key) {}
    private:
        uint64_t key_;
    }
    

    This will automatically convert a handle<T> to a uint64_t when the context calls for it.

    #include <iostream>
    #include <string>
    
    template<typename T>
    class handle
    {
    public:
        operator uint64_t() { return key_; }
        explicit handle(uint64_t key) : key_(key) {}
    private:
        uint64_t key_;
    };
    
    template<typename T>
    int plus_20(T t)
    {
        return t + 20;
    }
    
    int main()
    {
      handle<int> hand(4);
      std::cout << hand << std::endl; // 4
      std::cout << hand + 1 << std::endl; // 5
      std::cout << (hand << 3) << std::endl; // 32
      std::cout << plus_20(hand) << std::endl; // 24
      //std::cout << plus_20<std::string>(hand) << std::endl; // doesn't compile
    
      std::unordered_map<uint64_t, std::string> umap;
      umap[hand] = "test";
      for(auto [key, value] : umap)
      {
          std::cout << key << " --> " << value << std::endl;
      }
    }
    

    Now, your class can look like this (skipping unchanged parts):

    template<typename T>
    class ComponentManager {
        // ...
        std::unordered_map<uint64_t, unsigned int> handles; // maps handles to indices
        // ...
    public:
        // ...
        handle<T> add(T component); // Adds an element to the component manager, and returns the handle for that element
    
        T* find(handle<T> key) const; // returns a pointer to the element refered to by this handle, or NULL if none exists
    
        void remove(handle<T> key); // may invalidate all iterators. Does NOT invalidate any key (except the one to the element deleted)
        // ...
    };
    

    Notice that the std::unordered_map in the ComponentManager class takes a uint64_t as its key. It is the parameters and return values to the public methods add(), find(), and remove() that ensure type safety. The explicit designation on the handle<T> constructor does a lot of work to make sure one kind of handle cannot be implicitly converted into another.


    Addition for completely black-boxing the handle type:

    If you want to keep the unordered_map key as a handle<T>, you can do so without having to define all of the required operations. Just tell the unordered_map constructor which ones to use:

    template<typename T>
    class ComponentManager {
        // ...
        std::unordered_map<handle<T>,
                           unsigned int,
                           std::hash<uint64_t>,
                           std::equal_to<uint64_t>> handles; // maps handles to indices
        // ...
    };
    

    If you add a public using key_type = uint64_t; to the handle<T> class template, this can be generalized to

    template<typename T>
    class ComponentManager {
        // ...
        std::unordered_map<handle<T>,
                           unsigned int,
                           std::hash<typename handle<T>::key_type>,
                           std::equal_to<typename handle<T>::key_type>> handles; // maps handles to indices
        // ...
    };
    

    This allows for changing the date inside the handle<T> class template without having to update any other code.