Третья часть исследования Nau Engine
В финальной части нашей трилогии, посвящённой Nau Engine, мы уделим внимание ошибкам, возникающим при разработке классов. Приведённые в статье примеры наглядно демонстрируют, как даже небольшие недоработки могут обернуться серьёзными проблемами в работе приложения.
Как уже сказано в начале, это третья статья про проверку Nau Engine. В первой части мы рассмотрели функционал Nau Engine с акцентом на три группы ошибок: проблемы управления памятью, избыточное копирование кода и логические недочёты. Во второй части обсуждались ключевые аспекты оптимизации и повышения производительности.
Теперь предлагаю сфокусироваться на ошибках, обнаруженных статическим анализатором PVS-Studio в процессе разработки классов.
Фрагмент N1
Предупреждение PVS-Studio: V546 Member of a class is initialized with itself: 'value(std::move(value))'. attribute.h 118
В этом коде допущена ошибка, связанная с тем, что в первом конструкторе поле `AttributeField::value` инициализируется самим собой. В результате переменная используется до своей фактической инициализации, что приводит к неопределённому поведению. Такой код может привести к неожиданным ошибкам, затруднить отладку и оставить объект в некорректном состоянии.
Исправленный вариант инициализации должен использовать переданный аргумент `inValue`, а не сам член класса:
Фрагмент N2
Предупреждение PVS-Studio: V730 Not all members of a class are initialized inside the constructor. Consider inspecting: m_animationState. animation_instance.cpp 28
В этом фрагменте рассматривается конструктор класса `AnimationInstance`, который принимает два параметра: имя объекта и rvalue ссылку на `assetRef`. Обратите внимание, что в списке инициализации и теле конструктора явно задаются лишь значения для `m_name` и `m_animationAsset`, в то время как поле `m_animationState` остаётся без инициализации. Такая ситуация может привести к неопределённому поведению, поскольку неинициализированные члены класса потенциально содержат мусорные значения из-за default-инициализации. Вероятно, автор кода забыл добавить инициализацию для всех членов класса. Рекомендуется обеспечить явную инициализацию каждого поля: либо через список инициализации конструктора, либо посредством значений по умолчанию при объявлении, чтобы гарантировать корректное состояние объекта при его создании.
Фрагмент N3
Предупреждение PVS-Studio: V1053 Calling the 'ceaseReading' virtual function indirectly in the destructor may lead to unexpected result at runtime. Check lines: 'dag_lzmaIo.h:27', 'lzmaDecIo.cpp:48', 'dag_genIo.h:249'. dag_lzmaIo.h 27
В этом примере класс `LzmaLoadCB` реализует деструктор, в котором сначала вызывается функция `close`, а затем при выполнении определённого условия происходит вызов функции `ceaseReading`, объявленной как виртуальная в базовом классе `IGenLoad`. Вызов виртуальных функций в контексте конструирования или разрушения объекта может быть опасным: на этом моменте у объекта либо ещё не инициализированы производные части (конструирование), либо уже уничтожены его наиболее производные части (разрушение). Это означает, что, если у виртуальной функции `ceaseReading` есть переопределение в наиболее производном классе, на момент работы конструктора или деструктора всегда будет вызвана виртуальная функция из текущего или базового класса. Это может привести к игнорированию важного функционала, реализованного в наиболее производном классе. Лучше избегать подобных вызовов в конструкторах и деструкторах, чтобы не нарушить корректность работы программы.
И вот ещё случаи:
V1053 Calling the 'Type' virtual function indirectly in the constructor may lead to unexpected result at runtime. Check lines: 318, 252, 245. d3dx12_state_object.h 318
Фрагмент N4
Предупреждение PVS-Studio: V794 The assignment operator should be protected from the case of 'this == &other'. fs_path.cpp 36
В представленном фрагменте реализован оператор перемещения присваивания класса `FsPath`, в котором происходит перенос данных из объекта `other` в текущий экземпляр. Следует отметить, что отсутствует проверка на самоприсваивание (проверка вида `(this == &other)`), что может привести к нежелательным последствиям.
Если объект пытаются присвоить самому себе, то операция `m_path = std::move(other.m_path);` перемещает содержимое `other.m_path` в `m_path`, а последующий вызов `other.m_path.clear();` очищает данные. В результате `m_path` оказывается в неожиданном состоянии, и остаётся лишь пожелать удачной отладки :)
Чтобы устранить этот риск, рекомендую добавить в начало оператора следующую проверку:
Использование `std::addressof` вместо оператора `&` гарантирует корректное сравнение адресов даже при перегрузке оператора `&` в классе.
Фрагмент N5
Предупреждение PVS-Studio: V762 It is possible a virtual function was overridden incorrectly. See first argument of function 'reorderChild' in derived class 'Node' and base class 'Node'. node.h 145
В примере определены два класса с именем `Node`:
- `cocos2d::Node` — оригинальный класс;`nau::ui::Node` — класс;
- `nau::ui::Node` — класс, который наследуется от `cocos2d::Node`.
Проблема кроется в том, что функция `reorderChild` объявлена в обоих классах с первым параметром типа `Node`. Однако по правилам поиска неквалифицированных имён, при объявлении функции в базовом классе имя `Node` будет разрешёно как `cocos2d::Node`, а в производном классе — как `nau::ui::Node`. Из-за этого различия компилятор не сделает переопределение виртуальной функции из базового класса. В результате механизм виртуальных функций сработает некорректно, и при вызове метода через указатель на базовый класс выполняется версия из базового класса, что может привести к непредвиденному поведению программы.
Чтобы избежать подобных ошибок, рекомендуется при переопределении виртуальных функций явно указать тип первого параметра, а также всегда не забывать о применении спецификатора `override`:
Это обеспечивает полное совпадение сигнатуры метода в производном классе с сигнатурой базового метода, и в случае несовпадения компилятор выдаст ошибку.
И вот ещё случай:
- V762 It is possible a virtual function was overridden incorrectly. See first argument of function 'removeChild' in derived class 'Sprite' and base class 'Sprite'. sprite.h 87
Фрагмент N6
Предупреждение PVS-Studio: V1061 Extending the 'std' namespace may result in undefined behavior. stopwatch.h 26
В пространство имён `std::chrono` добавили функцию `PrintTo`. Эта функция – один из способов научить GoogleTest печатать значения пользовательских типов, если для них не перегружен оператор `<<`. Согласно документации, нужно определить эту функцию в том же пространстве имён, где определён наш пользовательский тип. В данной ситуации, этим типом является специализация шаблона класса `std::chrono::duration` для работы с миллисекундами.
Расширение стандартного пространства имён `std` (а также некоторых других пространств, например, `posix`) — опасная практика, которая может привести к неопределённому поведению. Проблема в том, что стандарт C++ предполагает неизменность своего пространства имён, а реализация библиотек может со временем расширяться, добавляя новые функции и типы. Если в `std` или его вложенных пространствах объявить пользовательскую функцию или класс, есть вероятность, что в одной из будущих версий стандарта появится аналогичное определение, что приведёт к конфликтам и нестабильной работе программы.
Современный стандарт C++ разрешает добавлять сущности в `std` из достаточно скромного списка. И, к сожалению, обычные функции в этот список не входят. Поэтому поведение будет не определено при добавлении функции `PrintTo` для типа `std::chrono::milliseconds`. Но что же делать, если мы всё же хотим печатать время в миллисекундах в рамках GoogleTest? Есть два способа решения проблемы.
Решение N2. Ну раз в стандартной библиотеке пока нет этого оператора, значит мы определим его сами в глобальном пространстве имён. А дальше же компилятор найдёт нужный нам оператор, ведь так?
Нет, не найдёт.
Реализация GoogleTest ограничивает неквалифицированный поиск в обрамляющих пространствах имён (при помощи перегруженного оператора `operator<<(LookupBlocker, LookupBlocker)`. А ADL ищет функции только в тех пространствах имён, где объявлены типы аргументов.
Поэтому до C++20 и реализации проползала P0355 придётся использовать код похитрее. Мы унаследуемся от `std::chrono::duration` и разместим наследника в пространстве имён `nau::test`. Там же мы определим перегрузку оператора `<<`, который будет уметь печатать объекты типа `std::chrono::duration` в произвольный поток вывода. Далее, когда будет необходимо через GoogleTest напечатать длительность, нужно просто сконвертировать `std::chrono::duration` в нашего наследника.
Возможная имплементация.
На посошок
Предупреждение PVS-Studio: V1076 Code contains invisible characters that may alter its logic. Consider enabling the display of invisible characters in the code editor. test_runtime_attributes.cpp 91
Анализатор обнаружил, что в комментарии к тесту закралась последовательность байтов `E2 80 8B`, которая интерпретируется как Zero Width Space (ZWSP). Этот символ представляет собой невидимый пробел, который не отображается на экране, но при этом остаётся частью текста.
Воспользуемся HEX-редактором, что убедиться в этом:
Сразу хочу сказать, что в этом коде никакого Trojan Source не заложено. Однако я хотел бы напомнить читателям, что такая опасность по-прежнему существует. Если опасные невидимые символы случайно окажутся или в имени переменной, или строковом литерале, или даже в комментарии, то поведение вашей программы может отличаться от того, что вы видите в коде. При этом компиляторы прекрасно пропускают такой код без ошибок.
Хочу также отметить, что не всегда включение отображения невидимых символов в вашем редакторе кода полностью исправит положение. Троян может затесаться где-то среди сотен файлов, которые пришли вам в качестве Pull Request. Поэтому, чтобы защититься от подобных проблем, рекомендуется также применять автоматизированные инструменты. Здесь выбор остаётся за вами: это может быть и обычный `grep`, а может и статический анализатор кода.
Заключение
Приведённые примеры наглядно демонстрируют, как даже незначительные ошибки могут перерасти в серьёзные проблемы на этапе выполнения программы. Независимо от того, связаны ли недочёты с неправильной инициализацией членов, ошибками переопределения виртуальных функций или с расширением стандартных пространств имён, своевременное обнаружение и устранение подобных уязвимостей является залогом создания стабильного и надёжного программного обеспечения.
Надеюсь, что наша трилогия о Nau Engine станет полезным ориентиром для разработчиков, поможет избегать типичных ошибок и повышать качество проектов. Стоит применять полученные знания на практике, постоянно анализировать и совершенствовать свой код, а также делиться опытом с коллегами. Ведь успешная разработка это не только владение техническими навыками, но и способность учиться на ошибках и стремиться к постоянному развитию.
Как говорил Эдсгер Дейкстра: 'Тестирование показывает наличие ошибок, а не их отсутствие'. Поэтому важно не только исправлять баги, но и изначально писать код так, чтобы их было как можно меньше.
Благодарю за внимание!