MyTetra Share
Делитесь знаниями!
Понимание наследования и механизма виртуальных методов в C++
Время создания: 27.09.2019 15:50
Автор: xintrea
Текстовые метки: c++, наследование, класс, метод, виртуальный, virtual, vtable, vptr, upcast, downcast, приведение типов, преобразование, виртуальная таблица, указатель, ссылка
Раздел: Компьютер - Программирование - Язык C++ (Си++)
Запись: xintrea/mytetra_syncro/master/base/15695886150idjbe38lk/text.html на raw.github.com

Здесь я попытаюсь объяснить, как следует воспринимать переопределение виртуальных методов в C++, как этот механизм устроен на более низком уровне.


Все объяснение будет вестись на примере двух классов - First и Second, где Second унаследован от First. Метод getName() в классе Second переопределяет виртуальный метод класса First, и при выводе имени класса в виде строки, использует свойство, доступное только в классе Second. Другими словами, класс First - это базовый класс, и в нем хранится даннных меньше, чем в расширяющем его классе Second. Это важно понимать для дальнейшего повествования.


Итак, вот начальный код:



#include <iostream>

#include <string>


using namespace std;


// Базовый класс

class First

{

public:

virtual string getName() { return "First"; }

};


// Производный класс

class Second: public First

{

public:

virtual string getName() { return prefix+"Second"; }

protected:

string prefix="Prefix"; // Некое дополнительное свойство производного класса

};


int main()

{

Second second;


First &ref = second; // Обратить внимание на эту строку


std::cout << "Ref is: " << ref.getName() << '\n';


return 0;

}



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


Вопрос: что будет выведено на экран, если обратиться к методу getName() данной ссылки? А вот что:



Ref is: PrefixSecond



Теперь надо понять, что же здесь происходит. Данный код - это классическая ситуация в C++, когда тип базового класса используется для работы с производным классом. Ситуация эта настолько стандартная, что приведение типа из производного класса в основной, не требует никаких лишних телодвижений. Просто делается присваивание ссылки (можно делать и присваивание указателя), и всё, код работает! Такое приведение от производного типа к базовому называется upcast.


Но как же так? Мы имеем ссылку с типом First&, а через нее происходит работа с данными производного класса Second, о которых класс First ничего не знает. Как это вообще возможно?


Дело в том, что несмотря на строгую систему типов C++, жесткие правила приведения типов и весь обвес по контролю над типами, язык C++ достаточно примитивно устроен.



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


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


В нашем случае есть два класса - First и Second. В классе First есть виртуальная функция getName(), значит класс First получит свою таблицу виртуальных функций "для класса First". Язык C++ устроен так, что если какая-либо функция объявлена как виртуальная, то во всех производных классах такая функция тоже будет виртуальной. Поэтому ключевое слово virtual возле функции getName() в производном классе Second вообще-то не требуется писать. Но оно традиционно пишется для того, чтобы было видно, что эта функция виртуальная и она может быть преопределена в последующих производных классах. В любом случае, видно, что класс Second имеет виртуальную функцию getName(), а значит он тоже получит таблицу виртуальных функций "для класса Second".


Таблица виртуальных функций содержит адреса виртуальных функций. Условно, в рассматриваемом случае, можно считать, что каждая таблица для класса First и Second состоит из одной записи, так как имеется всего одна виртуальная функция getName(). Для класса First таблица VTABLE будет, условно, выглядеть так:


getName() -- Address1


А для класса Second таблица VTABLE будет с другим адресом:


getName() -- Address2


Такого умозрительного представления таблиц VTABLE вполне достаточно, чтобы понимать дальнейшее изложение.



Устройство объекта виртуального класса



Определение. Класс называется виртуальным, если в нем имеется хотя бы одна виртуальная функция.



Согласно этому определению, в программе есть два класса First и Second, и они оба виртуальные.


Когда создается экземпляр виртуального класса (другими словами - когда создается объект), в нем предусматривается место для хранения указателя на таблицу виртуальных функций. Этот указатель традиционно называется VPTR. Да, он хранится в данных каждого объекта виртуального класса. Это достаточно расточительно, но ничего лучшего пока, вроде бы, не придумали.


Когда в объекте происходит вызов виртуального метода getName(), берется указатель VPTR, по нему определяется местоположение VTABLE в памяти, в этом VTABLE ищется адрес для метода getName(), и происходит, собственно, вызов кода по полученному адресу.


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



First &ref = second;



Практически, не происходит ровным счетом ничего! Просто ссылка ref начинает указывать на область памяти, где хранится объект second, и все. А это значит, что в данных объекта будет продолжать хранится указатель VPTR, указывающий на VTABLE класса Second.


Именно поэтому, при вызове функции getName(), через ссылку ref класса First, будет вызываться функция getName() класса Second.



Теперь возникает вопрос: как же так, мы имеем ссылку с типом First&, а по ней происходит работа с данными string prefix="Prefix", которых вообще нет в типе First. Ответ тут такой: с этими данными работает функция getName() класса Second, потому что именно она вызывается через VTABLE. А эта функция "знает" смещения в памяти для данных класса Second. Кроме того, при присвоении ссылки (или указателя) никаких копирований объектов не производится. То есть, данные для строки prefix как лежали на своем месте, созданные при создании объекта Second, так и лежат. С ними и работает вариант функции getName() класса Second.



Вот так примитивно работа с VTABLE и устроена. Но такой примитивный подход чреват непонятками, о которых рассказывается далее.



Копирование объекта


Когда идет разработка программы, будут возникать ситуации, не такие идеальные, как было написано выше. Объекты могут не просто создаваться производным типом и далее использоваться базовым типом, но и копироваться, имея на руках указатель базового типа.


Здесь возникает дилема: имеются данные, созданные производным типом. И их надо корректно скопировать, пользуясь ссылкой на базовый тип. При этом базовый тип, естественно, не знает, какие дополнительные данные есть в производном типе. Как выйти из этого тупика?


Вначале нужно понять, в каких случаях происходит копирование объекта. Например, копирование объекта происходит при выполнении оператора присваивания. Мы могли бы скопировать ссылку на данные производного класса в объект первичного класса, вот так:



First &ref = second;

...

First obj=ref;

std::cout << "Obj is: " << obj.getName() << '\n';



Но при таком копировании произойдет приведние типа, которое будет сопровождаться "слайсингом". То есть, все данные, которые не относятся к приводимому типу, будут отброшены, а указатель VPTR в копии будет установлен на таблицу VTABLE, соответствующую приводимому типу. Поэтому вышеприведенный код напишет в консоль:



Obj is: First



Тут приходит на ум решение: надо сделать реализацию оператора присваивания operator= виртуальным! Однако такой подход не будет компилироваться, так как в разных классах в иерархии наследования требуются различные типы параметров.


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



public:

virtual First* clone() const;



Реализация для класса First будет такая:



First* First::clone() const

{

return new First(this);

}



А реализация в классе Second должна выглядеть так:



First* Second::clone() const

{

return new Second(this);

}



Идея для класса Second все та же: создаются и копируются данные класса Second, а метод clone() возвращает указатель базового класса First. И через этот указатель можно нормально работать с данными производного класса Second ровно по тем же причинам, что были описаны выше в этой статье.



Небольшое отступление для тех, кто плавает в синтаксисе C++. Многие не понимают, что обозначает конструкция:


new First(this)


Это просто создание объекта класса First с получением указателя на новый экземпляр. То, что создание объекта записано с параметром (у которого тоже тип First), говорит о том, что будет вызываться стандартный конструктор копирования (который автоматически создается компилятором для класса First). Значение this в качестве параметра - это инициализирующее значение. То есть, новый создаваемый объект будет копией текущего объекта, в контексте которого вызван метод clone().



Кстати, данный подход с клонированием можно реализовать не через сырые указатели, а через умные, что справедливо для C++11 и выше:



// В заголовочных файлах First и Second

virtual std::unique_ptr<First> clone() const;


// В классе First

std::unique_ptr<First> First::clone() const override

{

return {new First(this)};

}


// В классе Second

std::unique_ptr<First> Second::clone() const override

{

return {new Second(this)};

}



Еще несколько замечаний


В этой статье был рассмотрен вариант прямого наследования, когда в отношениях наследования состоят два класса. А как будет работать механизм наследования, если уровней наследования больше? При наследовании "по цепочке", например когда класс B наследуется от A, а класс C наследуется от B, при вычислении адресов виртуальных методов используется принцип обращения к самому дочерному виртуальному методу, который доступен данному классу.


Более подробно об этом можно почитать в следующих уроках на сайте ravesli.com:


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


Урок №167. Виртуальные таблицы



Бонус №1


Для тех, кто плавает в синтаксисе C++, приведу вариант функции main(), написанной в начале статьи, в которой используются указатели, а не ссылки:



int main()

{

Second second;


First *ref = &second;


std::cout << "Ref is: " << ref->getName() << '\n';


return 0;

}



Результат:


Ref is: PrefixSecond



Бонус №2


А что произойдет, если работать не через указатели или ссылки, а просто через присвоение объектов, вот так:



int main()

{

Second second;


First ref = second;


std::cout << "Ref is: " << ref.getName() << '\n';


return 0;

}



Результат будет таким:



Ref is: First



Почему так происходит? Потому что при присвоении объектов происходит все тот же пресловутый "слайсинг" из-за приведения типа в момент присваивания. И поэтому в объекте-копии данные производного класса будут отброшены, а VPTR будет установлен на VTABLE целевого типа First.



Так же в этом разделе:
 
MyTetra Share v.0.65
Яндекс индекс цитирования