C++
C++ Operator Overloading: Mathematical Notation for Custom Types
Introduction
Physics engines, graphics renderers, financial calculators, and signal processors all share a common pattern: they work with custom types that represent mathematical objects, and the code that manipulates them should read like the mathematics it encodes. Without operator overloading, a velocity update looks like velocity = velocity.add(gravity.scale(dt)). With it, it looks like velocity += gravity * dt. The intent is identical; the readability difference is not.
C++ allows any class or struct to define what its operators do. This is operator overloading — the ability to give +, *, ==, <<, [], and others meaningful behavior for your own types. Used correctly, it produces code that matches domain notation exactly. Used carelessly, it produces surprises. This tutorial builds a 2D physics vector from scratch, adding one operator family at a time, and demonstrates both the gains and the rules that prevent pitfalls.
Background
C++ operators are just functions with special names. a + b is syntactic sugar for operator+(a, b) or a.operator+(b) — the compiler transforms the operator syntax into a function call, and you define what that function does for your types.
There are two places to define them:
- Member functions: the left operand is implicitly
*this. Used when the left side must be your type. - Non-member functions (often declared
friend): both operands are explicit parameters. Necessary for symmetric operations likescalar * vector, where the left side is a primitive type that you cannot modify.
Practical Scenario
We are building a 2D particle physics simulation. Each particle has a position and a velocity. Every simulation step, the engine applies gravity, updates velocity, and advances position:
velocity += gravity * dt
position += velocity * dt
These two lines are Euler integration — the foundation of almost every real-time physics engine. Without operator overloading, expressing them in C++ requires nested method calls that bury the physics inside syntax. With it, the code and the equations match exactly.
The Problem
Let's start with a Vec2 struct that uses only ordinary named methods.
Create a new file:
touch particle_sim.cpp
Compile and run with:
g++ -std=c++17 -o particle_sim particle_sim.cpp && ./particle_sim
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
Vec2(float x = 0.0f, float y = 0.0f) : x(x), y(y) {}
Vec2 add(Vec2 other) const { return Vec2(x + other.x, y + other.y); }
Vec2 subtract(Vec2 other) const { return Vec2(x - other.x, y - other.y); }
Vec2 scale(float s) const { return Vec2(x * s, y * s); }
bool equals(Vec2 other) const { return x == other.x && y == other.y; }
float magnitude() const { return std::sqrt(x * x + y * y); }
void print() const {
std::cout << "(" << x << ", " << y << ")";
}
};
void simulate() {
Vec2 position(0.0f, 10.0f);
Vec2 velocity(3.0f, 0.0f);
Vec2 gravity (0.0f, -9.8f);
float dt = 0.2f;
std::cout << "Simulating particle trajectory:\n";
for (int step = 0; step < 5; ++step) {
velocity = velocity.add(gravity.scale(dt));
position = position.add(velocity.scale(dt));
std::cout << " step " << step + 1 << ": pos = ";
position.print();
std::cout << " vel = ";
velocity.print();
std::cout << "\n";
}
}
int main() {
simulate();
}
Simulating particle trajectory:
step 1: pos = (0.6, 9.608) vel = (3, -1.96)
step 2: pos = (1.2, 8.824) vel = (3, -3.92)
step 3: pos = (1.8, 7.648) vel = (3, -5.88)
step 4: pos = (2.4, 6.08) vel = (3, -7.84)
step 5: pos = (3, 4.12) vel = (3, -9.8)
The simulation is numerically correct but velocity.add(gravity.scale(dt)) does not look like a physics equation — it looks like a compiler's idea of one. A reader must unwrap the nested calls mentally to understand what is being computed. As the physics grow — drag, friction, multiple force contributions — this becomes unreadable.
Arithmetic Operators: + and -
The first operators to add are binary + and -. They are member functions because the left operand must be a Vec2.
Replace the add and subtract methods inside the struct with the following:
Vec2 operator+(Vec2 other) const { return Vec2(x + other.x, y + other.y); }
Vec2 operator-(Vec2 other) const { return Vec2(x - other.x, y - other.y); }
Now update the two lines inside the simulation loop:
velocity = velocity + gravity.scale(dt);
position = position + velocity.scale(dt);
Compile and run — the output is unchanged. But the intent is already cleaner: + communicates vector addition directly, the same symbol a physicist would write on a whiteboard.
Why this is better
The operator version matches how the domain expert writes the equation. The method-call version requires the reader to look up what add does. With a well-named operator, the behavior is self-evident from the symbol — and because this is a convention shared across the entire C++ ecosystem, any reader recognizes it immediately.
Scalar Multiplication: * and /
Scaling a vector by a scalar is the most common operation in physics code. gravity.scale(dt) should become gravity * dt. A member operator* handles the vec * scalar case, but scalar * vec requires a separate non-member — the left operand there is a float, and you cannot add members to a primitive type.
Replace the scale method and add the following inside the struct definition:
Vec2 operator*(float s) const { return Vec2(x * s, y * s); }
Vec2 operator/(float s) const { return Vec2(x / s, y / s); }
friend Vec2 operator*(float s, Vec2 v) { return v * s; }
Update the simulation loop:
velocity = velocity + gravity * dt;
position = position + velocity * dt;
This is Euler integration, written in code. No translation required.
Why this is better
Without the friend non-member, 2.0f * velocity would not compile — the compiler finds no definition for float * Vec2. The friend form makes multiplication symmetric, matching the mathematical convention where 2v and v2 are the same thing. The friend declaration lives inside the struct to keep the definition visible in the same place as the rest of the interface, but it is a free function, not a member.
Compound Assignment: +=, -=, *=
The simulation loop still creates a temporary Vec2 on the right side and assigns it back. Compound assignment operators update in place and, more importantly, let the loop read exactly like the recurrence relation it implements.
Add the following inside the struct. Note that these return *this by reference:
Vec2& operator+=(Vec2 other) { x += other.x; y += other.y; return *this; }
Vec2& operator-=(Vec2 other) { x -= other.x; y -= other.y; return *this; }
Vec2& operator*=(float s) { x *= s; y *= s; return *this; }
Update the simulation loop one final time:
velocity += gravity * dt;
position += velocity * dt;
Compile and run to confirm the output is still identical.
Why this is better
Returning *this by reference enables chaining (v += a += b works as expected) and matches the semantics of built-in compound assignment. The simulation loop now has a one-to-one correspondence with the mathematical equations it encodes — and that correspondence will remain intact as the physics grow more complex.
Equality and Comparison: ==, !=, <
Comparison operators make custom types work with the standard library. std::find, std::sort, and ordered containers like std::set all rely on them.
Add the following inside the struct:
bool operator==(Vec2 other) const { return x == other.x && y == other.y; }
bool operator!=(Vec2 other) const { return !(*this == other); }
bool operator< (Vec2 other) const { return magnitude() < other.magnitude(); }
Now add the necessary headers and a demonstration function. At the top of the file, add:
#include <vector>
#include <algorithm>
Then add this function and call it from main:
void demonstrate_comparison() {
std::vector<Vec2> forces = {
{0.0f, -9.8f},
{3.0f, 1.5f},
{1.0f, 0.5f},
{5.0f, 5.0f},
};
std::sort(forces.begin(), forces.end());
std::cout << "\nForces sorted by magnitude:\n";
for (const Vec2& f : forces)
std::cout << " " << f.magnitude() << "\n";
Vec2 a(1.0f, 0.0f), b(1.0f, 0.0f), c(0.0f, 1.0f);
std::cout << "a == b: " << (a == b ? "yes" : "no") << "\n";
std::cout << "a == c: " << (a == c ? "yes" : "no") << "\n";
}
Forces sorted by magnitude:
1.11803
3.35411
7.07107
9.8
a == b: yes
a == c: no
Why this is better
Implementing != in terms of == means the equality logic lives in exactly one place — if you change the equality definition, inequality updates automatically. Implementing < via magnitude() gives std::sort a physically meaningful ordering for force vectors without any extra comparator code.
Note: Comparing float values with == is exact and can produce surprising results when values have accumulated rounding error. For production use, replace the equality body with an epsilon comparison: std::abs(x - other.x) < 1e-5f && std::abs(y - other.y) < 1e-5f.
Stream Insertion: <<
Replacing the .print() method with operator<< lets Vec2 work anywhere a stream is expected — std::cout, std::ofstream, string streams, logging frameworks — without any special cases in calling code.
Replace the print method with the following friend non-member:
friend std::ostream& operator<<(std::ostream& os, Vec2 v) {
return os << "(" << v.x << ", " << v.y << ")";
}
Update the simulation loop to use the natural stream syntax:
std::cout << " step " << step + 1
<< ": pos = " << position
<< " vel = " << velocity << "\n";
Why this is better
The return type std::ostream& is what enables chaining: std::cout << a << b << "\n" works because each << call returns the same stream for the next. A .print() member function cannot provide this because it returns void and cannot be embedded in a chain. The friend form is necessary for the same reason as operator*(float, Vec2) — the left operand is std::ostream, which you cannot modify.
Subscript Operator: []
In generic algorithms — physics solvers, SIMD wrappers, serialization code — it is common to access vector components by index rather than by name. operator[] makes v[0] and v[1] work alongside the named fields.
Add the following inside the struct. Two overloads are required:
float& operator[](int i) { return i == 0 ? x : y; }
float operator[](int i) const { return i == 0 ? x : y; }
Add a demonstration function and call it from main:
void demonstrate_subscript() {
Vec2 velocity(3.0f, -1.96f);
std::cout << "\nComponent access:\n";
for (int i = 0; i < 2; ++i)
std::cout << " velocity[" << i << "] = " << velocity[i] << "\n";
velocity[0] *= 0.5f; // apply horizontal drag
std::cout << "After drag: " << velocity << "\n";
}
Component access:
velocity[0] = 3
velocity[1] = -1.96
After drag: (1.5, -1.96)
Why this is better
The non-const overload returns float& so you can write v[0] = 1.0f — a reference to the actual member, not a copy. The const overload returns float by value so reading from a const Vec2 compiles. If you define only one, either writes or reads from const objects will fail to compile. Both versions are necessary.
Unary Minus: Reversing Direction
Unary - negates a vector, representing a reversed force, an opposing direction, or a reflected velocity. It takes no operand beyond *this.
Add the following inside the struct:
Vec2 operator-() const { return Vec2(-x, -y); }
Add a demonstration function and call it from main:
void demonstrate_unary() {
Vec2 gravity(0.0f, -9.8f);
Vec2 lift = -gravity;
std::cout << "\nGravity : " << gravity << "\n";
std::cout << "Lift : " << lift << "\n";
std::cout << "Net : " << gravity + lift << "\n";
}
Gravity : (0, -9.8)
Lift : (0, 9.8)
Net : (0, 0)
Why this is better
Without unary -, negating a direction requires either a named method or Vec2(0,0) - v, both of which obscure the intent. The unary operator encodes "reverse this vector" in a symbol every reader recognises from arithmetic. Note that this does not conflict with the binary - — the compiler distinguishes them by the number of operands.
Putting It All Together
With every operator in place, here is the complete final file. Paste it over the current contents of particle_sim.cpp and compile:
#include <iostream>
#include <cmath>
#include <vector>
#include <algorithm>
struct Vec2 {
float x, y;
Vec2(float x = 0.0f, float y = 0.0f) : x(x), y(y) {}
Vec2 operator+ (Vec2 other) const { return Vec2(x + other.x, y + other.y); }
Vec2 operator- (Vec2 other) const { return Vec2(x - other.x, y - other.y); }
Vec2 operator- () const { return Vec2(-x, -y); }
Vec2 operator* (float s) const { return Vec2(x * s, y * s); }
Vec2 operator/ (float s) const { return Vec2(x / s, y / s); }
Vec2& operator+=(Vec2 other) { x += other.x; y += other.y; return *this; }
Vec2& operator-=(Vec2 other) { x -= other.x; y -= other.y; return *this; }
Vec2& operator*=(float s) { x *= s; y *= s; return *this; }
bool operator==(Vec2 other) const { return x == other.x && y == other.y; }
bool operator!=(Vec2 other) const { return !(*this == other); }
bool operator< (Vec2 other) const { return magnitude() < other.magnitude(); }
float& operator[](int i) { return i == 0 ? x : y; }
float operator[](int i) const { return i == 0 ? x : y; }
float magnitude() const { return std::sqrt(x * x + y * y); }
friend Vec2 operator*(float s, Vec2 v) { return v * s; }
friend std::ostream& operator<<(std::ostream& os, Vec2 v) {
return os << "(" << v.x << ", " << v.y << ")";
}
};
void simulate() {
Vec2 position(0.0f, 10.0f);
Vec2 velocity(3.0f, 0.0f);
Vec2 gravity (0.0f, -9.8f);
float dt = 0.2f;
std::cout << "Simulating particle trajectory:\n";
for (int step = 0; step < 5; ++step) {
velocity += gravity * dt;
position += velocity * dt;
std::cout << " step " << step + 1
<< ": pos = " << position
<< " vel = " << velocity << "\n";
}
}
void demonstrate_comparison() {
std::vector<Vec2> forces = {{0.0f,-9.8f},{3.0f,1.5f},{1.0f,0.5f},{5.0f,5.0f}};
std::sort(forces.begin(), forces.end());
std::cout << "\nForces sorted by magnitude:\n";
for (const Vec2& f : forces)
std::cout << " " << f.magnitude() << "\n";
}
void demonstrate_subscript() {
Vec2 velocity(3.0f, -1.96f);
velocity[0] *= 0.5f;
std::cout << "\nAfter horizontal drag: " << velocity << "\n";
}
void demonstrate_unary() {
Vec2 gravity(0.0f, -9.8f);
std::cout << "\nGravity : " << gravity << "\n";
std::cout << "Lift : " << -gravity << "\n";
std::cout << "Net : " << gravity + -gravity << "\n";
}
int main() {
simulate();
demonstrate_comparison();
demonstrate_subscript();
demonstrate_unary();
}
Simulating particle trajectory:
step 1: pos = (0.6, 9.608) vel = (3, -1.96)
step 2: pos = (1.2, 8.824) vel = (3, -3.92)
step 3: pos = (1.8, 7.648) vel = (3, -5.88)
step 4: pos = (2.4, 6.08) vel = (3, -7.84)
step 5: pos = (3, 4.12) vel = (3, -9.8)
Forces sorted by magnitude:
1.11803
3.35411
7.07107
9.8
After horizontal drag: (1.5, -1.96)
Gravity : (0, -9.8)
Lift : (0, 9.8)
Net : (0, 0)
Compare the simulation loop here to the original velocity.add(gravity.scale(dt)) version at the start. The logic is identical. The notation now matches the physics.
Summary
Operator overloading lets custom types participate in the same syntax as built-in types. In this tutorial we built a Vec2 class that demonstrates the full operator toolkit:
- Binary
+and-are member functions that return a newVec2by value — the simplest and most common form - Scalar
*requires two overloads: a member forvec * scalarand afriendnon-member forscalar * vec, because the left operand of the second form is afloatyou cannot modify - Compound
+=,-=,*=return*thisby reference, enabling chaining and making update loops read like the mathematical recurrences they implement !=delegates to==so the equality definition lives in one place;<based onmagnitude()makesVec2sortable by standard library algorithms without a custom comparatoroperator<<returnsstd::ostream&by reference — this is what enables the chainedcout << a << b << csyntaxoperator[]requires both a non-constreference-returning overload for writes and aconstvalue-returning overload for reads from const objects; providing only one causes compile errors in half the use cases- Unary
-is distinguished from binary-by operand count and represents a common domain concept — reversal — in a universally recognised symbol
The same rules apply to any domain type: matrices, quaternions, complex numbers, physical units, monetary amounts. Define operators that match the conventions of the domain, keep their behaviour unsurprising, and the code that uses them will be readable by anyone who knows the mathematics.