C++ Part I · Module 15 · Operator Overloading
Member vs Non-Member Operators
An overloaded operator can be written two ways — as a member function of the class, called as though the left operand were the object, or as a free function taking both operands as ordinary parameters. The choice looks like a style question and is usually presented as one, with advice about encapsulation on one side and convention on the other. It is not a style question. A member function's left operand is the object the function was called on, and the rules for that implicit object argument are not the rules for a parameter: the compiler will not apply a user-defined conversion to it. So a type with a perfectly ordinary converting constructor accepts a double on the right of its member operator and rejects the identical double on the left, with a diagnostic this chapter measures. Any operator a reader expects to be symmetric — multiplication by a scalar, comparison against a literal, addition of a plain number — therefore has its placement decided for it, before anyone's taste is consulted. On top of that the language simply forbids some choices: four operators must be members and will tell you so in the same words, and stream insertion must be a non-member for a reason that falls directly out of the same rule about which operand comes first.
Intermediate17 min readC++Part IFundamentalsOperators
Part I · Module 15 · Chapter 15.2 · Member vs Non-Member Operators
1. Two Placements
The same operator, two ways to write it.
As a member:
struct Scaled {
double v;
Scaled operator*(double k) const { return Scaled{v * k}; }
};The left operand is the object. s * 2.0 means s.operator*(2.0).
As a non-member:
Scaled operator*(const Scaled& a, const Scaled& b) { return Scaled{a.v * b.v}; }Both operands are parameters. s * t means operator*(s, t) — Chapter 15.1 §2's spelling.
Chapter 15.1 §4 measured that neither placement costs anything, so this is not a performance decision. It is decided by three things, in this order: what the language requires (§6), whether the operator must be symmetric (§4), and only then convention (§8).
2. The Member Version Works — Halfway
Scaled s{2.5};
std::printf("s * 2.0 = %.1f\n", (s * 2.0).v);
std::printf("2.0 * s = %.1f\n", (2.0 * s).v);The first line is fine. The second does not compile:
member.cpp:11:42: error: invalid operands to binary expression ('double' and 'Scaled')s * 2.0 compiles and 2.0 * s does not, from one definition, for one type, with one multiplication. A reader who writes 2.0 * s has written something a mathematician would consider identical, and the compiler disagrees.
3. Why — The Implicit Object Argument
The reason is specific and worth getting exactly right, because the loose version of it ("members are different") predicts the wrong things.
For s.operator*(2.0):
- The left operand is the implicit object argument. It becomes
*this. It is not an ordinary parameter, and user-defined conversions are not applied to it. The object must already be aScaled. - The right operand is an ordinary parameter, so every conversion the language allows is on the table.
So in 2.0 * s, the compiler must find an operator* whose left operand can be a double. The member belongs to Scaled, and a double is not a Scaled and will not be converted into one for this purpose. There is no candidate, hence invalid operands.
A member operator's left operand is fixed to the class type. Its right operand is negotiable. That asymmetry is the whole of this chapter.
Note what this is not. It is not that the member is private, or that double has no operator* for Scaled — it is specifically that the one position which could have been converted is the one position the language will not convert.
4. The Non-Member Version Is Symmetric
Same type, same converting constructor, operator moved out of the class:
struct Scaled {
double v;
Scaled() = default;
Scaled(double x) : v(x) {} // non-explicit: enables the conversion
};
Scaled operator*(const Scaled& a, const Scaled& b) { return Scaled{a.v * b.v}; }s * 2.0 = 5.0
2.0 * s = 5.0
s * s = 6.25All three orders work. Both operands are now ordinary parameters, so the converting constructor applies on either side, and one definition covers every combination.
This gives the rule the frozen description of this chapter states:
If both operand orders must work, the operator cannot be a member.
Which decides the placement for most arithmetic and all comparison operators, because a reader who can write v * 2 expects 2 * v to mean the same thing — and a type where only one of them compiles is a type that fails Chapter 15.1 §8's test, since no reader guesses that one direction is an error.
5. The Conversion Is Doing Work, and That Has a Cost
§4's symmetry depends on Scaled(double) being non-explicit. Chapter 12.5 §1 established that a one-argument constructor is a conversion, and Chapter 12.5 §2 measured the hazard: the same conversion that makes 2.0 * s work also makes unrelated nonsense compile.
So §4's convenience is bought with a conversion you have to actually want. There are two honest positions:
Accept the conversion, when the scalar genuinely is a degenerate value of your type — a Scaled, a Duration, a Complex. Here 2.0 meaning "the number two" is exactly right, and the implicit conversion is the feature.
Or keep the constructor explicit and write the overloads you want, when it is not:
Scaled operator*(const Scaled& a, double k) { return Scaled{a.v * k}; }
Scaled operator*(double k, const Scaled& a) { return Scaled{a.v * k}; }Two non-member overloads, no conversion, both orders working, and nothing unintended enabled. This is more typing and it is the safer default — Chapter 12.5 §5's advice applied to operators.
What does not work is the member form plus an explicit constructor, which gives you neither symmetry nor conversions, and is the combination people arrive at by accident.
6. Four Operators Must Be Members
Not a convention — a rule, and all four say so identically. Each of these is a non-member declaration:
S& operator=(S&, const S&); // assignment
int& operator[](S&, int); // subscript
int operator()(S&, int); // function call
int* operator->(S&); // member access through a pointeroverloaded 'operator=' must be a non-static member function
overloaded 'operator[]' must be a non-static member function
overloaded 'operator()' must be a non-static member function
overloaded 'operator->' must be a non-static member functionThe same message, four times. The reason is common to all four: each one's left operand is the object in a way the language treats as fundamental rather than negotiable. Assignment writes to the object; subscript and call and arrow are operations of the object. None of them has a symmetric reading, so losing symmetry costs nothing.
Chapter 13.3 §1 already wrote operator= as a member for exactly this reason. Chapter 15.6 covers the other three.
A fifth entry belongs with them in practice: a conversion operator (operator bool, operator double) is necessarily a member, because it has no second operand at all. Chapter 15.7 §5 measures what a non-explicit one permits.
7. And Stream Insertion Cannot Be One
The mirror image of §6, and it falls straight out of §3.
std::ostream& operator<<(std::ostream& os, const Transaction& t);The expression is std::cout << t. The left operand is the stream, so a member version would have to be a member of std::ostream — and you cannot add members to a class in the standard library.
Writing it as a member of your own type instead gives you an operator whose left operand is a Transaction:
struct Transaction { std::ostream& operator<<(std::ostream& os) const; }; // t << std::coutThat compiles, and it is worse than it looks. Measured, all three cases:
A. the member form, called its own way:
t << std::cout compiles, prints tx@64
B. the natural order:
std::cout << t error: invalid operands to binary expression
('ostream' (aka 'basic_ostream<char>') and 'Transaction')
C. chaining:
a << b << std::cout error: invalid operands to binary expression
('Transaction' and 'Transaction')A confirms the member version works — t << std::cout printed tx@64. B is the reading every user expects, and it is a hard error. And C is the fatal one: the member form cannot be chained at all, because a << b needs an operator<< whose left operand is a Transaction and whose right operand is another Transaction, and the whole value of stream insertion is that os << a << b << '\n' works.
So the member form fails Chapter 15.1 §8's test on reading order and loses the one property that makes the operator worth having — the same shape of loss as Chapter 15.1 §6's short-circuiting. Chapter 15.5 is the chapter on these; the point here is that the placement was never a choice.
The same argument applies to operator>>, and to any operator whose natural left operand is a type you do not own.
8. Choosing, When the Language Lets You
With the forced cases out of the way, a short procedure:
| Operator | Placement | Because |
|---|---|---|
=, [], (), ->, conversions | member | required (§6) |
<<, >> | non-member | the left operand is the stream (§7) |
+, -, *, /, % | non-member | symmetry (§4) |
==, !=, <, <=, >, >=, <=> | non-member | symmetry; see Chapter 15.4 |
+=, -=, *=, /= | member | modifies the left operand, which is always the object |
++, -- | member | one operand, and it is modified |
unary -, !, ~ | member | one operand |
The pattern behind the table is worth stating, because it generalises past the list:
If the operator modifies its left operand or has only one, make it a member. If a reader would expect it to be symmetric, make it a non-member.
Chapter 15.3 shows the two halves working together: += as a member carrying the logic, + as a non-member built on it.
One mechanical note. A non-member operator needs access to the class's data. Three ways, in order of preference: use the public interface if it suffices; declare the operator a friend (Chapter 11.4's access mechanism — named here, taught later); or define it as a friend inline in the class body, which keeps it textually with the type while leaving it a non-member:
struct Scaled {
double v;
friend Scaled operator*(const Scaled& a, const Scaled& b) { return Scaled{a.v * b.v}; }
};This is the common idiom and it is worth recognising: it looks like a member and is not one. It is found by argument-dependent lookup — the name-lookup mechanism Module 18 covers — which is also why it is the form that works most reliably in templates.
9. What to Actually Do
Make =, [], (), -> and conversions members — required.
Make << and >> non-members — the left operand is the stream.
Make symmetric operators non-members, so both operand orders work.
Make mutating and unary operators members — +=, ++, unary -.
Write a test using a literal on the left — it settles placement in one line.
Decide deliberately between a converting constructor and two explicit overloads — Chapter 12.5's trade-off, not a default.
And prefer the inline friend form for non-members needing access: it reads with the class and works best in templates.
10. Mistakes Worth Making Once
A member operator* on a type people will multiply by a scalar. Measured: 2.0 * s gives invalid operands to binary expression ('double' and 'Scaled').
Thinking explicit fixes it. It removes the conversion that made the right side work and does nothing for the left.
Thinking friend makes a member symmetric. friend is about access, not placement.
Declaring operator= or operator[] as a non-member. must be a non-static member function, all four identically.
A member operator<< — you get t << std::cout, which is backwards.
Making += a non-member. Legal, but it always modifies the object, so symmetry buys nothing.
Enabling an implicit conversion only to get symmetry. Chapter 12.5 §2 measured the hazard; two overloads are often better.
Assuming placement is a performance question. Chapter 15.1 §4 measured byte-identical code.
11. Interview Relevance
"Member or non-member for operator+?" Non-member, so both operand orders work. The strong answer gives the 2.0 * s failure and names the implicit object argument.
"Why exactly does 2.0 * s fail with a member operator?" Because the left operand is the implicit object argument, and user-defined conversions are not applied to it — so no candidate exists. This precision is the discriminator; "members are different" is not an answer.
"Which operators must be members?" =, [], (), ->, and conversion operators.
"Why must operator<< be a non-member?" Its left operand is the stream, and you cannot add members to std::ostream.
"Where does operator+= go?" Member — it modifies the left operand, which is always the object.
"What is the inline friend idiom for?" A non-member that needs access and reads with the class; it is also the form found by argument-dependent lookup, which matters in templates.
12. What You Should Be Able to Say Now
- A member operator's left operand is the object (
*this); a non-member's operands are both parameters. - Measured: with a member
operator*,s * 2.0compiles and2.0 * sgivesinvalid operands to binary expression ('double' and 'Scaled'). - The cause: user-defined conversions are not applied to the implicit object argument, so the one convertible position is the one the language will not convert.
- Measured: moved out of the class, the same type gave
s * 2.0 = 5.0,2.0 * s = 5.0,s * s = 6.25— all three orders. - If both operand orders must work, the operator cannot be a member.
- The symmetry relies on a non-explicit converting constructor, which Chapter 12.5 §2 showed has its own hazard.
- The alternative is two non-member overloads and an
explicitconstructor — more typing, nothing unintended enabled. - Member plus
explicitis the worst combination — neither symmetry nor conversions. - Measured:
=,[],()and->as non-members each givemust be a non-static member function. - Conversion operators are necessarily members — there is no second operand.
<<and>>must be non-members because the left operand is a stream you do not own.- Mutating and unary operators belong as members; symmetric ones as non-members.
- Placement is not a performance question — Chapter 15.1 §4 measured byte-identical code.
- The inline
friendform looks like a member and is not one, and is found by argument-dependent lookup. - One test with a literal on the left settles the placement — unlike Chapter 15.1's failure mode, this one is a compile error.
13. Exercises
Exercise 1 — predict the error. A Duration with a member operator*(int). Say which of d * 3 and 3 * d compiles, and give the diagnostic for the other.
Exercise 2 — state the rule precisely. Explain in two sentences why the member version rejects a literal on the left, using the phrase implicit object argument.
Exercise 3 — fix it two ways. Make both orders work for Duration, once with a converting constructor and once with two overloads. Say what each enables that the other does not.
Exercise 4 — the worst combination. Describe what you get from a member operator plus an explicit constructor, and why it is the accidental result.
Exercise 5 — the four. Name the operators that must be members, give the exact diagnostic, and say what they have in common.
Exercise 6 — streams. Explain why operator<< cannot be a member of your type, and what call syntax you would get if you wrote it that way.
Exercise 7 — place them all. Member or non-member, with a reason: ==, +=, unary -, [], <=>, operator bool, >>, %.
Exercise 8 — the friend idiom. Write operator== for a class with private members as an inline friend, and say why it is not a member despite its position.
14. Summary
- A member operator's left operand is the object; a non-member's are both parameters.
- Measured: a member
operator*accepteds * 2.0and rejected2.0 * swithinvalid operands to binary expression ('double' and 'Scaled'). - The cause is one rule: user-defined conversions are not applied to the implicit object argument.
- Measured: as a non-member, all three of
s * 2.0,2.0 * sands * sworked. - So symmetry forces non-member placement — and a type where only one direction compiles fails Chapter 15.1 §8's test.
- The symmetry is bought with a converting constructor, or avoided with two overloads and
explicit. - Measured:
=,[],(),->must be members — identical diagnostics — and conversion operators necessarily are. <<and>>must be non-members, because the left operand is a stream you cannot extend.- Placement costs nothing; the inline
friendform reads with the class without being a member.
Placement settled, the next question is what the functions should actually do, and arithmetic is where a convention readers genuinely rely on first appears. There are two operators for every arithmetic idea — + and += — and writing them independently is how they drift apart, so that a += b and a = a + b stop agreeing on a type where both compile. The convention that prevents it is to make one of them the only implementation and derive the other, and the direction of that derivation is not the obvious one. Along the way, three return types that look interchangeable and are not: one of them produces a function whose result cannot be chained, one produces a dangling reference that this compiler warns about and then prints the right answer anyway, and only the third is correct.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
