When exploring the complex landscape of C++ operator precedence, one quirk frequently surprises developers: the pointer-to-member operators (.* and ->*) have lower precedence than the standard member access operators (. and ->).

For example, given the expression:

a.b.*c.d

C++ parses this expression as:

(a.b).*(c.d)

Why did C++ creator Bjarne Stroustrup and the standardization committee give .* and ->* lower precedence than primary postfix operators? What problem does this solve, and what are the architectural advantages?

The Core Reason: Identifiers vs. General Expressions

To understand the precedence hierarchy, we must first look at what each operator expects as its operands:

  • The dot operator (.): The right-hand side of a dot operator must be an unqualified identifier (a member name). It cannot be an arbitrary expression. You can write obj.field, but not obj.(expression).
  • The pointer-to-member operator (.*): The right-hand side of a pointer-to-member operator is an arbitrary expression evaluating to a pointer-to-member type (e.g., Type::*).

Because the right-hand operand of .* can be any expression yielding a pointer to a member, it is common to fetch that pointer from a container, another struct, or a function return value. By giving .* lower precedence than postfix operators (such as ., ->, and []), the compiler lets you retrieve the pointer seamlessly without wrapping the lookup in parentheses.

Real-World Example: Fetching Member Pointers

Consider a scenario where you store member pointers inside a configuration structure or a lookup table:

struct Target {
    int value = 42;
};

struct Config {
    int Target::*memberPtr = &Target::value;
};

struct Wrapper {
    Target target;
    Config config;
};

Wrapper w;
// Evaluates as: (w.target).*(w.config.memberPtr)
int result = w.target.*w.config.memberPtr;

If .* had the same precedence as . with standard left-to-right associativity, the compiler would attempt to parse the expression as:

((w.target).*w).config.memberPtr // Compilation error!

This would fail because w is not a valid pointer to a member of w.target. Consequently, every time you fetched a member pointer from a struct, array, or object, you would be forced to write defensive parentheses:

w.target.*(w.config.memberPtr) // Required if precedence were equal

Why Didn't Bjarne Stroustrup Make Them Higher Precedence?

In The Design and Evolution of C++, Bjarne Stroustrup explains that new operators in C++ were designed to mesh as naturally as possible with existing C conventions.

Member access via . and -> sits at precedence level 2 (along with postfix increments, array indexing, and function calls). Pointer-to-member operators were introduced at precedence level 4 (just below unary prefix operators like *, &, and !, but above multiplicative operators like * and /).

This positioning yields two significant benefits:

  1. Symmetric operand resolution: Both the left-hand operand (the target object) and the right-hand operand (the member pointer) can be complex postfix expressions (e.g., object_array[i].*vtable[fn_index]).
  2. Clear separation from binary multiplication: Keeping .* separate from binary * avoids syntactic ambiguities during compilation.

The Catch: Calling Member Function Pointers

While the lower precedence of .* works cleanly when retrieving member pointers, it does introduce one well-known syntactic annoyance: invoking member function pointers requires parentheses.

struct Worker {
    void execute() {}
};

Worker w;
void (Worker::*fn)() = &Worker::execute;

// Error: tries to parse as w.*(fn())
// w.*fn(); 

// Correct:
(w.*fn)();

Because the function call operator () has higher precedence than .*, w.*fn() attempts to call fn as a standalone function before dereferencing the member pointer. While inconvenient, this trade-off was necessary to maintain consistent postfix evaluation throughout the grammar.

Modern C++ Alternative: std::invoke

In modern C++ (C++17 and later), you can bypass confusing operator precedence entirely by using std::invoke from the <functional> header:

#include <functional>

// Accessing member variable
int val = std::invoke(w.config.memberPtr, w.target);

// Calling member function
std::invoke(fn, w);

std::invoke provides a clean, uniform syntax for dereferencing pointers to members, free functions, and lambdas without worrying about precedence parentheses.

Summary

The precedence difference between . and .* is intentional: a.b.*c.d parses as (a.b).*(c.d) because the right side of a pointer-to-member dereference is an expression, not a static identifier. Placing .* lower down the hierarchy allows complex lookups on both sides of the operator without drowning your code in parentheses.