Introduction to overloading the I/O operators

In the prior lesson, we showed this example, where we used a function to convert an enumeration into an equivalent string:

#include <iostream>
#include <string_view>

enum Priority
{
    low,
    medium,
    high,
};

constexpr std::string_view getPriorityName(Priority priority)
{
    switch (priority)
    {
    case low:    return "low";
    case medium: return "medium";
    case high:   return "high";
    default:     return "???";
    }
}

int main()
{
    constexpr Priority taskPriority{ high };

    std::cout << "Your task priority is " << getPriorityName(taskPriority) << '\n';

    return 0;
}

Although the above example works just fine, there are two downsides:

  1. We have to remember the name of the function we created to get the enumerator name.
  2. Having to call such a function adds clutter to our output statement.

Ideally, it would be nice if we could somehow teach operator<< to output an enumeration, so we could do something like this: std::cout << taskPriority and have it do what we expect.

Introduction to operator overloading

In an earlier lesson, we introduced function overloading, which allows us to create multiple functions with the same name so long as each function has a unique function prototype. Using function overloading, we can create variations of a function that work with different data types, without having to think up a unique name for each variant.

Similarly, C++ also supports operator overloading, which lets us define overloads of existing operators, so that we can make those operators work with our program-defined data types.

Basic operator overloading is fairly straightforward:

  • Define a function using the name of the operator as the function's name.
  • Add a parameter of the appropriate type for each operand (in left-to-right order). One of these parameters must be a user-defined type (a class type or an enumerated type), otherwise the compiler will error.
  • Set the return type to whatever type makes sense.
  • Use a return statement to return the result of the operation.

When the compiler encounters the use of an operator in an expression and one or more of the operands is a user-defined type, the compiler will check to see if there is an overloaded operator function that it can use to resolve that call. For example, given some expression a + b, the compiler will use function overload resolution to see if there is an operator+(a, b) function call that it can use to evaluate the operation. If a non-ambiguous operator+ function can be found, it will be called, and the result of the operation returned as the return value.

Related Content
We cover operator overloading in much more detail in a later chapter.

Advanced note: Operators can also be overloaded as member functions of the left-most operand. We discuss this in a later lesson.

Overloading operator<< to print an enumerator

Before we proceed, let's quickly recap how operator<< works when used for output.

Consider a simple expression like std::cout << 10. std::cout has type std::ostream (which is a user-defined type in the standard library), and 10 is a literal of type int.

When this expression is evaluated, the compiler will look for an overloaded operator<< function that can handle arguments of type std::ostream and int. It will find such a function (also defined as part of the standard I/O library) and call it. Inside that function, std::cout is used to output the value (exactly how is implementation-defined). Finally, the operator<< function returns its left-operand (which in this case is std::cout), so that subsequent calls to operator<< can be chained.

With the above in mind, let's implement an overload of operator<< to print a Priority:

#include <iostream>
#include <string_view>

enum Priority
{
    low,
    medium,
    high,
};

constexpr std::string_view getPriorityName(Priority priority)
{
    switch (priority)
    {
    case low:    return "low";
    case medium: return "medium";
    case high:   return "high";
    default:     return "???";
    }
}

// Teach operator<< how to print a Priority
// std::ostream is the type of std::cout, std::cerr, etc...
// The return type and parameter type are references (to prevent copies from being made)
std::ostream& operator<<(std::ostream& out, Priority priority)
{
    out << getPriorityName(priority); // print our priority's name to whatever output stream out
    return out;                        // operator<< conventionally returns its left operand

    // The above can be condensed to the following single line:
    // return out << getPriorityName(priority)
}

int main()
{
    Priority taskPriority{ high };
    std::cout << "Your task priority is " << taskPriority << '\n'; // it works!

    return 0;
}

This prints:

Your task priority is high

Let's unpack our overloaded operator function a bit. First, the name of the function is operator<<, since that is the name of the operator we're overloading. operator<< has two parameters. The left parameter (which will be matched with the left operand) is our output stream, which has type std::ostream. We use pass by non-const reference here because we don't want to make a copy of a std::ostream object when the function is called, but the std::ostream object needs to be modified in order to do output. The right parameter (which will be matched with the right operand) is our Priority object. Since operator<< conventionally returns its left operand, the return type matches the type of the left-operand, which is std::ostream&.

Now let's look at the implementation. A std::ostream object already knows how to print a std::string_view using operator<< (this comes as part of the standard library). So out << getPriorityName(priority) simply fetches our priority's name as a std::string_view and then prints it to the output stream.

Note that our implementation uses parameter out instead of std::cout because we want to allow the caller to determine which output stream they will output to (e.g. std::cerr << priority should output to std::cerr, not std::cout).

Returning the left operand is also easy. The left operand is parameter out, so we just return out.

Putting it all together: when we call std::cout << taskPriority, the compiler will see that we've overloaded operator<< to work with objects of type Priority. Our overloaded operator<< function is then called with std::cout as the out parameter, and our taskPriority variable (which has value high) as parameter priority. Since out is a reference to std::cout, and priority is a copy of enumerator high, the expression out << getPriorityName(priority) prints "high" to the console. Finally out is returned back to the caller in case we want to chain additional output.

Overloading operator>> to input an enumerator

Similar to how we were able to teach operator<< to output an enumeration above, we can also teach operator>> how to input an enumeration:

#include <iostream>
#include <limits>
#include <optional>
#include <string>
#include <string_view>

enum Mood
{
    happy,   // 0
    sad,     // 1
    anxious, // 2
    calm,    // 3
};

constexpr std::string_view getMoodName(Mood mood)
{
    switch (mood)
    {
    case happy:   return "happy";
    case sad:     return "sad";
    case anxious: return "anxious";
    case calm:    return "calm";
    default:      return "???";
    }
}

constexpr std::optional<Mood> getMoodFromString(std::string_view sv)
{
    if (sv == "happy")   return happy;
    if (sv == "sad")     return sad;
    if (sv == "anxious") return anxious;
    if (sv == "calm")    return calm;

    return {};
}

// mood is an in/out parameter
std::istream& operator>>(std::istream& in, Mood& mood)
{
    std::string input{};
    in >> input; // get input string from user

    std::optional<Mood> match{ getMoodFromString(input) };
    if (match) // if we found a match
    {
        mood = *match; // dereference std::optional to get matching enumerator
        return in;
    }

    // We didn't find a match, so input must have been invalid
    // so we will set input stream to fail state
    in.setstate(std::ios_base::failbit);

    // On an extraction failure, operator>> zero-initializes fundamental types
    // Uncomment the following line to make this operator do the same thing
    // mood = {};

    return in;
}

int main()
{
    std::cout << "Enter a mood: happy, sad, anxious, or calm: ";
    Mood currentMood{};
    std::cin >> currentMood;

    if (std::cin) // if we found a match
        std::cout << "You chose: " << getMoodName(currentMood) << '\n';
    else
    {
        std::cin.clear(); // reset the input stream to good
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
        std::cout << "Your mood was not valid\n";
    }

    return 0;
}

There are a few differences from the output case worth noting here. First, std::cin has type std::istream, so we use std::istream& as the type of our left parameter and return value instead of std::ostream&. Second, the mood parameter is a non-const reference. This allows our operator>> to modify the value of the right operand that is passed in if our extraction results in a match.

Key Concept
Our right operand (`mood`) is an out parameter. We cover out parameters in an earlier lesson.

If mood was a value parameter rather than a reference parameter, then our operator>> function would end up assigning a new value to a copy of the right operand rather than the actual right operand. We want our right operand to be modified in this case.

Inside the function, we use operator>> to input a std::string (something it already knows how to do). If the value the user enters matches one of our moods, then we can assign mood the appropriate enumerator and return the left operand (in).

If the user did not enter a valid mood, then we handle that case by putting std::cin into "failure mode". This is the state that std::cin typically goes into when an extraction fails. The caller can then check std::cin to see if the extraction succeeded or failed.

In a later lesson on arrays, we show how we can use std::array to make our input and output operators less redundant, and avoid having to modify them when a new enumerator is added.

Summary

Operator overloading: Allows us to define how existing operators work with program-defined types. To overload an operator, create a function using the operator name (e.g., operator<<), with parameters matching the operands. At least one parameter must be a user-defined type.

Overloading operator<< for output: Takes a std::ostream& parameter (the output stream) and a parameter of your type. The function should output the value to the stream and return the stream by reference. This enables chaining of output operations and allows your type to work with std::cout, std::cerr, and any other output stream.

Overloading operator>> for input: Takes a std::istream& parameter (the input stream) and a non-const reference to your type (an out parameter). The function should read from the stream, validate the input, and either set the output parameter or put the stream into a failure state. Return the stream by reference for consistency.

Input validation and failure handling: When input is invalid, set the stream to failbit state using in.setstate(std::ios_base::failbit). The caller can then check the stream's state to determine whether extraction succeeded or failed.

Benefits over helper functions: Overloading I/O operators provides cleaner, more intuitive syntax compared to calling helper functions. Instead of std::cout << getPriorityName(priority), we can simply write std::cout << priority, making code more readable and consistent with built-in types.

These operator overloads integrate seamlessly with the standard library's stream system, making program-defined types feel like first-class citizens in C++.