C++ Programming
Operator Overloading and Conversion Design
PGCP-AC
1. Operators as Part of a Class Interface
Operator overloading lets an existing C++ operator work with class or enumeration values. It is appropriate when the operation has the familiar meaning users already associate with that symbol. Addition is natural for complex numbers, equality is natural for value objects, subscripting is natural for indexed collections and stream insertion is natural for printable values.
class Point {
public:
Point(int x, int y) : x_(x), y_(y) {}
Point& operator+=(const Point& other) {
x_ += other.x_;
y_ += other.y_;
return *this;
}
private:
int x_;
int y_;
};
An overloaded operator is a function with a special name beginning with operator. The expression a += b is translated into a call to the selected operator function. Overloading supplies behavior; it does not change the grammar of the language.
2. Rules That Cannot Be Changed
At least one operand of an overloaded operator must have class or enumeration type. A program cannot redefine addition for two built-in integers.
Overloading cannot:
- invent a new operator symbol;
- change an operator's precedence;
- change its associativity;
- change the number of operands it normally takes;
- change the grouping imposed by the original expression grammar.
If multiplication and addition are overloaded, a + b * c still groups multiplication before addition. Parentheses remain the way to express another grouping.
Operators such as scope resolution, member access with dot, conditional selection, sizeof, typeid and the named cast forms cannot be overloaded. The exact overloadable set is fixed by the language.
3. Member and Nonmember Operators
A member operator uses the object as its implicit left operand:
class Counter {
public:
Counter& operator+=(int amount) {
value_ += amount;
return *this;
}
private:
int value_ = 0;
};
The expression counter += 3 calls counter.operator+=(3).
A nonmember operator receives every operand explicitly:
Point operator+(Point left, const Point& right) {
left += right;
return left;
}
This pattern copies the left operand, applies the compound assignment and returns the result. It keeps the fundamental mutation logic in operator+=.
Nonmember binary operators permit conversions on both operands. A member operator permits implicit conversion of its ordinary parameters, but the object to the left must already be a suitable class object. Symmetric operations such as arithmetic and equality are therefore often nonmembers.
4. Operators That Must Be Members
Some operators must be nonstatic member functions, including assignment, subscripting, function call and member access through arrow. Conversion functions must also be members.
Stream insertion usually cannot be a member of the value class because its left operand is std::ostream. It is therefore written as a nonmember:
std::ostream& operator<<(std::ostream& out, const Point& point);
A nonmember may be declared as a friend when it genuinely needs private representation. Often it can use the public interface instead, which preserves encapsulation.
5. Arithmetic Operators
Value-returning arithmetic normally leaves both operands unchanged:
class Money {
public:
Money& operator+=(const Money& other) {
cents_ += other.cents_;
return *this;
}
friend Money operator+(Money left, const Money& right) {
left += right;
return left;
}
private:
long long cents_ = 0;
};
Implementing + from += prevents the two operations from developing different arithmetic rules. Similar relationships apply between subtraction and -=, multiplication and *= and division and /=.
Return a new value for ordinary arithmetic. Return a reference to the modified object from compound assignment so expressions such as a += b += c behave in the conventional right-to-left manner.
6. Unary Operators
Unary operators have one explicit operand as nonmembers or no explicit operands as members:
class Number {
public:
Number operator-() const {
return Number(-value_);
}
private:
explicit Number(int value) : value_(value) {}
int value_;
};
The const qualifier shows that unary minus produces a new value without changing its operand. Mutating unary operations, such as increment, are nonconst.
7. Prefix and Postfix Increment
Prefix and postfix increment use the same symbol but different signatures:
class Counter {
public:
Counter& operator++() {
++value_;
return *this;
}
Counter operator++(int) {
Counter old = *this;
++(*this);
return old;
}
private:
int value_ = 0;
};
The unused int parameter distinguishes postfix syntax; the compiler supplies a dummy value. Prefix increments first and conventionally returns a reference to the modified object. Postfix must preserve and return the old value, so it generally returns by value. The same distinction applies to decrement.
Prefer prefix when the old value is unnecessary. For iterator-like types it may avoid an otherwise needless copy.
8. Equality and Ordering
Equality should compare the logical value or identity defined by the abstraction:
class Point {
public:
friend bool operator==(const Point& a, const Point& b) {
return a.x_ == b.x_ && a.y_ == b.y_;
}
friend bool operator!=(const Point& a, const Point& b) {
return !(a == b);
}
private:
int x_;
int y_;
};
Two Point objects with the same coordinates are equal even if stored at different addresses. Comparing raw object bytes is usually wrong because padding, inactive representation and non-value state may differ.
Ordering must be coherent. A strict weak ordering used by sorting and ordered containers must be irreflexive and transitive, with consistent equivalence. Lexicographic comparison commonly uses std::tie for multi-field records.
9. Assignment Operators
Copy assignment replaces the state of an existing object and conventionally returns *this by reference:
Buffer& operator=(const Buffer& other);
The reference return supports chained assignment. A resource-owning implementation must preserve ownership rules, release old resources at the right time and handle self-assignment.
Copy-and-swap is one possible design:
Buffer& operator=(Buffer other) {
swap(other);
return *this;
}
The parameter acquires the new value first. Swapping commits it and the parameter later destroys the old state. Other direct implementations can be more efficient. Standard member types often make custom assignment unnecessary under the Rule of Zero.
Assignment operators cannot be free functions. Move assignment has an rvalue-reference parameter and transfers resources from an expiring source.
10. The Subscript Operator
An indexed class typically provides const and nonconst overloads:
class IntArray {
public:
int& operator[](std::size_t index) {
return values_[index];
}
const int& operator[](std::size_t index) const {
return values_[index];
}
private:
std::vector<int> values_;
};
The nonconst version permits assignment to an element. The const version permits reading through a const object without allowing modification.
By convention, operator[] is often unchecked, matching built-in arrays and standard containers. A named at function can perform bounds checking and throw an exception. The interface should state which behavior it provides.
11. The Function-Call Operator
Defining operator() creates a function object, also called a functor:
class GreaterThan {
public:
explicit GreaterThan(int limit) : limit_(limit) {}
bool operator()(int value) const {
return value > limit_;
}
private:
int limit_;
};
GreaterThan test(10);
bool result = test(15);
Unlike an ordinary function pointer, a function object can carry state. Standard algorithms accept function objects, lambdas and other callable types. operator() may be overloaded for different parameter lists.
12. Stream Insertion and Extraction
Stream insertion takes the stream by nonconst reference and returns the same reference:
std::ostream& operator<<(std::ostream& out, const Point& point) {
return out << '(' << point.x() << ", " << point.y() << ')';
}
Returning ostream& enables chaining:
std::cout << first << ' ' << second;
Extraction similarly returns istream&:
std::istream& operator>>(std::istream& in, Point& point);
Extraction should respect stream state. A robust implementation reads into temporary values, validates them and updates the object only after the full input succeeds. This prevents partial invalid state.
13. Pointer-Like Operators
Resource-owning and iterator-like types may overload dereference and arrow:
T& operator*() const;
T* operator->() const;
These operators should preserve familiar pointer expectations. A const smart-pointer object and a pointer-to-const represent different concepts, so const behavior needs careful design.
Overloading address-of, comma, logical AND or logical OR is rarely helpful. Overloaded && and || are function calls and do not provide the built-in short-circuit guarantee. Both argument expressions are evaluated according to function-call rules, making such overloads surprising.
14. Conversion Constructors
A constructor callable with one argument can define conversion from that argument type:
class Distance {
public:
explicit Distance(double meters) : meters_(meters) {}
private:
double meters_;
};
Without explicit, a double might silently become Distance wherever a Distance is expected. Such convenience can also create ambiguous overloads and hide unit errors. Mark single-argument constructors explicit unless the conversion is safe, unsurprising and intended as part of the type's normal value model.
Explicit constructors still support direct initialization:
Distance d(12.5);
Distance e{12.5};
They do not participate in ordinary implicit conversion.
15. Conversion Functions
A member conversion function converts an object to another type:
class Flag {
public:
explicit operator bool() const {
return active_;
}
private:
bool active_ = false;
};
Conversion functions have no written return type; the target type appears after operator. Making them explicit avoids unintended conversions. Explicit operator bool has special language support in conditions, so a Flag can be tested in if while avoiding many accidental arithmetic conversions.
Too many bidirectional implicit conversions make overload resolution unpredictable. Prefer named functions such as toString, count or fromMeters when conversion has cost, can fail, loses information or is not conceptually obvious.
16. Overload Resolution and Conversions
For an operator expression, the compiler gathers viable built-in, member and nonmember candidates, applies allowed conversions and selects the best match. User-defined conversions are limited within one implicit conversion sequence, but interactions between constructors, conversion functions and overloaded operators can still create ambiguity.
If both A can convert to B and B can convert to A, an expression combining them may have no clearly better path. Reduce the implicit conversion surface. Explicit constructors and named conversions give the caller control and make source code state what happens.
17. Preserving Expected Meaning
An operator should obey the broad laws expected for its domain. Addition should not unexpectedly modify its operands. Equality should be symmetric and consistent with inequality. Assignment should leave the target valid. Indexing should refer to an element. Stream insertion should write to the supplied stream rather than to a fixed global stream.
Not every mathematical law applies to every domain. Floating-point addition is not exactly associative and string addition represents concatenation. The essential rule is that the chosen meaning should be unsurprising to a competent reader.
Use a named function when an operator would conceal expensive work, an important failure mode or a domain action with no familiar symbolic meaning. readable operations such as account.transferTo(other, amount) communicate more than a creative use of >> or -.
18. A Complete Small Value Type
class Fraction {
public:
Fraction(int numerator, int denominator)
: numerator_(numerator), denominator_(denominator) {
normalize();
}
Fraction& operator+=(const Fraction& other) {
numerator_ = numerator_ * other.denominator_
+ other.numerator_ * denominator_;
denominator_ *= other.denominator_;
normalize();
return *this;
}
friend Fraction operator+(Fraction left,
const Fraction& right) {
left += right;
return left;
}
friend bool operator==(const Fraction& a,
const Fraction& b) {
return a.numerator_ == b.numerator_
&& a.denominator_ == b.denominator_;
}
private:
void normalize();
int numerator_;
int denominator_;
};
Normalization maintains a canonical representation, so equality can compare fields directly. Construction and every modifying operator restore the invariant. This is the larger purpose of operator design: natural syntax must still protect the class's meaning.
Continue learning
Related notes
Put this topic into timed practice
Open mock tests when you want full-exam pacing, or keep drilling in practice mode.