Урок №163. Виртуальные функции и Полиморфизм

  Юрий  | 

  |

  Обновл. 24 Янв 2022  | 

 113640

 ǀ   11 

На предыдущем уроке мы рассматривали ряд примеров, в которых использование указателей или ссылок родительского класса упрощало логику и уменьшало количество кода.


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

Тем не менее, мы сталкивались с проблемой, когда родительский указатель или ссылка вызывали только родительские методы, а не дочерние. Например:

Результат:

rParent is a Parent

Поскольку rParent является ссылкой класса Parent, то вызывается Parent::getName(), хотя фактически мы ссылаемся на часть Parent объекта child.

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

Виртуальная функция в языке С++ — это особый тип функции, которая, при её вызове, выполняет «наиболее» дочерний метод, который существует между родительским и дочерними классами. Это свойство еще известно, как полиморфизм. Дочерний метод вызывается тогда, когда совпадает сигнатура (имя, типы параметров и является ли метод константным) и тип возврата дочернего метода с сигнатурой и типом возврата метода родительского класса. Такие методы называются переопределениями (или «переопределенными методами»).

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

Результат:

rParent is a Child

Поскольку rParent является ссылкой на родительскую часть объекта child, то, обычно, при обработке rParent.getName() вызывался бы Parent::getName(). Тем не менее, поскольку Parent::getName() является виртуальной функцией, то компилятор понимает, что нужно посмотреть, есть ли переопределения этого метода в дочерних классах. И компилятор находит Child::getName()!

Рассмотрим пример посложнее:

Как вы думаете, какой результат выполнения этой программы?

Рассмотрим всё по порядку:

   Сначала создается объект c класса C.

   rParent — это ссылка класса A, которой мы указываем ссылаться на часть A объекта c.

   Затем вызывается метод rParent.getName().

   Вызов rParent.GetName() приводит к вызову A::getName(). Однако, поскольку A::getName() является виртуальной функцией, то компилятор ищет «наиболее» дочерний метод между A и C. В этом случае — это C::getName().

Обратите внимание, компилятор не будет вызывать D::getName(), поскольку наш исходный объект был класса C, а не класса D, поэтому рассматриваются методы только между классами A и C.

Результат выполнения программы:

rParent is a C

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


Рассмотрим класс Animal из предыдущего урока, добавив тестовый код:

Результат выполнения программы:

Matros says ???
Barsik says ???

А теперь рассмотрим тот же класс, но сделав метод speak() виртуальным:



Результат выполнения программы:

Matros says Meow
Barsik says Woof

Сработало!

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

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

Аналогично со следующим примером с массивом животных:

Результат:

Matros says Meow
Barsik says Woof
Ivan says Meow
Tolik says Woof
Martun says Meow
Tyzik says Woof

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

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

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

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

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


Типы возврата виртуальной функции и её переопределений должны совпадать. Рассмотрим следующий пример:

В этом случае Child::getValue() не считается подходящим переопределением для Parent::getValue(), так как типы возвратов разные (метод Child::getValue() считается полностью отдельной функцией).

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

Вот еще одна ловушка для новичков. Вы не должны вызывать виртуальные функции в теле конструкторов или деструкторов. Почему?

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

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

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

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


«Если всё так хорошо с виртуальными функциями, то почему бы не сделать все методы виртуальными?» — спросите Вы. Ответ: «Это неэффективно!». Обработка и выполнение вызова виртуального метода занимает больше времени, чем обработка и выполнение вызова обычного метода. Кроме того, компилятор также должен выделять один дополнительный указатель для каждого объекта класса, который имеет одну или несколько виртуальных функций.

Тест

Какой результат выполнения следующих программ? Не нужно запускать/выполнять следующий код, вы должны определить результат, без помощи своих IDE.

a)

Ответ a)

Результат:

B

rParent — это ссылка класса A на объект c. rParent.getName() вызывает A::getName(), но, поскольку A::getName() является виртуальной функцией, вызываться будет наиболее дочерний метод между классами A и C. А это B::getName(), так как в классе C метода getName() нет.

b)

Ответ b)

Результат:

C

Всё довольно просто, C::getName() — это наиболее дочерний метод между классами B и C.

c)

Ответ c)

Результат:

A

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

d)

Ответ d)

Результат:

C

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

e)

Ответ e)

Результат:

A

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

f)

Ответ f)

Результат:

А

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

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (302 оценок, среднее: 4,88 из 5)
Загрузка...

Комментариев: 11

  1. Adam:

    У меня есть некоторые сомнения, стоило бы сразу рассказать о таком ключевом слове как override, а не в отдельной статье. Мне как изучившему сразу это было сложно понять почему нигде не контролируется переопределение функции. Может быть компилятор считает все функции virtual каждый раз разными а не одной сигнатуры. Я же и без virtual + override в другом наследуемом классе могу назвать функцию точно так же и работать она будет.

  2. Ironsaid:

    Вопрос явно не в тему но все же интересно, это такая "фича" или нет? Если достаточно уменьшить окно браузера, самолетик для возврата на начало страницы, попадает на пример кода. Более светлы оттенок перекрывает самолетик, а более темный цвет — нет. В итоге получается своеобразная зебра. Я бы еще скинул скрин, но в комментариях нет такой возможности.

    1. Фото аватара Юрий:

      Это больше похоже на баг, нежели на фичу 🙂 Я понимаю, о чем Вы говорите. Пробовал это исправить, но здесь получается конфликт кода плагина и кода скрипта, который средствами CSS исправить не получается, поэтому пока так.

  3. Kris:

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

    1. Петр:

      Причина в том, что виртуальный метод базового класса задает *интерфейс* для всех наследников. Т.е. должны быть возможность использовать виртуальные методы базового класса так, как будто это методы наследника (потому что именно им и передается управление в результате позднего связывания). Если возвращаемые типы будут отличаться, то вы не сможете это сделать. Допустим вы возвращаете целое в базовом классе, а в наследнике написали возврат своего какого-то класса. И что дальше делать? Клиент *интерфейса* ждет возврат целого, вызывает метод, дальше происходит позднее связывание и управление передается в функцию наследника, которая возвращает объект какого-нибудь класса A. Что делать клиенту, он ждет целое и вызывал метод с сигнатурой, которая предполагает возврат целого, а ему на рантайме подсунули A. Полная ерунда, согласитесь? 🙂

  4. Старый программист:

    Опять же спорный момент о снижении скорости виртуальных методов. Некоторые это даже приравнивают к интерпретации. Но вся виртуальность выполнена на таблице vTable (что позже легло в основу технологии COM). И опять же, сложность возрастает лишь на время извлечения адреса функции из таблицы, а это не более 5 машинных тактов, как и в случаем обращения по ссылке/указателю.

    1. Kris:

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

  5. Stikkerrr:

    А зачем для char возвращать значение с помощью указателя?

    1. Shom:

      Наследие C. Строка представляет собой массив символов,
      char* speak(){ return "Woof";} возвращает указатель на первый элемент массива с символами 'W', 'o', 'o', 'f'. Работает быстрее чем std::string и требует меньше памяти.

      1. Кетчуп:

        и забыли '\0' в конце, тут это важно

        1. Catalyst:

          Не забыли. Компилятор автоматически добавляет '\0' к статичной строке.

Добавить комментарий для Stikkerrr Отменить ответ

Ваш E-mail не будет опубликован. Обязательные поля помечены *