Skip to the content.

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

Виртуальные методы

#include <iostream>
 
class Parent
{
public:
    const char* getName() { return "Parent"; }
};
 
class Child: public Parent
{
public:
    const char* getName() { return "Child"; }
};
 
int main()
{
    Child child;
    std::cout << child.getName(); // Child
    
    // Однако, создадим ссылку на родительский класс и дадим ей адрес дочернего:
    Parent &p = child;
    std::cout << p.getName(); // Parent
    
    // Аналогично:
    Parent * p = &child;
    std::cout << p->getName() << '\n'; // Parent
}

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

Эту проблему можно решить используя виртуальные функции.

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

#include <iostream>
 
class Parent
{
public:
    virtual const char* getName() { return "Parent"; }
};
 
class Child: public Parent
{
public:
    virtual const char* getName() { return "Child"; }
};
 
int main()
{
    Child child;
    std::cout << child.getName(); // Child

    Parent & p = child;
    std::cout << p.getName(); // Child
}

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

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

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

Чистые виртуальные (абстрактные) методы

Абстрактный метод - метод без реализации.

Так как метод в родительском классе (ниже) абстрактный, то и сам класс становится абстрактным - нельзя создать экземпляр этого класса (потому что если бы можно было создать экзмепляр абстрактного класса, то вызов абстрактного метода привел бы к ошибке, он же не реализован). Зато абстрактный класс можно унаследовать и в дочерних классах уже определить абстрактный метод:

struct Person
{
  virtual string occupation() const = 0; // = 0 означает что у метода нет реализации  
};

struct Student : Person
{
  string occupation() const {return "Student";}  
};

struct Professor : Person
{
  string occupation() const {return "Professor";}  
};

Таким образом у нас получилось две различные реализации абстрактного метода occupation(). Несмотря на то что экземпляр абстрактного класса создавать нельзя, зато можно создать ссылки и указатели типа абстрактного класса, это нужно что бы работать с разными объектами при помощи одного и того же кода (полиморфизм):

Person * p = next_person(); // функция вернет либо профессора либо студента из списка
cout << p->occupation();    // выведет либо Student либо Professor

Виртуальный деструктор

Следующий код приведет к утечке памяти (uni_):

struct Person
{
  ...  
};

// расширяет класс Person полем uni_
struct Student : Person
{
    ...
private:
    string uni_;
};

int main()
{
    Person * p = new Student("Alex", 21, "Oxford"); // динамическое выделение экземпляра Student
    // при этом работаем со Student через указатель на родительский класс
    ...
    delete p; // вызовется деструктор ~Person(), который ничего не знает про поле uni_!
}

Как это исправить:

struct Person
{
    ...
    virtual ~Person() {}
};

В таком случае вначале вызовется деструктор ~Student() который уничтожит uni_ и потом ~Person().

! Виртуальные методы ведут себя как не виртуальные в конструкторе и деструкторе.

#include <iostream>

struct Person
{
    Person(std::string name) : name(name) {};
    virtual std::string getName() {return name;}
    std::string name;
};

struct Teacher : Person
{
    Teacher(std::string name) : Person(name)
    {
        std::cout << getName(); // выведет Andry
    }
};

struct Professor : Teacher
{
    Professor(std::string name) : Teacher(name)
    {}

    virtual std::string getName() {return "Professor: " + name;}
};

int main()
{
    Professor pr("Andry");
    return 0;
}

А все потому что если бы можно было виртуальное поведение в конструкторе, то это разрешило бы доступ к еще неиницализированным данным: отработал конструктор Person, работает конструктор Teacher, а в нем мы вызываем метод, который реализован в Professor? А если в этом методе происходит обращение к полям Professor, а конструктор Professor еще не отрабатывал, ошибка.

Механизмы полиморфизма

Полиморфизм - возможность единообразно обрабатывать разные типы данных.

Перегрузка функций - выбор подходящей по сигнатуре функции происходит в момент компиляции - статический полиморфизм (времени компиляции)

Виртуальные методы - выбор метода происходит в момент выполнения на основе типа объекта - динамический полиморфизм (времени выполнения)

Таблица виртуальных методов

Таблицы виртуальных методов применяются для реализации динамического полиморфизма (виртуальных методов).

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

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

virtual table

Примерно так может быть организована таблица виртуальных методов:

vtable_2

Запись в таблице выглядит так: номер метода, название метода, адрес наиболее дочернего метода доступного текущему объекту.

Абстрактный метод получил адрес 0, поскольку он не реализован (абстрактный) и попросту нет метода, адрес которого можно было бы записать в таблицу.

Вызов виртуального метода происходит так: вместо его названия подставляется номер метода из таблицы виртуальных методов (для сравнения: в обычных методах вместо названия подставляется его адрес):

p->occupation(); // p->vptr[1]()

Пример:

struct Person
{
  string name() const { return name_; }
  virtual string occupation() const = 0;
  virtual void print() { std::cout << "Parent\n"; }
  virtual ~Person() {}
  ...
};

struct Student : Person
{
  virtual int group() const { return group_; }
  virtual string occupation() const { return "student"; }
  ...
};

Для класса Person vtable будет совсем простой: объект класса Person имеет доступ только к Person и не имеет доступ к Student, соответственно в его таблицу будет записано:

При этом Person::name() не виртуальный метод и записан в vtable не будет!

В Student vtable будет записано:

Как будет происходить вызов:

Person * p = new Student;
Student->occupation();

Так как указатель на виртуальную таблицу содержится именно в экземпляре класса, то указатель Person * имеет доступ к виртуальной таблице класса Student. Соответственно, компилятор распознает что occupation() является виртуальным методом, обратиться к vtable p->vptr (это vtable Student’a) в которой найдёт и вызовет Student::occupation().

Посмотреть таблицу виртуальных методов (vtable):

g++ -fdump-lang-class person.cpp

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

Vtable for Person
Person::_ZTV6Person: 6 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI6Person)
16    (int (*)(...))__cxa_pure_virtual // абстрактный класс occupation
24    (int (*)(...))Person::print
32    (int (*)(...))Person::~Person
40    (int (*)(...))Person::~Person
Vtable for Student
Student::_ZTV7Student: 7 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Student)
16    (int (*)(...))Student::occupation
24    (int (*)(...))Person::print
32    (int (*)(...))Student::~Student
40    (int (*)(...))Student::~Student 
48    (int (*)(...))Student::group