Skip to content
C++
Language
Intermediate

Member Access

The operators and rules governing access to data members and member functions of class and struct types, including pointer-to-member syntax.

Member Accesssince C++98

Member access refers to the suite of operators and access-control rules that govern how data members and member functions of a class or struct type are reached from an object, pointer, or pointer-to-member.

Overview

C++ provides four built-in operators for member access:

OperatorFormOperands
.Direct member selectionobject or reference on the left
->Pointer member selectionpointer on the left
.*Pointer-to-member via objectobject/reference on the left, pointer-to-member on the right
->*Pointer-to-member via pointerpointer on the left, pointer-to-member on the right

The . and .* operators cannot be overloaded. The -> and ->* operators can be overloaded, a property exploited by every smart pointer type in the standard library.

Access to any member is also governed by an access specifier: public, protected, or private. With struct, the default access for members and base-class inheritance is public; with class, the default is private. This is the only semantic difference between struct and class in C++.

Syntax

Direct access: . and ->

cpp
struct Point { double x, y; };

Point  p{1.0, 2.0};
Point* pp = &p;

double a = p.x;        // dot: object
double b = pp->x;      // arrow: pointer
double c = (*pp).x;    // equivalent to pp->x

-> is definitionally equivalent to (*lhs).rhs; the compiler may optimise them identically, but the pointer dereference (*pp) is explicit with the dot form.

Pointer-to-member: .* and ->*

A pointer-to-member is a typed offset into a class, not a raw address. It encodes both the containing class and the member's type.

cpp
struct Widget {
    int value;
    void print() const;
};

// Pointer to data member
int Widget::*pm = &Widget::value;

Widget  w{42};
Widget* wp = &w;

w.*pm  = 10;     // via object
wp->*pm = 20;    // via pointer

// Pointer to member function
void (Widget::*pf)() const = &Widget::print;

(w.*pf)();       // call via object β€” outer parens are required
(wp->*pf)();     // call via pointer

The outer parentheses around (w.*pf)() are mandatory: .* and ->* bind more loosely than (), so without them the expression would be parsed as w.*(pf()), which is ill-formed.

Access specifiers

cpp
class Account {
public:
    void deposit(double amount);   // accessible everywhere
    double balance() const;

protected:
    double ledger_balance_;        // accessible in Account and derived classes

private:
    int account_id_;               // accessible only within Account
    double raw_balance_;
};

The protected level exists specifically for inheritance: a derived class can read and write protected members of its base but cannot reach private members. Friend declarations bypass all access restrictions for a named class or function.

Examples

Overloading -> for a smart-pointer-like type

-> must return either a raw pointer or an object that itself defines operator->. The chain terminates when a raw pointer is reached.

cpp
template <typename T>
class ScopedPtr {
    T* ptr_;
public:
    explicit ScopedPtr(T* p) : ptr_(p) {}
    ~ScopedPtr() { delete ptr_; }

    T* operator->() const { return ptr_; }
    T& operator*()  const { return *ptr_; }
};

struct Config { int timeout; std::string host; };

ScopedPtr<Config> cfg{new Config{30, "localhost"}};
cfg->timeout = 60;          // invokes operator->(), then accesses timeout
std::string h = cfg->host;

Overloading ->*

Overloading ->* is rare but arises when building proxy or delegate types that must support the full pointer-to-member protocol.

cpp
template <typename T>
class Proxy {
    T* obj_;
public:
    explicit Proxy(T* o) : obj_(o) {}
    T* operator->() const { return obj_; }

    // Return a callable bound to the member pointer
    template <typename R, typename... Args>
    auto operator->*(R (T::*mf)(Args...)) const {
        return [this, mf](Args... args) -> R {
            return (obj_->*mf)(std::forward<Args>(args)...);
        };
    }
};

Since C++11, lambdas make ->* overloads far more ergonomic than the manual functor approach required before.

Pointer-to-member as a dispatch table

A classic application: storing an array of member-function pointers to avoid a chain of if/switch branches.

cpp
class StateMachine {
    using Handler = void (StateMachine::*)(int);

    void on_idle(int event);
    void on_running(int event);
    void on_error(int event);

    static constexpr Handler table_[] = {
        &StateMachine::on_idle,
        &StateMachine::on_running,
        &StateMachine::on_error,
    };
    int state_ = 0;

public:
    void dispatch(int event) {
        (this->*table_[state_])(event);
    }
};

this->*table_[state_] retrieves the pointer-to-member and binds it to this in one expression.

Member access in templates with dependent names

When accessing a member through a template type parameter, the compiler cannot know during parsing whether a dependent name is a type, a value, or a template. The typename and template disambiguators are required.

cpp
template <typename Container>
void process(Container& c) {
    // Without 'typename', the compiler might parse '::value_type' as a non-type
    typename Container::value_type first = c.front();

    // 'template' disambiguates a member template following a dependent name
    auto it = c.template begin<int>();
}

This was a C++98 rule codified more clearly in C++11 and further clarified in C++17, where many previously required typename annotations in non-deduced contexts became optional in C++20.

Best Practices

Prefer -> over (*ptr).. The arrow form removes one level of parentheses and makes pointer intent explicit without adding verbosity.

Use typedef or using to alias pointer-to-member types. The raw declaration syntax is notoriously hard to read, and wrapping it in an alias pays dividends immediately:

cpp
using Handler = void (Widget::*)(int);
Handler h = &Widget::process;

Keep access specifiers coarse-grained. Group all public members together, then protected, then private. Mixing specifiers within a section forces readers to track which rule applies to each member.

Prefer std::invoke (C++17) over direct .*/->* when calling through a member-function pointer at a call site. std::invoke handles regular functions, lambdas, and pointer-to-member uniformly, which simplifies generic code:

cpp
#include <functional>  // C++17

auto call = [](auto&& obj, auto&& mem, auto&&... args) {
    return std::invoke(mem, obj, std::forward<decltype(args)>(args)...);
};

Common Pitfalls

Forgetting parentheses around .*/->* calls. (obj.*pmf)(args) is valid; obj.*pmf(args) attempts to call pmf as a regular function first, which will not compile if pmf is a pointer-to-member type.

Taking the address of a virtual function and treating it as a final dispatch. &Base::virtual_fn gives a pointer to that slot in the class, not to a specific override. The correct override is still selected at runtime through the vtable when you call through the pointer β€” this is expected behaviour, but confuses engineers who expect the address to freeze the dispatch.

Accessing a member through -> on a null pointer. This is undefined behaviour even if the accessed member is not the result of a dereference that would fault. Some compilers appear to tolerate ptr->static_member through null, but the standard provides no guarantee.

Mismatching the class in a pointer-to-member with the object type. A Derived::* pointer-to-member can be used with a Base only if the member was inherited β€” the compiler enforces this, but the error messages for pointer-to-member type mismatches are often cryptic.

operator-> Chaining: The Drill-Through Rule

When the compiler processes ptr->member, it first checks whether ptr is a raw pointer. If it is, the built-in dereference applies. If ptr is a class type, the compiler calls ptr.operator->(). The crucial rule, found in [over.ref] of the standard, is that this process is applied recursively: if operator->() returns a class type that itself defines operator->, the compiler calls that operator in turn. The chain continues until a raw pointer is produced, at which point built-in member selection takes over.

This "drill-through" behaviour is what makes nested smart-pointer and proxy compositions transparent at the call site.

cpp
#include <memory>

template <typename T>
struct Logged {
    std::unique_ptr<T> inner_;

    explicit Logged(std::unique_ptr<T> p) : inner_(std::move(p)) {}

    // Returns a unique_ptr, not a raw pointer β€” the drill-through continues
    std::unique_ptr<T>& operator->() {
        // log, audit, etc.
        return inner_;
    }
};

struct Config { int timeout; };

Logged<Config> cfg{std::make_unique<Config>()};

// Resolves as: cfg.operator->()  ->  (unique_ptr<Config>).operator->()  ->  Config*
cfg->timeout = 30;

The step-by-step expansion for cfg->timeout:

  1. cfg.operator->() returns std::unique_ptr<Config>&
  2. unique_ptr<Config>::operator->() returns Config*
  3. Built-in Config*->timeout selects the member

Lazy-proxy pattern

The drill-through rule enables lazy initialisation proxies without any syntax cost:

cpp
template <typename T>
struct LazyInit {
    mutable T* obj_ = nullptr;

    T* operator->() const {
        if (!obj_) obj_ = new T{};  // initialise on first access
        return obj_;
    }

    ~LazyInit() { delete obj_; }
};

struct HeavyResource { void compute(); };

LazyInit<HeavyResource> res;
res->compute();   // HeavyResource constructed only here, on first call

Because operator-> may have side effects (allocation, locking, logging) that run on every arrow access, implementations must take care not to assume the operator is called exactly once per expression.

operator->*: Pointer-to-Member via Smart Pointer

The built-in ->* operator binds a pointer-to-member to an object reached through a pointer: ptr->*mem is equivalent to (*ptr).*mem. When ptr is a user-defined type, the compiler calls operator->*(ptr, mem) via overload resolution β€” making ->* the one pointer-to-member operator that can be overloaded.

The overload must be a non-static member function or a free function; the standard form takes the smart-pointer type and a pointer-to-member type as parameters and returns something callable or a reference.

cpp
template <typename T>
class SmartPtr {
    T* ptr_;
public:
    explicit SmartPtr(T* p) : ptr_(p) {}
    ~SmartPtr() { delete ptr_; }

    T* operator->() const { return ptr_; }
    T& operator*()  const { return *ptr_; }

    // Support smart_ptr->*&Class::data_member  (data members)
    template <typename R>
    R& operator->*(R T::*pm) const {
        return ptr_->*pm;
    }

    // Support smart_ptr->*&Class::member_fn (member functions) β€” return a bound callable
    template <typename R, typename... Args>
    auto operator->*(R (T::*pmf)(Args...)) const {
        return [this, pmf](Args... args) -> R {
            return (ptr_->*pmf)(std::forward<Args>(args)...);
        };
    }
};

struct Widget {
    int value = 42;
    int doubled() const { return value * 2; }
};

SmartPtr<Widget> w{new Widget};

int& ref = w->*&Widget::value;          // data member: returns int&
ref = 10;

auto fn = w->*&Widget::doubled;         // member function: returns lambda
int result = fn();                      // calls w.ptr_->doubled()

Without the overload, smart_ptr->*pmf would not compile because ->* on a non-pointer left-hand side is undefined. Libraries that implement full pointer semantics (e.g., std::experimental::propagate_const) provide this overload so that the entire member-access protocol works uniformly.

Why operator. Cannot Be Overloaded

The dot operator is the primitive used to access members of an object whose type is known at compile time. Allowing operator. to be overloaded would create an insurmountable ambiguity: the expression obj.foo could mean "call obj.operator.() and then access foo on the result", but the syntax for invoking operator. itself would also require obj.operator.() β€” a chicken-and-egg problem. There is no intermediate raw-pointer step to terminate the recursion as there is for ->.

Proposal N4477 / P0416 (Bjarne Stroustrup and Gabriel Dos Reis, 2004–2016) explored dot-operator overloading for transparent handle types. The core difficulty the proposal encountered was specifying which names are looked up on the wrapper class first versus being forwarded to the referent. The paper was debated across multiple WG21 meetings and ultimately not adopted, primarily because no formulation that satisfied both the transparency requirement and ordinary name-lookup rules could be agreed upon.

Workarounds in practice:

Public inheritance β€” when Derived publicly inherits from Base, dot-access to Base members works directly on Derived objects. This is the zero-overhead approach when an "is-a" relationship is appropriate.

cpp
struct Logger : Sensor {
    // dot-access to Sensor members works on Logger objects
    using Sensor::read;   // pull into scope explicitly if shadowed
};

Explicit forwarding with composition β€” define every forwarding accessor by hand, or use a macro/code-generation approach:

cpp
struct LoggedSensor {
    Sensor sensor_;

    // Forward individual members explicitly
    double read() const { return sensor_.read(); }
    void calibrate()    { sensor_.calibrate(); }
};

operator-> as an approximation β€” if callers can tolerate arrow syntax instead of dot syntax, a smart-reference wrapper with operator-> exposes the inner object's members without inheritance.

Subscript operator[] and C++23 Multi-Dimensional Access

Before C++23

The language restricted operator[] to exactly one argument. Accessing a two-dimensional matrix element required either a proxy-row object or repurposing operator():

cpp
// Pre-C++23: proxy row
class Matrix {
    double data_[4][4];
public:
    // Returns a proxy that owns a second operator[]
    double* operator[](int row) { return data_[row]; }
    const double* operator[](int row) const { return data_[row]; }
};

Matrix m;
m[1][2] = 3.14;   // two separate operator[] calls; no bounds check on second

Alternatively, many linear-algebra libraries overloaded operator() with two arguments, giving m(i, j) syntax β€” functionally identical but visually distinct from built-in array access.

C++23: Multi-Dimensional operator[] (P2128)

C++23 lifts the one-argument restriction. operator[] may now accept any number of parameters including zero and may have default arguments. The expression obj[a, b, c] (using the comma as a separator inside []) calls obj.operator[](a, b, c).

cpp
// C++23
template <typename T, std::size_t Rows, std::size_t Cols>
class Matrix {
    T data_[Rows][Cols];
public:
    T& operator[](std::size_t r, std::size_t c) {
        return data_[r][c];
    }
    const T& operator[](std::size_t r, std::size_t c) const {
        return data_[r][c];
    }
};

Matrix<double, 4, 4> m;
m[1, 2] = 3.14;   // calls operator[](1, 2) β€” clean, no proxy
double v = m[0, 3];

The multi-argument form applies only when the subscript operator is explicitly overloaded; built-in array types still accept exactly one index. Note that m[1, 2] uses the comma operator inside [] in earlier standards, which is deprecated in C++20 when it appears in a subscript context, precisely to clear the way for the C++23 multi-argument meaning.

std::mdspan (also C++23) uses this operator to provide ergonomic multi-dimensional span access:

cpp
#include <mdspan>

std::vector<double> buf(12);
std::mdspan<double, std::extents<int, 3, 4>> grid{buf.data()};
grid[1, 2] = 99.0;

operator(): Making Objects Callable

Any class may overload the function-call operator. The result is a functor β€” an object that behaves syntactically like a function but can carry state.

cpp
struct Adder {
    int base_;
    explicit Adder(int b) : base_(b) {}
    int operator()(int x) const { return base_ + x; }
};

Adder add5{5};
int result = add5(3);   // calls add5.operator()(3) => 8

Functors predate lambdas as the primary way to pass behaviour to algorithms. A std::sort comparator written as a functor is inlined by the compiler more reliably than a function pointer, because the type is known at the call site.

Lambdas as anonymous functors

A lambda expression is syntactic sugar for a compiler-generated class with operator(). The two are equivalent:

cpp
// Lambda
auto add5 = [base = 5](int x) { return base + x; };

// Equivalent hand-written functor (simplified)
struct __lambda {
    int base;
    int operator()(int x) const { return base + x; }
};
auto add5 = __lambda{5};

Generic call operator (C++14)

C++14 allows auto parameters in lambdas, producing a templated operator():

cpp
auto identity = [](auto x) { return x; };
// Generates:
//   template <typename T>
//   T operator()(T x) const { return x; }

identity(42);       // int
identity(3.14);     // double
identity("hello");  // const char*

The same effect for a named functor requires an explicit template on operator():

cpp
struct Identity {
    template <typename T>
    T operator()(T x) const { return x; }
};

Relationship to std::function

std::function<R(Args...)> is a type-erased wrapper that holds any callable β€” lambda, functor, or function pointer β€” with matching signature. It uses virtual dispatch internally, which has overhead compared to a concrete functor type. Prefer templates or auto parameters over std::function on hot paths; reserve std::function for heterogeneous storage or callbacks across ABI boundaries.

C++23: static operator() and static operator[] (P1169)

C++23 permits both operator() and operator[] to be declared static. This matters for stateless callables such as comparison objects, projections, and customisation-point objects (CPOs).

The problem with non-static call operators

A non-static operator() always receives an implicit this pointer, even when the object has no state. Compilers can often optimise this away after inlining, but in non-inlined scenarios β€” common with virtual dispatch or across translation units β€” the pointer occupies a register or stack slot unnecessarily. Assembly inspection of stateless functors passed to std::count_if showed extra zeroing and register usage absent in equivalent free-function calls.

static operator() (C++23)

cpp
// C++23
struct IsEven {
    static bool operator()(int n) { return n % 2 == 0; }
    //     ^^^^^^ no implicit this
};

// Lambdas can also opt in:
auto is_odd = [](int n) static { return n % 2 != 0; };
// static lambdas cannot capture anything and cannot be mutable

Because there is no this, static operator() is callable without an object instance:

cpp
bool b = IsEven::operator()(4);   // direct static call β€” legal in C++23

Deduction guides for std::function and std::packaged_task were updated to recognise static call operators via decltype(&F::operator()).

static operator[] (C++23)

The same static permission extends to operator[]. A static subscript operator is primarily useful for enum-indexed lookup tables where the table itself is a type, not an instance:

cpp
enum class Day { Mon, Tue, Wed, Thu, Fri, Sat, Sun };

struct DayNames {
    static const char* operator[](Day d) {
        static constexpr const char* names[] = {
            "Monday","Tuesday","Wednesday",
            "Thursday","Friday","Saturday","Sunday"
        };
        return names[static_cast<int>(d)];
    }
};

const char* name = DayNames{}[Day::Wed];   // "Wednesday"
// Or, in C++23, also callable as:
const char* name2 = DayNames::operator[](Day::Fri);

When to use static call/subscript operators

Use static operator() or static operator[] when:

  • The callable type is stateless (no data members that affect behaviour).
  • The type is used as a comparator, hash, projection, or CPO passed by value to algorithms.
  • You want to allow callers to invoke the operator without constructing an instance.

Do not use static when the operator needs access to this (stateful functors, smart references, proxy objects).

See Also

  • reference/language/special-member-functions β€” constructors, destructors, and other compiler-generated members accessed implicitly
  • reference/language/const-correctness β€” how const qualifiers interact with member access
  • reference/language/auto β€” type deduction with pointer-to-member and std::invoke