Вызов виртуального метода из конструктора

Обновлено: 21.06.2024

Сегодня пришла очередь виртуальных функций. И, во-первых, я сразу оговорюсь, что статья моя (в принципе, как и предыдущая) ни в коей мере не претендует на полноту изложения. А во-вторых, как и раньше, эта статья не для профессионалов. Она будет полезна тем, кто уже нормально разбирается в основах C++, но имеет недостаточно опыта, либо же тем, кто не любит читать книжек.

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

Если в материале про inline-методы миф был не совсем очевиден, то в этом — напротив. Собственно, перейдем к «мифу».

Виртуальные функции и ключевое слово virtual

К моему удивлению, я очень часто сталкивался и сталкиваюсь с людьми (да что там говорить, я и сам был таким же), которые считают, что ключевое слово virtual делает функцию виртуальной только на один уровень иерархии. Объясню, что имеется в виду, на примере:

Итак, имеем простую иерархию классов. В каждом классе определены 3 метода: , и . Рассмотрим неверную логику людей, которые находятся под действием мифа:
когда указатель pA указывает на объект типа B имеем вывод:

pA is B:
B::foo() // потому что в родительском классе A метод foo() помечен как virtual
B::bar() // потому что в родительском классе A метод bar() помечен как virtual
A::baz() // потому что в родительском классе A метод baz() не помечен как virtual

pA is C:
С::foo() // потому что в родительском классе B метод foo() помечен как virtual
B::bar() // потому что в родительском классе B метод bar() не помечен как virtual,
// но он помечен как virtual в классе A, указатель на который мы используем
A::baz() // потому что в классе A метод baz() не помечен как virtual

С невиртуальной функцией baz() всё и так ясно. А вот с логикой вызова виртуальных функций есть неувязочка. Думаю, не стоит говорить, что на самом деле вывод будет следующим:

Вывод: виртуальная функция становится виртуальной до конца иерархии, а ключевое слово virtual является «ключевым» только в первый раз, а в последующие разы оно несет в себе чисто информативную функцию для удобства программистов.

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

Раннее и позднее связывание. Таблица виртуальных функций

Связывание — это сопоставление вызова функции с вызовом. В C++ все функции по умолчанию имеют раннее связывание, то есть компилятор и компоновщик решают, какая именно функция должна быть вызвана, до запуска программы. Виртуальные функции имеют позднее связывание, то есть при вызове функции нужное тело выбирается на этапе выполнения программы.

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

Вывод может отличаться в зависимости от платформы, но в моем случае (Win32, msvc2008) он был следующим:

Что можно понять из этого примера. Во-первых, размер «пустого» класса всегда больше нуля, потому что компилятор специально вставляет в него фиктивный член. Как пишет Эккель, «представьте процесс индексирования в массиве объектов нулевого размера, и все станет ясно» ;) Во-вторых, мы видим, что размер «непустого» класса NotEmptyVirt при добавлении в него виртуальной функции увеличился на стандартный размер указателя на void; а в «пустом» классе EmptyVirt фиктивный член, который компилятор ранее добавлял для приведения класса к ненулевому размеру, был заменен на указатель. В то же время добавление невиртуальной функции в класс на размер не влияет (спасибо nullbie за совет). Имя указателя на таблицу отличается в зависимости от компилятора. К примеру, компилятор называет его __vfptr, а саму таблицу ‘vftable’ (кто не верит, может посмотреть в отладчике :) В литературе указатель на таблицу виртуальных функций принято называть VPTR, а саму таблицу VTABLE, поэтому я буду придерживаться таких же обозначений.

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

Таблиц виртуальных функций у нас будет столько, сколько есть классов, содержащих виртуальные функции — по одной таблице на класс. Объекты каждого из классов содержат именно указатель на таблицу, а не саму таблицу! Вопросы на эту тему любят задавать преподаватели, а также те, кто проводит собеседования. (Примеры каверзных вопросов, на которых можно подловить новичков: «если класс содержит таблицу виртуальных функций, то размер объекта класса будет зависеть от количества виртуальных функций, содержащихся в нем, верно?»; «имеем массив указателей на базовый класс, каждый из которых указывает на объект одного из производных классов — сколько у нас будет таблиц виртуальных функций?» и т.д.).

Итак, для каждого класса у нас будет создана таблица виртуальных функций. Каждой виртуальной функции базового класса присваивается подряд идущий индекс (в порядке объявления функций), по которому в последствие и будет определяться адрес тела функции в таблице VTABLE. При наследовании базового класса, производный класс «получает» и таблицу адресов виртуальных функций базового класса. Если какой-либо виртуальный метод в производном классе переопределяется, то в таблице виртуальных функций этого класса адрес тела соответствующего метода просто будет заменен на новый. При добавлении в производный класс новых виртуальных методов VTABLE производного класса расширяется, а таблица базового класса естественно остается такой же, как и была. Поэтому через указатель на базовый класс нельзя виртуально вызвать методы производного класса, которых не было в базовом — ведь базовый класс о них ничего «не знает» (дальше мы все это посмотрим на примере).

Конструктор класса теперь должен делать дополнительную операцию: инициализировать указатель VPTR адресом соответствующей таблицы виртуальных функций. То есть, когда мы создаем объект производного класса, сначала вызывается конструктор базового класса, инициализирующий VPTR адресом «своей» таблицы виртуальных функций, затем вызывается конструктор производного класса, который перезаписывает это значение.

При вызове функции через адрес базового класса (читайте — через указатель на базовый класс) компилятор сначала должен по указателю VPTR обратиться к таблице виртуальных функций класса, а из неё получить адрес тела вызываемой функции, и только после этого делать call.

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

Думаю, на примере все станет понятнее. Рассмотрим следующую иерархию:


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

Base
0
Base::foo()
1 Base::bar()
2 Base::baz()

и
Inherited
0
Base::foo()
1 Inherited::bar()
2 Base::baz()
3 Inherited::qux()

Как видим, в таблице производного класса адрес второго метода был заменен на соответствующий переопределенный. Пруфкод:

Что происходит при запуске программы? Вначале объявляем указатель на объект типа Base, которому присваиваем адрес вновь созданного объекта типа Inherited. При этом вызывается конструктор Base, инициализирует VPTR адресом VTABLE класса Base, а затем конструктор Inherited, который перезаписывает значение VPTR адресом VTABLE класса Inherited. При вызове , и компилятор через указатель VPTR достает фактический адрес тела функции из таблицы виртуальных функций. Как это происходит? Вне зависимости от конкретного типа объекта компилятор знает, что адрес функции находится на первом месте, — на втором, и т.д. (как я и говорил, в порядке объявления функций). Таким образом, для вызова, к примеру, функции он получает адрес функции в виде VPTR+2 — смещение от начала таблицы виртуальных функций, сохраняет этот адрес и подставляет в команду call. По этой же причине, вызов приводит к ошибке: несмотря на то, что фактический тип объекта Inherited, когда мы присваиваем его адрес указателю на Base, происходит восходящее приведение типа, а в таблице VTABLE класса Base никакого четвертого метода нет, поэтому VPTR+3 указывало бы на «чужую» память (к счастью, такой код даже не компилируется).

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

Также становится понятно, почему виртуальные функции работают только при обращении по адресу объекта (через указатели либо через ссылки). Как я уже сказал, в этой строке
Base * pBase = new Inherited;
происходит повышающее приведение типа: Inherited* приводится к Base*, но в любом случае указатель всего лишь хранит адрес «начала» объекта в памяти. Если же повышающее приведение производить непосредственно для объекта, то он фактически «обрезается» до размера объекта базового класса. Поэтому логично, что для вызова функций «через объект» используется раннее связывание — компилятор и так «знает» фактический тип объекта.

Собственно, это всё. Жду комментариев. Спасибо за внимание.

P.S. Данная статья помечена грифом «Гарантия Скора» ©
(Skor, если ты это читаешь, это для тебя ;)

P.P.S. Да, забыл сказать… Джависты сейчас начнут кричать, что в Java по умолчанию все функции виртуальные.
_________
Текст подготовлен в ХабраРедакторе

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

В каких случаях- это может понадобится? Мне кажется, что больше -, чем +.

Рихтер, вроде, тоже не рекомендует так делать.

чем оно странно и опасно? что может привести к непредсказуемым результатам. - этак про любой код можно сказать

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

Вопрос я думаю в первую очередь про конструктор и вызов в нём виртуальных функций. Там вроде есть какие то подводные камни потенциально. Как минимум - вызов функции у недостроенного класса.

2 ответа 2

В каких случаях- это может понадобится?

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

Аналогичный код в C++ вывел бы "New Generic animal is born!" для любого животного, что явно не является интуитивно ожидаемым поведением.

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

Запретить вызывать виртуальные методы из конструктора, как мы видим, не поможет избежать проблемы. Запретить вызывать виртуальные методы из невиртуальных — слишком сильное ограничение: если это будет так, мы не сможем выносить общую логику в отдельные, невиртуальные методы. Запретить вызов любых методов из конструктора кажется слишком сильным запретом.

Поэтому решение проблемы — внимательно следить за тем, какие именно предусловия накладывают методы на вызывающий код.

Т.е. в наследнике мы переопределяем метод createUI() , который вызывается в конструкторе базового класса. Как я понимаю, нужно использовать позднее связывание, т.е. объявить метод виртуальным. Но это не работает. В чём я не прав?

4 ответа 4

Когда создается объект производного класса, сначала вызывается конструктор базового класса.
В этот момент еще НЕТ объекта производного класса, поэтому вызов виртуальной функции и позднее связывание невозможны - вызываются только функции создаваемого класса.

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

"В конструкторе виртуальность не работает и не может работать. ". В конструкторе виртуальность работает и работает так же как и всегда: выбор конкретной функции для вызова делается на основе динамического типа объекта. Только не надо забывать, что во время работы конструктора класса A динамический тип конструируемого объекта - именно A . Поэтому виртуальность в иерархии работает от корня иерархии и вниз вплоть до типа A , но не ниже. Пока мы не "упираемся" в тип A, виртуальность работает "привычным" образом.

@ixSci: Ну, эта часть понятна, но я всё равно не назвал бы это поведение безопасным. Если программист считает, что функция вызывается, а реально она не вызывается в некоторых случаях (а увидеть проблему при косвенном вызове шансов никаких), то это дорога к проблемам.

@AnT Это скорее не виртуальность, потому что, как я понимаю, позднее связывание тут не работает. Вызовы разрешаются при компиляции, а не через vtable.

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

Так вот, в языке С++ во время непосредственной работы конструктора (или деструктора) класса A динамической тип конструируемого объекта принимается равным именно A . То есть даже если конструируемый объект типа A является базовым подобъектом какого-то объекта класса-наследника B , динамический тип конструируемого объекта во время работы конструктора A - это именно A . Только когда конструктор класса A закончит свою работу и передаст управление конструктору класса B , динамический тип конструируемого объекта магическим образом изменится и станет B .

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

Именно это вы и наблюдаете.

P.S. Утверждения о том, что виртуальные вызовы, сделанные из конструктора, якобы "не работают" или "ведут себя как невиртуальные", не соответствуют действительности. Виртуальные методы ведут себя как всегда - в полном соответствии с динамическим типом конструируемого объекта. Иллюзия "невиртуальности" возникает только из-за тривиальности и непоказательности рассматриваемого примера: совершающий виртуальный вызов класс вообще не имеет предков.

I'm asking and answering my own question because I want to get the explanation for this bit of C++ esoterica into Stack Overflow. A version of this issue has struck our development team twice, so I'm guessing this info might be of use to someone out there. Please write out an answer if you can explain it in a different/better way.

What surprises me is the lack of a compiler warning. The compiler substitutes a call to the “function defined in the class of the current constructor” for what would in any other case be the “most overridden” function in a derived class. If the compiler said “substituting Base::foo() for call to virtual function foo() in constructor” then the programmer would be warned that the code will not do what they expected. That would be a lot more helpful than making a silent substitution, leading to mysterious behavior, lots of debugging, and eventually a trip to stackoverflow for enlightenment.

@CraigReynolds Not necessarily. There is no need for special compiler treatment of virtual calls inside constructors The base class constructor creates the vtable for the current class only, so at that point the compiler can just call the vitrual function via that vtable in exactly the same way as usual. But the vtable doesn't point to any function in any derived class yet. The vtable for the derived class is adjusted by the derived class's constructor after the base class constructor returns, which is how the override will work once the derived class is constructed.

15 Answers 15

You can now choose to sort by Trending, which boosts votes that have happened recently, helping to surface more up-to-date answers.

Trending is based off of the highest score sort and falls back to it if no posts are trending.

Calling virtual functions from a constructor or destructor is dangerous and should be avoided whenever possible. All C++ implementations should call the version of the function defined at the level of the hierarchy in the current constructor and no further.

The C++ FAQ Lite covers this in section 23.7 in pretty good detail. I suggest reading that (and the rest of the FAQ) for a followup.

[. ] In a constructor, the virtual call mechanism is disabled because overriding from derived classes hasn’t yet happened. Objects are constructed from the base up, “base before derived”.

[. ]

Destruction is done “derived class before base class”, so virtual functions behave as in constructors: Only the local definitions are used – and no calls are made to overriding functions to avoid touching the (now destroyed) derived class part of the object.

EDIT Corrected Most to All (thanks litb)

Not most C++ implementations, but all C++ implementations have to call the current class's version. If some don't, then those have a bug :). I still agree with you that it's bad to call a virtual function from a base class - but semantics are precisely defined.

It's not dangerous, it's just non-virtual. In fact, if methods called from the constructor were called virtually, it would be dangerous because the method could access uninitialized members.

Why is calling virtual functions from destructor dangerous? Isn't the object still complete when destructor runs, and only destroyed after the destructor finishes?

−1 "is dangerous", no, it's dangerous in Java, where downcalls can happen; the C++ rules remove the danger through a pretty expensive mechanism.

Calling a polymorphic function from a constructor is a recipe for disaster in most OO languages. Different languages will perform differently when this situation is encountered.

The basic problem is that in all languages the Base type(s) must be constructed previous to the Derived type. Now, the problem is what does it mean to call a polymorphic method from the constructor. What do you expect it to behave like? There are two approaches: call the method at the Base level (C++ style) or call the polymorphic method on an unconstructed object at the bottom of the hierarchy (Java way).

In C++ the Base class will build its version of the virtual method table prior to entering its own construction. At this point a call to the virtual method will end up calling the Base version of the method or producing a pure virtual method called in case it has no implementation at that level of the hierarchy. After the Base has been fully constructed, the compiler will start building the Derived class, and it will override the method pointers to point to the implementations in the next level of the hierarchy.

In Java, the compiler will build the virtual table equivalent at the very first step of construction, prior to entering the Base constructor or Derived constructor. The implications are different (and to my likings more dangerous). If the base class constructor calls a method that is overriden in the derived class the call will actually be handled at the derived level calling a method on an unconstructed object, yielding unexpected results. All attributes of the derived class that are initialized inside the constructor block are yet uninitialized, including 'final' attributes. Elements that have a default value defined at the class level will have that value.

As you see, calling a polymorphic (virtual in C++ terminology) methods is a common source of errors. In C++, at least you have the guarantee that it will never call a method on a yet unconstructed object.

Когда-то давным давно я собирался и даже обещал написать про механизм виртуальных функций относительно деструкторов. Теперь у меня наконец появилось свободное время и я решил воплотить эту затею в жизнь. На самом деле эта мини-статья служит «прологом» к моей следующей статье. Но я постарался изложить доходчиво и понятно основные моменты по текущей теме. Если вы чувствуете, что еще недостаточно разобрались в механизме виртуальных вызовов, то, возможно, вам следует для начала прочитать мою предыдущую статью.

Сразу же, как обычно, оговорюсь, что: 1) статья моя не претендует на полноту изложения материала; 2) мегапрограммеры ничего нового здесь не узнают; 3) материал не новый и давно описан во многих книгах, но если явно об этом не прочитать и самому специально не задумываться, то можно о некоторых моментах даже не подозревать (до поры, до времени). Также прошу прощения за надуманные примеры :)

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

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

Основное правило: если у вас в классе присутствует хотя бы одна виртуальная функция, деструктор также следует сделать виртуальным. При этом не следует забывать, что деструктор по умолчанию виртуальным не будует, поэтому следует объявить его явно. Если этого не сделать, у вас в программе почти наверняка будут утечки памяти (memory leaks). Чтобы понять почему, опять же много ума не надо. Рассмотрим несколько примеров.

В первом случае создадим объект производного класса в стеке:

using std :: cout ;
using std :: endl ;

Всем ясно, что вывод программы будет следующим:

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

Попробуем теперь создать тот же объект в динамической памяти, используя при этом указатель на объект базового класса (код классов не изменился, поэтому привожу только код функции main()):

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

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

Чтобы этого избежать, деструктор в базовом классе должен быть объявлен как виртуальный:

using std :: cout ;
using std :: endl ;

int main ( )
<
A * pA = new B ;
delete pA ;
return EXIT_SUCCESS ;
>

Теперь-то мы получим желаемый порядок вызовов:

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

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

Давайте для начала рассмотрим ситуацию с вызовом виртуальных функций внутри класса. Предположим, что у нас есть Кот, который просит покушать мяуканьем, а затем приступает к процессу :) Так поступают многие коты, но не Чеширский! Чеширский, как известно, мало того что вечно улыбается, так еще и довольно разговорчив, поэтому мы научим его говорить, переопределив метод speak():

using std :: cout ;
using std :: endl ;

class Cat
<
public :
void askForFood ( ) const
<
speak ( ) ;
eat ( ) ;
>
virtual void speak ( ) const < cout
virtual void eat ( ) const < cout
> ;

class CheshireCat : public Cat
<
public :
virtual void speak ( ) const < cout
> ;

delete cats [ 0 ] ; delete cats [ 1 ] ;
return EXIT_SUCCESS ;
>

Вывод этой программы будет следующим:

Рассмотрим код более подробно. Есть класс Cat с парой виртуальных методов, один из которых переопределен в производном CheshireCat. Но всё самое интересное происходит в методе askForFood() класса Cat.

Как видно, метод всего лишь содержит вызовы двух других методов, однако конструкция speak() в данном контексте эквивалента this->speak(), то есть вызов происходит через указатель, а значит — будет использовано позднее связывание. Вот почему при вызове метода askForFood() через указатель на CheshireCat мы видим то, что и хотели: механизм виртуальных функций работает исправно даже несмотря на то, что вызов непосредственно виртуального метода происходит внутри другого метода класса.

А теперь самое интересное: что будет, если попытаться воспользоваться этим в деструкторе? Модернизируем код так, чтобы при деструкции наши питомцы прощались, кто как умеет:

using std :: cout ;
using std :: endl ;

class Cat
<
public :
virtual ~Cat ( ) < sayGoodbye ( ) ; >
void askForFood ( ) const
<
speak ( ) ;
eat ( ) ;
>
virtual void speak ( ) const < cout
virtual void eat ( ) const < cout
virtual void sayGoodbye ( ) const < cout
> ;

class CheshireCat : public Cat
<
public :
virtual void speak ( ) const < cout
virtual void sayGoodbye ( ) const < cout
> ;

delete cats [ 0 ] ; delete cats [ 1 ] ;
return EXIT_SUCCESS ;
>

Можно ожидать, что, как и в случае с вызовом метода speak(), будет выполнено позднее связывание, однако это не так:

Ordinary Cat: Meow! *champing*
Cheshire Cat: WTF?! Where's my milk? =) *champing*
Meow-meow!
Meow-meow!

Почему? Да потому что при вызове виртуальных методов из деструктора компилятор использует не позднее, а раннее связывание. Если подумать, зачем он делает именно так, всё становится очевидным: нужно просто рассмотреть порядок конструирования и разрушения объектов. Все помнят, что конструирование объекта происходит, начиная с базового класса, а разрушение идет в строго обратном порядке. Таким образом, когда мы создаем объект типа CheshireCat, порядок вызовов конструкторов/деструкторов будет таким:

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

Мораль: если в вашей голове витают помыслы выделить какой-то алгоритм «зачистки» в отдельный метод, переопределяемый в производных классах, а затем виртуально вызывать его в деструкторе, у вас ничего не выйдет.

Читайте также: