Chapter 11: Operator Overloading
Operator overloading lets your types behave like built-in values with familiar syntax. Used carefully it improves clarity; used carelessly it creates puzzles. This chapter shows which operators make sense to overload, how to choose between member and free function forms, how to use the comparison and spaceship operators, how to integrate with streams, and how strong types avoid accidental conversions.
Which operators to overload
Overload only when the meaning is obvious to readers. Arithmetic types benefit from +, -, *, and /. Containers often define == for equality. Smart pointers define * and -> to act as transparent handles. Avoid surprising uses; do not redefine + to do unrelated work, and do not overload operators that would hide important preconditions.
Consistency with built-in expectations
If you implement an operator, match the usual algebraic or logical expectations. Addition should not modify its operands. Equality should be reflexive and symmetric. Ordering should be stable and transitive. These expectations anchor readability and make generic algorithms behave without surprises.
struct vec2 {
float x, y;
vec2 operator+(const vec2& b) const noexcept { return {x + b.x, y + b.y}; }
bool operator==(const vec2& b) const noexcept { return x == b.x && y == b.y; }
};
Operators that rarely make sense
Bitwise operators on classes seldom communicate meaning unless your type truly models bit patterns. Likewise overloading logical operators such as && and || does not give short-circuiting and creates hard-to-read code. Prefer explicit functions for these behaviors.
Member vs free function operators
Many operators can be written as either members or free functions. Members receive the left operand as *this. Free functions take both operands as parameters. For symmetric operations such as == and +, free functions are often more flexible because they allow conversions on both sides.
When to choose a member operator
Use a member when the operator conceptually belongs to the left operand or when only the left operand should convert. Assignment operators must be members, since the left operand is always a modifiable object.
class counter {
int n_ = 0;
public:
counter& operator+=(int x) noexcept { n_ += x; return *this; }
};
When to choose a free function
A free function helps with symmetric operations, since either operand may convert. It also decouples the operator from the class, which improves encapsulation. You can declare the free function as a friend when it needs internal access.
class point {
int x_, y_;
public:
point(int x, int y) : x_(x), y_(y) {}
friend point operator+(const point& a, const point& b) {
return {a.x_ + b.x_, a.y_ + b.y_};
}
};
Comparison and spaceship operator
C++ lets you define equality and ordering in a compact way. The spaceship operator <=> synthesizes all six relational operators when possible. It also helps generic code by making comparison semantics explicit and uniform.
Defaulted comparisons
Let the compiler generate comparisons when member-wise comparison describes your type. This keeps code short and consistent. You may default both operator== and <=> if all members are comparable.
struct box {
int w, h, d;
auto operator<=>(const box&) const = default;
bool operator==(const box&) const = default;
};
Custom comparison rules
Some types need comparisons that reflect semantic meaning rather than raw members. For example a duration type may compare seconds after normalizing internal fields. In these cases write the comparison manually and leave ordering well defined for all valid objects.
class duration {
long seconds_;
public:
explicit duration(long s) : seconds_(s) {}
long count() const noexcept { return seconds_; }
auto operator<=>(const duration& other) const noexcept {
return seconds_ <=> other.seconds_;
}
bool operator==(const duration& other) const noexcept {
return seconds_ == other.seconds_;
}
};
Stream insertion and extraction
Stream operators integrate your type with std::ostream and std::istream. Insertion prints readable text. Extraction parses values while propagating stream state. These operators must be free functions and often need friendship for private state.
Implementing operator<<
Write a clear textual form that helps debugging. Keep formatting simple and predictable, since users may wrap your output inside larger structures. Return the stream to support chaining.
class range {
int lo_, hi_;
public:
range(int lo, int hi) : lo_(lo), hi_(hi) {}
friend std::ostream& operator<<(std::ostream& os, const range& r) {
return os << "[" << r.lo_ << "," << r.hi_ << "]";
}
};
Implementing operator>>
Extraction must leave the stream in a sensible state. Validate separators and fields carefully; set failbit on malformed input. Never throw exceptions for normal parse errors; streams already convey state.
friend std::istream& operator>>(std::istream& is, range& r) {
char c1, c2, c3;
int lo, hi;
if (is >> c1 >> lo >> c2 >> hi >> c3 && c1 == '[' && c2 == ',' && c3 == ']') {
r = range(lo, hi);
} else {
is.setstate(std::ios::failbit);
}
return is;
}
Strong types and explicit conversions
Strong types wrap primitive values to prevent accidental mixing of units or meanings. They rely on explicit constructors and named operations rather than implicit conversions. This reduces subtle bugs while keeping intent clear.
Basic strong type pattern
Wrap the underlying value in a small class with an explicit constructor and well defined operations. Provide comparisons and arithmetic only when the meaning is unambiguous. Expose the stored value through a named function.
class meters {
double v_;
public:
explicit meters(double v) : v_(v) {}
double value() const noexcept { return v_; }
meters operator+(const meters& m) const noexcept { return meters(v_ + m.v_); }
auto operator<=>(const meters&) const = default;
};
Preventing accidental conversions
Implicit conversions from raw values can hide errors. Mark single argument constructors as explicit. When converting back, require a named function such as value(). This forces the call site to carry intent rather than accident.