Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Output Streaming for Hierarchies
Implement polymorphic output by combining virtual functions with operator<<.
Printing inherited classes using operator<<
Consider the following program that makes use of a virtual function:
#include <iostream>
class Storage
{
public:
virtual void display() const { std::cout << "Storage"; }
};
class CloudStorage : public Storage
{
public:
void display() const override { std::cout << "CloudStorage"; }
};
int main()
{
CloudStorage cs{};
Storage& s{ cs };
s.display(); // will call CloudStorage::display()
return 0;
}
By now, you should be comfortable with the fact that s.display() will call CloudStorage::display() (because s is referencing a CloudStorage class object, Storage::display() is a virtual function, and CloudStorage::display() is an override).
While calling member functions like this to do output is okay, this style of function doesn't mix well with std::cout:
#include <iostream>
int main()
{
CloudStorage cs{};
Storage& s{ cs };
std::cout << "s is a ";
s.display(); // messy, we have to break our print statement to call this function
std::cout << '\n';
return 0;
}
In this lesson, we'll look at how to override operator<< for classes using inheritance, so that we can use operator<< as expected, like this:
std::cout << "s is a " << s << '\n'; // much better
The challenges with operator<<
Let's start by overloading operator<< in the typical way:
#include <iostream>
class Storage
{
public:
virtual void display() const { std::cout << "Storage"; }
friend std::ostream& operator<<(std::ostream& out, const Storage& s)
{
out << "Storage";
return out;
}
};
class CloudStorage : public Storage
{
public:
void display() const override { std::cout << "CloudStorage"; }
friend std::ostream& operator<<(std::ostream& out, const CloudStorage& cs)
{
out << "CloudStorage";
return out;
}
};
int main()
{
Storage s{};
std::cout << s << '\n';
CloudStorage cs{};
std::cout << cs << '\n';
return 0;
}
Because there is no need for virtual function resolution here, this program works as we'd expect, and prints:
Storage CloudStorage
Now, consider the following main() function instead:
int main()
{
CloudStorage cs{};
Storage& sref{ cs };
std::cout << sref << '\n';
return 0;
}
This program prints:
Storage
That's probably not what we were expecting. This happens because our version of operator<< that handles Storage objects isn't virtual, so std::cout << sref calls the version of operator<< that handles Storage objects rather than CloudStorage objects.
Therein lies the challenge.
Can we make operator<< virtual?
If this issue is that operator<< isn't virtual, can't we simply make it virtual?
The short answer is no. There are a number of reasons for this.
First, only member functions can be virtualized -- this makes sense, since only classes can inherit from other classes, and there's no way to override a function that lives outside of a class (you can overload non-member functions, but not override them). Because we typically implement operator<< as a friend, and friends aren't considered member functions, a friend version of operator<< is ineligible to be virtualized. (For a review of why we implement operator<< this way, please revisit lesson 21.5 -- Overloading operators using member functions).
Second, even if we could virtualize operator<< there's the problem that the function parameters for Storage::operator<< and CloudStorage::operator<< differ (the Storage version would take a Storage parameter and the CloudStorage version would take a CloudStorage parameter). Consequently, the CloudStorage version wouldn't be considered an override of the Storage version, and thus be ineligible for virtual function resolution.
So what's a programmer to do?
A solution
The answer, as it turns out, is surprisingly simple.
First, we set up operator<< as a friend in our base class as usual. But rather than have operator<< determine what to print, we will instead have it call a normal member function that can be virtualized! This virtual function will do the work of determining what to print for each class.
In this first solution, our virtual member function (which we call identify()) returns a std::string, which is printed by Storage::operator<<:
#include <iostream>
class Storage
{
public:
// Here's our overloaded operator<<
friend std::ostream& operator<<(std::ostream& out, const Storage& s)
{
// Call virtual function identify() to get the string to be printed
out << s.identify();
return out;
}
// We'll rely on member function identify() to return the string to be printed
// Because identify() is a normal member function, it can be virtualized
virtual std::string identify() const
{
return "Storage";
}
};
class CloudStorage : public Storage
{
public:
// Here's our override identify() function to handle the CloudStorage case
std::string identify() const override
{
return "CloudStorage";
}
};
int main()
{
Storage s{};
std::cout << s << '\n';
CloudStorage cs{};
std::cout << cs << '\n'; // note that this works even with no operator<< that explicitly handles CloudStorage objects
Storage& sref{ cs };
std::cout << sref << '\n';
return 0;
}
This prints the expected result:
Storage CloudStorage CloudStorage
Let's examine how this works in more detail.
In the case of Storage s, operator<< is called with parameter s referencing the Storage object. Virtual function call s.identify() thus resolves to Storage::identify(), which returns "Storage" to be printed. Nothing too special here.
In the case of CloudStorage cs, the compiler first looks to see if there's an operator<< that takes a CloudStorage object. There isn't one, because we didn't define one. Next the compiler looks to see if there's an operator<< that takes a Storage object. There is, so the compiler does an implicit upcast of our CloudStorage object to a Storage& and calls the function (we could have done this upcast ourselves, but the compiler is helpful in this regard). Because parameter s is referencing a CloudStorage object, virtual function call s.identify() resolves to CloudStorage::identify(), which returns "CloudStorage" to be printed.
Note that we don't need to define an operator<< for each derived class! The version that handles Storage objects works just fine for both Storage objects and any class derived from Storage!
The third case proceeds as a mix of the first two. First, the compiler matches variable sref with operator<< that takes a Storage reference. Because parameter s is referencing a CloudStorage object, s.identify() resolves to CloudStorage::identify(), which returns "CloudStorage".
Problem solved.
A more flexible solution
The above solution works great, but has two potential shortcomings:
- It makes the assumption that the desired output can be represented as a single std::string.
- Our
identify()member function does not have access to the stream object.
The latter is problematic in cases where we need a stream object, such as when we want to print the value of a member variable that has an overloaded operator<<.
Fortunately, it's straightforward to modify the above example to resolve both of these issues. In the previous version, virtual function identify() returned a string to be printed by Storage::operator<<. In this version, we'll instead define virtual member function print() and delegate responsibility for printing directly to that function.
Here's an example that illustrates the idea:
#include <iostream>
class Storage
{
public:
// Here's our overloaded operator<<
friend std::ostream& operator<<(std::ostream& out, const Storage& s)
{
// Delegate printing responsibility for printing to virtual member function print()
return s.print(out);
}
// We'll rely on member function print() to do the actual printing
// Because print() is a normal member function, it can be virtualized
virtual std::ostream& print(std::ostream& out) const
{
out << "Storage";
return out;
}
};
// Some class or struct with an overloaded operator<<
struct Server
{
std::string name{};
int id{};
friend std::ostream& operator<<(std::ostream& out, const Server& srv)
{
out << "Server(" << srv.name << ", " << srv.id << ")";
return out;
}
};
class CloudStorage : public Storage
{
private:
Server m_server{}; // CloudStorage now has a Server member
public:
CloudStorage(const Server& srv)
: m_server{ srv }
{
}
// Here's our override print() function to handle the CloudStorage case
std::ostream& print(std::ostream& out) const override
{
out << "CloudStorage: ";
// Print the Server member using the stream object
out << m_server;
return out;
}
};
int main()
{
Storage s{};
std::cout << s << '\n';
CloudStorage cs{ Server{"AWS", 101}};
std::cout << cs << '\n'; // note that this works even with no operator<< that explicitly handles CloudStorage objects
Storage& sref{ cs };
std::cout << sref << '\n';
return 0;
}
This outputs:
Storage CloudStorage: Server(AWS, 101) CloudStorage: Server(AWS, 101)
In this version, Storage::operator<< doesn't do any printing itself. Instead, it just calls virtual member function print() and passes it the stream object. The print() function then uses this stream object to do its own printing. Storage::print() uses the stream object to print "Storage". More interestingly, CloudStorage::print() uses the stream object to print both "CloudStorage: " and to call Server::operator<< to print the value of member m_server. The latter would have been more challenging to do in the prior example!
Summary
The problem with operator<< and inheritance: When implementing operator<< as a friend function (the typical approach), it cannot be made virtual because only member functions can be virtualized. Additionally, even if operator<< could be virtual, the function parameters differ between base and derived classes, so derived versions wouldn't be considered overrides.
Solution using virtual helper function: Instead of making operator<< virtual, implement it as a friend function that calls a virtual member function. The operator<< function handles stream operations while delegating the actual printing logic to a virtual member function that can be overridden in derived classes.
First approach - returning strings: One solution is to have the virtual member function return a std::string containing what should be printed. The operator<< function then prints this string. This works well when the output can be easily represented as a single string.
Second approach - passing the stream: A more flexible solution is to have the virtual member function accept the stream object as a parameter and perform the printing directly. This approach allows the derived class to print member variables that have overloaded operator<< functions and provides more control over formatting.
Automatic upcasting: When you use operator<< with a derived class object directly (not through a pointer or reference), the compiler automatically upcasts the derived class to the base class if no matching operator<< exists for the derived class. The base class operator<< then calls the virtual member function, which resolves to the correct derived class version.
No explicit derived operator<< needed: With either approach, you don't need to define operator<< for each derived class. The base class version works for both base and derived class objects, with the virtual member function handling the type-specific behavior.
Polymorphic printing: When using a base class reference or pointer to a derived class object, operator<< correctly calls the derived class's virtual member function through dynamic dispatch, ensuring the derived class's printing logic is used.
This pattern of using a non-virtual operator<< that calls a virtual helper function is a common and effective way to enable polymorphic printing behavior in C++ class hierarchies. It maintains the convenience of operator<< while leveraging the flexibility of virtual functions for customized printing in derived classes.
Output Streaming for Hierarchies - Quiz
Test your understanding of the lesson.
Practice Exercises
Printing Inherited Classes
Implement operator<< for printing polymorphic classes. Learn the virtual function helper pattern to enable proper printing of derived classes through base class references.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!