Полиморфизм времени выполнения также называют динамическим полиморфизмом, а также ранним или поздним связыванием. При полиморфизме времени выполнения вызов функции разрешается во время выполнения.
Во время компиляции или статического полиморфизма компилятор выводит объект во время выполнения, а затем решает, какой вызов функции привязать к объекту. В C++ полиморфизм времени выполнения реализуется с помощью переопределения методов.
В этой статье мы подробно рассмотрим все, что касается полиморфизма во время выполнения.
Переопределение функции
Переопределение функции — это механизм, с помощью которого функция, определенная в базовом классе, снова определяется в производном классе. В этом случае мы говорим, что функция переопределена в производном классе.
Вы должны помнить, что переопределение функции не может быть выполнено внутри класса. Функция переопределяется только в производном классе. Следовательно, для переопределения функций должно присутствовать наследование.
Во-вторых, функция из базового класса, которую мы переопределяем, должна иметь ту же сигнатуру или прототип, т.е. она должна иметь то же имя, тот же тип возвращаемого значения и тот же список аргументов.
Давайте рассмотрим пример, демонстрирующий переопределение метода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <iostream> using namespace std; class Base { public: void show_val() { cout << "Class::Base"<<endl; } }; class Derived:public Base { public: void show_val() //функция переопределена из базового класса { cout << "Class::Derived"<<endl; } }; int main() { Base b; Derived d; b.show_val(); d.show_val(); } |
Вывод данных:
Class::Base Class::Derived |
В приведенной выше программе есть базовый класс и производный класс. В базовом классе есть функция show_val, которая переопределена в производном классе. В основной функции мы создаем объект каждого класса Base и Derived и вызываем функцию show_val для каждого объекта. Это производит желаемый результат.
Приведенная выше привязка функций с использованием объектов каждого класса является примером статической привязки.
Теперь давайте посмотрим, что происходит, когда мы используем указатель базового класса и назначаем объекты производного класса в качестве его содержимого.
Пример программы показан ниже:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> using namespace std; class Base { public: void show_val() { cout << "Class::Base"; } }; class Derived:public Base { public: void show_val() //overridden function { cout << "Class::Derived"; } }; int main() { Base* b; //Указатель базового класса, производный от d; //Объект производного класса b = &d; b->show_val(); //Раннее связывание } |
Вывод данных:
Class::Base |
Из программы видно, что независимо от того, объект какого типа содержит базовый указатель, программа выводит содержимое функции класса, типом которого является базовый указатель. В этом случае также выполняется статическая линковка.
Чтобы сделать вывод базового указателя, правильное содержимое и правильное связывание, мы пользуемся динамическим связыванием функций. Это достигается с помощью механизма виртуальных функций, который объясняется ниже в статье.
Виртуальная функция
Поскольку переопределенная функция должна быть динамически связана с телом функции, мы делаем функцию базового класса виртуальной с помощью ключевого слова «virtual». Эта виртуальная функция является функцией, которая переопределяется в производном классе, и компилятор выполняет динамическое связывание для этой функции.
Теперь давайте изменим приведенную выше программу, включив ключевое слово virtual:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> using namespace std;. class Base { public: virtual void show_val() { cout << "Class::Base"; } }; class Derived:public Base { public: void show_val() { cout << "Class::Derived"; } }; int main() { Base* b; //Указатель базового класса, производный от d; //Объект производного класса b = &d; b->show_val(); //Позднее связывание } |
Вывод данных:
Class::Derived |
Итак, в приведенном выше определении класса Base мы сделали функцию show_val «виртуальной». Поскольку функция базового класса делается виртуальной, когда мы назначаем объект производного класса указателю базового класса и вызываем функцию show_val, связывание (привязка) происходит во время выполнения.
Таким образом, поскольку указатель базового класса содержит объект производного класса, тело функции show_val в производном классе привязано к функции show_val и, следовательно, к выводным данным.
В C++ переопределенная функция в производном классе также может быть закрытой. Компилятор только проверяет тип объекта во время компиляции и связывает функцию во время выполнения, поэтому это не имеет никакого значения, даже если функция открыта или закрыта.
Обратите внимание, что если функция объявлена виртуальной в базовом классе, то она будет виртуальной во всех производных классах.
Но до сих пор мы не обсуждали, как именно виртуальные функции играют роль в определении правильной функции для связывания или, другими словами, как на самом деле происходит позднее связывание.
Виртуальная функция точно привязывается к телу функции во время выполнения с помощью концепции виртуальной таблицы (VTABLE) и скрытого указателя с именем _vptr.
Обе эти концепции являются внутренней реализацией и не могут использоваться непосредственно программой.
Работа виртуальной таблицы и _vptr
Во-первых, давайте разберемся, что такое виртуальная таблица (VTABLE).
Компилятор во время компиляции устанавливает одну VTABLE для каждого класса, имеющего виртуальные функции, а также для классов, производных от классов, имеющих виртуальные функции.
VTABLE содержит записи, которые являются указателями функций на виртуальные функции, которые могут вызываться объектами класса. Для каждой виртуальной функции существует одна запись указателя функции.
В случае чисто виртуальных функций эта запись имеет значение NULL. (По этой причине мы не можем создать экземпляр абстрактного класса).
Следующая сущность, _vptr, которая называется указателем vtable, является скрытым указателем, который компилятор добавляет к базовому классу. Этот _vptr указывает на vtable класса. Все классы, производные от этого базового класса, наследуют _vptr.
Каждый объект класса, содержащий виртуальные функции, внутренне хранит этот _vptr и является прозрачным для пользователя. Каждый вызов виртуальной функции с использованием объекта разрешается с помощью этого _vptr.
Давайте рассмотрим пример, демонстрирующий работу vtable и _vptr:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include<iostream> using namespace std; class Base_virtual { public: virtual void function1_virtual() {cout<<"Base :: function1_virtual()\n";}; virtual void function2_virtual() {cout<<"Base :: function2_virtual()\n";}; virtual ~Base_virtual(){}; }; class Derived1_virtual: public Base_virtual { public: ~Derived1_virtual(){}; virtual void function1_virtual() { cout<<"Derived1_virtual :: function1_virtual()\n";}; }; int main() { Derived1_virtual *d = new Derived1_virtual; Base_virtual *b = d; b->function1_virtual(); b->function2_virtual(); delete (b); return (0); } |
Вывод данных:
Derived1_virtual :: function1_virtual() Base :: function2_virtual() |
В приведенной выше программе есть базовый класс с двумя виртуальными функциями и виртуальным деструктором. Мы также получили класс от базового класса и в этом; мы переопределили только одну виртуальную функцию. В основной функции указатель производного класса присваивается базовому указателю.
Затем мы вызываем обе виртуальные функции, используя указатель базового класса. Теперь видно, что при вызове вызывается переопределенная функция, а не базовая функция. Тогда как во втором случае, поскольку функция не переопределяется, вызывается функция базового класса.
А сейчас, давайте посмотрим, как представленная выше программа внутренне представлена с помощью vtable и _vptr.
Согласно предыдущему объяснению, поскольку есть два класса с виртуальными функциями, у нас будет две виртуальные таблицы — по одной для каждого класса. Также _vptr будет присутствовать для базового класса.
Выше показано графическое представление того, каким будет макет виртуальной таблицы для вышеуказанной программы. Виртуальная таблица для базового класса проста. В случае производного класса переопределяется только функция function1_virtual.
Следовательно, мы можем видеть, что в производном классе vtable указатель функции для function1_virtual указывает на переопределенную функцию в производном классе. С другой стороны, указатель функции для function2_virtual указывает на функцию в базовом классе.
Таким образом, в приведенной выше программе, когда базовому указателю назначается объект производного класса, базовый указатель указывает на _vptr производного класса.
Это означает, что когда выполняется вызов b->function1_virtual(), вызывается функция function1_virtual из производного класса, а когда выполняется вызов функции b->function2_virtual(), поскольку указатель этой функции указывает на функцию базового класса, вызывается функция базового класса.
Чистые виртуальные функции и абстрактный класс
Мы рассказали подробно о виртуальных функциях в C++ выше. Но в C++ мы также можем определить «чистую виртуальную функцию», которая обычно приравнивается к нулю.
Чистая виртуальная функция объявляется так, как показано ниже:
virtual return_type function_name(arg list) = 0; |
Класс, который имеет хотя бы одну чистую виртуальную функцию, называется «абстрактным классом». Мы никогда не сможем создать экземпляр абстрактного класса, т.е. мы не сможем создать объект абстрактного класса.
Это потом (вы это должны помнить), что для каждой виртуальной функции делается запись в VTABLE (виртуальная таблица). Но в случае чистой виртуальной функции эта запись не имеет адреса, что делает ее неполной. Таким образом, компилятор не позволяет создать объект для класса с неполной записью VTABLE.
Это причина, по которой мы не можем создать экземпляр абстрактного класса.
В приведенном ниже примере демонстрируется работа чистой виртуальной функции, а также абстрактного класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <iostream> using namespace std; class Base_abstract { public: virtual void print() = 0; // Чистая виртуальная функция }; class Derived_class:public Base_abstract { public: void print() { cout << "Overriding pure virtual function in derived class\n"; } }; int main() { // Базовый объект; // Base obj; //Compile Time Error Base_abstract *b; Derived_class d; b = &d; b->print(); } |
Вывод данных:
Overriding pure virtual function in the derived class |
В приведенной выше программе есть класс, определенный как Base_abstract, который содержит чистую виртуальную функцию, что делает его абстрактным классом. Затем мы получаем класс «Derived_class» из Base_abstract и переопределяем в нем чистую виртуальную функцию print.
В основной функции закомментирована не та первая строка. Это потому, что если мы его раскомментируем, компилятор выдаст ошибку, так как мы не можем создать объект для абстрактного класса.
Но со второй строки код работает. Мы можем успешно создать указатель базового класса, а затем присвоить ему объект производного класса. Затем мы вызываем функцию печати, которая выводит содержимое функции печати, переопределенной в производном классе.
Вот некоторые моменты, касающиеся абстрактного класса:
- Мы не можем создать экземпляр абстрактного класса.
- Абстрактный класс содержит по крайней мере одну чистую виртуальную функцию.
- Хотя мы не можем создать экземпляр абстрактного класса, мы всегда можем создать указатели или ссылки на этот класс.
- Абстрактный класс может иметь некоторую реализацию, такую как свойства и методы, а также чистые виртуальные функции.
- Когда мы получаем класс из абстрактного класса, производный класс должен переопределять все чистые виртуальные функции в абстрактном классе. Если этого сделать не удалось, то производный класс также будет абстрактным классом.
Виртуальные деструкторы
Деструкторы класса могут быть объявлены виртуальными. Всякий раз, когда мы выполняем восходящее преобразование, т.е. присваиваем объект производного класса указателю базового класса, обычные деструкторы могут давать неприемлемые результаты.
Например, рассмотрим следующее преобразование обычного восходящего деструктора:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#include <iostream> using namespace std; class Base { public: ~Base() { cout << "Base Class:: Destructor\n"; } }; class Derived:public Base { public: ~Derived() { cout<< "Derived class:: Destructor\n"; } }; int main() { Base* b = new Derived; delete b; } |
Вывод данных:
Base Class:: Destructor |
В приведенной выше программе есть производный класс, унаследованный от базового класса. В основном мы присваиваем объект производного класса указателю базового класса.
В идеале, деструктор, который вызывается при вызове «delete b», должен быть деструктором производного класса, но мы можем видеть из вывода, что деструктор базового класса вызывается, поскольку указатель базового класса указывает на него.
Из-за этого, деструктор производного класса не вызывается, а объект производного класса остается нетронутым, что приводит к утечке памяти. Решение этой проблемы состоит в том, чтобы сделать конструктор базового класса виртуальным, чтобы указатель объекта указывал на правильный деструктор и выполнялось правильное уничтожение объектов.
Использование виртуального деструктора показано в примере ниже:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using namespace std; class Base { public: virtual ~Base() { cout << "Base Class:: Destructor\n"; } }; class Derived:public Base { public: ~Derived() { cout<< "Derived class:: Destructor\n"; } }; int main() { Base* b = new Derived; delete b; } |
Вывод данных:
Derived class:: Destructor Base Class:: Destructor |
Это та же программа, что и предыдущая, за исключением того, что мы добавили ключевое слово virtual перед деструктором базового класса. Сделав деструктор базового класса виртуальным, мы достигли желаемого результата.
Теперь видно, что когда мы присваиваем объект производного класса указателю базового класса, а затем удаляем указатель базового класса, деструкторы вызываются в порядке, обратном созданию объекта. Это означает, что сначала вызывается деструктор производного класса и уничтожается объект, а затем уничтожается объект базового класса.
Кстати, в C++ конструкторы никогда не могут быть виртуальными, поскольку конструкторы участвуют в создании и инициализации объектов. Следовательно, нам нужно, чтобы все конструкторы выполнялись полностью.
Итог
Полиморфизм времени выполнения реализуется с помощью переопределения методов. Это прекрасно работает, когда мы вызываем методы с соответствующими объектами. Но когда у нас есть указатель базового класса и мы вызываем переопределенные методы, используя указатель базового класса, указывающий на объекты производного класса, из-за статической компоновки возникают неожиданные результаты.
Чтобы исправить это, мы используем концепцию виртуальных функций. Благодаря внутреннему представлению vtables и _vptr виртуальные функции помогают нам точно вызывать нужные функции.
На этом мы завершаем серию статей по объектно-ориентированному программированию на C++. Мы надеемся, что данные статьи будут полезны для понимания концепций объектно-ориентированного программирования на C++.
С Уважением, МониторБанк