C вызов виртуальной функции из конструктора

Обновлено: 28.04.2024

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

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

Сразу же, как обычно, оговорюсь, что: 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(), то фактически попытаемся обратиться к той части объекта, которая уже была разрушена.

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

Недавно мне задали вопрос: как бы я реализовал механизм виртуальных функций на языке C?

Поначалу я понятия не имел, как это можно сделать: ведь C не является языком объектно-ориентированного программирования, и здесь нет такого понятия, как наследование. Но поскольку у меня уже было немного опыта с C, и я знал, как работают виртуальные функции, я подумал, что должен быть способ сымитировать поведение виртуальных функций, используя структуры (struct).

Краткое пояснение для тех, кто не знает, что такое виртуальные функции:
Виртуальная функция — это функция, которая может быть переопределена классом-наследником, для того чтобы тот имел свою, отличающуюся, реализацию. В языке C++ используется такой механизм, как таблица виртуальных функций
(кратко vtable) для того, чтобы поддерживать связывание на этапе выполнения программы. Виртуальная таблица — статический массив, который хранит для каждой виртуальной функции указатель на ближайшую в иерархии наследования реализацию этой функции. Ближайшая в иерархии реализация определяется во время выполнения посредством извлечения адреса функции из таблицы методов объекта.

Давайте теперь посмотрим на простой пример использования виртуальных функций в C++

В приведенном примере у нас есть класс ClassA , имеющий два метода: get() и set() . Метод get() помечен как виртуальная функция; в классе ClassB его реализация меняется. Целое число data помечено ключевым словом protected , чтобы класс-наследник ClassB имел доступ к нему.


Теперь давайте подумаем, как реализовать концепцию виртуальных функций на C. Зная, что виртуальные функции представлены в виде указателей и хранятся в vtable, а vtable — статический массив, мы должны создать структуру, имитирующую сам класс ClassA, таблицу виртуальных функций для ClassA, а также реализацию методов ClassA.


В C не существует указателя this , который указывал бы на вызывающий объект. Я назвал параметр та́к, для того чтобы сымитировать использование указателя this в C++ (кроме того это похоже на то, как на самом деле работает вызов метода объекта в C++).

Как мы видим из кода, приведенного выше, реализация ClassA_get() вызывает функцию set() через указатель из vtable. Теперь посмотрим на реализацию класса-наследника:


Как видно из кода, мы так же вызываем функцию set() из реализации get() ClassB, используя vtable, указывающую на нужную функцию set() , а также обращаемся к тому же целому числу data через «унаследованный» класс ClassA.

Вот так выглядит функция main() :


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

Вызов виртуальных функций в конструкторах (C++)


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

Теория

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

Для начала поясню это рисунком.

Вызов виртуальных функций в конструкторах (C++)

  • От класса A наследуется класс B;
  • От класса B наследуется класс C;
  • Функции foo и bar являются виртуальными;
  • У функции foo нет реализации в классе B.

Создадим объект класса C и рассмотрим, что произойдёт, если мы вызовем эти две функции в конструкторе класса B.

  • Функция foo. Класс C ещё не создан, а в классе B нет функции foo. Поэтому будет вызвана реализация функции из класса A.
  • Функция bar. Класс C ещё не создан. Поэтому вызывается функция, относящаяся к текущему классу B.

Теперь продемонстрирую то же самое кодом.

Если скомпилировать и запустить этот код, то он распечатает:

При вызове виртуальных методов в деструкторах всё работает точно так же.

Казалось бы, в чём проблема? Всё это описано в книжках по программированию на языке С++.

Проблема в том, что про это легко забыть! И считать, что функции foo и bar будут вызваны из крайнего наследника, т.е. из класса C.

Вопрос "Почему код работает неожиданным образом?" вновь и вновь поднимается на форумах. Пример: Calling virtual functions inside constructors.

Если её запустить, то будет распечатано:

Соответствующая визуальная схема:

Вызывается функция в наследнике из конструктора базового класса!

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

Стоит отметить, что такое поведение тоже может быть чревато ошибками. Например, проблемы могут возникнуть, если виртуальный метод работает с членами производного типа, которые ещё не были проинициализированы в его конструкторе.

При попытке создания экземпляра типа Derived возникнет исключение типа NullReferenceException, даже если в качестве аргумента в конструктор передаётся значение, отличное от null: new Derived("Hello there").

При исполнении тела конструктора типа Base будет вызвана реализация метода Test из типа Derived. Этот метод обращается к свойству MyStr, которое в текущий момент проинициализировано значением по умолчанию (null), а не параметром, переданным в конструктор (myStr).

С теорией разобрались. Теперь расскажу, почему я вообще решил написать эту статью.

Как появилась статья

Всё началось с вопроса "Scan-Build for clang-13 not showing errors" на сайте StackOverflow. Хотя вернее будет сказать, что всё началось с обсуждения статьи "О том, как мы с сочувствием смотрим на вопрос на StackOverflow, но молчим".

Можете не переходить по ссылкам. Я сейчас кратко перескажу суть истории.

Человек спросил, как с помощью статического анализа искать ошибки двух видов. Первая ошибка касается переменных типа bool, и сейчас нам не интересна. Вторая часть вопроса как раз касалась поиска вызовов виртуальных функций в конструкторе и деструкторе.

Если удалить всё, не относящееся к теме, то задача состоит в выявлении вызовов виртуальных функций в этом коде:

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

Опасный код

К публикации на сайте habr появились комментарии (RU) следующего вида:

Сокращенный комментарий N1. Так что компилятор прав, ошибки нет. Ошибка только в логике программиста, его пример кода всегда будет возвращать единицу в первом случае. И он мог бы даже использовать inline для того, чтобы ускорить работу и кода конструктора, и деструктора. Но компилятору это все равно не имеет значение, либо результат функции нигде не используется, функция не задействует никакие внешние аргументы — компилятор просто выкинет пример в качестве оптимизации. И это логичный правильный поступок. Как итог, ошибки просто нет.

Сокращенный комментарий N2. Про виртуальные функции вообще вашего юмора не понял. [цитата из книги про виртуальные функции]. Автор подчеркивает, что ключевое слово virtual используется только один раз. Далее в книге разъясняется, что оно наследуется. А теперь студенты ответьте мне на вопрос: "Где вы увидели проблему вызова виртуальной функции в конструкторе и деструкторе класса? Ответ дать по отдельности для каждого случая". Подразумевая, что вы оба, как неприлежные студенты, не разбираетесь в вопросе, когда вызываются конструктор и деструктор класса. И в добавок совершенно упустили тему "В каком порядке определяются объекты родительских классов при определение предка, и в каком порядке они уничтожаются".

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

Тот, кто оставлял комментарии, просто не догадывается, от какой проблемы хочет защититься человек, задавший вопрос на StackOverflow.

Да, стоит признать, что вопрос можно было бы сформулировать лучше. Собственно, как таковой проблемы в приведённом коде действительно нет. Пока нет. Она появится в дальнейшем, когда у классов появятся новые наследники, реализующие функцию GetAge, которые что-то делают. Если бы в примере присутствовал ещё один класс, наследующий P, то вопрос стал бы более полным.

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

Запрет на вызов виртуальных функций в конструкторах/деструкторах нашёл своё отражение и в стандартах кодирования. Например в SEI CERT C++ Coding Standard есть правило: OOP50-CPP. Do not invoke virtual functions from constructors or destructors. Это диагностическое правило реализуют многие анализаторы кода, такие как Parasoft C/C++test, Polyspace Bug Finder, PRQA QA-C++, SonarQube C/C++ Plugin. В их число входит и разрабатываемый нашей командой PVS-Studio (диагностика V1053).

А если ошибки нет?

Мы не рассмотрели ситуацию, что никакой ошибки нет. Другими словами, всё работает ровно так, как задумывалось. В этом случае можно явно указать, какие функции мы планируем вызывать:

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

Заключение

Цените статический анализ кода. Он поможет выявить потенциальные проблемы в коде, причём такие, о которых вы и ваши коллеги могут даже не догадываться. Несколько примеров:

    . The 'Foo' function should not be called from 'DllMain' function. . Pointer is cast to a more strictly aligned pointer type. . Potentially unsafe double-checked locking.

Работа виртуальных функций, конечно, не такое тайное знание, как примеры по ссылкам :). Однако, как показывают комментарии и вопросы на StackOverflow, эта тема заслуживает внимания и контроля. Было бы всё очевидно – не было бы этой статьи. Хорошо, что анализаторы кода способны подстраховать программиста в его работе.

Спасибо за внимание, и приходите попробовать анализатор PVS-Studio.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Andrey Karpov. Virtual function calls in constructors and destructors (C++).

Вот нужно достичь примерно этого. Чтобы базовый класс в конструкторе вызвал переопределенную функцию. Кто поможет как нибудь реализовать такой код чтобы полиморфизм сработал.

Это плохая идея вызывать виртуальные функции в конструкторе. Как вы хотите вызвать функцию класса Derived , когда у вас Base еще не создан? См. хорошее правило

Оттуда же: "Есть разные варианты решения этой проблемы. Один из них – сделать функцию logTransaction невиртуальной в классе Transaction, затем потребовать, чтобы конструкторы производного класса передавали необходимую для записи в протокол информацию конструктору Transaction. Эта функция затем могла бы безопасно вызвать невиртуальную logTransaction". Переводя, на ваш случай: сделать функцию невиртуальной, и передавать ей нужную информацию в качестве параметра.

1 ответ 1

Любая реализация С++ должна вызывать в конструкторе или деструкторе реализацию функции объявленную на уровне текущего класса в иерархии.

[. ]

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.

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

Мне на ум приходит три варианта как это зарефакторить.

Суть: нет виртуальных функций - нет проблем.

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

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

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