Skip to main content
C++ · lesson 5 of 24
C++ intermediate Lesson 5 of 24

Operators and Operator Overloading in C++

C++ operators run for real: integer division, short-circuiting, bit flags, overloading for a vector type, and the C++20 spaceship operator's categories.

By SkillByExample · Updated

Built-in Operators

C++ integer division truncates toward zero, and the logical operators short-circuit. Both show up in one run.

#include <iostream>

bool loud(bool v) { std::cout << "[evaluated " << v << "] "; return v; }

int main() {
    int a = 17, b = 5;
    std::cout << a / b << " " << a % b << "\n";          // integer division truncates
    std::cout << -a / b << " " << -a % b << "\n";        // toward zero, remainder keeps sign
    std::cout << 17.0 / 5 << "\n";                        // one double operand: real division

    a += 3; a *= 2; a %= 7;                               // (17+3)*2 = 40, 40 % 7
    std::cout << "a = " << a << "\n";

    int n = 5;
    std::cout << n++ << " ";                              // post: yields old value
    std::cout << n << " ";
    std::cout << ++n << "\n";                             // pre: yields new value

    std::cout << "false && ...: "; bool r1 = loud(false) && loud(true); std::cout << r1 << "\n";
    std::cout << "true  || ...: "; bool r2 = loud(true) || loud(false); std::cout << r2 << "\n";
    std::cout << "true  && ...: "; bool r3 = loud(true) && loud(false); std::cout << r3 << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o arith arith.cpp
$ ./arith
3 2
-3 -2
3.4
a = 5
5 6 7
false && ...: [evaluated 0] 0
true  || ...: [evaluated 1] 1
true  && ...: [evaluated 1] [evaluated 0] 0

17 / 5 is 3 because both operands are int; making either one a double gives 3.4. For negatives, division truncates toward zero (-3) and the remainder keeps the dividend’s sign (-2), so (a / b) * b + a % b == a always holds. n++ yields the old value, ++n the new one. The loud calls show short-circuiting: after false && and true || the right side never runs, because the result is already decided. That’s what makes guards like p != nullptr && p->ok() safe.

Bitwise Operators

Bitwise operators work on individual bits — the tool for flags and masks.

#include <bitset>
#include <iostream>

int main() {
    unsigned int flags = 0b1010;
    auto show = [](const char* label, unsigned int v) {
        std::cout << label << std::bitset<8>(v) << " = " << v << "\n";
    };
    show("flags      ", flags);
    show("flags & 12 ", flags & 0b1100);    // AND: keep bits set in both
    show("flags | 5  ", flags | 0b0101);    // OR: set bits
    show("flags ^ 15 ", flags ^ 0b1111);    // XOR: toggle bits
    show("flags << 1 ", flags << 1);        // shift left: x2
    show("flags >> 1 ", flags >> 1);        // shift right: /2
    std::cout << "~flags     = " << ~flags << "\n";   // all 32 bits flipped

    constexpr unsigned READ = 1u << 0, WRITE = 1u << 1, EXEC = 1u << 2;
    unsigned perms = READ | EXEC;
    std::cout << std::boolalpha << "can write? " << bool(perms & WRITE)
              << ", can exec? " << bool(perms & EXEC) << "\n";
    std::cout << "1 << 2 + 1 = " << (1 << 2 + 1) << "\n";   // + binds tighter than <<
}
$ g++ -std=c++20 -Wall -Wextra -o bits bits.cpp
bits.cpp: In function 'int main()':
bits.cpp:21:45: warning: suggest parentheses around '+' inside '<<' [-Wparentheses]
   21 |     std::cout << "1 << 2 + 1 = " << (1 << 2 + 1) << "\n";   // + binds tighter than <<
      |                                           ~~^~~
$ ./bits
flags      00001010 = 10
flags & 12 00001000 = 8
flags | 5  00001111 = 15
flags ^ 15 00000101 = 5
flags << 1 00010100 = 20
flags >> 1 00000101 = 5
~flags     = 4294967285
can write? false, can exec? true
1 << 2 + 1 = 8

std::bitset<8> prints the low eight bits so you can see each operation. ~flags flips all 32 bits of the unsigned int, not just the four you were thinking about, which is why it prints 4294967285. The permission flags idiom — | to combine, & to test — packs several booleans into one integer. The last line is the precedence trap, and GCC flags it: + binds tighter than <<, so 1 << 2 + 1 is 1 << 3 = 8, not (1 << 2) + 1 = 5. Parenthesize shifts.

Operator Overloading

An overloaded operator is a function named operator+, operator== and so on. Compound assignments that modify the object are members; symmetric binary operators are non-members built on them.

#include <cmath>
#include <iostream>

struct Vector2D {
    double x = 0, y = 0;
    Vector2D& operator+=(const Vector2D& r) { x += r.x; y += r.y; return *this; }
    Vector2D& operator*=(double s) { x *= s; y *= s; return *this; }
    Vector2D operator-() const { return {-x, -y}; }
    double magnitude() const { return std::sqrt(x * x + y * y); }
};

// Binary operators as non-members, built on the compound ones
Vector2D operator+(Vector2D l, const Vector2D& r) { return l += r; }
Vector2D operator*(Vector2D v, double s) { return v *= s; }
Vector2D operator*(double s, Vector2D v) { return v *= s; }   // scalar on the left
bool operator==(const Vector2D& l, const Vector2D& r) { return l.x == r.x && l.y == r.y; }

std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
    return os << "(" << v.x << ", " << v.y << ")";
}

int main() {
    Vector2D a{3, 4}, b{1, 2};
    std::cout << a + b << " " << a * 2 << " " << 3 * b << " " << -a << "\n";
    std::cout << "|a| = " << a.magnitude() << "\n";
    a += b;
    std::cout << "a += b -> " << a << ", a == b? " << (a == b)
              << ", a != b? " << (a != b) << "\n";   // != rewritten from == in C++20
}
$ g++ -std=c++20 -Wall -Wextra -o vector2d vector2d.cpp
$ ./vector2d
(4, 6) (6, 8) (3, 6) (-3, -4)
|a| = 5
a += b -> (4, 6), a == b? 0, a != b? 1

operator+ takes its left operand by value, modifies that copy with += and returns it — the arithmetic lives in one place. 3 * b needs the non-member operator*(double, Vector2D); a member function can’t have a double on its left. operator<< returns the stream so calls chain. And a != b works with no operator!= defined: since C++20, the compiler rewrites a != b as !(a == b).

operator() and Multidimensional operator[]

operator() makes an object callable; here it provides bounds-checked element access. C++23 also allows operator[] with several arguments.

#include <iostream>
#include <stdexcept>
#include <vector>

class Matrix {
public:
    Matrix(int rows, int cols) : rows_(rows), cols_(cols), data_(rows * cols, 0.0) {}

    double& operator()(int r, int c) {                     // bounds-checked access
        if (r < 0 || r >= rows_ || c < 0 || c >= cols_)
            throw std::out_of_range("Matrix index out of bounds");
        return data_[r * cols_ + c];
    }
    double operator()(int r, int c) const {                // read-only version
        return const_cast<Matrix&>(*this)(r, c);
    }
    double& operator[](int r, int c) { return data_[r * cols_ + c]; }  // C++23, unchecked

private:
    int rows_, cols_;
    std::vector<double> data_;
};

int main() {
    Matrix m(2, 2);
    m(0, 0) = 1.5;
    m[1, 1] = 5.0;
    const Matrix& view = m;
    std::cout << view(0, 0) << " " << view(1, 1) << "\n";
    try {
        m(2, 0) = 9;
    } catch (const std::out_of_range& e) {
        std::cout << "caught: " << e.what() << "\n";
    }
}
$ g++ -std=c++23 -Wall -Wextra -o matrix matrix.cpp
$ ./matrix
1.5 5
caught: Matrix index out of bounds

m(0, 0) goes through the checked operator(), and m(2, 0) throws. m[1, 1] uses C++23’s multidimensional subscript — before C++23, m[1, 1] meant the comma operator and called operator[](1). It needs -std=c++23 (GCC 12+, Clang 15+). The const overload of operator() is what lets view(0, 0) compile through a const Matrix&.

The Spaceship Operator <=> (C++20)

Before C++20, full ordering meant six hand-written operators that had to agree. A defaulted <=> generates all of them, comparing members in declaration order.

#include <algorithm>
#include <compare>
#include <iostream>
#include <vector>

struct Version {
    int major, minor, patch;
    auto operator<=>(const Version&) const = default;   // also gives ==
};

std::ostream& operator<<(std::ostream& os, const Version& v) {
    return os << v.major << "." << v.minor << "." << v.patch;
}

int main() {
    Version v1{1, 2, 3}, v2{1, 10, 0}, v3{1, 2, 3};
    std::cout << std::boolalpha;
    std::cout << "v1 < v2:  " << (v1 < v2) << "\n";
    std::cout << "v1 == v3: " << (v1 == v3) << "\n";
    std::cout << "v1 >= v2: " << (v1 >= v2) << "\n";

    auto cmp = v2 <=> v1;
    std::cout << "(v2 <=> v1) > 0: " << (cmp > 0) << "\n";

    std::vector<Version> vs = {{2, 0, 0}, {1, 10, 0}, {1, 2, 3}, {1, 9, 9}};
    std::ranges::sort(vs);                                // uses the generated <
    for (const auto& v : vs) std::cout << v << " ";
    std::cout << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o spaceship spaceship.cpp
$ ./spaceship
v1 < v2:  true
v1 == v3: true
v1 >= v2: false
(v2 <=> v1) > 0: true
1.2.3 1.9.9 1.10.0 2.0.0 

{1, 2, 3} < {1, 10, 0} is true because minor 2 < 10 — the comparison is numeric per member, so 1.10.0 correctly sorts after 1.9.9, which string comparison would get wrong. v2 <=> v1 returns an ordering value you compare against 0. A defaulted <=> also implicitly defaults ==, which is why v1 == v3 works.

Comparison Categories

The return type of <=> says what “equal” means for the type.

#include <algorithm>
#include <cctype>
#include <cmath>
#include <compare>
#include <iostream>
#include <string>

struct CaseInsensitive {
    std::string s;
    std::weak_ordering operator<=>(const CaseInsensitive& o) const {
        auto lower = [](std::string t) {
            std::ranges::transform(t, t.begin(), [](unsigned char c) { return std::tolower(c); });
            return t;
        };
        return lower(s) <=> lower(o.s);   // strong_ordering converts to weak
    }
};

int main() {
    CaseInsensitive a{"ABC"}, b{"abc"};
    std::cout << std::boolalpha;
    std::cout << "ABC equivalent to abc: " << ((a <=> b) == 0) << "\n";
    std::cout << "but the strings equal: " << (a.s == b.s) << "\n";

    double nan = std::nan("");
    auto r = nan <=> 1.0;                  // double <=> double is partial_ordering
    std::cout << "NaN < 1: " << (r < 0) << ", NaN > 1: " << (r > 0)
              << ", NaN == 1: " << (r == 0) << ", unordered: "
              << (r == std::partial_ordering::unordered) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o categories categories.cpp
$ ./categories
ABC equivalent to abc: true
but the strings equal: false
NaN < 1: false, NaN > 1: false, NaN == 1: false, unordered: true

std::weak_ordering says “ABC” and “abc” are equivalent for ordering even though they aren’t identical strings. std::partial_ordering exists for values like NaN: NaN <=> 1.0 is unordered, so <, > and == are all false. std::strong_ordering (what int <=> int returns) promises equivalent means identical.

The category matters to the compiler. Here’s a custom ordering — salary descending, then id — declared as strong_ordering:

#include <compare>

struct Employee {
    int id;
    double salary;
    std::strong_ordering operator<=>(const Employee& o) const {
        if (salary != o.salary)
            return o.salary <=> salary;    // double <=> double: partial_ordering
        return id <=> o.id;
    }
};

int main() { return Employee{1, 10} < Employee{2, 20}; }
$ g++ -std=c++20 -Wall -Wextra -o employee_bad employee_bad.cpp 2>&1 | grep error
employee_bad.cpp:8:29: error: could not convert '(((double)o.Employee::salary) <=> ((double)((const Employee*)this)->Employee::salary))' from 'std::partial_ordering' to 'std::strong_ordering'

double <=> double yields partial_ordering because of NaN, and it can’t be converted to the stronger strong_ordering. Declare the weaker category (or use std::strong_order(a, b), which imposes a total order on doubles):

#include <algorithm>
#include <compare>
#include <iostream>
#include <string>
#include <vector>

struct Employee {
    int id;
    std::string name;
    double salary;
    std::partial_ordering operator<=>(const Employee& o) const {
        if (auto c = o.salary <=> salary; c != 0) return c;   // salary descending
        return id <=> o.id;                                     // then id ascending
    }
    bool operator==(const Employee& o) const { return id == o.id; }
};

int main() {
    std::vector<Employee> staff = {
        {3, "Cy", 90'000}, {1, "Ann", 120'000}, {2, "Bo", 90'000}, {4, "Di", 75'000}};
    std::ranges::sort(staff, std::less{});
    for (const auto& e : staff)
        std::cout << e.id << " " << e.name << " " << e.salary << "\n";
    std::cout << std::boolalpha << "same id, different salary equal? "
              << (Employee{1, "Ann", 1} == Employee{1, "Ann", 2}) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o employee employee.cpp
$ ./employee
1 Ann 120000
2 Bo 90000
3 Cy 90000
4 Di 75000
same id, different salary equal? true

Ann sorts first on salary; Bo and Cy tie at 90000 and fall back to id. The last line shows a design hazard carried over from the ordering: operator== compares only id, so two employees with different salaries are “equal” but not equivalent under <=>. Keep == and <=> consistent unless you have a reason not to.

Practice

  1. Make vector2d.cpp support a - b and a -= b using the same pattern as +.

    Solution
    // member
    Vector2D& operator-=(const Vector2D& r) { x -= r.x; y -= r.y; return *this; }
    // non-member
    Vector2D operator-(Vector2D l, const Vector2D& r) { return l -= r; }

    a - b for a{3, 4}, b{1, 2} prints (2, 2). The unary operator-() member and binary non-member operator- coexist because they take different numbers of operands.

  2. Replace the hand-written operator== in vector2d.cpp with bool operator==(const Vector2D&) const = default; inside the struct. Does the output change?

    Solution

    No. A defaulted == compares x then y, exactly what the hand-written one did, and != is still rewritten from it.

  3. Add Flags helpers to bits.cpp: set(perms, WRITE) and clear(perms, EXEC) as one-line functions.

    Solution
    unsigned set(unsigned p, unsigned f)   { return p | f; }
    unsigned clear(unsigned p, unsigned f) { return p & ~f; }

    clear ANDs with the complement of the flag, keeping every bit except that one: clear(READ | EXEC, EXEC) leaves READ (1).

  4. Give Version a std::string label member declared first and keep <=> = default. How does {"b",1,0,0} compare to {"a",2,0,0}?

    Solution

    {"b",1,0,0} is greater: the defaulted <=> compares members in declaration order, so label decides before major is looked at. Member order is part of the comparison’s meaning.

  5. Fix employee_bad.cpp while keeping the std::strong_ordering return type.

    Solution
    std::strong_ordering operator<=>(const Employee& o) const {
        if (auto c = std::strong_order(o.salary, salary); c != 0) return c;
        return id <=> o.id;
    }

    std::strong_order on doubles uses the IEEE total order, which places NaNs at the ends instead of leaving them unordered, so it can promise strong_ordering.

Frequently Asked Questions

Should overloaded operators be member or non-member functions?
Make operators that modify the object members: +=, -=, [] and (). Make symmetric binary operators such as +, * and == non-members, usually built on the compound member, so conversions apply to both operands and scalar * vector works as well as vector * scalar.
What does the spaceship operator <=> do?
It performs a three-way comparison, returning an ordering that is less, equal or greater. Defaulting it with auto operator<=>(const T&) const = default; makes the compiler generate ==, !=, <, <=, > and >=, comparing members in declaration order.
What is the difference between strong_ordering, weak_ordering and partial_ordering?
strong_ordering means equivalent values are identical, as with integers. weak_ordering allows equivalent but distinguishable values, such as case-insensitive strings. partial_ordering allows values that are not comparable at all, such as NaN for floating-point numbers.
Why does -17 % 5 give -2 in C++?
Since C++11, integer division truncates toward zero, so -17 / 5 is -3, and the remainder takes the sign of the dividend so that (a / b) * b + a % b == a holds: -3 * 5 + -2 == -17.
Does && always evaluate both operands?
No. The built-in && and || short-circuit: the right operand is evaluated only if the left one does not already decide the result. Overloaded && and || lose this property, which is one reason not to overload them.