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

Теория

Я полагаю, что читатель знаком с виртуальными функциями в 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.