MyTetra Share
Делитесь знаниями!
Понимание наследования и механизма виртуальных методов в C++
27.09.2019
15:50
Автор: xintrea
Текстовые метки: c++, наследование, метод, виртуальный, virtual, vtable, upcast, downcast, приведение типов, виртуальная таблица
Раздел: Компьютер - Программирование - Язык C++

Здесь я попытаюсь объяснить, как следует воспринимать переопределение виртуальных методов в 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 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, и все. А это значит, что в данных объекта будет продолжать хранится указатель на VTABLE для класса Second.


Именно поэтому, при вызове функции getName(), через ссылку ref, будет вызываться функция 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';



Но при таком копировании произойдет приведние типа, которое будет сопровождаться "слайсингом". То есть, все данные, которые не относятся к приводимому типу, будут отброшены, а указатель на 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. И через этот указатель можно нормально работать с данными производного класса ровно по тем же причинам, что были описаны выше в этой статье.


Кстати, данный подход можно реализовать не через сырые указатели, а через умные, что справдливо для 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.52
Яндекс индекс цитирования