Understanding C++ Pointer-to-Member Operator Precedence: Why .* Ranks Lower Than .
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.dC++ 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 writeobj.field, but notobj.(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 equalWhy 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:
- 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]). - 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.