What Is Polymorphic Printing?

A hierarchy earns its keep when code that holds a base reference still gets derived behaviour. Virtual member functions deliver exactly that, and by now the pattern is routine. Stream output is the one place where the machinery seems to stop working: std::cout << device picks its function the same way any other overloaded call does, which is from the type written in the source, not from the object that happens to be there at run time.

Polymorphic printing is the arrangement that restores the missing dispatch. It is two cooperating pieces, and the whole lesson follows from the fact that they are chosen at different times:

Piece What it is When it is chosen
operator<< one non-member function taking a const reference to the base class build time, from the static type of the expression
the hook a virtual member function that the operator calls run time, from the dynamic type of the object

The operator never varies. The hook does all the varying. Neither piece is unusual on its own, and the combination costs about five lines.

Overload resolution reads the static type

Start with the arrangement most people reach for first: give every class in the hierarchy its own operator<<. It looks like an override, and for objects named directly it even behaves like one, but no part of it is virtual. The program below prints the same object twice and gets two different answers.

#include <iostream>

class Sensor
{
private:
    int m_channel{};

public:
    explicit Sensor(int channel)
        : m_channel{ channel }
    {
    }

    virtual ~Sensor() = default;

    int channel() const { return m_channel; }

    friend std::ostream& operator<<(std::ostream& stream, const Sensor& device)
    {
        stream << "Sensor on channel " << device.channel();
        return stream;
    }
};

class ThermalSensor : public Sensor
{
public:
    explicit ThermalSensor(int channel)
        : Sensor{ channel }
    {
    }

    friend std::ostream& operator<<(std::ostream& stream, const ThermalSensor& device)
    {
        stream << "ThermalSensor on channel " << device.channel();
        return stream;
    }
};

int main()
{
    ThermalSensor probe{ 7 };
    Sensor& wired{ probe };

    std::cout << probe << '\n';
    std::cout << wired << '\n';

    return 0;
}

Output:

ThermalSensor on channel 7
Sensor on channel 7

There is only one object in this program. probe and wired name the same ThermalSensor, so the second line is wrong about what it is describing.

The reason is overload resolution. probe has static type ThermalSensor, so the compiler picks the operator whose parameter is const ThermalSensor&. wired has static type Sensor&, so the compiler picks the operator whose parameter is const Sensor& and never looks any further. Both decisions were made while the program was being compiled, when the dynamic type of the object was not available to consult. Anything you write inside a second operator<< is therefore unreachable through a base reference, which is the form every polymorphic container, parameter, and return value takes.

Why the operator cannot be virtual itself

The obvious repair is to mark operator<< virtual and let the usual dispatch take over. C++ blocks that repair twice over, for two independent reasons.

Obstacle Why it applies here
Only member functions can be virtual operator<< is written as a non-member (usually a friend) because its left operand must be the stream, and a member operator would put the class on the left instead. A non-member has no class to be virtual in.
An override must match the signature Even inside a class, Sensor::operator<< would take a Sensor parameter and ThermalSensor::operator<< a ThermalSensor parameter. Different parameter types make the second one an unrelated overload, not an override, so it would never be reached through the base.

Notice that the second obstacle survives the first: fixing the non-member problem would not help, because the two functions still differ in the parameter that is supposed to vary. Any solution has to leave operator<< alone and put the variation somewhere a real override can live.

One operator, one virtual hook

That somewhere is an ordinary virtual member function. Write a single operator<< for the base class, and have it ask the object for its own contribution to the output. The simplest hook returns a std::string.

#include <iostream>
#include <string>

class Sensor
{
private:
    int m_channel{};

public:
    explicit Sensor(int channel)
        : m_channel{ channel }
    {
    }

    virtual ~Sensor() = default;

    int channel() const { return m_channel; }

    virtual std::string label() const { return "Sensor"; }

    friend std::ostream& operator<<(std::ostream& stream, const Sensor& device)
    {
        stream << device.label() << " on channel " << device.channel();
        return stream;
    }
};

class ThermalSensor : public Sensor
{
public:
    explicit ThermalSensor(int channel)
        : Sensor{ channel }
    {
    }

    std::string label() const override { return "ThermalSensor"; }
};

int main()
{
    Sensor plain{ 12 };
    ThermalSensor probe{ 7 };
    Sensor& wired{ probe };

    std::cout << plain << '\n';
    std::cout << probe << '\n';
    std::cout << wired << '\n';

    return 0;
}

Output:

Sensor on channel 12
ThermalSensor on channel 7
ThermalSensor on channel 7

ThermalSensor has no operator<< of its own now, and it does not need one. Each of the three statements takes a slightly different route to the same function:

  • std::cout << plain matches the only candidate exactly. Inside it, device refers to a Sensor, so device.label() dispatches to Sensor::label().
  • std::cout << probe finds no operator taking a ThermalSensor, so the compiler applies the implicit conversion from ThermalSensor& to Sensor& and calls the base operator anyway. The reference now denotes a ThermalSensor, so device.label() dispatches to ThermalSensor::label().
  • std::cout << wired matches the base operator directly, and dispatches to ThermalSensor::label() for the same reason.

The last two lines are the point. The static type of the expression decides which operator runs, and every path funnels into the same one; the dynamic type of the object decides which label() runs, and that is where the derived class gets its say. Add ten more classes to the hierarchy and the operator does not change: one override each is the entire cost.

Nomenclature
The hook is an ordinary member function, so its name is yours to pick. label, describe, and print are all common. Nothing in the language ties operator<< to a particular helper name.

Two shapes for the hook

Returning a string is not the only option, and it is the more limited one. The alternative takes the stream as a parameter and writes to it directly.

Hook returns std::string Hook takes std::ostream&
Signature virtual std::string label() const virtual std::ostream& writeTo(std::ostream& stream) const
Who writes to the stream operator<< the hook
Building the text assemble a whole string first write pieces as they are produced
Member with its own operator<< needs a conversion to string first streams straight through
Stream state (std::setw, flags) not reachable reachable
Cost one string allocated per call none

The first shape reads well when the output is a short fixed name. The second is what you want as soon as a class has to print something it does not know how to spell itself.

Handing the stream to the hook

Here the derived class holds a Calibration member, and Calibration has its own operator<<. With the stream in hand, the hook simply forwards to it.

#include <iostream>

struct Calibration
{
    double offset{};
    int revision{};

    friend std::ostream& operator<<(std::ostream& stream, const Calibration& setup)
    {
        stream << "rev " << setup.revision << ", offset " << setup.offset;
        return stream;
    }
};

class Sensor
{
private:
    int m_channel{};

public:
    explicit Sensor(int channel)
        : m_channel{ channel }
    {
    }

    virtual ~Sensor() = default;

    int channel() const { return m_channel; }

    virtual std::ostream& writeTo(std::ostream& stream) const
    {
        stream << "Sensor on channel " << m_channel;
        return stream;
    }

    friend std::ostream& operator<<(std::ostream& stream, const Sensor& device)
    {
        return device.writeTo(stream);
    }
};

class ThermalSensor : public Sensor
{
private:
    Calibration m_setup{};

public:
    ThermalSensor(int channel, const Calibration& setup)
        : Sensor{ channel }
        , m_setup{ setup }
    {
    }

    std::ostream& writeTo(std::ostream& stream) const override
    {
        stream << "ThermalSensor on channel " << channel() << " (" << m_setup << ')';
        return stream;
    }
};

int main()
{
    Sensor plain{ 12 };
    ThermalSensor probe{ 7, Calibration{ 0.25, 3 } };
    Sensor& wired{ probe };

    std::cout << plain << '\n';
    std::cout << probe << '\n';
    std::cout << wired << '\n';

    return 0;
}

Output:

Sensor on channel 12
ThermalSensor on channel 7 (rev 3, offset 0.25)
ThermalSensor on channel 7 (rev 3, offset 0.25)

operator<< has shrunk to a single forwarding line and now prints nothing itself. ThermalSensor::writeTo uses the stream twice: once for its own text, and once to invoke Calibration's operator on m_setup. Reaching that second use through a string-returning hook would have meant building the nested text by hand.

Returning the stream from the hook is what lets operator<< end with return device.writeTo(stream);. A void hook works just as well if the operator returns stream itself.

Keeping the hook private to the hierarchy

The hook exists to serve operator<<, so callers have no business invoking it directly. Since operator<< is a friend of the base, it can still reach a hook declared protected, and access to an override is checked against the static type, so the derived class may keep its version protected or private too.

Marking the base hook pure virtual goes one step further: it makes Sensor abstract and turns a forgotten override into a compile error rather than a line of base-class text appearing in the log.

#include <iostream>
#include <memory>
#include <vector>

class Sensor
{
private:
    int m_channel{};

protected:
    virtual std::ostream& writeTo(std::ostream& stream) const = 0;

public:
    explicit Sensor(int channel)
        : m_channel{ channel }
    {
    }

    virtual ~Sensor() = default;

    int channel() const { return m_channel; }

    friend std::ostream& operator<<(std::ostream& stream, const Sensor& device)
    {
        return device.writeTo(stream);
    }
};

class ThermalSensor : public Sensor
{
protected:
    std::ostream& writeTo(std::ostream& stream) const override
    {
        stream << "ThermalSensor on channel " << channel();
        return stream;
    }

public:
    explicit ThermalSensor(int channel)
        : Sensor{ channel }
    {
    }
};

class PressureSensor : public Sensor
{
private:
    double m_fullScale{};

protected:
    std::ostream& writeTo(std::ostream& stream) const override
    {
        stream << "PressureSensor on channel " << channel() << " up to " << m_fullScale << " bar";
        return stream;
    }

public:
    PressureSensor(int channel, double fullScale)
        : Sensor{ channel }
        , m_fullScale{ fullScale }
    {
    }
};

int main()
{
    std::vector<std::unique_ptr<Sensor>> rack{};
    rack.push_back(std::make_unique<ThermalSensor>(7));
    rack.push_back(std::make_unique<PressureSensor>(12, 6.5));

    for (const auto& device : rack)
        std::cout << *device << '\n';

    return 0;
}

Output:

ThermalSensor on channel 7
PressureSensor on channel 12 up to 6.5 bar

The loop body is the payoff. It knows nothing about either concrete class, *device has static type Sensor, and each iteration still prints the right text.

Best practice
Give a hierarchy exactly one operator<<, declared for the base and taking a const reference, and put every type-specific decision in a virtual hook. Prefer the hook that takes std::ostream&, since it costs nothing and keeps composition open.
Warning
Adding a second operator<< for a derived class after the hook is in place is worse than useless. Direct uses of that class quietly switch to the new operator while every base reference keeps using the hook, so the same object prints two different strings depending on how the calling code happens to spell its type.

Summary

operator<< is not a member function, so it cannot be virtual, and even as a member it could not be an override because base and derived versions differ in the parameter that matters. Overload resolution therefore picks it from the static type of the expression, which is why one operator<< per class prints base text whenever the object is reached through a base reference.

The fix keeps operator<< non-virtual and single. Declare one for the base, and have it call a virtual member function that each derived class overrides. The operator is selected at compile time; the hook is dispatched at run time; between them the output follows the real type of the object.

Two hook shapes are in common use. A hook returning std::string is the simplest, and the operator prints whatever string comes back. A hook taking std::ostream& writes to the stream directly, which is what you need when the class must print a member that has its own operator<<, or when it wants control over stream state.

Either way, derived classes never need an operator<< of their own. A derived object passed by value or by reference converts implicitly to the base parameter, the base operator runs, and the virtual call inside it lands on the derived override. That is what makes a std::vector of base pointers print correctly with one unremarkable loop.