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{};
};