Для получения триального ключа
заполните форму ниже
Team License (базовая версия)
Enterprise License (расширенная версия)
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Бесплатная лицензия PVS-Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

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

Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте папку
Spam/Junk и нажмите на письме кнопку "Не спам".
Так Вы не пропустите ответы от нашей команды.

>
>
>
Вызов виртуальных функций в конструктор…

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

26 Ноя 2021

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

0891_virtual_function_call_ru/image1-ab88da19841c8dc8e6574abeee352b2c.png

Теория

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

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

0891_virtual_function_call_ru/image2-6b0ccbbd5525265fcfe984f1d50b6151.png

Пояснения:

  • От класса A наследуется класс B;
  • От класса B наследуется класс C;
  • Функции 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()

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

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

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

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

Думаю, теперь понятно, почему в подобном коде легко допустить ошибку. Особенно легко запутаться, если довелось программировать на других языках, где поведение отличается. Рассмотрим следующую программу на языке 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

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

0891_virtual_function_call_ru/image3-002d775c823102677913589ff5c1177d.png

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

При вызове виртуального метода из конструктора учитывается тип времени выполнения создаваемого экземпляра. Исходя из этого типа и происходит виртуальный вызов. Несмотря на то, что вызов метода происходит в конструкторе базового типа, фактический тип создаваемого экземпляра – 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("Hello there").

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

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

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

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

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

Человек спросил, как с помощью статического анализа искать ошибки двух видов. Первая ошибка касается переменных типа 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
};

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

0891_virtual_function_call_ru/image4-315fbb080b2672f321512f2a0f115fc4.png

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

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

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

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

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

Да, стоит признать, что вопрос можно было бы сформулировать лучше. Собственно, как таковой проблемы в приведённом коде действительно нет. Пока нет. Она появится в дальнейшем, когда у классов появятся новые наследники, реализующие функцию 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).

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

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

B() {
  std::cout << "B()\n";
  A::foo();
  B::bar();
};

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

Заключение

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

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

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

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

Популярные статьи по теме
Четыре причины проверять, что вернула функция malloc

Дата: 20 Апр 2022

Автор: Андрей Карпов

Некоторые разработчики пренебрежительно относятся к проверкам: удалось ли выделить память с помощью функции malloc или нет. Их логика проста – памяти всегда должно хватить. А если не хватит, всё равн…
Атака Trojan Source: скрытые уязвимости

Дата: 15 Апр 2022

Автор: Гость

Мы представляем новый тип атаки для внедрения в исходный код вредоносных изменений, по-разному выглядящих для компилятора и человека. Такая атака эксплуатирует тонкости стандартов кодирования символо…
Дизайн и эволюция constexpr в C++

Дата: 13 Янв 2022

Автор: Гость

constexpr - одно из самых магических ключевых слов в современном C++. Оно дает возможность создать код, который будет выполнен еще до окончания процесса компиляции, что является абсолютным пределом д…
C++20 Ranges — Полное руководство

Дата: 07 Дек 2021

Автор: Гость

C++20 Ranges, также известные как STL v2, эффективно заменяют существующие возможности и алгоритмы STL. В этой статье я расскажу вам о нововведениях, которые внесли собой Ranges. Также мы поговорим о…
Зачем нужен отчёт MISRA Compliance и как его получить в PVS-Studio?

Дата: 06 Сен 2021

Автор: Николай Миронов

Если вы не понаслышке знаете о MISRA и хотели бы понимать, соответствует ли ваш проект какому-то из стандартов ассоциации MISRA, то есть решение. Имя ему - MISRA Compliance. С недавних пор PVS-Studio…

Комментарии (0)

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