How to Use std::hash for Custom Types in C++

This article explains how the C++ standard library's std::hash template generates hash keys for custom user-defined data structures. Because C++ lacks built-in reflection, std::hash cannot automatically inspect or hash user-defined classes and structs. To use custom types inside unordered associative containers such as std::unordered_map or std::unordered_set, developers must explicitly define how to hash their types by specializing std::hash or supplying a custom hash functor, while also satisfying the equality contract.

Why std::hash Needs Custom Definitions

The standard library provides template specializations of std::hash<T> for fundamental types (such as int, double, and pointers) and select standard library types (like std::string and std::vector<bool>). When a user-defined struct or class is passed as a key to an unordered container, the compiler checks for a matching std::hash specialization. If none exists, compilation fails.

To compute a hash for a custom structure, the developer must combine the hashes of the individual member variables that define the object's identity.

Implementing a Hash Function

There are two primary methods to enable hashing for a custom type: specializing the std::hash template in the std namespace, or creating a standalone functor.

1. Template Specialization of std::hash

Specializing std::hash<T> allows your custom type to be used by default without explicitly specifying a hash functor in container declarations.

#include <iostream>
#include <string>
#include <unordered_set>

struct Employee {
    int id;
    std::string name;

    // operator== is required to resolve hash collisions
    bool operator==(const Employee& other) const {
        return id == other.id && name == other.name;
    }
};

// Inject the specialization into namespace std
namespace std {
    template <>
    struct hash<Employee> {
        std::size_t operator()(const Employee& e) const noexcept {
            std::size_t h1 = std::hash<int>{}(e.id);
            std::size_t h2 = std::hash<std::string>{}(e.name);
            
            // Combine hashes using bit-mixing
            return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2));
        }
    };
}

2. Passing a Custom Functor

Alternatively, a custom functor can be defined and passed directly as a template argument to containers like std::unordered_map or std::unordered_set. This keeps the std namespace untouched:

struct EmployeeHasher {
    std::size_t operator()(const Employee& e) const noexcept {
        std::size_t h1 = std::hash<int>{}(e.id);
        std::size_t h2 = std::hash<std::string>{}(e.name);
        return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2));
    }
};

// Usage:
std::unordered_set<Employee, EmployeeHasher> employee_set;

How Hashes Are Combined

A primary challenge when hashing custom structures is combining the discrete hash values of multiple fields into a single std::size_t output.

Directly XORing (^) individual hashes is often insufficient because identical values cancel each other out or create order-independent hashes (e.g., hashing a pair (A, B) yields the same value as (B, A)). A widely adopted pattern is the hash combination formula used in Boost (boost::hash_combine):

\[\text{seed} \leftarrow \text{seed} \oplus (\text{hash}(v) + \text{0x9e3779b9} + (\text{seed} \ll 6) + (\text{seed} \gg 2))\]

The constant 0x9e3779b9 is the fractional part of the golden ratio (\(2^{32} / \phi\)) represented in 32-bit hexadecimal. Adding this constant and bit-shifting ensures that changes in any input bit propagate thoroughly across all bits of the combined result.

Essential Requirements for Custom Hashes

To function correctly within C++ associative containers, a hash implementation must uphold three rules:

  1. Determinism: For any given object, the hash function must always produce the same value throughout its lifecycle in the container. Modifying a key while it resides in an associative container leads to undefined behavior.
  2. Equality Consistency: If two objects are equal according to operator==, their hash values must be identical: \[a == b \implies \text{hash}(a) == \text{hash}(b)\] If two objects have identical hashes, they are not strictly required to be equal (a hash collision), but collisions should be minimized.
  3. Uniform Distribution: The function should distribute generated keys as evenly as possible across the range of std::size_t to maintain the average \(O(1)\) lookup performance of hash buckets.