Skip to content
C++
Idiom
Intermediate

ADL Customization

Use argument-dependent lookup to let generic algorithms discover type-specific overloads defined in a type's own namespace, enabling non-intrusive extensibility.

ADL Customizationsince C++98

A technique where generic algorithms call unqualified free functions so that argument-dependent lookup finds type-specific overloads defined in the type's own namespace, enabling extension without modifying the algorithm or sharing a base class.

Overview

Argument-dependent lookup (ADL) extends unqualified function name resolution to include the namespaces of all argument types. The ADL customization idiom deliberately exploits this: a library algorithm calls a function without namespace qualification, allowing user code to inject custom behaviour purely by placing an overload in the right namespace.

This mechanism underpins much of the Standard Library's extensibility. std::swap, std::begin, std::end, and every algorithm that accepts a callable object have historically relied on ADL-based customization. The pattern scales from a single extension point to full protocol suites.

The defining advantage over virtual dispatch is that ADL customization is resolved entirely at compile time, has zero runtime cost, and requires no common base class or vtable.

Mechanism

When a call like swap(a, b) is written without a namespace qualifier, the compiler builds a candidate set from:

  1. Names visible through ordinary lookup at the call site, including any using declarations.
  2. The namespaces of the types of every argument β€” including enclosing namespaces.

For ADL customization to work reliably in generic code, the conventional two-step pattern must be used:

cpp
template <typename T>
void generic_swap(T& x, T& y) {
    using std::swap;   // fallback: std::swap handles all types
    swap(x, y);        // unqualified call: ADL finds namespace-local swap for T if present
}

Without using std::swap, the call fails for fundamental types whose namespace (::) has no swap. Without the unqualified call, ADL is bypassed entirely and the generic std::swap runs unconditionally, ignoring any user-provided optimisation.

Defining a Customization Point

Namespace-scope free function

Place the customization function in the same namespace as the type. No modification to the class internals is required:

cpp
namespace geometry {

struct Rect {
    double x, y, w, h;
};

// Found by ADL whenever a Rect appears as an argument to an unqualified swap
void swap(Rect& a, Rect& b) noexcept {   // C++98
    using std::swap;
    swap(a.x, b.x); swap(a.y, b.y);
    swap(a.w, b.w); swap(a.h, b.h);
}

} // namespace geometry

Any template that follows the two-step pattern and receives geometry::Rect arguments will find this overload via ADL.

Hidden friend

C++11 codebases increasingly prefer the hidden friend pattern: a friend function defined inside the class body. The function is injected into the enclosing namespace but is visible only through ADL β€” not through ordinary qualified or unqualified lookup. This reduces overload-set noise and lets the compiler reject ill-formed calls faster:

cpp
namespace io {

class Buffer {
public:
    explicit Buffer(std::size_t cap);

    // Hidden friends: injected into namespace io, but found only via ADL
    friend void swap(Buffer& a, Buffer& b) noexcept {   // C++11 friend-in-class
        using std::swap;
        swap(a.data_,     b.data_);
        swap(a.capacity_, b.capacity_);
        swap(a.size_,     b.size_);
    }

    friend std::ostream& operator<<(std::ostream& os, const Buffer& buf) {
        return os.write(buf.data_, static_cast<std::streamsize>(buf.size_));
    }

private:
    char*       data_     = nullptr;
    std::size_t capacity_ = 0;
    std::size_t size_     = 0;
};

} // namespace io

swap and operator<< live in namespace io but cannot be found by a qualified call like io::swap(a, b) β€” only by unqualified calls when an io::Buffer is one of the arguments.

Range Customization: begin and end

Making a user type work with range-based for and standard algorithms requires ADL-findable begin and end in the type's namespace. std::begin (C++11) uses ADL internally for non-array types:

cpp
namespace grid {

struct Grid {
    double* data;
    std::size_t rows, cols;
};

// ADL customization points for range protocol
double*       begin(Grid& g)        noexcept { return g.data; }              // C++11
double*       end(Grid& g)          noexcept { return g.data + g.rows * g.cols; }
const double* begin(const Grid& g)  noexcept { return g.data; }
const double* end(const Grid& g)    noexcept { return g.data + g.rows * g.cols; }

} // namespace grid

void process(const grid::Grid& g) {
    for (double v : g)               // range-based for calls std::begin(g) β†’ grid::begin(g) via ADL
        use(v);
}

Problems with the Raw Two-Step

The two-step works, but it has two serious failure modes that motivated the CPO design.

Using the wrong incantation silently calls the wrong function. There are three ways to call swap, and only one is correct in generic code:

cpp
template <typename T>
void example(T& a, T& b) {
    swap(a, b);           // WRONG β€” may not find std::swap at all (only works if T's namespace
                          //          has a swap, or one is visible via ordinary lookup)
    std::swap(a, b);      // WRONG β€” qualified call; ADL is bypassed; never finds T's custom swap
    using std::swap;      // CORRECT β€” ADL finds T's swap; falls back to std::swap if none
    swap(a, b);
}

ADL cannot check that the found function is correct. If begin(e) happens to be a void function that starts some execution context and has nothing to do with ranges, the two-step will call it anyway. The standard library has no way to enforce that the ADL-discovered overload actually satisfies the iterator/range contracts.

C++20: Customization Point Objects

C++20's std::ranges replaces raw ADL calls with customization point objects (CPOs): inline constexpr function objects such as std::ranges::swap, std::ranges::begin, and std::ranges::end. A CPO is a semiregular, const-invocable function object that does the two-step internally so callers don't have to.

The priority chain that std::ranges::begin follows when called with an argument r is:

  1. If r is an array, return a pointer to the first element.
  2. If r.begin() is a valid member call whose result models input_or_output_iterator, call r.begin().
  3. Search for an ADL begin(r) whose result models input_or_output_iterator.
  4. Reject the call with a concept-checked error otherwise.

This means callers can simply write std::ranges::begin(r) and always get the right thing, with constraints enforced at the call site:

cpp
#include <concepts>    // C++20
#include <ranges>      // C++20

namespace mylib {

struct Matrix {
    std::vector<double> elems;
    std::size_t rows, cols;

    // ADL-found hidden friend; std::ranges::swap and std::ranges::iter_swap both discover it
    friend void swap(Matrix& a, Matrix& b) noexcept {    // C++11 hidden friend, C++20 CPO-compatible
        using std::swap;
        swap(a.elems, b.elems);
        swap(a.rows,  b.rows);
        swap(a.cols,  b.cols);
    }
};

} // namespace mylib

void example(mylib::Matrix& x, mylib::Matrix& y) {
    std::ranges::swap(x, y);   // C++20: finds mylib::swap via ADL; concept-checked
}

Because std::ranges::swap is an object rather than a function, unqualified lookup that finds it stops immediately β€” ADL is never triggered on the call site itself. This prevents the infinite-recursion footgun where a user's swap accidentally re-enters std::ranges::swap.

CPOs vs Niebloids

The C++20 ranges library also defines niebloids β€” the algorithm objects in std::ranges such as std::ranges::copy and std::ranges::find. These are not CPOs. The distinction matters:

  • A CPO (std::ranges::begin, std::ranges::swap, etc.) exists to handle constrained ADL dispatch. Its job is to find a user-provided customization and call it safely. CPOs are specified to be copyable objects.
  • A niebloid (std::ranges::copy, std::ranges::find, etc.) exists only to prevent using namespace std::ranges; from causing unqualified lookup to accidentally prefer the C++17 std::copy over std::ranges::copy through "more specialised" tiebreaking. Niebloids are specified to inhibit ADL; the standard mandates the behaviour but not the mechanism. The only portable implementation strategy today is a function object, but the standard does not require niebloids to be copyable.

In practice the implementation of both looks the same (inline constexpr function object), but they solve different problems.

tag_invoke: A Single Universal Customisation Point (P1895)

C++20 CPOs solve the two-step problem but introduce a new one: every CPO reserves its name globally within the ADL lookup space. Two independent libraries that both want a customisation point called serialize may collide. Scaling this to hundreds of algorithm customisation points β€” as std::execution (P2300) requires β€” becomes unmanageable.

Proposal P1895 (targeting C++23, not yet standardised) addresses this with tag_invoke: a single ADL customisation point that uses a tag type β€” the CPO itself β€” as the first argument. User code overloads tag_invoke for a specific tag, so the dispatch is unambiguous and only one identifier (tag_invoke) is reserved globally.

The pattern works as follows:

cpp
// ---- library side ----
namespace mylib {

// 1. Define a tag type (the CPO is both the tag and the callable)
struct serialize_fn {
    // 2. The CPO calls tag_invoke(tag, args...) via ADL
    template <typename T>
    auto operator()(const T& value) const
        -> decltype(tag_invoke(*this, value))
    {
        return tag_invoke(*this, value);
    }
};

// 3. Expose as a global function object
inline constexpr serialize_fn serialize{};

} // namespace mylib

// ---- user side ----
namespace userlib {

struct Point { double x, y; };

// 4. Overload tag_invoke for this type, with the tag as first arg
std::string tag_invoke(mylib::serialize_fn, const Point& p) {
    return "(" + std::to_string(p.x) + ", " + std::to_string(p.y) + ")";
}

} // namespace userlib

// ---- call site ----
userlib::Point p{1.0, 2.0};
std::string s = mylib::serialize(p);   // dispatches to userlib's tag_invoke via ADL

When mylib::serialize(p) is called, the CPO calls tag_invoke(mylib::serialize, p) without qualification. ADL searches userlib (because p is a userlib::Point) and finds the overload. The tag type β€” mylib::serialize_fn β€” disambiguates: even if two libraries both define a tag_invoke for Point, they use different tag types and cannot collide.

Why tag_invoke Was Not Adopted

P1895 did not make C++23. The primary objections were ergonomic: tag_invoke overloads are verbose to write, concepts constraining them are awkward to express, and the indirection is unfamiliar to most C++ programmers. A future language feature β€” "customisation point functions" β€” may provide the same collision-avoidance guarantees with better syntax. Nevertheless, tag_invoke is widely used in the wild, most notably as the customisation mechanism throughout libunifex and the reference implementation of std::execution.

Hidden Friends as the Preferred ADL Hook

The current best practice for exposing an ADL customisation point is the hidden friend idiom: define the function inside the class body with the friend keyword, but do not also declare it at namespace scope.

cpp
namespace physics {

class Vec3 {
public:
    double x, y, z;

    // Hidden friend: injected into namespace physics, found only via ADL
    friend void swap(Vec3& a, Vec3& b) noexcept {
        using std::swap;
        swap(a.x, b.x);
        swap(a.y, b.y);
        swap(a.z, b.z);
    }

    friend bool operator==(const Vec3& a, const Vec3& b) noexcept {
        return a.x == b.x && a.y == b.y && a.z == b.z;
    }

    friend std::ostream& operator<<(std::ostream& os, const Vec3& v) {
        return os << "(" << v.x << ", " << v.y << ", " << v.z << ")";
    }
};

} // namespace physics

Three properties follow from this definition:

Only found when an argument is a Vec3. Unqualified swap(a, b) where a and b are physics::Vec3 objects will find the hidden friend. Unqualified swap(x, y) for any other type will not. Qualified physics::swap(a, b) will also not find it β€” ADL is required. This prevents the overload from polluting the candidate set for unrelated calls.

Implicit conversions are suppressed. If a type ColorVec is convertible to Vec3, comparing two ColorVec objects with == will fail β€” the hidden operator== is invisible because neither argument is already a Vec3. This is usually the correct behaviour: you did not intend to compare ColorVec objects by converting them to Vec3 first. With a namespace-scope declaration, that silent conversion would happen.

Compile times improve. With a namespace-scope declaration, every use of == in the entire translation unit adds physics::operator== to the candidate set. As a hidden friend it is invisible unless a Vec3 argument is present, shrinking the overload set the compiler must consider. In a large codebase with hundreds of types this can meaningfully reduce build time.

Best Practices

Place the customization function in the same namespace as the type. ADL searches the namespace of argument types and their enclosing namespaces. A function in the global namespace or an unrelated namespace will not be found.

Prefer hidden friends for swap and operators. Ordinary namespace-scope free functions are visible to all unqualified lookups, inflating overload sets globally. Hidden friends are discoverable only when the defining type appears as an argument β€” exactly the desired semantics.

Mark swap noexcept. Move constructors and move assignment operators are typically noexcept; swap is the primitive they rely on. A throwing swap breaks exception-safety guarantees in containers and algorithms. Add noexcept unconditionally unless your swap can genuinely throw.

Follow the two-step in generic code. Inside any template that calls an ADL customization point, always write the using std::X; X(a, b); pattern. Never qualify the call (std::swap(a, b)) β€” this silently bypasses every user-defined overload and is the most common source of unexpected performance regressions.

Adopt C++20 CPOs in new projects. std::ranges::swap, std::ranges::begin, and their siblings provide concept-checked, niebloid-safe alternatives. They still discover ADL overloads; adopting them at call sites costs nothing in terms of the overloads you define.

Common Pitfalls

Qualified call suppresses ADL. std::swap(a, b) inside a generic function always calls the standard version, silently ignoring every user-provided overload. This is a latent bug that only surfaces when a type's custom swap matters for performance or correctness.

Wrong namespace. Defining swap in the global namespace (::) for a type in mylib means ADL will not find it in template contexts because the type's associated namespace is mylib, not ::.

Parenthesised name disables ADL. (swap)(a, b) is not an unqualified function call; it is a call through a parenthesised expression. ADL does not apply. This is occasionally used deliberately to call only the in-scope or std version, but it is a common accidental mistake.

Overload-set contamination from non-hidden free functions. A namespace-scope overload of swap or operator== accepting common types like std::string can be found by ADL in completely unrelated contexts, causing hard-to-diagnose ambiguity. Restrict overloads to user-defined parameter types, or use hidden friends.

Template specialisation vs. overloading. You cannot partially specialise a function template; the idiomatic extension mechanism for std::swap and similar points is overloading in the type's namespace, not specialising std::swap. Specialising std::swap in namespace std is technically permitted (C++98) but unnecessary given ADL, and specialising anything else in namespace std is undefined behaviour.

ADL and std::swap

std::swap is the canonical example of the two-step ADL customization idiom. Every standard container, smart pointer, and algorithm that swaps elements uses unqualified swap with a using std::swap fallback β€” the same pattern you must replicate in your own generic code.

cpp
// C++11
#include <algorithm>  // std::sort, std::reverse β€” all use the two-step internally

namespace net {

struct Packet {
    std::vector<std::byte> payload;
    uint32_t               sequence_id;

    // Hidden friend: found by ADL, never pollutes global scope
    friend void swap(Packet& a, Packet& b) noexcept {
        using std::swap;
        swap(a.payload,     b.payload);
        swap(a.sequence_id, b.sequence_id);
    }
};

} // namespace net

void reorder(std::vector<net::Packet>& pkts) {
    // std::sort internally calls swap(a, b) with the two-step;
    // net::swap is found by ADL β€” O(1) per element instead of O(n) copy
    std::sort(pkts.begin(), pkts.end(),
              [](const net::Packet& a, const net::Packet& b) {
                  return a.sequence_id < b.sequence_id;
              });
}

Supplying a custom swap matters most when the default std::swap would invoke two moves and a temporary. For types with heap-allocated members, the hidden-friend swap reduces that to three pointer exchanges.

Customization Point Objects (CPOs) in C++20

CPOs are inline constexpr function objects that encapsulate the entire dispatch chain so call sites never need the two-step. They are defined in namespace std::ranges and serve as the sanctioned replacement for raw ADL calls.

cpp
// C++20
#include <ranges>
#include <concepts>

namespace audio {

class SampleBuffer {
public:
    explicit SampleBuffer(std::size_t n) : data_(n) {}

    // Member begin/end β€” preferred by std::ranges::begin over ADL free function
    float*       begin()       noexcept { return data_.data(); }
    float*       end()         noexcept { return data_.data() + data_.size(); }
    const float* begin() const noexcept { return data_.data(); }
    const float* end()   const noexcept { return data_.data() + data_.size(); }

    friend void swap(SampleBuffer& a, SampleBuffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
    }

private:
    std::vector<float> data_;
};

} // namespace audio

void normalize(audio::SampleBuffer& buf) {
    // std::ranges::begin uses the member .begin() β€” concept-checked
    auto first = std::ranges::begin(buf);  // C++20
    auto last  = std::ranges::end(buf);    // C++20

    float peak = *std::ranges::max_element(buf);  // C++20 niebloid
    if (peak > 0.0f)
        for (auto it = first; it != last; ++it) *it /= peak;

    // std::ranges::swap calls net::swap via ADL; concept-checked
    audio::SampleBuffer tmp(4);
    std::ranges::swap(buf, tmp);  // C++20
}

CPOs differ from ordinary functions in one critical way: because they are objects, an unqualified call to std::ranges::begin is a call through a named object and never triggers ADL on the CPO's own name β€” eliminating the recursive re-entry bug common with plain two-step implementations.

tag_invoke as a Universal Customization Mechanism

tag_invoke reserves only one identifier globally while allowing an unlimited number of distinct customization points. Each CPO doubles as a tag type; user code overloads the single function tag_invoke with that tag as the first parameter.

cpp
// C++20 β€” tag_invoke pattern (P1895, used in libunifex / std::execution)
#include <utility>

namespace proto {

// --- library: define the CPO / tag ---
struct hash_fn {
    template <typename T>
    auto operator()(const T& v) const
        -> decltype(tag_invoke(*this, v))
    {
        return tag_invoke(*this, v);
    }
};
inline constexpr hash_fn hash{};

} // namespace proto

// --- user: specialize via tag_invoke in the type's own namespace ---
namespace gfx {

struct Color { uint8_t r, g, b, a; };

// tag_invoke overload β€” found by ADL when Color appears as second arg
std::size_t tag_invoke(proto::hash_fn, const Color& c) noexcept {
    // FNV-1a style mix
    std::size_t h = 2166136261u;
    for (uint8_t byte : {c.r, c.g, c.b, c.a})
        h = (h ^ byte) * 16777619u;
    return h;
}

} // namespace gfx

// --- call site ---
gfx::Color red{255, 0, 0, 255};
std::size_t h = proto::hash(red);  // dispatches to gfx::tag_invoke via ADL

The collision-avoidance guarantee: two independent libraries can each define a hash_fn tag. Their tag_invoke overloads for the same user type carry different first-parameter types and therefore never collide, even in the same translation unit.

ADL and Friend Functions

Friend functions defined inside a class body gain a special property: they are injected into the enclosing namespace but remain invisible to ordinary (non-ADL) lookup. This is the formal basis of the hidden-friend idiom and has been valid since C++98, though it became widespread after N4381 (C++11).

cpp
// C++11
namespace math {

class Quaternion {
    double w_, x_, y_, z_;
public:
    constexpr Quaternion(double w, double x, double y, double z)
        : w_(w), x_(x), y_(y), z_(z) {}

    // --- hidden friends ---

    // Found only when at least one argument is Quaternion
    friend constexpr Quaternion operator*(const Quaternion& a,
                                          const Quaternion& b) noexcept {
        return {
            a.w_*b.w_ - a.x_*b.x_ - a.y_*b.y_ - a.z_*b.z_,
            a.w_*b.x_ + a.x_*b.w_ + a.y_*b.z_ - a.z_*b.y_,
            a.w_*b.y_ - a.x_*b.z_ + a.y_*b.w_ + a.z_*b.x_,
            a.w_*b.z_ + a.x_*b.y_ - a.y_*b.x_ + a.z_*b.w_
        };
    }

    friend constexpr bool operator==(const Quaternion& a,
                                     const Quaternion& b) noexcept {
        return a.w_==b.w_ && a.x_==b.x_ && a.y_==b.y_ && a.z_==b.z_;
    }

    // ADL-findable by any serialization framework that calls operator<<
    friend std::ostream& operator<<(std::ostream& os, const Quaternion& q) {
        return os << q.w_ << " + " << q.x_ << "i + "
                  << q.y_ << "j + " << q.z_ << "k";
    }
};

} // namespace math

// math::operator* is NOT callable as math::operator*(a, b) β€” ADL required.
// This compiles fine:
math::Quaternion q1{1,0,0,0}, q2{0,1,0,0};
auto q3 = q1 * q2;  // ADL finds math::operator* because q1, q2 are math::Quaternion

The friend-function mechanism is the reason operator<< for user types "just works" with std::cout β€” the stream object's type (std::ostream) is in namespace std, so ADL also searches namespace std, but the operand on the right hand side brings in the user's own namespace, where the hidden friend lives.

Ranges CPOs: std::ranges::begin and std::ranges::end

std::ranges::begin and std::ranges::end follow a strict priority order that makes member functions, ADL free functions, and array decay all cooperate transparently. Understanding this chain is necessary when making a third-party type range-compatible without modifying it.

cpp
// C++20
#include <ranges>
#include <iterator>

// Scenario: third-party type you cannot modify
namespace vendor {
struct Span {
    float* ptr;
    int    len;
};
} // namespace vendor

// Add range support in vendor's namespace (ADL will find these)
namespace vendor {
float* begin(Span& s)        noexcept { return s.ptr; }          // C++11 pattern
float* end(Span& s)          noexcept { return s.ptr + s.len; }
const float* begin(const Span& s) noexcept { return s.ptr; }
const float* end(const Span& s)   noexcept { return s.ptr + s.len; }
} // namespace vendor

static_assert(std::ranges::range<vendor::Span>);  // C++20 concept check

void scale(vendor::Span s, float factor) {
    // std::ranges::begin dispatches to vendor::begin via ADL (step 3 of priority chain)
    for (auto it = std::ranges::begin(s); it != std::ranges::end(s); ++it)
        *it *= factor;

    // Alternatively, works with range-based for directly
    for (float& v : s)  // range-based for calls std::begin β†’ ADL β†’ vendor::begin
        v *= factor;
}

When neither a member begin() nor an ADL begin is available, std::ranges::begin produces a hard concept-checked error naming the exact constraint that failed β€” a significant improvement over the cryptic iterator errors produced by the raw two-step in C++17 and earlier.

Pitfall: ADL Hijacking

ADL hijacking occurs when an unrelated function in an argument type's associated namespace accidentally matches an unqualified call, silently replacing the intended one. It is one of the most insidious misuses of ADL and is the principal reason CPOs use function objects rather than plain functions.

cpp
// C++17 β€” illustrating the hijack
namespace evil {

struct Tag {};  // user passes this as a sentinel argument

// Completely unrelated function that happens to be named "begin"
// and lives in the same namespace as Tag
void begin(Tag) {
    std::cout << "side effect!\n";  // starts some unrelated operation
}

} // namespace evil

template <typename Container, typename Sentinel>
void process(Container& c, Sentinel) {
    using std::begin;
    begin(c);  // intended: get iterator to c
               // if Sentinel is evil::Tag, ADL adds evil::begin to candidates;
               // if Container is also in namespace evil, evil::begin wins!
}

The hijack is most dangerous when a template accepts arguments from multiple namespaces and the function being called is a short, common name (begin, end, swap, size, data). Three mitigations exist:

cpp
// Mitigation 1 (C++20): use a CPO β€” it is an object, not a function,
// so it never participates in ADL at the call site
std::ranges::begin(c);   // safe: no ADL on the CPO name itself

// Mitigation 2 (C++11): parenthesise the name to suppress ADL deliberately
(begin)(c);              // calls only the in-scope or std:: version; ADL disabled

// Mitigation 3: concept-constrain the template so only valid containers match,
// preventing evil::Tag from being a viable Sentinel in the first place
template <std::ranges::range Container>   // C++20
void safe_process(Container& c) {
    std::ranges::begin(c);
}

ADL hijacking can also occur indirectly: including a third-party header that defines a function named swap or begin accepting a common type (e.g., std::string) may silently change which overload wins in unrelated code, purely because the include added new names to an associated namespace.

See Also

  • Hidden Friend Idiom β€” defining friend functions inside the class body to restrict visibility to ADL contexts
  • Swap Idiom β€” the full two-step swap pattern and its interaction with move semantics
  • CRTP β€” compile-time static polymorphism as an alternative to ADL-based dispatch
  • Tag Dispatch β€” using type tags to steer overload resolution at compile time