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

Последнее обновление: 15.03.2023

При вызове функции программа должна определять, с какой именно реализацией функции соотносить этот вызов, то есть связать вызов функции с самой функцией. В С++ есть два типа связывания - статическое и динамическое.

Когда вызовы функций фиксируются до выполнения программы на этапе компиляции, это называется статическим связыванием (static binding), либо ранним связыванием (early binding). При этом вызов функции через указатель определяется исключительно типом указателя, а не объектом, на который он указывает. Например:

#include <iostream>
 
class Person
{
public:
    Person(std::string name): name{name}
    { }
    void print() const
    {
        std::cout << "Name: " << name << std::endl;
    }
private:
    std::string name;       //  имя
};
class Employee: public Person
{
public:
    Employee(std::string name, std::string company): Person{name}, company{company}
    { }
    void print() const
    {
        Person::print();
        std::cout << "Works in " << company << std::endl;
    }
private:
    std::string company;    // компания
};
 
int main()
{
    Person tom {"Tom"};
    Person* person {&tom};
    person->print();     // Name: Tom

    Employee bob {"Bob", "Microsoft"};
    person = &bob;
    person->print();    // Name: Bob
}

В данном случае класс Employee наследуется от класса Person, но оба этих класса определяют функцию print(), которая выводит данные об объекте. В функции main создаем два объекта и поочередно присваиваем их указателю на тип Person и вызываем через этот указатель функцию print. Однако даже если этому указателю присваивается адрес объекта Employee, то все равно вызывает реализация функции из класса Person:

Employee bob {"Bob", "Microsoft"};
person = &bob;
person->print();    // Name: Bob

То есть выбор реализации функции определяется не типом объекта, а типом указателя. Консольный вывод программы:

Name: Tom
Name: Bob

Динамическое связывание и виртуальные функции

Другой тип связывания представляет динамическое связывание (dynamic binding), еще называют поздним связыванием (late binding), которое позволяет на этапе выполнения решать, функцию какого типа вызвать. Для этого в языке С++ применяют виртуальные функции. Для определения виртуальной функции в базовом классе функция определяется с ключевым словом virtual. Причем данное ключевое слово можно применить к функции, если она определена внутри класса. А производный класс может переопределить ее поведение.

Итак, сделаем функцию print в базовом классе Person виртуальной:

#include <iostream>
 
class Person
{
public:
    Person(std::string name): name{name}
    { }
    virtual void print() const  // виртуальная функция
    {
        std::cout << "Name: " << name << std::endl;
    }
private:
    std::string name;
};
class Employee: public Person
{
public:
    Employee(std::string name, std::string company): Person{name}, company{company}
    { }
    void print() const
    {
        Person::print();
        std::cout << "Works in " << company << std::endl;
    }
private:
    std::string company;
};
 
int main()
{
    Person tom {"Tom"};
    Person* person {&tom};
    person->print();     // Name: Tom
    Employee bob {"Bob", "Microsoft"};
    person = &bob;
    person->print();    // Name: Bob
                            // Works in Microsoft
}

Таким образом, базовый класс Person определяет виртуальную функцию print, а производный класс Employee переопределяет ее. В первом же примере, где функция print не была виртуальной, класс Employee не переопределял, а скрывал ее. Теперь при вызове функции print для объекта Employee через указатель Person* будет вызываться реализация функции именно класса Employee. Соответственно тепепрь мы получим другой консольный вывод:

Name: Tom
Name: Bob
Works in Microsoft

В этом и состоит отличие переопределения виртуальных функций от скрытия.

Класс, который определяет или наследует виртуальную функцию, еще назвается полиморфным (polymorphic class). То есть в данном случае Person и Employee являются полиморфными классами.

Стоит отметить, что вызов виртуальной функции через имя объекта всегда разрешается статически.

Employee bob {"Bob", "Microsoft"};
Person p = bob;
p.print();  // Name: Bob - статическое связывание

Динамическое связывание возможно только через указатель или ссылку.

Employee bob {"Bob", "Microsoft"};
Person &p {bob};    // присвоение ссылке
p.print();  // динамическое связывание

Person *ptr {&bob};    // присвоение адреса указателю
ptr->print();  // динамическое связывание

При определении вирутальных функций есть ряд ограничений. Чтобы функция попадала под динамическое связывание, в производном классе она должна иметь тот же самый набор параметров и возвращаемый тип, что и в базовом классе. Например, если в базовом классе виртуальная функция определена как константная, то в производном классе она тоже должна быть константной. Если же функция имеет разный набор параметров или несоответствие по константности, то мы будем иметь дело со скрытием функций, а не переопределением. И тогда будет применяться статическое связывание.

Также статические функции не могут быть виртуальными.

Ключевое слово override

Чтобы явным образом указать, что мы хотим переопредлить функцию, а не скрыть ее, в производном классе после списка параметров функции указывается слово override

#include <iostream>
 
class Person
{
public:
    Person(std::string name): name{name}
    { }
    virtual void print() const  // виртуальная функция
    {
        std::cout << "Name: " << name << std::endl;
    }
private:
    std::string name;
};
class Employee: public Person
{
public:
    Employee(std::string name, std::string company): Person{name}, company{company}
    { }
    void print() const override // явным образом указываем, что функция переопределена
    {
        Person::print();
        std::cout << "Works in " << company << std::endl;
    }
private:
    std::string company;
};
 
int main()
{
    Person tom {"Tom"};
    Person* person {&tom};
    person->print();     // Name: Tom
    Employee bob {"Bob", "Microsoft"};
    person = &bob;
    person->print();     // Name: Bob
                        // Works in Microsoft
}

То есть здесь выражение

void print() const override

указывает, что мы явным образом хотим переопределить функцию print. Однако может возникнуть вопрос: в предыдущем примере мы не указывали override для вирутальной функции, но переопределение все равно работало, зачем же тогда нужен override? Дело в том, что override явным образом указывает компилятору, что это переопределяемая функция. И если она не соответствует виртуальной функции в базовом классе по списку параметров, возвращаемому типу, константности, или в базовом классе вообще нет функции с таким именем, то компилятор при компиляции сгенерирует ошибку. И по ошибке мы увидим, что с нашей переопределенной функцией что-то не так. Если же override не указать, то компилятор будет считать, что речь идет о скрытии функции, и никаких ошибок не будет генерировать, компиляция пройдет успешно. Поэтмоу при переопределении виртуальной функции в производном классе лучше указывать слово override

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

Принцип выполнения виртуальных функций

Стоит отметить, что виртальные функции имеют свою цены - объекты классов с виртуальными функциями требуют немного больше памяти и немного больше времени для выполнения. Поскольку при создании объекта полиморфного класса (который имеет виртуальные функции) в объекте создается специальный указатель. Этот указатель используется для вызова любой виртуальной функции в объекте. Специальный указатель указывает на таблицу указателей функций, которая создается для класса. Эта таблица, называемая виртуальной таблицей или vtable, содержит по одной записи для каждой виртуальной функции в классе.

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

  1. Указатель на vtable в объекте используется для поиска адреса vtable для класса.

  2. Затем в таблице идет поиск указателя на вызываемую виртуальную функцию.

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

vtable, полиформизм и виртуальные функции в C++

Запрет переопределения

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

class Person
{
public:
	virtual void print() const final
	{
		
	}
};
class Employee : public Person
{
public:
	void print() const override		// Ошибка!!!
	{
		
	}
};

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

class Person
{
public:
	virtual void print() const // переопределение разрешено
	{
		
	}
};
class Employee : public Person
{
public:
	void print() const override final	// в классах, производных от Employee переопределение запрещено
	{
		
	}
};
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850