Skip to main content

📝 Lesson 13: Operator Overloading

Make your custom types work naturally with operators like +, -, ==, and the stream operators << and >>.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand the purpose and syntax of operator overloading in C++.
  • Implement both member and non-member operator functions appropriately.
  • Follow C++ conventions for return types and const-correctness.
  • Handle assignment and compound assignment operators correctly (including self-assignment).
  • Implement type conversion operators and understand when implicit vs. explicit conversion is appropriate.

Estimated Time: 60–75 minutes

Project: Implement a Matrix2x2 class with support for arithmetic, comparison, and stream operators.

In This Lesson

What is Operator Overloading?

Operator overloading is like teaching your custom types to speak the language of mathematics and logic. Instead of calling functions like add() or equals(), you can use natural operators like + and ==, making your code more intuitive and readable.

Basic Operator Overloading

Let's start with a simple example: a 2D Vector class that supports mathematical operations.

graph TD A[Operator Overloading] --> B[Member Functions] A --> C[Non-Member Functions] A --> D[Friend Functions] B --> E[Unary Operators] B --> F[Binary Left Operand] C --> G[Binary Operators] C --> H[Symmetric Operations] D --> I[Access Private Members] style A fill:#3498db,color:#111827 style B fill:#2ecc71,color:#111827 style C fill:#f39c12,color:#111827

Vector2D Example

// Basic 2D Vector class with operator overloading
class Vector2D {
private:
    float x, y;

public:
    // Constructor
    Vector2D(float x = 0.0f, float y = 0.0f) : x(x), y(y) {}

    // Getters
    float GetX() const { return x; }
    float GetY() const { return y; }

    // Addition operator (member function)
    Vector2D operator+(const Vector2D& other) const {
        return Vector2D(x + other.x, y + other.y);
    }

    // Subtraction operator
    Vector2D operator-(const Vector2D& other) const {
        return Vector2D(x - other.x, y - other.y);
    }

    // Scalar multiplication (member function)
    Vector2D operator*(float scalar) const {
        return Vector2D(x * scalar, y * scalar);
    }

    // Compound assignment operators
    Vector2D& operator+=(const Vector2D& other) {
        x += other.x;
        y += other.y;
        return *this;  // Return reference for chaining
    }

    Vector2D& operator-=(const Vector2D& other) {
        x -= other.x;
        y -= other.y;
        return *this;
    }

    // Unary minus (negation)
    Vector2D operator-() const {
        return Vector2D(-x, -y);
    }

    // Equality comparison.
    // Since C++20 you can replace both of these with a single line:
    //     bool operator==(const Vector2D&) const = default;
    // The compiler then generates a correct member-wise == for you, and
    // synthesizes != from it automatically. See the C++20 section below.
    [[nodiscard]] bool operator==(const Vector2D& other) const {
        return (x == other.x && y == other.y);
    }

    // Pre-C++20 you had to supply != by hand (C++20 derives it from ==):
    [[nodiscard]] bool operator!=(const Vector2D& other) const {
        return !(*this == other);  // Reuse == operator
    }

    // Subscript operator for array-like access
    float operator[](int index) const {
        if (index == 0) return x;
        if (index == 1) return y;
        throw std::out_of_range("Vector2D index out of range");
    }

    float& operator[](int index) {
        if (index == 0) return x;
        if (index == 1) return y;
        throw std::out_of_range("Vector2D index out of range");
    }
};

// Non-member operator for scalar * vector
Vector2D operator*(float scalar, const Vector2D& vec) {
    return vec * scalar;  // Reuse member operator
}

// Stream operators (non-member)
std::ostream& operator<<(std::ostream& os, const Vector2D& vec) {
    os << "(" << vec.GetX() << ", " << vec.GetY() << ")";
    return os;
}

std::istream& operator>>(std::istream& is, Vector2D& vec) {
    float x, y;
    char c1, c2, c3;  // For parentheses and comma
    is >> c1 >> x >> c2 >> y >> c3;
    if (c1 == '(' && c2 == ',' && c3 == ')') {
        vec = Vector2D(x, y);
    }
    return is;
}

Operator Overloading Rules

Not all operators can be overloaded, and there are specific rules about how to implement them correctly.

Implementation Guidelines

// 1. Member vs Non-Member Functions
class Complex {
    double real, imag;

public:
    // Member function - when left operand is always this type
    Complex operator+(const Complex& other) const {
        return Complex(real + other.real, imag + other.imag);
    }

    // Friend function - needs access to private members
    friend Complex operator*(double scalar, const Complex& c);
};

// Non-member - allows scalar * complex
Complex operator*(double scalar, const Complex& c) {
    return Complex(scalar * c.real, scalar * c.imag);
}

// 2. Return Types Convention
class String {
public:
    // Value return for arithmetic
    String operator+(const String& other) const {
        String result = *this;
        result.append(other);
        return result;
    }

    // Reference return for assignment
    String& operator=(const String& other) {
        if (this != &other) {  // Check self-assignment
            // Copy data
        }
        return *this;  // Enable chaining: a = b = c
    }

    // Reference return for compound assignment
    String& operator+=(const String& other) {
        append(other);
        return *this;
    }

    // Bool return for comparison
    bool operator==(const String& other) const {
        return compare(other) == 0;
    }

    // Reference return for subscript
    char& operator[](size_t index) {
        return data[index];
    }

    // Const version for const objects
    const char& operator[](size_t index) const {
        return data[index];
    }
};

// 3. Pre vs Post Increment/Decrement
class Counter {
    int value;

public:
    // Pre-increment (++counter) - more efficient
    Counter& operator++() {
        ++value;
        return *this;
    }

    // Post-increment (counter++) - note dummy int parameter
    Counter operator++(int) {
        Counter temp = *this;  // Save old value
        ++value;
        return temp;  // Return old value
    }
};

C++20: Defaulted Comparisons and the Spaceship Operator

Before C++20, a type that needed the full family of relational operators had to hand-write all six of them (==, !=, <, >, <=, >=) — tedious and easy to get inconsistent. C++20 lets the compiler generate them from a single three-way comparison operator, operator<=> (nicknamed the "spaceship" operator), plus a defaulted operator==.

#include <compare>   // std::strong_ordering, std::partial_ordering, ...

struct Version {
    int major, minor, patch;

    // One line generates <, >, <=, >= (member-wise, in declaration order)...
    auto operator<=>(const Version&) const = default;
    // ...and a defaulted == (which also gives you != for free).
    bool operator==(const Version&) const = default;
};

Version a{1, 2, 0}, b{1, 3, 0};
bool older    = a < b;      // true  — synthesized from <=>
bool same     = a == b;     // false — from defaulted ==
auto ordering = a <=> b;    // std::strong_ordering::less

The result type of <=> describes how strong the ordering is:

  • std::strong_ordering — a total order where equal values are truly interchangeable (integers, enums).
  • std::weak_ordering — a total order where "equivalent" values may still differ (e.g. case-insensitive strings).
  • std::partial_ordering — some pairs are unordered. Floating-point types use this because NaN compares unordered against everything.

That last point matters for the earlier Vector2D: because its members are float, a defaulted operator<=> would yield std::partial_ordering. Defaulted comparisons are implicitly constexpr and noexcept when their members allow it, so you get compile-time-friendly, exception-safe comparisons at no extra cost. When you need custom logic (say, comparing a fraction by value rather than field-by-field), write your own operator<=> and pick the return category yourself.

Advanced Operator Overloading

Some operators require special attention and techniques for proper implementation.

Function Call and Subscript Operators

// Function object (Functor)
class LinearFunction {
    double a, b;  // f(x) = ax + b

public:
    LinearFunction(double a, double b) : a(a), b(b) {}

    // Function call operator
    double operator()(double x) const {
        return a * x + b;
    }

    // Multiple parameters
    double operator()(double x, double y) const {
        return a * x + b * y;
    }
};

// Matrix class with 2D subscript
class Matrix {
    std::vector<std::vector<double>> data;

public:
    Matrix(size_t rows, size_t cols)
        : data(rows, std::vector<double>(cols, 0.0)) {}

    // Proxy class for [][] syntax
    class RowProxy {
        std::vector<double>& row;
    public:
        RowProxy(std::vector<double>& r) : row(r) {}
        double& operator[](size_t col) { return row[col]; }
        const double& operator[](size_t col) const { return row[col]; }
    };

    // First [] returns proxy
    RowProxy operator[](size_t row) {
        return RowProxy(data[row]);
    }

    const RowProxy operator[](size_t row) const {
        return RowProxy(const_cast<std::vector<double>&>(data[row]));
    }

    // Alternative: operator() for 2D access
    double& operator()(size_t row, size_t col) {
        return data[row][col];
    }

    const double& operator()(size_t row, size_t col) const {
        return data[row][col];
    }
};

// Smart pointer operator->
template<typename T>
class SmartPtr {
    T* ptr;

public:
    explicit SmartPtr(T* p = nullptr) : ptr(p) {}
    ~SmartPtr() { delete ptr; }

    // Dereference operators
    T& operator*() const { return *ptr; }
    T* operator->() const { return ptr; }

    // Boolean conversion
    explicit operator bool() const { return ptr != nullptr; }
};

Type Conversion Operators

Type conversion operators allow your objects to be converted to other types implicitly or explicitly.

// Type conversion operators
class Fraction {
    int numerator, denominator;

public:
    Fraction(int n = 0, int d = 1) : numerator(n), denominator(d) {}

    // Implicit conversion to double (be careful!)
    operator double() const {
        return static_cast<double>(numerator) / denominator;
    }

    // Explicit conversion to int (C++11)
    explicit operator int() const {
        return numerator / denominator;
    }

    // Conversion to bool for if statements
    explicit operator bool() const {
        return numerator != 0;
    }
};

// Usage
Fraction f(3, 4);
double d = f;            // OK: implicit conversion to double (0.75)
// int i = f;            // ERROR: explicit conversion required
int i = static_cast<int>(f);  // OK: explicit conversion (0)
if (f) { }              // OK: explicit bool conversion

// Safe bool idiom (pre-C++11)
class OldStyleClass {
    typedef void (OldStyleClass::*bool_type)() const;
    void safe_bool_helper() const {}

public:
    operator bool_type() const {
        return isValid() ? &OldStyleClass::safe_bool_helper : nullptr;
    }
};

Unreal Engine Operator Examples

Unreal Engine makes extensive use of operator overloading for its math types.

Unreal Engine Examples

// Unreal Engine operator overloading examples

// FVector operators
void VectorOperations() {
    FVector A(1.0f, 2.0f, 3.0f);
    FVector B(4.0f, 5.0f, 6.0f);

    // Addition and subtraction
    FVector Sum = A + B;
    FVector Diff = A - B;

    // Scalar multiplication
    FVector Scaled = A * 2.0f;
    FVector ScaledLeft = 2.0f * A;  // Both work

    // Dot product (| operator)
    float DotProduct = A | B;

    // Cross product (^ operator)
    FVector CrossProduct = A ^ B;

    // Component-wise multiplication
    FVector ComponentMult = A * B;

    // Comparison
    if (A == B) { }  // Exact equality (rare)
    if (A.Equals(B, 0.01f)) { }  // With tolerance
}

// FRotator operators
void RotatorOperations() {
    FRotator Rot1(30.0f, 45.0f, 0.0f);  // Pitch, Yaw, Roll
    FRotator Rot2(10.0f, -15.0f, 0.0f);

    // Combine rotations
    FRotator Combined = Rot1 + Rot2;

    // Difference
    FRotator Delta = Rot1 - Rot2;

    // Scaling rotation
    FRotator HalfRot = Rot1 * 0.5f;
}

// FTransform operators
void TransformOperations() {
    FTransform T1(FRotator(0, 90, 0), FVector(100, 0, 0));
    FTransform T2(FRotator(0, 45, 0), FVector(50, 50, 0));

    // Combine transforms (applies T2 then T1)
    FTransform Combined = T1 * T2;

    // Transform a point
    FVector Point(10, 20, 30);
    FVector TransformedPoint = T1.TransformPosition(Point);
}

// Custom Unreal types with operators
USTRUCT()
struct FDamageInfo {
    GENERATED_BODY()

    UPROPERTY()
    float BaseDamage = 0.0f;

    UPROPERTY()
    float Multiplier = 1.0f;

    // Addition operator for combining damage
    FDamageInfo operator+(const FDamageInfo& Other) const {
        FDamageInfo Result;
        Result.BaseDamage = BaseDamage + Other.BaseDamage;
        Result.Multiplier = Multiplier * Other.Multiplier;
        return Result;
    }

    // Scalar multiplication for damage scaling
    FDamageInfo operator*(float Scale) const {
        FDamageInfo Result = *this;
        Result.BaseDamage *= Scale;
        return Result;
    }

    // Get final damage
    float GetTotalDamage() const {
        return BaseDamage * Multiplier;
    }
};

Common Pitfalls and Solutions

Avoid these common mistakes when overloading operators.

// Pitfall 1: Inconsistent behavior
class Bad1 {
    int value;
public:
    // BAD: + doesn't return sum!
    Bad1 operator+(const Bad1& other) {
        std::cout << "Adding\n";  // Side effect!
        return *this;  // Returns first operand, not sum
    }
};

// Solution: Follow expected behavior
class Good1 {
    int value;
public:
    Good1 operator+(const Good1& other) const {
        return Good1(value + other.value);  // Returns actual sum
    }
};

// Pitfall 2: Not handling self-assignment
class Bad2 {
    int* data;
public:
    Bad2& operator=(const Bad2& other) {
        delete data;  // Problem if this == &other!
        data = new int(*other.data);
        return *this;
    }
};

// Solution: Check for self-assignment
class Good2 {
    int* data;
public:
    Good2& operator=(const Good2& other) {
        if (this != &other) {  // Self-assignment check
            delete data;
            data = new int(*other.data);
        }
        return *this;
    }
};

// Pitfall 3: Wrong return types
class Bad3 {
public:
    // BAD: Assignment should return reference
    Bad3 operator=(const Bad3& other) {  // Returns by value!
        // ...
        return *this;
    }

    // BAD: Comparison should return bool
    int operator==(const Bad3& other) {  // Returns int!
        return 1;
    }
};

// Pitfall 4: Asymmetric operators
class Temperature {
    double celsius;
public:
    // Member function only allows Temperature + double
    Temperature operator+(double degrees) const {
        return Temperature(celsius + degrees);
    }
};

// Solution: Use non-member functions for symmetry
Temperature operator+(double degrees, const Temperature& temp) {
    return temp + degrees;  // Reuse member operator
}

// Pitfall 5: Implicit conversions causing ambiguity
class Number {
    int value;
public:
    Number(int v) : value(v) {}  // Implicit conversion from int
    operator int() const { return value; }  // Implicit conversion to int

    Number operator+(const Number& other) const {
        return Number(value + other.value);
    }
};

// Usage causes ambiguity:
// Number n1(5), n2(10);
// Number n3 = n1 + 5;  // Ambiguous! int->Number or Number->int?

Practice Exercise: Matrix Class

🏋️ Challenge: Implement a 2x2 Matrix Class

Create a Matrix2x2 class with proper operator overloading:

// Implement this Matrix2x2 class
class Matrix2x2 {
private:
    float m[2][2];

public:
    // TODO: Implement these operators
    // 1. Constructor that takes 4 floats
    // 2. Addition: Matrix + Matrix
    // 3. Subtraction: Matrix - Matrix
    // 4. Multiplication: Matrix * Matrix
    // 5. Scalar multiplication: Matrix * float and float * Matrix
    // 6. Equality: Matrix == Matrix
    // 7. Subscript: Matrix[row][col] for access
    // 8. Stream output: cout << Matrix
    // 9. Determinant function
    // 10. Inverse operator: ~Matrix (returns inverse)

    // Test your implementation:
    // Matrix2x2 A(1, 2, 3, 4);
    // Matrix2x2 B(5, 6, 7, 8);
    // Matrix2x2 C = A + B;
    // Matrix2x2 D = A * B;
    // float det = A.Determinant();
    // Matrix2x2 Inv = ~A;
    // cout << "Matrix A:\n" << A << endl;
};

// Bonus: Add these features
// - Transpose operator: !Matrix
// - Identity matrix static function
// - Rotation matrix factory function
// - Compound assignment operators (+=, -=, *=)

🎯 Quick Quiz

Question 1: Why should operator= check for self-assignment with something like if (this != &other)?

Question 2: What is the conventional return type for a compound assignment operator such as operator+=?

Question 3: In the lesson's Fraction and SmartPtr examples, what does marking a conversion operator explicit — as in explicit operator bool() const — actually do?

Summary

🎉 Key Takeaways

  • ✓ Operator overloading makes custom types behave like built-in types
  • ✓ Keep the natural meaning of operators - don't surprise users
  • ✓ Use member functions when the left operand is always your type
  • ✓ Use non-member functions for symmetric operations
  • ✓ Return appropriate types: references for assignment, values for arithmetic
  • ✓ Always handle self-assignment in operator=
  • ✓ In C++20, prefer a defaulted operator<=> / operator== over hand-writing all six comparisons
  • ✓ Make operators const-correct
  • ✓ Consider making single-parameter constructors explicit
  • ✓ Be careful with implicit conversions
  • ✓ Some operators cannot be overloaded (::, ., .*, ?:)

Remember: With great power comes great responsibility. Use operator overloading to make code more readable, not to show off clever tricks!

📚 Additional Resources

🚀 What's Next?

You now know how to make custom C++ types read and write naturally with the language's own operators. Next, in Lesson 14: Header Files and Forward Declarations, you'll organize all these classes across multiple files — using headers and forward declarations to keep large projects compiling cleanly and quickly.

🎉 You finished the course!

From your first "Hello, World!" to overloading operators on your own types — that's a real journey, and you walked every step of it. Go build something great with it.