В разных языках программирования поведение виртуальных функций отличается, когда речь идет о конструкторах и деструкторах. Неправильное использование виртуальных функций — классическая ошибка. Разработчики часто неправильно используют виртуальные функции. В этой статье мы обсудим эту классическую ошибку.
Теория
Я полагаю, что читатель знаком с виртуальными функциями в C++. Давайте сразу к делу. Когда мы вызываем виртуальную функцию в конструкторе, функция переопределяется только внутри базового класса или класса, созданного в данный момент. Конструкторы в производных классах еще не вызывались. Поэтому реализованные в них виртуальные функции вызываться не будут.
Позвольте мне проиллюстрировать это.
Пояснения:
- Класс B является производным от класса A;
- Класс C является производным от класса B;
- Функции foo и bar являются виртуальными;
- Функция foo не имеет реализации в классе B.
Давайте создадим объект класса C и вызовем эти две функции в конструкторе класса B. Что случилось бы?
- Функция foo. Класс C еще не создан. В классе B нет функции foo. Поэтому вызывается реализация из класса A.
- Функция bar. Класс C еще не создан. Таким образом, вызывается функция, связанная с текущим классом B.
Теперь посмотрите на то же самое в коде.
#include <iostream>
class A { public: A() { std::cout << "A()\n"; }; virtual void foo() { std::cout << "A::foo()\n"; }; virtual void bar() { std::cout << "A::bar()\n"; }; };
class B : public A { public: B() { std::cout << "B()\n"; foo(); bar(); }; void bar() { std::cout << "B::bar()\n"; }; };
class C : public B { public: C() { std::cout << "C()\n"; }; void foo() { std::cout << "C::foo()\n"; }; void bar() { std::cout << "C::bar()\n"; }; };
int main() { C x; return 0; }
Если мы скомпилируем и запустим код, он выведет следующее:
A()
B()
A::foo()
B::bar()
C()
То же самое происходит, когда мы вызываем виртуальные методы в деструкторах.
Так в чем проблема? Вы можете найти эту информацию в любой книге по программированию на C++.
Проблема в том, что об этом легко забыть! Таким образом, некоторые программисты предполагают, что функции foo и bar вызываются из самого производного класса C.
Люди продолжают задавать на форумах один и тот же вопрос: Почему код работает не так, как надо? Пример: Вызов виртуальных функций внутри конструкторов.
Думаю, теперь вы понимаете, почему в таком коде легко ошибиться. Особенно, если вы пишете код на других языках, где поведение отличается. Посмотрим на фрагмент кода на C#:
class Program
{
class Base
{
public Base()
{
Test();
}
protected virtual void Test()
{
Console.WriteLine("From base");
}
}
class Derived : Base
{
protected override void Test()
{
Console.WriteLine("From derived");
}
}
static void Main(string[] args)
{
var obj = new Derived();
}
}
Если мы запустим его, программа выведет следующее:
From derived
Соответствующая визуальная схема:
Функция, переопределенная в производном классе, вызывается из конструктора базового класса!
При вызове виртуального метода из конструктора учитывается тип времени выполнения созданного экземпляра. Виртуальный вызов основан на этом типе. Метод вызывается в конструкторе базового типа. Несмотря на это, фактический тип создаваемого экземпляра — Derived. Это определяет выбор метода. Подробнее о виртуальных методах можно прочитать в спецификации.
Обратите внимание, что такое поведение может привести к ошибкам. Например, если виртуальный метод работает с членами производного типа, которые еще не были инициализированы в его конструкторе. В этом случае будут проблемы.
Посмотрите на пример:
class Base { public Base() { Test(); }
protected virtual void Test() { } }
class Derived : Base { public String MyStr { get; set; }
public Derived(String myStr) { MyStr = myStr; }
protected override void Test() => Console.WriteLine($"Length of {nameof(MyStr)}: {MyStr.Length}"); }
Если мы попытаемся создать экземпляр типа Derived, будет выброшено NullReferenceException. Это происходит, даже если мы передаем в качестве аргумента значение, отличное от null: new Derived("Привет").
Конструктор типа Base вызывает экземпляр метода Test из типа Derived. Этот метод обращается к свойству MyStr. В настоящее время он инициализируется значением по умолчанию (null), а не параметром, переданным конструктору (myStr).
С теорией покончено. Теперь позвольте мне рассказать вам, почему я решил написать эту статью.
Как появилась эта статья
Все началось с вопроса на StackOverflow: «Сканирование сборки на clang-13 не показывает ошибок». Точнее, все началось с обсуждения в комментариях под нашей статьей — Как мы сочувствуем вопросу о StackOverflow, но молчим.
Вам не нужно переходить по ссылкам. Кратко перескажу историю.
Один человек спросил, как статический анализ помогает искать две закономерности. Первый шаблон относится к переменным типа bool. Мы не обсуждаем это в этой статье, поэтому этот паттерн нас сейчас не интересует. Второй — поиск вызовов виртуальных функций в конструкторах и деструкторах.
По сути, задача состоит в том, чтобы идентифицировать вызовы виртуальных функций в следующем фрагменте кода:
class M { public: virtual int GetAge(){ return 0; } };
class P : public M { public: virtual int GetAge() { return 1; } P() { GetAge(); } // maybe warn ~P() { GetAge(); } // maybe warn };
Внезапно оказывается, что не все понимают, в чем здесь опасность и почему инструменты статического анализа предупреждают разработчиков о вызове виртуальных методов в конструкторах/деструкторах.
К статье на хабре есть следующие комментарии (RU):
Сокращенный комментарий N1: Итак, компилятор прав, здесь нет ошибки. Ошибка только в логике разработчика. Этот фрагмент кода всегда возвращает 1 в первом случае. Он мог бы использовать inline для ускорения конструктора и деструктора. В любом случае компилятору это не важно. Результат функции никогда не используется, функция не использует никаких внешних аргументов — компилятор просто выкинет пример в качестве оптимизации. Это правильно. В результате здесь нет ошибки.
Сокращенный комментарий N2: я вообще не понял шутки про виртуальные функции. [цитата из книги о виртуальных функциях]. Автор подчеркивает, что ключевое слово виртуальный используется только один раз. Далее в книге объясняется, что это передается по наследству. А теперь, мои дорогие студенты, ответьте мне: что плохого в вызове виртуальной функции в конструкторе и деструкторе класса? Опишите каждый случай отдельно. Я предполагаю, что вы оба далеко не прилежные ученики. Вы понятия не имеете, когда вызывается конструктор и деструктор класса. Кроме того, вы пропустили урок «В каком порядке определять объекты родительских классов при определении родителя и в каком порядке их уничтожать».
Прочитав комментарии, вы наверняка задаетесь вопросом, какое отношение они имеют к теме, обсуждаемой далее. И вы имеете на это полное право. Ответ заключается в том, что они этого не делают.
Человек, оставивший эти комментарии, не мог догадаться, от какой проблемы хотел защитить код автор вопроса на StackOverflow.
Я допускаю, что автор мог бы лучше сформулировать вопрос. На самом деле в приведенном выше коде нет проблем. Еще. Но они появятся позже, когда у классов появятся новые потомки, реализующие функцию GetAge. Если бы в этом фрагменте кода был другой класс, наследующий P, вопрос стал бы более полным.
Однако любой, кто хорошо знает язык C++, сразу понимает проблему и то, почему этого человека так волнуют вызовы функций.
Даже стандарты кодирования запрещают вызовы виртуальных функций в конструкторах/деструкторах. Например, стандарт кодирования SEI CERT C++ содержит следующее правило: OOP50-CPP. Не вызывайте виртуальные функции из конструкторов или деструкторов. Многие анализаторы кода реализуют это диагностическое правило. Например, Parasoft C/C++test, Polyspace Bug Finder, PRQA QA-C++, плагин SonarQube C/C++. В PVS-Studio (разработанный нами инструмент статического анализа) реализована и она — диагностика V1053.
А если здесь нет ошибки?
Мы не изучали такую ситуацию. То есть все работает так, как мы ожидали. В этом случае мы можем явно указать, какие функции мы планируем вызывать:
B() {
std::cout << "B()\n";
A::foo();
B::bar();
};
Таким образом, ваши товарищи по команде будут правильно понимать код. Статические анализаторы тоже поймут код и промолчат.
Заключение
Статический анализ полезен. Он определяет потенциальные проблемы в коде. Даже те, которые вы и ваши товарищи по команде могли пропустить. Несколько примеров:
- В718. Функция Foo не должна вызываться из функции DllMain.
- В1032. Указатель приводится к более строго выровненному типу указателя.
- В1036. Потенциально небезопасная блокировка с двойной проверкой.
То, как работают виртуальные функции, не такое тайное знание, как примеры выше :). Однако комментарии и вопросы на StackOverflow показывают, что эта тема заслуживает внимания и контроля. Если бы это было очевидно, я бы не писал эту статью. Статические анализаторы помогают разработчикам работать с кодом.
Спасибо за внимание, приходите попробовать анализатор PVS-Studio.