Generic handles in C++
Introduction
Using handles is a tried and true method for abstracting away resource management in client code. In C this can be achieved with an incomplete type in a header file with its implementation tucked away safely in the source file. Users of the API simply ask for a resource to be created and use the returned pointer to manipulate the resource.
struct FILE;
FILE* open (...);
int read (FILE*, ...);
void close (FILE*);
Other C APIs (notably OpenGL) use integer handles which principly achieve the same thing, only now the user is not carrying around a pointer anymore. This allows the backend to move resources around in memory without invalidating the handle.
unsigned int id;
glCreateBuffers (&id, 1);
glBindBuffer (id, ...);
glDeleteBuffers(&id, 1);
However, it would be nice to have a more type-safe handle type. Type aliasing will only make it appear to be a unique type, but integer handles still play by the rules of integers. Further, it is not inconceivable that handles from one API can be passed to another accidentally; they are, after all, the same type!
typedef int HandleA;
typedef unsignde int HandleB;
void f(HandleA);
voif g(HandleB);
HandleA handleA = ...;
g(handleA); // Oops, should've been HandleB!
A Type Safe Approach
Using C++ templates we can leverage the type system to create a strong handle type that is more difficult to use incorrectly. With C++20 we can place simple contraints on the underlying type and even remove boilerplate. The implementation is rather trivial:
#include <concepts>
#include <optional>
template <std::equality_comparable T, class Tag>
class handle final
{
public:
using value_type = T;
handle() = default;
constexpr explicit handle(value_type id)
: m_id{id}
{}
constexpr value_type value() const { return *m_id; }
constexpr value_type operator*() const { return value(); }
constexpr bool is_valid() const noexcept { return m_id.has_value(); }
constexpr operator bool() const noexcept { return is_valid(); }
private:
std::optional<value_type> m_id;
constexpr friend bool operator==(handle const& lhs,
handle const& rhs) noexcept = default;
};
Using type aliases we can now create strong handle types:
using my_handle = handle<int, struct my_handle_tag>;
Note the second argument is an incomplete type which is fine as we never instantiate it. As long as
the Tag
is unique per handle type it is impossible to mix the handle types!
using handle_a = handle<int, struct handle_a_tag>;
using handle_b = handle<int, struct handle_b_tag>;
void f(handle_a);
void g(handle_b);
handle_a a = ...;
g(a); // compile error
Some other short notes:
- Default constructed handles are considered invalid by default
- Invalidating a handle is just re-assigning with a default constructed handle
- Handles are equality comparable but not sortable
- Sorting is an implementation specific detail related to storing handles
- Implicit conversions are deliberately missing
Hashing
It may be useful to store handle
s in a hash map. For std::unordered_map
, for example,
std::hash
needs to be specialised with something like the following:
template<std::equality_comparable T, class Tag>
requires hashable<T>
struct std::hash<handle<T, Tag>>
{
std::size_t operator()(Handle<T, Tag> const& h) const noexcept
{
return std::hash<T>(*h);
}
};
which just hashes the underlying value. This assumes that T
itself is hashable which we can check
with the following concept
:
template <class T>
concept hashable = requires (T t)
{
{ std::hash<T>{}(t) };
};