Proxy Pattern
Intercept operations on C++ objects to add logging, lazy loading, access control, or subscript read/write discrimination via proxy objects.
Proxy Patternsince C++98A proxy is an object that stands in for another, intercepting operations—member access, dereferencing, assignment, subscript—via operator overloading to inject behavior (logging, lazy init, access control, read/write discrimination) without changing the target type.
Overview
The proxy pattern is pervasive in C++ because operator overloading makes it possible to create objects that are syntactically indistinguishable from what they wrap. std::shared_ptr and std::unique_ptr are proxies—pointer syntax, lifetime management underneath. std::string_view (C++17) proxies a string without owning it. std::reference_wrapper (C++11) proxies a reference so it can be stored in containers.
The distinguishing C++ capability: different operators on the same proxy object take different code paths. A plain reference cannot distinguish container[i] = x (write) from x = container[i] (read). A proxy can—making subscript proxies the canonical C++ example of the pattern.
Three design axes for any proxy:
- Transparency: should callers know they hold a proxy? Smart pointers aim to be invisible; auditing proxies may be explicit wrapper types.
- Lifetime: does the proxy hold a reference, raw pointer, or take ownership? A dangling proxy is silent undefined behavior.
- Direction: wrapping (proxy delegates to real object) vs. replacing (proxy is the public interface, real object may not exist yet).
Syntax
The minimum skeleton. Overload the operators that clients will use:
template<typename T>
class Proxy {
T* target_;
public:
explicit Proxy(T* t) noexcept : target_{t} {} // noexcept: C++11
T* operator->() { return target_; }
const T* operator->() const { return target_; }
T& operator*() { return *target_; }
const T& operator*() const { return *target_; }
explicit operator bool() const noexcept { return target_ != nullptr; } // explicit: C++11
};For subscript proxies, the return value of operator[] is itself the proxy:
struct ElementProxy {
operator ValueType() const; // read path: implicit conversion
ElementProxy& operator=(ValueType); // write path: intercept assignment
ElementProxy& operator=(const ElementProxy&); // proxy-to-proxy: critical (see Pitfalls)
};Examples
Subscript Proxy: Read vs. Write Discrimination
A packed bit container cannot return a bool&—bits have no addressable storage. The proxy bridges the gap:
class BitVector {
std::vector<uint8_t> data_;
std::size_t size_;
public:
explicit BitVector(std::size_t n)
: data_((n + 7) / 8, 0), size_{n} {}
struct BitRef {
uint8_t& byte_;
uint8_t mask_;
operator bool() const noexcept { // read path
return (byte_ & mask_) != 0;
}
BitRef& operator=(bool v) noexcept { // write path
v ? (byte_ |= mask_) : (byte_ &= static_cast<uint8_t>(~mask_));
return *this;
}
BitRef& operator=(const BitRef& other) noexcept { // proxy-to-proxy (see Pitfalls)
return *this = static_cast<bool>(other);
}
};
BitRef operator[](std::size_t i) {
return {data_[i / 8], static_cast<uint8_t>(1u << (i % 8))};
}
bool operator[](std::size_t i) const noexcept {
return (data_[i / 8] >> (i % 8)) & 1u;
}
};
BitVector bv{16};
bv[3] = true; // BitRef::operator=(bool)
bool b = bv[3]; // BitRef::operator bool()
bv[5] = bv[3]; // right side: operator bool(); left side: operator=(bool)The same technique applies to copy-on-write strings (std::string pre-C++11 was implemented this way), transactional containers that log mutations, and sparse matrix storage.
Lazy Initialization Proxy
Defers expensive construction until first access. std::optional (C++17) manages the uninitialized state; mutable allows initialization through a const proxy:
template<typename T, typename Factory>
class LazyProxy {
mutable std::optional<T> value_; // std::optional: C++17
Factory factory_;
public:
explicit LazyProxy(Factory f) : factory_{std::move(f)} {}
const T& get() const {
if (!value_) value_.emplace(factory_());
return *value_;
}
T& get() {
if (!value_) value_.emplace(factory_());
return *value_;
}
T* operator->() { return &get(); }
const T* operator->() const { return &get(); }
T& operator*() { return get(); }
const T& operator*() const { return get(); }
[[nodiscard]] bool loaded() const noexcept { return value_.has_value(); } // [[nodiscard]]: C++17
};
auto config = LazyProxy<Config>([] {
return Config::parse_file("/etc/app/config.toml");
});
// File is not touched until:
if (config->debug_mode) { /* first access triggers parse */ }
bool already = config.loaded(); // true after first accessFor multithreaded use, replace std::optional + mutable with std::call_once / std::once_flag (C++11)—optional with mutable is not thread-safe.
Auditing Proxy
Intercepts every member access without touching the target type. Useful for security-sensitive objects, debugging transient state, or enforcing access policies in tests:
template<typename T>
class AuditedRef {
T& ref_;
std::string_view name_; // std::string_view: C++17
public:
AuditedRef(T& r, std::string_view name) noexcept : ref_{r}, name_{name} {}
T* operator->() {
audit(name_, "mut");
return &ref_;
}
const T* operator->() const {
audit(name_, "read");
return &ref_;
}
private:
static void audit(std::string_view name, std::string_view op) {
std::clog << "[audit] " << op << ' ' << name << '\n';
}
};
DatabaseConfig db_cfg{"prod-host", 5432};
AuditedRef<DatabaseConfig> audited{db_cfg, "db_config"};
audited->host = "staging"; // logs: [audit] mut db_configAccess Control Proxy
ReadOnlyProxy enforces immutability at compile time—no runtime overhead:
template<typename T>
class ReadOnlyProxy {
const T* ptr_;
public:
explicit ReadOnlyProxy(const T& t) noexcept : ptr_{&t} {}
const T* operator->() const noexcept { return ptr_; }
const T& operator*() const noexcept { return *ptr_; }
// No non-const overloads: mutation attempts are compile errors
};
// Factory that hands out read-only views of internal state
class Engine {
Config config_;
public:
ReadOnlyProxy<Config> config() const { return ReadOnlyProxy{config_}; }
};
Engine e;
e.config()->timeout = 30; // error: assignment of member in read-only objectBest Practices
Override operator=(const Proxy&) in subscript proxies. Without it, container[i] = container[j] copies the proxy struct (both now alias the same element) rather than transferring the logical value. The override must route through the conversion operator: return *this = static_cast<T>(*other).
Mark conversion operators explicit by default. Implicit proxy-to-value conversions cause overload resolution surprises and suppress useful compiler errors. Only omit explicit when full transparency is the goal (smart pointer-style).
Maintain const symmetry. Every T* operator->() needs a const T* operator->() const counterpart that returns const access. A const proxy that returns mutable access to its target undermines the type system.
Document that auto captures the proxy. auto x = container[i] binds to the proxy type, not the value. In public APIs, use a concept (C++20) or static_assert to reject proxy types where value semantics are expected.
Common Pitfalls
auto captures the proxy, not the value. auto bit = bv[3] gives a BitRef with a reference into bv. After bv is destroyed or reallocated, bit dangles silently. Use bool bit = bv[3] or auto bit = static_cast<bool>(bv[3]).
Missing proxy-to-proxy assignment operator. The compiler synthesizes operator=(const BitRef&) as a memberwise copy—both proxy instances then reference the same storage. Always write it explicitly to go through the conversion path.
Non-const operator[] returning proxy, const returning value. Generic code that calls std::as_const(container)[i] or binds the container to a const reference takes the const path. If that returns a plain value while the non-const path returns a proxy, the two paths have different semantics and generic algorithms will misbehave.
Lazy proxy thread safety. A mutable std::optional<T> initialized on first access is a data race when accessed concurrently. Either synchronize with std::mutex (C++11) or use std::call_once with std::once_flag (C++11) for one-time initialization.
Lifetime of reference-holding proxies. A BitRef returned by operator[] holds a reference into the container's internal buffer. Any operation that invalidates that storage (push_back, reallocation) silently dangles the proxy. Proxies should never outlive the expression they were created in unless the design explicitly handles extended lifetimes.
std::reference_wrapper<T> — the Standard Library Proxy
std::reference_wrapper<T> (C++11, <functional>) wraps a reference so it can be copied, assigned, and stored in containers that require value semantics. Unlike a plain T&, a reference_wrapper is reassignable—rebinding it to a different object is legal and intentional.
// C++11
#include <functional>
#include <vector>
#include <algorithm>
struct Widget { int id; };
Widget a{1}, b{2}, c{3};
// Non-owning collection: no copies, no pointers-to-pointers
std::vector<std::reference_wrapper<Widget>> widgets{a, b, c};
std::sort(widgets.begin(), widgets.end(),
[](const Widget& x, const Widget& y){ return x.id < y.id; });
// Implicit conversion to T& for transparent use
for (Widget& w : widgets)
w.id *= 10; // mutates the originalsThe helper functions std::ref(x) and std::cref(x) create reference_wrapper<T> and reference_wrapper<const T> respectively. They are most commonly needed when passing arguments to std::bind or std::thread, both of which copy their arguments by default:
// C++11
#include <thread>
#include <functional>
void increment(int& x) { ++x; }
int counter = 0;
std::thread t(increment, std::ref(counter)); // ref ensures the thread sees the real counter
t.join();
// counter == 1reference_wrapper is reassignable; plain references are not. Assigning to a reference_wrapper<T> rebinds it to a new target. Assigning through a plain T& modifies the value. This distinction catches developers who expect ref = other_ref to copy-assign the underlying values.
Key operations: construction from T&, implicit conversion to T&, get() accessor for explicit retrieval, and operator() for wrapping callable objects. The get() accessor is particularly useful when you need to break the implicit conversion to pass through an overload set unambiguously.
Copy-on-Write Proxy
Copy-on-write (COW) defers the actual copy until write access is requested. The proxy holds a shared_ptr<const T> and performs a deep copy only when mutation is needed and the reference count is greater than one, making reads free even after logical copies.
// C++11
#include <memory>
#include <string>
class CowString {
std::shared_ptr<const std::string> data_;
void ensure_unique() {
if (data_.use_count() > 1)
data_ = std::make_shared<std::string>(*data_); // deep copy on first write
}
public:
explicit CowString(std::string s)
: data_{std::make_shared<std::string>(std::move(s))} {}
// Read path: no copy; returns const reference directly
const std::string& read() const noexcept { return *data_; }
// Write path: copy only if shared
std::string& write() {
ensure_unique();
return const_cast<std::string&>(*data_);
}
std::size_t size() const noexcept { return data_->size(); }
};
CowString original{"hello"};
CowString alias = original; // still shares the same buffer
alias.write() += " world"; // triggers deep copy; original is unchanged
// original.read() == "hello"
// alias.read() == "hello world"The standard library used COW for std::string before C++11 — and removed it. COW strings violate iterator and pointer stability guarantees under concurrent access: two threads taking the read path can trigger a copy simultaneously, requiring a mutex on every read. The pattern remains valid for large, rarely mutated objects — images, parsed configuration trees, immutable document models — where copies are genuinely expensive and write contention is low.
The pattern pairs naturally with the subscript proxy: operator[] can return a CowElementRef that triggers ensure_unique on assignment, giving per-element COW semantics to a container.
Thread-Safe Access Proxy
A locked-access proxy acquires a mutex on construction and releases it on destruction, providing a scoped, syntactically convenient window into mutex-guarded state. The caller never touches the mutex directly.
// C++11
#include <mutex>
template<typename T>
class LockedRef {
std::unique_lock<std::mutex> lock_;
T* ptr_;
public:
LockedRef(std::mutex& m, T& v)
: lock_{m}, ptr_{&v} {} // mutex locked here
T* operator->() noexcept { return ptr_; }
T& operator*() noexcept { return *ptr_; }
// mutex released in unique_lock destructor
};
template<typename T>
class Guarded {
mutable std::mutex mtx_;
T value_;
public:
template<typename... Args>
explicit Guarded(Args&&... args) : value_{std::forward<Args>(args)...} {}
[[nodiscard]] LockedRef<T> lock() { return {mtx_, value_}; }
[[nodiscard]] LockedRef<const T> lock() const { return {mtx_, value_}; }
};
Guarded<std::vector<int>> safe_vec;
// Lock held for exactly the duration of the chained call:
safe_vec.lock()->push_back(42);
// Lock held across multiple operations in a scope:
{
auto ref = safe_vec.lock();
ref->push_back(1);
ref->push_back(2);
// mutex released when ref goes out of scope
}The proxy must not be stored across thread boundaries. The entire safety guarantee rests on the lock being held for exactly the proxy's lifetime. [[nodiscard]] on lock() ensures the caller cannot silently discard the proxy — doing so would unlock immediately after the call, giving a false sense of protection.
For read/write discrimination, split into SharedLockedRef<T> (acquires std::shared_lock for concurrent reads) and UniqueLockRef<T> (acquires std::unique_lock for exclusive writes), exposing them via separate read_lock() and write_lock() accessors on a std::shared_mutex (C++17).
Proxy References in C++20 Ranges
C++20 ranges generalize iteration, but some range adaptors produce proxy references from operator* rather than true T& references. The canonical examples are std::vector<bool> (whose iterator yields a bit-proxy, not bool&) and std::views::zip (whose iterator yields std::pair<T&, U&>, not a true reference to a pair).
// C++20
#include <ranges>
#include <vector>
std::vector<bool> flags{true, false, true};
// range_reference_t is the bit-proxy type, not bool&
using Ref = std::ranges::range_reference_t<decltype(flags)>;
using Val = std::ranges::range_value_t<decltype(flags)>;
// Ref != Val — proxy vs. value distinctionThis creates a concept compatibility gap. Algorithms that require std::LvalueReference (e.g., std::ranges::sort) will not compile over a proxy range:
// C++20
#include <algorithm>
#include <ranges>
std::vector<bool> v{true, false, true};
// std::ranges::sort(v); // ill-formed: sort requires genuine lvalue referencesMaterialize the range into a std::vector before applying algorithms that require lvalue references. C++23 provides std::ranges::to<std::vector>() for exactly this pattern:
// C++23
#include <ranges>
#include <vector>
auto flags = std::vector<bool>{true, false, true}
| std::ranges::to<std::vector<bool>>(); // new bool storage, sortableThe concepts std::indirectly_readable and std::indirectly_writable (C++20) specify the minimum interface a proxy iterator must satisfy. When writing custom range adaptors that return proxies, ensure iter_move and iter_swap are specialized via ADL — the standard algorithms call these instead of direct assignment when they detect proxy references.
std::mdspan Proxy Reference Types (C++23)
std::mdspan<T, Extents, Layout, Accessor> (C++23, <mdspan>) is a multi-dimensional non-owning view over contiguous or strided data. Its design is explicitly built around proxy accessors: the fourth template parameter controls how element access is performed, making the operator[] return type an implementation-defined proxy reference type.
// C++23
#include <mdspan>
#include <array>
std::array<double, 6> storage{1, 2, 3, 4, 5, 6};
// 2×3 row-major view, default accessor
std::mdspan<double, std::extents<std::size_t, 2, 3>> mat{storage.data()};
mat[0, 2] = 3.14; // operator[] with multi-index (C++23 syntax)
double v = mat[1, 1]; // read through accessorA custom accessor intercepts every element access, enabling bounds checking, logging, or memory-mapped I/O without altering the layout:
// C++23
struct CheckedAccessor {
using offset_policy = CheckedAccessor;
using element_type = double;
using reference = double&;
using data_handle_type = double*;
reference access(data_handle_type p, std::ptrdiff_t i) const {
// bounds check performed here; extents available via the mapping
return p[i];
}
data_handle_type offset(data_handle_type p, std::ptrdiff_t i) const {
return p + i;
}
};The accessor's reference typedef is the proxy reference type seen by callers. It can be any type that models std::indirectly_writable — a custom proxy class that logs writes, enforces units, or translates between storage and logical types. C++26 std::linalg functions accept mdspan with arbitrary accessors, so a logging accessor composes transparently with BLAS-style operations.
std::atomic_ref<T> as a Non-Owning Proxy (C++20)
std::atomic_ref<T> (C++20, <atomic>) is a proxy that adds atomic operation semantics to an existing, non-atomic object. It borrows a T& and exposes load, store, fetch_add, compare_exchange_weak, and the full atomic interface — without requiring the object to be declared std::atomic<T>.
// C++20
#include <atomic>
#include <thread>
struct Stats {
int requests{0}; // plain int — not atomic
int errors{0};
};
Stats s;
// During a critical section, wrap the field atomically:
std::atomic_ref<int> atomic_requests{s.requests};
std::atomic_ref<int> atomic_errors{s.errors};
auto worker = [&]{
atomic_requests.fetch_add(1, std::memory_order_relaxed);
};
std::thread t1{worker}, t2{worker};
t1.join(); t2.join();
// Outside the critical section, s.requests is accessed as a plain int
// (no atomic overhead)The referenced object must meet the alignment requirement. std::atomic_ref<T>::required_alignment may be stricter than alignof(T) — on x86-64, atomic_ref<double> may require 8-byte alignment while double only guarantees 4 on some ABIs. Use alignas(std::atomic_ref<T>::required_alignment) on the field declaration when this matters.
std::atomic<T> owns its value; std::atomic_ref<T> borrows it. This distinction is the proxy pattern in miniature: atomic_ref is the intercepting wrapper that adds behavior (atomicity) without changing the underlying storage or requiring the type to be redesigned. The same object can be accessed atomically in one module and directly in another — at the programmer's explicit responsibility. All atomic_ref instances referring to the same object must be used consistently; mixing atomic and non-atomic access to the same location is a data race.
See Also
- RAII — the ownership model behind smart-pointer proxies
- Type Erasure — hiding concrete types behind a uniform interface, often combined with proxy
- CRTP — zero-cost alternative to virtual dispatch in performance-critical proxy paths
- Move Semantics — proxies that own their target must implement move correctly to avoid copies