on
Unique ID types
Introduction
When we have a C++ application that has an embedded database, we often use numeric IDs to identify objects. Consider the following API that stores cars and houses in a database:
using CarID = int32_t;
using HouseID = int32_t;
class Car
{
};
class House
{
};
class Database
{
public:
const Car& getCar(CarID id) const;
const House& getHouse(HouseID id) const;
protected:
std::unordered_map<CarID, Car> m_cars;
std::unordered_map<HouseID, Car> m_houses;
};
With this API, the programmer can pass a CarID
to getHouse()
because the underlying types are identical (int32_t
). This is less than ideal and can be avoided by introducing a small templated ID type that relies on tags.
Tags are empty structures whose sole purpose is to allow the compiler to differentiate between types. The ID template is as follows:
template<class tag>
class ID
{
public:
using ValueType = int32_t;
static constexpr ValueType INVALID_VALUE = -1;
/** default constructor created an invalid ID */
explicit constexpr ID() = default;
/** set the ID upon creation */
explicit constexpr ID(ValueType id) : m_id(id) {}
/** allow conversion to int */
explicit constexpr operator int() const { return static_cast<int>(m_id); }
/** allow convertion to std::size_t so IDs can index STL containers */
explicit constexpr operator std::size_t() const { return static_cast<std::size_t>(m_id); }
protected:
ValueType m_id{INVALID_VALUE};
};
template<typename tag>
std::ostream& std::operator<<(std::ostream &os, const ID<tag> &id)
{
os << static_cast<int>(id);
return os;
}
The above ID template supports conversion to int
so it can be printed to the console or a file. A negative value of -1
signals an invalid ID. At this point, however, the ID is not suitable for keying a std::map
and associated containers because the compiler does not know how to hash it. This is fixed by adding a specialisation for std::hash
:
// make sure we can hash the ID
template<typename tag>
struct std::hash<ID<tag>>
{
using ValueType = typename ID<tag>::ValueType;
std::size_t operator()(const ID<tag> id) const noexcept
{
return std::hash<ValueType>()(id.m_id);
}
};
In case it’s important to sort by ID, the ID template needs comparison and equality operators:
#include <cstdint>
#include <iostream>
#include <functional>
#include <unordered_map>
template<typename tag>
class ID
{
public:
using ValueType = int32_t;
static constexpr ValueType INVALID_VALUE = -1;
/** return an invalid/uninitialised ID */
static constexpr ID invalid() noexcept
{
return ID();
}
explicit constexpr ID() = default;
/** no automatic construction from any other type but ValueType */
explicit constexpr ID(ValueType id) : m_id(id) {}
/** allow conversion to bool to check for validity */
explicit constexpr operator bool() const { return m_id != INVALID_VALUE; }
/** allow convertion to std::size_t so IDs can index STL containers */
explicit constexpr operator std::size_t() const { return static_cast<std::size_t>(m_id); }
/** allow hashing of IDs */
friend std::hash< ID<tag> >;
// comparison operators
friend constexpr bool operator== <>(const ID<tag>& lhs, const ID<tag>& rhs);
friend constexpr bool operator!= <>(const ID<tag>& lhs, const ID<tag>& rhs);
friend constexpr bool operator< <>(const ID<tag>& lhs, const ID<tag>& rhs);
friend std::ostream& operator<< <>(std::ostream& out, const ID<tag>& rhs);
protected:
ValueType m_id{INVALID_VALUE};
};
template<typename tag>
std::ostream& operator<<(std::ostream &os, const ID<tag> &id)
{
os << id.m_id;
return os;
}
// make sure we can hash the ID
template<typename tag>
struct std::hash<ID<tag>>
{
using ValueType = ID<tag>::ValueType;
std::size_t operator()(const ID<tag> id) const noexcept
{
return std::hash<ValueType>()(id.m_id);
}
};
template<typename tag>
constexpr bool operator== (const ID<tag>& lhs, const ID<tag>& rhs)
{
return lhs.m_id == rhs.m_id;
};
template<typename tag>
constexpr bool operator!= (const ID<tag>& lhs, const ID<tag>& rhs)
{
return lhs.m_id != rhs.m_id;
};
template<typename tag>
constexpr bool operator< (const ID<tag>& lhs, const ID<tag>& rhs)
{
return lhs.m_id < rhs.m_id;
};
Usage:
struct HouseTag{};
using HouseID = ID<HouseTag>;
class House
{
public:
};
int main(int argc, const char *argv[])
{
std::unordered_map<HouseID, House> houses;
houses[HouseID{1}] = House{};
houses[HouseID{2}] = House{};
for (auto &h : houses)
{
std::cout << h.first << "\n";
}
};
The following will not compile:
struct HouseTag{};
using HouseID = ID<HouseTag>;
struct CarTag{};
using CarID = ID<CarTag>;
class Car
{
public:
};
int main(int argc, const char *argv[])
{
std::unordered_map<HouseID, House> houses;
std::unordered_map<CarID, Car> car;
houses[HouseID{1}] = House{};
houses[HouseID{2}] = House{};
// cannot use CarID on houses:
houses[CarID{3}] = House{};
};