Unicorn and class members
К нам достаточно давно обращались клиенты и потенциальные клиенты с просьбой реализовать диагностику для поиска неинициализированных членов класса. Мы долго сопротивлялись, осознавая сложность задачи, но всё-таки сдались. В результате мы создали диагностику V730. Диагностика получилась далеко не идеальной и предвидя множество писем о том, что что-то работает не так, я решил написать заметку о технической сложности этой задачи. Думаю, эта информация заранее даст пользователям PVS-Studio ответы на некоторые вопросы и просто будет интересна широкому кругу наших читателей.

Говоря о поиске неинициализированных членов класса, человек представляет себе достаточно простые ситуации. Есть в классе скажем 3 члена. Два из них мы инициализировали, а один забыли. Ну что-то в этом духе:
class Vector
{
public:
  int x, y, z;
  Vector() { x = 0; y = 0; }
};

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

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

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

Рисунок 1. Единорог гадает, инициализирован член класса, или нет.
Рисунок 1. Единорог гадает, инициализирован член класса, или нет.

О некоторых способах инициализации:
  1. Просто присвоить члену класса значение: A() { x = 1; }.
  2. Использовать список инициализации: A(): x(1) {}
  3. Использовать доступ через this: A(int x) { this->x = x; }
  4. Использовать доступ через "::": A(int x) { A::x = x; }
  5. Использовать инициализацию в духе C++11: class A { int x = 1; int y { 2 };… };
  6. Инициализировать поле с помощью функций типа memset(): A() { memset(&x, 0, sizeof(x); }.
  7. Инициализировать с помощью memset() сразу все поля класса (да, да, так делают): A() { memset(this, 0, sizeof(*this)); }
  8. Использовать делегирующий конструктор (С++11): A(): A(10, 20) {}
  9. Использовать специальную функцию инициализации: A() { Init(); }
  10. Члены класса могут сами инициализировать себя: class A { std::string m_s;… };
  11. Члены класса могут быть статическими.
  12. Можно инициализировать класс явно вызывая другой конструктор: A() { this->A(0); }
  13. Можно вызвать другой конструктор, используя placement new (программисты такие выдумщики): A() { new (this) A(1,2); }
  14. Инициализировать члены можно косвенно через указатель: A() { int *p = &x; *p = 1; }
  15. И через ссылку: A() { int &r = x; r = 1; }
  16. Можно инициализировать члены, если они являются классами, вызывая у них функции: A() { member.Init(1, 2); }
  17. Можно «постепенно» инициализировать члены, являющиеся структурами: A() { m_point.x = 0; m_point.y = 1; }
  18. Есть и другие способы.
Как видите, надо учитывать очень много способов того, как инициализируются члены класса. Думаю, мы знаем ещё далеко не про все. А ведь эти разнообразные ситуации надо предвидеть и учитывать!

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

Однако даже если научиться распознавать все-все способы инициализации классов, этого все равно будет мало. Отсутствие инициализации какого-то члена не всегда является ошибкой. Классический случай — реализация разновидности какого-то контейнера. Нередко можно встретить подобный код:
class MyVector
{
  size_t m_count;
  float *m_array;
public:
  MyVector() : m_count(0) { }
  ....
};

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

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

Возможно, для надежности следовало инициализировать m_array значением nullptr. Однако обсуждение стиля программирования выходит за рамки заметки. Важно, что на практике, если в конструкторе инициализируются не все члены, это ещё ничего не значит. Код может совершенно корректно работать и не инициализировать что-то до поры до времени вполне разумно. Здесь я показал очень упрощенный пример. Бывают гораздо более сложные ситуации.

А теперь о двойственности мира. Посмотрите на абстрактный код:
class X
{
  ....
  char x[n];
  X() { x[0] = 0; }
  ....
};

Ошибка, что в классе X инициализируется только 1 элемент? Дать ответ невозможно. Все зависит от того, что представляет из себя класс X. И понять это анализатор не может. Для этого нужен человек.

Если это какой-то класс строки, то ошибки нет:
class MyString
{
  ....
  char m_str[100];
  MyString() { m_str[0] = 0; }
  ....
};

В начало строки записывается терминальный ноль. Тем самым программист указывает, что строка пустая. Остальные элементы массива можно не инициализировать. Код корректен.

Если это класс для работы с цветом, то имеет место ошибка:
class Color
{
  ....
  char m_rgba[4];
  Color() { m_rgba[0] = 0; }
  ....
};

Здесь инициализирован только один элемент массива, а следовало инициализировать все элементы. В данном случае, кстати, анализатор посчитает, что класс полноценно инициализирован и не выдаст предупреждения (false negative). Приходится отдавать предпочтение подходу «промолчать», иначе анализатор будет генерировать слишком много шума.

Видите, как все непросто и неоднозначно? Очень сложно говорить, когда есть ошибка, а когда нет. Пришлось реализовать в анализаторе множество эмпирических проверок, которые пытаются угадать, ошибочен код или нет. Естественно, эмпирический подход будет давать сбой, и мы хотим заранее попросить за это прощения. Однако надеюсь, теперь вы понимаете, что поиск неинициализированных членов класса является сложной задачей и будете снисходительны к PVS-Studio.

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


  1. xoposhiy
    27.10.2015 13:29

    Немного некстати, но все же, а много вы ошибок от участников SECR собрали?


    1. Andrey2008
      27.10.2015 14:04

      По итогам недели отвечу.


  1. datacompboy
    27.10.2015 13:58
    +2

    Даёшь кружку из !!!


  1. datacompboy
    27.10.2015 14:21
    +1

    А реально ловить не просто «неинициализированные» а «обращение к неинициализированной»?
    Чтобы избавиться от «MyVector» проблемы. Ведь в теории, статический анализатор должен обрабатывать весь поток вероятностных путей, соответственно, ругань на обращение к методу который может пощупать неинициализированное поле и должна быть.


    1. Andrey2008
      27.10.2015 14:31
      +1

      Теоретически можно всё, практически нет. Это уже сфера динамических анализаторов.


  1. vanxant
    28.10.2015 02:11
    -1

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


    1. AWE64
      28.10.2015 09:43

      Ну, может, кто-то экономит инструкции процессора на инициализации)


      1. vanxant
        28.10.2015 16:05

        А потом другие люди изливают крик души, когда из корок на них смотрит указатель со значением 7 (с)


  1. Andrey2008
    29.10.2015 16:23
    +1

    Вот и вышла PVS-Studio 5.30 с диагностикой V730. Предлагаю всем интересующихся опробовать.