18.2 – Виртуальные функции и полиморфизм

Добавлено 15 августа 2021 в 16:31

В предыдущем уроке, посвященном указателям и ссылкам базового класса на объекты производных классов, мы рассмотрели ряд примеров, в которых использование указателей или ссылок базового класса могло упростить код. Однако в каждом случае мы сталкивались с проблемой, заключающейся в том, что указатель или ссылка базового класса могли вызывать только базовую версию функции, а не ее производную версию.

Вот простой пример такого поведения:

#include <iostream>
#include <string_view>
 
class Base
{
public:
    std::string_view getName() const { return "Base"; }
};
 
class Derived: public Base
{
public:
    std::string_view getName() const { return "Derived"; }
};
 
int main()
{
    Derived derived;
    Base& rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';
 
    return 0;
}

Этот пример выводит следующий результат:

rBase is a Base

Поскольку rBase является ссылкой типа Base, она вызывает Base::getName(), даже если на самом деле ссылается на часть Base объекта Derived.

В этом уроке мы покажем, как решить эту проблему с помощью виртуальных функций.

Виртуальные функции и полиморфизм

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

Чтобы сделать функцию виртуальной, просто поместите перед объявлением функции ключевое слово virtual.

Вот приведенный выше пример, переписанный с использованием виртуальной функции:

#include <iostream>
#include <string_view>
 
class Base
{
public:
    // обратите внимание на ключевое слово virtual
    virtual std::string_view getName() const { return "Base"; }
};
 
class Derived: public Base
{
public:
    virtual std::string_view getName() const { return "Derived"; }
};
 
int main()
{
    Derived derived;
    Base& rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';
 
    return 0;
}

В этом примере выводится следующий результат:

rBase is a Derived

Поскольку rBase является ссылкой на часть Base объекта Derived, при вычислении rBase.getName() вызов обычно преобразуется в Base::getName(). Однако Base::getName() является виртуальной, что указывает программе посмотреть, есть ли еще производные версии этой функции, доступные в Base и Derived. В этом случае вызов будет преобразован в Derived::getName()!

Давайте посмотрим на более сложный пример:

#include <iostream>
#include <string_view>
 
class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};
 
class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};
 
class C: public B
{
public:
    virtual std::string_view getName() const { return "C"; }
};
 
class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};
 
int main()
{
    C c;
    A& rBase{ c };
    std::cout << "rBase is a " << rBase.getName() << '\n';
 
    return 0;
}

Как вы думаете, что будет выводить эта программа?

Давайте посмотрим, как это работает. Сначала мы создаем экземпляр объекта класса C. rBase – это ссылка типа A, которую мы установили для ссылки на часть A объекта C. Наконец, мы вызываем rBase.getName(). rBase.getName() вычисляется как A::getName(). Однако A::getName() является виртуальной, поэтому компилятор вызовет наиболее дочернюю совпадающую функцию между классами A и C. В данном случае это C::getName(). Обратите внимание, что он не будет вызывать D::getName(), потому что наш исходный объект был C, а не D, поэтому рассматриваются функции только между A и C.

В результате наша программа выводит:

rBase is a C

Более сложный пример

Давайте еще раз посмотрим на пример с животными, с которым мы работали в предыдущем уроке. Вот исходный код классов и тестовый код:

#include <iostream>
#include <string>
#include <string_view>
 
class Animal
{
protected:
    std::string m_name;
 
    // Мы делаем этот конструктор защищенным, потому что
    // не хотим, чтобы люди создавали объекты Animal напрямую,
    // но мы по-прежнему хотим, чтобы производные классы могли
    // его использовать.
    Animal(const std::string& name)
        : m_name{ name }
    {
    }
 
public:
    const std::string& getName() const { return m_name; }
    std::string_view speak() const { return "???"; }
};
 
class Cat: public Animal
{
public:
    Cat(const std::string& name)
        : Animal{ name }
    {
    }
 
    std::string_view speak() const { return "Meow"; }
};
 
class Dog: public Animal
{
public:
    Dog(const std::string& name)
        : Animal{ name }
    {
    }
 
    std::string_view speak() const { return "Woof"; }
};
 
void report(const Animal& animal)
{
    std::cout << animal.getName() << " says " << animal.speak() << '\n';
}
 
int main()
{
    Cat cat{ "Fred" };
    Dog dog{ "Garbo" };
 
    report(cat);
    report(dog);
 
    return 0;
}

Этот код печатает:

Fred says ???
Garbo says ???

А вот эквивалентные классы с виртуальной функцией speak():

#include <iostream>
#include <string>
#include <string_view>
 
class Animal
{
protected:
    std::string m_name;
 
    // Мы делаем этот конструктор защищенным, потому что
    // не хотим, чтобы люди создавали объекты Animal напрямую,
    // но мы по-прежнему хотим, чтобы производные классы могли
    // его использовать.
    Animal(const std::string& name)
        : m_name{ name }
    {
    }
 
public:
    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const { return "???"; }
};
 
class Cat: public Animal
{
public:
    Cat(const std::string& name)
        : Animal{ name }
    {
    }
 
    virtual std::string_view speak() const { return "Meow"; }
};
 
class Dog: public Animal
{
public:
    Dog(const std::string& name)
        : Animal{ name }
    {
    }
 
    virtual std::string_view speak() const { return "Woof"; }
};
 
void report(const Animal& animal)
{
    std::cout << animal.getName() << " says " << animal.speak() << '\n';
}
 
int main()
{
    Cat cat{ "Fred" };
    Dog dog{ "Garbo" };
 
    report(cat);
    report(dog);
 
    return 0;
}

Эта программа дает следующий результат:

Fred says Meow
Garbo says Woof

Всё работает!

При вычислении animal.speak() программа замечает, что Animal::speak() является виртуальной функцией. В случае, когда animal ссылается на часть Animal объекта Cat, программа просматривает все классы между Animal и Cat, чтобы узнать, может ли она найти более дочернюю функцию. В этом случае она находит Cat::speak(). В случае, когда animal ссылается на часть Animal объекта Dog, программа вычисляет этот вызов функции как Dog::speak().

Обратите внимание, что мы не сделали виртуальной Animal::getName(). Это связано с тем, что getName() никогда не переопределяется ни в одном из производных классов, поэтому в этом нет необходимости.

Точно так же следующий пример массива теперь работает правильно:

Cat fred{ "Fred" };
Cat misty{ "Misty" };
Cat zeke{ "Zeke" };
 
Dog garbo{ "Garbo" };
Dog pooky{ "Pooky" };
Dog truffle{ "Truffle" };
 
// Создаем массив указателей на животных и устанавливаем
// эти указатели на наши объекты Cat и Dog
Animal *animals[]{ &fred, &garbo, &misty, &pooky, &truffle, &zeke };
 
for (const auto *animal : animals)
    std::cout << animal->getName() << " says " << animal->speak() << '\n';

Что дает следующий результат:

Fred says Meow
Garbo says Woof
Misty says Meow
Pooky says Woof
Truffle says Woof
Zeke says Meow

Несмотря на то, что в этих двух примерах используются только Cat и Dog, любые другие классы, производные от Animal, также будут работать с нашей функцией report() и массивом animals без дальнейших изменений! Это, пожалуй, самое большое преимущество виртуальных функций – возможность структурировать код таким образом, чтобы новые производные классы автоматически работали со старым кодом без его изменения!

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

Использование ключевого слова virtual

Если функция помечена как виртуальная, все совпадающие переопределения также считаются виртуальными, даже если они явно не отмечены как таковые. Однако наличие ключевого слова virtual в производных функциях не повредит и служит полезным напоминанием о том, что данная функция является виртуальной функцией, а не обычной. Следовательно, обычно в производных классах рекомендуется использовать ключевое слово virtual для виртуализированных функций, даже если это не является строго необходимым.

Типы возвращаемых значений виртуальных функций

В обычных условиях тип возвращаемого значения виртуальной функции и ее переопределения должны совпадать. Рассмотрим следующий пример:

class Base
{
public:
    virtual int getValue() const { return 5; }
};
 
class Derived: public Base
{
public:
    virtual double getValue() const { return 6.78; }
};

В этом случае Derived::getValue() не считается совпадающим переопределением для Base::getValue(), и компиляция завершится ошибкой.

Не вызывайте виртуальные функции из конструкторов или деструкторов

Вот еще одна проблема, с которой часто сталкиваются ничего не подозревающие программисты. Вы не должны вызывать виртуальные функции из конструкторов или деструкторов. Почему?

Помните, что когда создается объект производного класса Derived, сначала создается часть базового класса Base. Если бы вы вызывали виртуальную функцию из конструктора класса Base, когда часть класса Derived еще даже не была создана, было бы невозможно вызвать версию этой функции из Derived, потому что еще нет объекта класса Derived, с которым функция из Derived могла бы работать. В C++ вместо этого вызывается версия из класса Base .

Аналогичная проблема существует и для деструкторов. Если вы вызываете виртуальную функцию в деструкторе базового класса, она всегда будет преобразовываться в версию функции базового класса, потому что часть производного класса к этому моменту уже будет уничтожена.

Лучшая практика


Никогда не вызывайте виртуальные функции из конструкторов или деструкторов.

Обратная сторона виртуальных функций

Поскольку в большинстве случаев вы хотите, чтобы ваши функции были виртуальными, почему бы просто не сделать все функции виртуальными? Ответ в том, что это неэффективно – разрешение вызова виртуальной функции занимает больше времени, чем разрешение вызова обычной функции. Кроме того, компилятор также должен выделить дополнительный указатель для каждого объекта класса, который имеет одну или несколько виртуальных функций. Мы поговорим об этом подробнее в будущих уроках данной главы.

Небольшой тест

Вопрос 1

Что печатают следующие программы? Это упражнение должно выполняться путем проверки в уме, а не путем компиляции примеров с помощью компилятора.

1а)

#include <iostream>
#include <string_view>
 
class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};
 
class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};
 
class C: public B
{
public:
// Обратите внимание: здесь нет функции getName()
};
 
class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};
 
int main()
{
    C c;
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';
 
    return 0;
}

B

rBase – это ссылка типа A, указывающая на объект C. Обычно rBase.getName() вызывает A::getName(), но A::getName() является виртуальной, поэтому вместо этого вызывается наиболее дочерняя совпадающая функция между классами A и C. Это B::getName(), которая печатает B.

1b)

#include <iostream>
#include <string_view>
 
class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};
 
class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};
 
class C: public B
{
public:
    virtual std::string_view getName() const { return "C"; }
};
 
class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};
 
int main()
{
    C c;
    B& rBase{ c }; // обратите внимание: rBase - на этот раз типа B
    std::cout << rBase.getName() << '\n';
 
    return 0;
}

C

Это довольно просто, поскольку C::getName() является наиболее дочерним совпадающим вызовом между классами B и C.

1c)

#include <iostream>
#include <string_view>
 
class A
{
public:
    // обратите внимание: нет ключевого слова virtual
    std::string_view getName() const { return "A"; }
};
 
class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};
 
class C: public B
{
public:
    virtual std::string_view getName() const { return "C"; }
};
 
class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};
 
int main()
{
    C c;
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';
 
    return 0;
}

A

Поскольку getName() в A не является виртуальной, при вызове rBase.getName() вызывается A::getName().

1d)

#include <iostream>
#include <string_view>
 
class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};
 
class B: public A
{
public:
    // обратите внимание: нет ключевого слова virtual в B, C и D
    std::string_view getName() const { return "B"; }
};
 
class C: public B
{
public:
    std::string_view getName() const { return "C"; }
};
 
class D: public C
{
public:
    std::string_view getName() const { return "D"; } 
};
 
int main()
{
    C c;
    B& rBase{ c }; // обратите внимание: rBase на этот раз типа B
    std::cout << rBase.getName() << '\n';
 
    return 0;
}

C

Несмотря на то, что getName() в B и C не отмечены как виртуальные функции, A::getName() является виртуальной, а B::getName() и C::getName() переопределяют ее. Следовательно, B::getName() и C::getName() неявно считаются виртуальными, и поэтому вызов rBase.getName() преобразуется в C::getName(), а не в B::getName().

1e)

#include <iostream>
#include <string_view>
 
class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};
 
class B: public A
{
public:
    // Обратите внимание: функции в B, C и D не константные.
    virtual std::string_view getName() { return "B"; }
};
 
class C: public B
{
public:
    virtual std::string_view getName() { return "C"; }
};
 
class D: public C
{
public:
    virtual std::string_view getName() { return "D"; }
};
 
int main()
{
    C c;
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';
 
    return 0;
}

А

Это немного сложнее. rBase – это ссылка типа A на объект C, поэтому rBase.getName() обычно вызывает A::getName(). Но A::getName() является виртуальной, поэтому вызывается наиболее производная версия функции между A и C. И это A::getName(). Поскольку B::getName() и C::getName() не являются константными, они не считаются переопределениями! Следовательно, эта программа печатает A.

1f)

#include <iostream>
#include <string_view>
 
class A
{
public:
	A() { std::cout << getName(); } // обратите внимание на дополнение в конструкторе
 
	virtual std::string_view getName() const { return "A"; }
};
 
class B : public A
{
public:
	virtual std::string_view getName() const { return "B"; }
};
 
class C : public B
{
public:
	virtual std::string_view getName() const { return "C"; }
};
 
class D : public C
{
public:
	virtual std::string_view getName() const { return "D"; }
};
 
int main()
{
	C c;
 
	return 0;
}

А

Еще один хитрый пример. Когда мы создаем объект C, сначала создается часть A. Когда для этого вызывается конструктор A, он вызывает виртуальную функцию getName(). Поскольку части B и C класса еще не созданы, этот вызов преобразуется в A::getName().

Теги

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

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

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