Object-Oriented Programming (C++)

Chapter 7
Operator Overloading and Advanced Class Features

Bachelors Program in Computer Science Engineering
Object-Oriented Programming (C++)   •   Post Graduate

Operator Overloading and Advanced Class Features

Introduction

Operator overloading is a central C++ mechanism for expressing operations on user-defined types with notation that resembles operations on built-in types. A well-designed overload can make a matrix, rational number, iterator, smart pointer, or physical quantity easier to use. A poorly designed overload can obscure meaning, introduce surprising conversions, or violate users’ expectations about efficiency and correctness.

Advanced class features extend this design space. They include friend functions, conversion operators, explicit constructors, static members, nested types, special member functions, move semantics, inheritance-related issues, and exception-safe resource management. At postgraduate level, these features should be studied not as isolated syntax but as tools for constructing abstractions whose semantics, lifetime, ownership, and performance are explicit.

Learning Objectives

After completing this chapter, the learner should be able to:

  • explain the language rules governing operator overloading;
  • distinguish member, non-member, and friend overloads;
  • design overloads that preserve intuitive algebraic and relational semantics;
  • apply explicit conversions, conversion operators, and the three-way comparison operator safely;
  • implement resource-owning classes using the Rule of Zero, Rule of Five, and copy-and-swap reasoning;
  • use static members, nested classes, delegating constructors, and inheritance-aware interfaces appropriately;
  • analyse exception safety, value categories, move semantics, and performance trade-offs;
  • evaluate an object-oriented design critically in a realistic software scenario.

1. The Semantic Basis of Operator Overloading

An operator overload is a function with an operator name. For example, an expression such as a + b may be interpreted as a call to a member or non-member function. For a non-member overload, the conceptual form is operator+(a,b). For a member overload, the left operand is the implicit object, so a.operator+(b) is considered.

Only existing operators may be overloaded. C++ does not allow programmers to invent a new operator or change an operator’s precedence, associativity, number of operands, or fundamental meaning for built-in types. At least one operand of an overloaded operator must have a class or enumeration type. Consequently, a declaration attempting to overload an operator solely for two built-in types is ill-formed.

The following operators cannot be overloaded:

  • :: scope resolution;
  • . member access;
  • .* pointer-to-member access;
  • ?: conditional operator;
  • sizeof;
  • typeid;
  • alignof;
  • noexcept.

The number of operands is fixed. Unary operators remain unary, and binary operators remain binary. Operator overloading therefore changes how an operation is implemented for a user-defined type, not the grammatical structure of the language.

1.1 Member and non-member forms

A member overload is often appropriate when the left operand must be an object of the class:

class Counter {
    int value_{};
public:
    Counter& operator++() {       // prefix increment
        ++value_;
        return *this;
    }
};

A non-member overload is often preferable when symmetry is required, especially for arithmetic or comparisons:

class Complex {
    double re_, im_;
public:
    Complex(double r = 0.0, double i = 0.0) : re_(r), im_(i) {}
    double real() const { return re_; }
    double imag() const { return im_; }
};

Complex operator+(const Complex& x, const Complex& y) {
    return Complex{x.real() + y.real(), x.imag() + y.imag()};
}

A non-member permits implicit conversion of either operand, subject to the conversion rules. A member overload permits conversion of the right operand but not conversion of the left operand to the class type for ordinary operator syntax. This distinction is important when a type has a converting constructor.

For a binary operator, the following table summarizes common design choices.

Operator categoryCommon formTypical rationale
Assignment =, +=, -=, *=, /=MemberModifies the left object
Subscript []MemberAccesses representation of the object
Function call ()MemberGives the object callable behavior
Arrow ->MemberDefines pointer-like access
Arithmetic +, -, *Usually non-memberSymmetry and non-mutating semantics
Equality and orderingOften non-member or defaultedSymmetric comparison
Stream insertion/extractionNon-member, often friendStream is the left operand

2. Designing Arithmetic and Relational Operators

Operator notation creates strong user expectations. If a + b is supported, users normally expect neither operand to be modified, and they expect the result to be a new value. If a += b is supported, users expect mutation of a and a reference return to support chaining.

A common implementation pattern is to define a compound assignment as the fundamental operation and implement the corresponding binary operator in terms of it:

class Vector2 {
    double x_, y_;
public:
    Vector2(double x = 0, double y = 0) : x_(x), y_(y) {}

    Vector2& operator+=(const Vector2& rhs) {
        x_ += rhs.x_;
        y_ += rhs.y_;
        return *this;
    }

    friend Vector2 operator+(Vector2 lhs, const Vector2& rhs) {
        lhs += rhs;
        return lhs;
    }
};

Passing lhs by value deliberately creates a copy (or move) and allows the same implementation to handle lvalue and rvalue operands. The friend declaration gives the function access to private data while keeping the function itself a non-member.

2.1 Prefix and postfix increment

The signatures distinguish prefix and postfix increment:

class Index {
    int value_;
public:
    explicit Index(int value = 0) : value_(value) {}

    Index& operator++() {          // prefix: increment, then return
        ++value_;
        return *this;
    }

    Index operator++(int) {        // postfix: dummy int parameter
        Index old = *this;
        ++value_;
        return old;
    }
};

The dummy int parameter is not supplied by the programmer; it distinguishes postfix syntax. Prefix increment conventionally returns T&, while postfix returns the old value by value. For iterators and performance-sensitive abstractions, prefix increment is generally preferable when the old value is not needed.

2.2 Comparisons and ordering

C++20 introduced the three-way comparison operator, commonly called the spaceship operator, operator<=>. It can generate or support relational operations. A defaulted comparison is particularly useful when the desired ordering is lexicographic over members:

#include <compare>

struct Version {
    int major;
    int minor;
    int patch;

    auto operator<=>(const Version&) const = default;
};

The generated comparison compares members in declaration order. This is correct only when that order expresses the domain’s intended ordering. A version number, for example, is commonly lexicographic, but a class containing a cache pointer should not automatically compare that pointer merely because it is a data member.

Equality should represent semantic equality, not necessarily object identity. Two rational-number objects such as 1/2 and 2/4 may be mathematically equal even if their stored numerators and denominators differ. Such a class must either normalize representation or implement equality using cross multiplication, while also addressing denominator validity and possible overflow.

3. Stream Operators, Friends, and Encapsulation

Stream insertion is normally a non-member because the left operand is an output stream:

#include <ostream>

class Money {
    long long cents_;
public:
    explicit Money(long long cents) : cents_(cents) {}

    friend std::ostream& operator<<(std::ostream& out, const Money& m) {
        return out << (m.cents_ / 100) << '.'
                   << (m.cents_ % 100 < 10 ? "0" : "")
                   << (m.cents_ % 100);
    }
};

The return type permits chaining: std::cout << a << b. Extraction operators should validate input and set the stream’s failure state rather than silently constructing an invalid object. A friend function is not a member, despite being granted access to private and protected members. Friendship is not inherited, reciprocal, or automatically transitive. It should be granted narrowly, because it increases coupling between the class and the friend.

A useful alternative is to expose a public observer and implement the operator without friendship. This can improve encapsulation when private representation should not be part of formatting logic.

4. Conversion Control

Conversions are essential for interoperability but dangerous when they occur unexpectedly. A single-argument constructor can act as an implicit conversion unless marked explicit:

class Distance {
    double metres_;
public:
    explicit Distance(double metres) : metres_(metres) {}
    double metres() const { return metres_; }
};

With explicit, Distance d{3.5}; is valid, but void f(Distance); f(3.5); is rejected unless the caller writes f(Distance{3.5}). This makes units and domain boundaries visible.

A conversion operator converts a class object to another type:

class FileDescriptor {
    int fd_;
public:
    explicit operator bool() const noexcept { return fd_ >= 0; }
};

An explicit conversion to bool supports contextual tests such as if (descriptor) without allowing arbitrary arithmetic or accidental overload selection. Implicit conversion operators should be rare and should model a natural, lossless, and unsurprising relationship.

4.1 A quantity example

A robust units type can prevent accidental mixing of incompatible quantities:

class Metres {
    double value_;
public:
    explicit constexpr Metres(double v) : value_(v) {}
    constexpr double value() const noexcept { return value_; }
};

class Seconds {
    double value_;
public:
    explicit constexpr Seconds(double v) : value_(v) {}
    constexpr double value() const noexcept { return value_; }
};

constexpr double speed(Metres distance, Seconds time) {
    return distance.value() / time.value();
}

The compiler prevents a Seconds object from being passed where Metres is required. In safety-critical or scientific software, such type-level distinctions can prevent defects more effectively than comments.

5. Special Member Functions and Object Lifetime

A class may have six important special member functions:

  1. default constructor;
  2. destructor;
  3. copy constructor;
  4. copy assignment operator;
  5. move constructor;
  6. move assignment operator.

The compiler may implicitly declare or define some of them, but user declarations can suppress implicit generation. A destructor, copy operation, or move operation that manages a resource must be considered together with the others.

5.1 Rule of Zero, Three, and Five

The Rule of Zero recommends storing resources in RAII types such as std::string, std::vector, and smart pointers, so that the class itself needs no user-defined destructor, copy constructor, or assignment operator.

The Rule of Three states that if a class needs a user-defined destructor, copy constructor, or copy assignment operator, it probably needs all three. This is especially relevant to raw owning pointers.

The Rule of Five extends the reasoning to move constructor and move assignment operator. A resource-owning type may need all five operations when efficient transfer of ownership is required.

A simplified resource-owning class illustrates the principle:

class Buffer {
    std::size_t size_{0};
    std::unique_ptr<unsigned char[]> data_;
public:
    explicit Buffer(std::size_t n)
        : size_(n), data_(n ? std::make_unique<unsigned char[]>(n) : nullptr) {}

    // Rule of Zero: unique_ptr supplies safe ownership semantics.
    std::size_t size() const noexcept { return size_; }
};

This class is movable but not copyable because std::unique_ptr is non-copyable. If copying is part of the domain, an explicit deep-copy constructor and copy assignment operation should be implemented.

5.2 Copy-and-swap assignment

For a copyable resource type, the copy-and-swap idiom can provide strong exception safety:

class Text {
    std::unique_ptr<char[]> data_;
    std::size_t size_{};
public:
    Text(const Text& other);                 // deep copy
    Text& operator=(Text other) noexcept {   // copy or move construction first
        swap(*this, other);
        return *this;
    }

    friend void swap(Text& a, Text& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }
};

The parameter is passed by value. For an lvalue, it invokes copying; for an rvalue, it invokes moving. If construction fails, the target object remains unchanged. The swap operation must be non-throwing for the stated guarantee.

6. Move Semantics, Value Categories, and noexcept

C++ distinguishes lvalues, which identify persistent objects, from rvalues, which commonly represent temporary values. An rvalue reference, written T&&, can bind to an expiring object and enable resource transfer rather than duplication.

A move constructor should leave the source object valid but with an unspecified value, unless a stronger postcondition is documented. “Valid” means the object can be destroyed and can be assigned to; it does not necessarily retain its original contents.

Marking a move constructor or move assignment operator noexcept is significant. Standard containers may choose copying instead of moving during reallocation if moving could throw, because copying may preserve the strong exception guarantee. A move operation should be marked noexcept only when it genuinely cannot throw.

Perfect forwarding uses forwarding references and std::forward to preserve the value category of an argument. It is powerful in generic libraries but should not be used merely to make ordinary interfaces look sophisticated. Overloaded constructors and forwarding constructors can capture unintended types and interfere with copy or move operations.

7. Other Advanced Class Features

7.1 Static data and member functions

A static data member belongs to the class rather than to each object. A static member function has no this pointer and can directly access only static members:

class Connection {
    inline static std::size_t active_ = 0;
public:
    Connection() { ++active_; }
    ~Connection() { --active_; }
    static std::size_t active() noexcept { return active_; }
};

The inline variable declaration, available since C++17, permits a definition in a header while satisfying the one-definition rule. Global mutable counters raise concurrency concerns; atomic variables or controlled synchronization may be required in multithreaded programs.

7.2 Nested classes and local invariants

A nested class is a member of its enclosing class and is useful for implementation details such as iterators, node types, or builder objects. It does not automatically possess a pointer to an enclosing object. Access to private members is governed by ordinary access rules and explicit relationships, not by an implicit outer-object reference.

7.3 Delegating and inheriting constructors

Delegating constructors reduce duplication:

class Point {
    double x_, y_;
public:
    Point() : Point(0.0, 0.0) {}
    Point(double x, double y) : x_(x), y_(y) {}
};

A derived class may inherit constructors using using Base::Base, but inherited constructors do not replace the need to reason about derived members and invariants. Construction proceeds through the base subobject before derived members are initialized.

7.4 Virtual destructors and polymorphism

If a class is intended to be used polymorphically, its destructor should generally be virtual:

class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
};

Deleting a derived object through a base pointer without a virtual destructor produces undefined behavior. Conversely, a class not intended as a polymorphic base need not pay for a virtual destructor. The design should communicate intended use.

The override specifier should be used when overriding a virtual function. It causes the compiler to diagnose signature mismatches:

class Circle : public Shape {
public:
    double area() const override { return 3.141592653589793 * 1.0; }
};

8. Exception Safety and Class Invariants

An invariant is a condition that must hold for every observable valid state of an object. Examples include a denominator that is nonzero, a file descriptor that is either valid or marked closed, and a sorted container whose ordering is maintained.

Exception safety is commonly described in four levels:

GuaranteeMeaning
No-throwThe operation does not throw exceptions
StrongFailure leaves the program state unchanged
BasicNo leaks occur and invariants remain valid, but state may change
No guaranteeFailure may leave invalid state or cause resource loss

RAII is the primary C++ technique for achieving reliable cleanup. Constructors establish invariants; destructors release resources and should normally not throw. Public mutating operations should establish their postconditions or leave the object at least in a valid basic-guarantee state.

9. Case Study: A Money Type for a Billing Service

Background

An illustrative billing service originally represented prices as double. It suffered from rounding discrepancies, accidental addition of values from different currencies, and unclear output formatting.

Problem

The service required addition, subtraction, comparison, formatted output, and safe construction from a smallest currency unit. It also needed to reject invalid negative values for invoice totals while allowing signed adjustments such as refunds.

Analysis

Binary floating-point values do not represent every decimal fraction exactly. Instead of relying on a formula involving floating-point equality, the design stores integral minor units, such as cents. For a currency with 100 minor units per major unit, the displayed amount is obtained by integer division and remainder. The representation’s applicability depends on the currency’s minor-unit convention; not all currencies use two decimal places.

Approach

#include <compare>
#include <cstdint>
#include <ostream>

class Money {
    std::int64_t minor_;
public:
    explicit constexpr Money(std::int64_t minor = 0) noexcept
        : minor_(minor) {}

    constexpr std::int64_t minor_units() const noexcept { return minor_; }

    Money& operator+=(Money rhs) noexcept {
        minor_ += rhs.minor_;
        return *this;
    }

    friend Money operator+(Money lhs, Money rhs) noexcept {
        lhs += rhs;
        return lhs;
    }

    friend auto operator<=>(const Money&, const Money&) = default;

    friend std::ostream& operator<<(std::ostream& out, Money m) {
        auto whole = m.minor_ / 100;
        auto fraction = m.minor_ < 0 ? -(m.minor_ % 100) : m.minor_ % 100;
        return out << whole << '.' << (fraction < 10 ? "0" : "") << fraction;
    }
};

In production, the currency code should be part of the type or value and arithmetic should reject unlike currencies. Overflow of std::int64_t must also be addressed; checked arithmetic or a wider representation may be necessary. Formatting rules, negative zero, localization, taxation, and rounding policies belong at the application boundary rather than being hidden in a basic numeric operator.

Outcome

The revised design makes addition and comparison readable, prevents implicit construction from arbitrary numeric expressions through explicit, and provides deterministic storage. It does not, by itself, solve currency conversion, taxation, or international formatting.

Learning Points

  • Representation choices affect correctness more than operator syntax.
  • Operators should express domain semantics, not merely expose data members.
  • Currency identity and overflow require explicit policy.
  • Formatting is an interface concern and must be tested independently.

10. Best Practices and Common Failure Modes

  1. Define operators in terms of a small set of well-tested primitive operations.
  2. Preserve expected algebraic properties where the domain supports them. For example, a += b should normally have the same effect as a = a + b.
  3. Return *this by reference from compound assignment and prefix increment.
  4. Return a value from non-mutating arithmetic and postfix increment.
  5. Prefer non-member symmetric operators when neither operand has a privileged role.
  6. Use explicit for constructors and conversion operators unless implicit conversion is clearly safe.
  7. Use = default when compiler-generated semantics are correct; use = delete to state prohibited operations.
  8. Prefer the Rule of Zero and standard RAII components.
  9. Mark genuine non-throwing move operations noexcept.
  10. Use override, final, and virtual destructors deliberately in polymorphic hierarchies.
  11. Test self-assignment, moved-from objects, boundary values, overflow, invalid input, and exception paths.
  12. Avoid overloading operators with surprising side effects. A visually simple expression should not silently perform network I/O, mutate global state, or acquire an unrelated resource.

Common errors include returning references to temporary objects, implementing operator+ by mutating its operands, forgetting const qualification on observers and comparisons, allowing narrowing conversions, and comparing internal pointers instead of semantic values. Another frequent error is assuming that a defaulted comparison is automatically domain-correct; it is only correct when member-wise comparison matches the abstraction’s meaning.

Contemporary and Professional Considerations

Modern C++ design emphasizes value semantics, compile-time checking, concepts, ranges, and standard-library components. Operator overloads participate in generic algorithms, so const-correctness, iterator conventions, complexity, and exception specifications influence interoperability. In security-sensitive systems, implicit conversions and unchecked arithmetic should be treated as review concerns. Static analysis, sanitizers, property-based tests, and compiler warnings can identify lifetime errors and invalid assumptions, but they do not determine whether an operator’s semantics are appropriate for the domain.

AI-assisted code generation can produce syntactically valid overloads that nevertheless violate ownership, exception, or algebraic contracts. Generated code must therefore be reviewed against the C++ standard, tested with sanitizers, and evaluated against documented invariants. The responsibility for interface semantics remains with the engineer.

Chapter Summary

Operator overloading maps familiar C++ notation to user-defined semantics while preserving the language’s fixed syntax and operand structure. Member operators are natural for operations that modify or access the left object; non-member operators provide symmetry and enable conversions on both operands. Friend functions can support encapsulated non-member operations, but friendship should be narrow.

Safe class design requires deliberate conversion control, especially through explicit constructors and conversion operators. Comparison operators, including defaulted three-way comparison, must reflect semantic rather than accidental representation order. Resource management is governed by the Rule of Zero, Three, and Five, with RAII and noexcept move operations supporting robust and efficient object lifetimes. Static members, nested classes, delegating constructors, inheritance, virtual destructors, and exception guarantees extend the design toolkit. The central professional principle is that an overloaded operator is an interface contract: its syntax, semantics, complexity, ownership effects, and failure behavior must all be predictable.

Key Terms

Operator overloading: Defining a function that gives an existing C++ operator behavior for a user-defined type.

Member operator: An overloaded operator implemented as a member function of a class.

Non-member operator: An overloaded operator implemented outside the class.

Friend function: A non-member function granted access to a class’s private and protected members.

Compound assignment: An operator such as += that updates its left operand.

Conversion constructor: A constructor that can convert another type into the class type.

Explicit constructor: A constructor that cannot normally be used for implicit conversion.

Conversion operator: A member function defining conversion from the class to another type.

Value category: A language classification such as lvalue or rvalue describing expression identity and lifetime behavior.

Move semantics: Transfer of resources from an expiring object instead of copying them.

RAII: Resource Acquisition Is Initialization; ownership is tied to object lifetime.

Rule of Zero: Preference for resource-managing member types so no special member functions are needed.

Rule of Five: The need to consider five copy, move, and destruction operations together.

Invariant: A condition that must remain true for every valid observable object state.

Exception safety: Guarantees describing object and program state when an operation fails.

Three-way comparison: operator<=>, which supports ordering and comparison category results.

Static member: A class-level member shared among objects of the class.

Virtual destructor: A destructor enabling safe destruction through a base-class pointer.

Perfect forwarding: Passing an argument while preserving its original value category.

Copy-and-swap: An assignment technique that builds a safe temporary and exchanges state.

Review Questions

Short Answer Questions

  1. What restrictions does C++ place on the operators that may be overloaded?
  2. Why is a non-member often preferred for symmetric arithmetic operators?
  3. What is the purpose of the dummy int parameter in postfix increment?
  4. Explain the difference between the Rule of Zero and the Rule of Five.
  5. Why should a polymorphic base class usually have a virtual destructor?

Long Answer Questions

  1. Explain member, non-member, and friend operator overloads, using stream insertion and arithmetic as examples.
  2. Discuss implicit conversions, explicit constructors, and conversion operators, including their effect on interface safety.
  3. Analyse how copy, move, assignment, and destruction operations interact in a resource-owning class.
  4. Explain exception-safety guarantees and show how RAII and copy-and-swap support them.
  5. Discuss the design of comparison operators and the circumstances in which a defaulted operator<=> is inappropriate.

Analytical / Application Questions

  1. Design a rational-number class that maintains a nonzero denominator and supports arithmetic and equality. State its invariants and explain how you would prevent overflow.
  2. A matrix class provides operator() for indexing and returns a reference to an element. Analyse const-correctness, bounds checking, exception guarantees, and performance choices.
  3. Critically evaluate a class that has a raw owning pointer, a user-defined destructor, and a compiler-generated copy constructor. Identify likely defects and propose a modern redesign.

Multiple Choice Questions with Answers

Question 1

Which statement about overloaded operators is correct?

A. They may introduce new precedence levels.

B. At least one operand must have a class or enumeration type.

C. They may change a binary operator into a unary operator.

D. Every C++ token can be overloaded.

Correct Answer: B. At least one operand must have a class or enumeration type.

Question 2

Why is operator+ commonly implemented as a non-member?

A. It allows symmetric treatment of both operands.

B. It can access private data without permission.

C. It automatically changes the operands in place.

D. It is required by the compiler for all arithmetic types.

Correct Answer: A. It allows symmetric treatment of both operands.

Question 3

What does the dummy int parameter distinguish?

A. A conversion operator from a constructor

B. Prefix increment from decrement

C. Postfix increment from prefix increment

D. A static function from a member function

Correct Answer: C. Postfix increment from prefix increment

Question 4

Which return type is conventional for prefix increment?

A. void

B. T&

C. bool

D. const char*

Correct Answer: B. T&

Question 5

What is the primary purpose of explicit on a single-argument constructor?

A. To make the constructor virtual

B. To permit implicit conversions everywhere

C. To restrict unintended implicit conversions

D. To prevent direct initialization

Correct Answer: C. To restrict unintended implicit conversions

Question 6

What does the Rule of Zero recommend?

A. Every class must define five special member functions.

B. Raw pointers should be used for all resources.

C. Resource-managing member types should handle ownership so the class needs no custom special members.

D. Move operations should always be deleted.

Correct Answer: C. Resource-managing member types should handle ownership so the class needs no custom special members.

Question 7

Why can noexcept on a move constructor improve vector performance?

A. It makes vector elements immutable.

B. It allows the container to prefer moving during reallocation when appropriate.

C. It disables allocation.

D. It converts the move into a copy.

Correct Answer: B. It allows the container to prefer moving during reallocation when appropriate.

Question 8

What does a virtual destructor primarily ensure?

A. Correct destruction through a base-class pointer

B. Compile-time evaluation of every destructor

C. Automatic deep copying

D. That destructors cannot throw

Correct Answer: A. Correct destruction through a base-class pointer

Question 9

What is a characteristic of a friend function?

A. It is always inherited by derived classes.

B. It is a member with an implicit this pointer.

C. It is a non-member granted selected access to class internals.

D. It can access private members of every class.

Correct Answer: C. It is a non-member granted selected access to class internals.

Question 10

What is the main risk of defaulting a three-way comparison without analysis?

A. The program cannot compile under C++20.

B. Member-wise ordering may not represent the domain’s semantic ordering.

C. It always performs floating-point arithmetic.

D. It prevents equality comparisons.

Correct Answer: B. Member-wise ordering may not represent the domain’s semantic ordering.

Object-Oriented Programming (C++)
Chapter 7 of 7