Skip to content

Operator Overloading

For the basic types we've seen so far, operations and operators are already defined. For example, an integer-type variable can be used with operators like addition (+), subtraction (-), assignment (=), or increment (++). These have well-known effects on the variable's value. However, for the custom classes we create, these operators do not work by default. C++ gives us the ability to define how operators should work for our own types — we just need to specify what should happen, for instance, in the case of addition. This is called operator overloading.

Let’s take a look at how the addition operator works for integers! What does the expression a+b actually mean?

1
a+b => need to add: a, b => add(a,b) => +(a,b) => operator+(a,b)

It’s important to notice that the values of a and b do not change, and a new “object” is created to store the result of the expression (in the case of 3+2, this would be 5, which is also an integer). So, the operation has a return value. In the case of integers, this return value is an integer as well. If we extend the above with the return type, it would look like this:

1
int operator+(a,b) => int operator+(int, int)

For our own class, the concept is similar — we just need to define what we want to add to what, and what the result should be. If we take the Course class and add a Student to it, we get an extended course, which is also a Course.

So the two parameters are: Course and Student (which in practice will be a constant reference). The return type is Course.

1
Course operator+(Course, Student)

Since the behavior of the operator is closely tied to the Course (as we have defined it), according to OOP principles, the operation should be placed inside the class itself! We can write this method as a member function of the Course class.

If it's a member function, then the first parameter (Course) is already given — because when we call the operator+ function on an object, the object itself becomes the first parameter. Therefore, we can omit it from the parameter list.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define COURSE_LIMIT 10

class Course {
  string name, code;
  unsigned enrolled = 0;
  Student students[COURSE_LIMIT];
public:
  Course(const string& name, const string& code) : name(name), code(code) {
  }

  Course operator+(const Student& s) const { 
    // const, because the original course object does not change
    Course result = *this; // a new course object is created
    if ( result.enrolled == COURSE_LIMIT ) {
      cout << "The course is full. Cannot enroll." << endl;
    } else {
      result.students[result.enrolled++] = s;
    }

    return result; // we return a new course
  }
};

This adds the given student to the Course if there's still space. Its semantics are similar to that of integer addition.

Usage looks like this:

1
2
3
4
5
6
7
8
int main() {

  Course course("Native Programming.", "IBXYZG-1");
  Student student("Creative Name", "CNAA.UNI");

  course = course + student;
  // according to our explanation, this is actually: course = course.operator+(student);
}

With integers, if we want to add a value to a variable, we use addition like this:

1
a = a + 5
A shorter version of this:
1
a += 5
We can do the same with our own class by overloading the operator+=.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const unsigned COURSE_LIMIT = 10;

class Course {
  string name, code;
  unsigned enrolled = 0;
  Student students[COURSE_LIMIT];
public:
  Course(const string& name, const string& code) : name(name), code(code) {
  }

  Course& operator+=(const Student& s) { // NOT const, the current object will be modified
    if ( enrolled == COURSE_LIMIT ) {
      cout << "The course is full. Cannot enroll." << endl;
    } else {
      students[enrolled++] = s;
    }

    return *this;
  }
};

int main() {

  Course c("Native Programming", "IBXYZG-1");
  Student s("Creative Name", "CNAA.UNI");

  c += s;
}

This implementation is quite similar, but it's important to highlight a few significant differences!

The first is that the return type is not Course, but Course&, and we do not create a new Course object. The reason for this is the following:

When we used operator+, the operands remained unchanged, and a new object was created to store the result - completely independent of the two operands. This is visualized in the diagram below, where adding two Course objects results in a third Course:

operator+

If we want to use this in the form c1 = c1 + c2;, then the original object (c1) will be overwritten by the result object. The following two diagrams illustrate this:

operator+

operator+

In such cases, it's better to use operator+=. With this operator, the assignment is already included in the operation itself, meaning the original object is modified. This is also why we need to return by reference - because we're still referring to the same object.

Adding a student works in the same way:

operator+

It's important to note that the original Course was modified - the Student was added to the course - but the Student itself was not changed. That's why the parameter could be passed as a const &.

operator+

Since we treat operators as functions, their definition is determined not only by the operator's name but also by the parameter type. In the Course class, we can have two versions of operator+=: one with a Student parameter, and another with a Course parameter. The Course class extended in this way looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#define COURSE_LIMIT 10

class Course {
  string name, code;
  unsigned enrolled = 0;
  Student students[COURSE_LIMIT];
public:
  Course(const string& name, const string& code) : name(name), code(code) {
  }

  Course& operator+=(const Student& s) {
    if ( enrolled == COURSE_LIMIT ) {
      cout << "The course is full. Cannot enroll." << endl;
    }
    else {
      students[enrolled++] = s;
    }

    return *this;
  }

  Course& operator+=(const Course& c) {
    if ( c.enrolled + this->enrolled >= COURSE_LIMIT ) {
      cout << "The two courses cannot be merged. Not enough space." << endl;
    }
    else {
      for ( unsigned i = 0; i < c.enrolled; ++i ) {
        students[enrolled++] = c.students[i];
      }
    }

    return *this;
  }
};

int main() {

  Course c("Native Programming", "IBXYZG-1");
  Course c2("Native Programming 2", "IBXYZG-13");

  Student s1("Creative Name", "CNAA.UNI");
  Student s2("More Creative Name", "CBAA.UNI");

  c += s1;
  c2 += s2;
  c += c2;
}

The += operator works similarly for Course objects as it does for Student objects:

operator+

Természetesen, a kurzushoz adott paraméter most sem módosul, így az lehet const &.

operator+

Indexer Operator

The indexer operator is considered a special kind of operator. Its semantics are generally associated with arrays - used for accessing elements - though we can assign it any custom meaning.

To enable this, we need to overload the operator[](int) method.

When accessing an element in an array, we can do multiple things. For example, we can simply read its value (without modifying it):

1
cout << int_array[2];

Or we can modify the value:

1
int_array[2] = 55;

Since in the first case we're only interested in the value, we don't need to modify it. In the second case, however, we want to modify the returned value - meaning we actually want to overwrite the element at index 2 inside the int_array object.

We've already seen that we can indicate whether a function is allowed to modify the object using the const keyword. In the first case (read-only), we cannot modify the object, so we use const; in the second case (write), we don't.

Of course, simply marking the method as const is not enough - if there's a "loophole" in the return type, the object might still be altered. To prevent this, we also need to adjust the return type accordingly. This was already discussed in the section about const.

Extending the indexer operator for the Course class makes sense here, since Course contains an array-like structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#define COURSE_LIMIT 10

class Course {
  string name, code;
  unsigned enrolled = 0;
  Student students[COURSE_LIMIT];
public:
  Course(const string& name, const string& code) : name(name), code(code) {
  }

  // case 1: non-const access
  Student& operator[](unsigned i) {
    if (i < enrolled)
      return students[i];
    // proper error handling needed, will be added later
    cout << "ERROR: there are not " << i << " students assigned to the course" << endl;
    return students[0]; // not nice, but ...
  }

  // case 2: const access
  const Student& operator[](unsigned i) const {
    if (i < enrolled)
      return students[i];
    // proper error handling needed, will be added later
    cout << "ERROR (const): there are not " << i << " students assigned to the course" << endl;
    return students[0]; // not nice, but ...
  }
};

In the first case, we return a reference, so wherever the return value is used, it gives direct access to the specific element - meaning its value can even be overwritten. Since this can modify the Course, the function itself cannot be marked as const.

In the second case, we also return a reference, but this time it’s a const &, so the value cannot be overwritten. This guarantees that no matter what is done with the returned reference, the Course will not be modified - which is why this function can be marked as const.

++ / -- Operators

The expressions ++a, a++, --a, and a-- are familiar to most. The difference between prefix and postfix increment (or decrement), especially for integers, is that:

  • In the prefix version, the expression uses the already incremented/decremented value.
  • In the postfix version, the expression still uses the original (non-incremented/decremented) value when evaluated.

Since these are also operators, they can be overloaded as well. However, there's not much syntactic difference between the prefix and postfix versions, so we need a way to distinguish them in the implementation.

When overloading, the postfix version takes an extra (unused) parameter - typically an int - just to differentiate it from the prefix version. Because of their differing behavior, their return types also differ slightly.

Let’s look at an example!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
class Course {
  unsigned headcount = 0;

 public:
  Course operator++(int) {  // the unused parameter indicates postfix increment
    std::cout << "Welcome from Course postfix ++ operator" << std::endl;
    Course tmp = *this; // Since postfix returns the old state,
    // we need to make a copy of it.
    headcount++;
    return tmp; // since we return a local variable which is destroyed at the end of the method,
    // we cannot return, for example, a reference.
  }

  Course& operator++() {
      std::cout << "Welcome from Course prefix ++ operator" << std::endl;
      ++headcount;
      return *this; // since prefix returns the modified value,
      // no need to make a copy and we can return the object itself,
      // because we want the already updated data.
  }
};

int main() {
    Course c;
    ++c;
    c++;
}

It is useful to implement both together, and in that case, one can be implemented using the other. This way, when fixing bugs, you only need to modify the code in one place.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
class Course {
  unsigned numberOfStudents = 0;

 public:
  Course operator++(int) {  // The unused parameter indicates that this is the postfix increment
    Course tmp = *this; // Since the postfix version usually returns the old state,
    // we need to make a copy of it.
    /* The following part is the same as in the prefix version. Let's call it
    * Although, now it's only one line, it could be more complex code.
    numberOfStudents++;
    */
    operator++();   // Here we call the prefix version, so the increment happens
    // the old value was saved, functionality is preserved.
    return tmp; // since we return a local variable that will be destroyed at the end of the method,
    // we cannot return, for example, a reference.
  }

  Course& operator++() {
      ++numberOfStudents;
      return *this; // since the prefix version returns the modified value,
      // no need to make a copy, we can return the object itself because
      // we want the updated data.
  }
};

int main() {
    Course c;
    ++c;
    c++;
}

Further Operator Overloading

As we saw before, operator overloading simply means implementing a function or method. When we implement an operator as a method of a class, we can omit the first parameter of the operator because it implicitly refers to the object itself. Naturally, if the operator is unary (i.e., it takes only one parameter), then no parameters need to be specified at all, since only the object is required. Examples of such operators include operator++, operator--, operator!, etc.

operator!

The exclamation mark operator (!) often represents negation. It takes one parameter - the object itself. Staying with the Course example, we can consider that some courses require approval. We can store this as a bool value, but if we want to change it, we can also do so using negation. Let’s look at an example!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

class Course {
    // ...
    bool approved = false;

  public:
    Course() = default;

    bool isApproved() const { return approved; }

    /**
    * Our important method / operator.
    * This method toggles the approval status of the course.
    * Returns the new status as a bool.
    */
    bool operator!() { // Important: no parameter needed, since the only parameter is the object itself
        approved = !approved;
        return approved;
    }
};

using namespace std;

int main() {
    Course c;
    cout << "Is the course approved? " << c.isApproved() << endl;
    !c;
    cout << "Is the course approved? " << c.isApproved() << endl;
}

Conversion Operators

Often we convert data, and sometimes we need to do this for a class we've created. We could write a function that always performs the conversion, but it's more convenient to specify how a certain conversion should be done.

For example, if we want to convert a Course object to an int, where the conversion result is the number of enrolled students, we can implement this with a conversion operator.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>
using namespace std;

class Course {
    unsigned enrolled = 0;
  public:
    Course(unsigned enrolled) : enrolled(enrolled) {}

    operator unsigned() const { return enrolled; }
};

int main() {
    Course c(8);
    cout << (unsigned)c << endl;
}

We can see in the above example that we converted our course object to an unsigned type for output. This was done using the conversion operator defined inside the Course class.

Important: For conversion operators, you do not need to specify the return type! This is because the conversion operator's name clearly determines the return type - it is the type you want to convert to. If you use a different type, the compiler will generate an error.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;

class Course {
    unsigned count = 0;
public:
    Course(unsigned count) : count(count) {}

    operator unsigned() const { return count; }
};

void foo(unsigned a) {
    cout << "Foo a: " << a << endl;
}

int main() {
    Course c(8);
    foo(c);
}

In this example, we can see that the conversion does not need to be explicitly written; it happens automatically. However, this can lead to problems because sometimes we may not want the conversion to happen, or we might want to convert to a different type instead.

A solution to this is to make the conversion only possible through explicit casting. This means that the conversion must be explicitly stated, preventing automatic conversions.

To achieve this, we mark the conversion operator with the explicit keyword. This disables implicit conversions and requires explicit casting for the conversion to happen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

class Course {
    unsigned enrollment = 0;
  public:
    Course(unsigned enrollment) : enrollment(enrollment) {}

    explicit operator unsigned() const { return enrollment; }
};

void foo(unsigned a) {
    cout << "Foo a: " << a << endl;
}

int main() {
    Course c(8);
    cout << (unsigned)c << endl;
    // foo(c); // compilation error because implicit conversion is disallowed
    // and explicit cast is required.
}

Conversion in the Other Direction

So far, we have looked at how to convert an existing object into a value via conversion operators. The other direction is how to create an object of a given type from a value.

An example of this can be seen with strings:

1
2
3
4
5
6
7
8
9
#include <iostream>
#include <string>
using namespace std;

int main() {
    string fruit = "Apple";
    // In this case, we convert from a const char* type ("Apple") to a std::string type
    cout << fruit << endl;
}

We can do this with our own class as well. Since we need to create an object based on a value, we have to look at the parts related to the constructor!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
class Course {
    unsigned enrollment = 0;
  public:
    Course() = default;
    Course(unsigned enrollment) : enrollment(enrollment) {}
    unsigned getEnrollment() const { return enrollment; }
};

using namespace std;

int main() {
    Course c1(5u);  // Important: without the unsigned suffix, int literal is also converted
    Course c2;
    Course c3 = (unsigned)5;  // unsigned literal with value 5, could also be 5u!

    cout << c1.getEnrollment() << endl
         << c2.getEnrollment() << endl
         << c3.getEnrollment() << endl;
}

Output

5
0
5

We can see that the c3 object can be initialized with an unsigned value because there is a matching constructor. Naturally, there may be cases when we want to avoid this automatic conversion (constructor call). In such cases, we can mark the constructor as explicit so that it can only be called explicitly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
class Course {
    unsigned numberOfStudents = 0;
  public:
    Course() = default;
    explicit Course(unsigned numberOfStudents) : numberOfStudents(numberOfStudents) {}
    unsigned getNumberOfStudents() const { return numberOfStudents; }
};

using namespace std;

int main() {
    Course c1(5u);
    Course c2;
    // Course c3 = (unsigned)5;    // due to explicit call, this causes a compilation error
    Course c4(5u);
    cout << c1.getNumberOfStudents() << endl
         << c2.getNumberOfStudents() << endl
         // << c3.getNumberOfStudents() << endl
         << c4.getNumberOfStudents() << endl;
}

Last update: 2025-09-04
Created: 2025-09-04