18.11 – Печать объектов классов, использующих наследование, с помощью operator<<

Добавлено 21 августа 2021 в 17:07

Рассмотрим следующую программу, которая использует виртуальную функцию:

#include <iostream>
 
class Base
{
public:
    virtual void print() const { std::cout << "Base";  }
};
 
class Derived : public Base
{
public:
    void print() const override { std::cout << "Derived"; }
};
 
int main()
{
    Derived d{};
    Base& b{ d };
    b.print(); // вызовет Derived::print()
 
    return 0;
}

К настоящему времени вы должны быть удовлетоворены тем фактом, что b.print() будет вызывать Derived::print() (поскольку b указывает на объект класса Derived, Base::print() является виртуальной функцией, а Derived::print() – это переопределение).

Хотя вызов таких функций-членов для вывода результатов – это нормально, этот стиль функции плохо сочетается с std::cout:

#include <iostream>
 
int main()
{
    Derived d{};
    Base& b{ d };
 
    std::cout << "b is a ";
    b.print(); // неаккуратно, мы должны прервать нашу
               // инструкцию печати, чтобы вызвать эту функцию
    std::cout << '\n';
 
    return 0;
}

В этом уроке мы рассмотрим, как переопределить operator<< для классов, использующих наследование, чтобы мы могли использовать operator<< как обычно, например:

std::cout << "b is a " << b << '\n'; // намного лучше

Проблемы с operator<<

Начнем с перегрузки operator<< обычным способом:

#include <iostream>
 
class Base
{
public:
    virtual void print() const { std::cout << "Base"; }
 
    friend std::ostream& operator<<(std::ostream& out, const Base& b)
    {
        out << "Base";
        return out;
    }
};
 
class Derived : public Base
{
public:
    virtual void print() const override { std::cout << "Derived"; }
 
    friend std::ostream& operator<<(std::ostream& out, const Derived& d)
    {
        out << "Derived";
        return out;
    }
};
 
int main()
{
    Base b{};
    std::cout << b << '\n';
 
    Derived d{};
    std::cout << d << '\n';
 
    return 0;
}

Поскольку здесь нет необходимости в разрешении вызова виртуальной функции, эта программа работает так, как мы и ожидали, и печатает:

Base
Derived

Теперь рассмотрим вместо этого следующую функцию main():

int main()
{
    Derived d{};
    Base& bref{ d };
    std::cout << bref << '\n';
    
    return 0;
}

Эта программа печатает:

Base

Вероятно, это не то, чего мы ожидали. Это происходит потому, что наша версия operator<<, которая обрабатывает объекты Base, не является виртуальной, поэтому std::cout << bref вызывает версию operator<<, которая обрабатывает объекты Base, а не объекты Derived.

В этом и заключается проблема.

Можем ли мы сделать operator<< виртуальным?

Если проблема в том, что operator<< не виртуальный, не можем ли мы просто сделать его виртуальным?

Краткий ответ: нет. Для этого есть ряд причин.

Во-первых, виртуализированы могут быть только функции-члены – и это понятно, поскольку только классы могут наследоваться от других классов, и нет способа переопределить функцию, которая живет за пределами класса (вы можете перегружать функции, не являющиеся членами, но не переопределять их). Поскольку мы обычно реализуем operator<< как друг, а дружественные функции не считаются функциями-членами, дружественная версия оператора operator<< не может быть виртуализирована (чтобы узнать, почему мы реализуем operator<< таким образом, обратитесь к уроку «13.5 – Перегрузка операторов, используя функции-члены»).

Во-вторых, даже если бы мы могли виртуализировать operator<<, проблема в том, что параметры функции для Base::operator<< и Derived::operator<< различаются (версия для Base будет принимать параметр Base, а версия для Derived будет принимать параметр Derived). Следовательно, версия оператора для Derived не будет считаться переопределением версии для Base и, следовательно, не будет соответствовать разрешению вызова виртуальной функции.

Так что же делать программисту?

Решение

Ответ, как выясняется, на удивление прост.

Во-первых, мы, как обычно, установили operator<< в нашем базовом классе как друга. Но вместо того, чтобы печать выполнял operator<<, мы делегируем эту работу обычной функции-члену, которую можно виртуализировать!

Вот полное рабочее решение:

#include <iostream>
 
class Base
{
public:
    // Вот наш перегруженный operator<<
    friend std::ostream& operator<<(std::ostream& out, const Base& b)
    {
        // Делегируем ответственность за печать в функцию-члена print()
        return b.print(out);
    }
 
    // Для фактической печати мы будем полагаться на функцию-член print()
    // Поскольку print - обычная функция-член, ее можно виртуализировать
    virtual std::ostream& print(std::ostream& out) const
    {
        out << "Base";
        return out;
    }
};
 
class Derived : public Base
{
public:
    // Вот наша переопределенная функция print для обработки Derived
    virtual std::ostream& print(std::ostream& out) const override
    {
        out << "Derived";
        return out;
    }
};
 
int main()
{
    Base b{};
    std::cout << b << '\n';
 
    Derived d{};
 
    // обратите внимание, что это работает даже без operator<<,
    // который явно обрабатывает объекты Derived 
    std::cout << d << '\n'; 
 
    Base& bref{ d };
    std::cout << bref << '\n';
 
    return 0;
}

Показанная выше программа работает во всех трех случаях:

Base
Derived
Derived

Давайте разберемся, что здесь происходит, поподробнее.

Сначала в случае с Base мы вызываем operator<<, который вызывает виртуальную функцию print(). Поскольку наш параметр ссылки Base указывает на объект Base, вызов b.print() преобразуется в Base::print(), что выполняет печать. Здесь нет ничего особенного.

В случае с Derived компилятор сначала проверяет, есть ли operator<<, который принимает объект Derived. Его нет, потому что мы его не определили. Затем компилятор проверяет, есть ли operator<<, который принимает объект Base. Он есть, поэтому компилятор выполняет неявное преобразование нашего объекта Derived в Base& и вызывает эту функцию (мы могли бы выполнить это преобразование самостоятельно, но в этом отношении компилятор полезнее). Затем эта функция вызывает виртуальную функцию print(), вызов которой преобразуется в Derived::print().

Обратите внимание, что нам не нужно определять operator<< для каждого производного класса! Версия, которая обрабатывает объекты Base, отлично работает как с объектами Base, так и с любым классом, производным от Base!

Третий случай представляет собой смесь первых двух. Во-первых, компилятор сопоставляет переменную bref с функцией operator<<, которая принимает значение Base. Это вызывает нашу виртуальную функцию print(). Поскольку ссылка типа Base на самом деле указывает на объект Derived, этот вызов разрешается в Derived::print(), как мы и предполагали.

Задача решена.

Теги

C++ / CppLearnCppВиртуальная функцияДля начинающихНаследованиеОбучениеПерегрузка операторовПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.