Рецепты отладки. 3 типа нестабильностей.

Разных нудных и занимательных (нужное подчеркнуть) историй можно рассказывать ещё много, но надо бы заняться уже каталогизацией возникающих в приложениях проблем. Точно также, как врач ставит пациенту диагноз, основываясь на каких то симптомах, программист может найти ошибку в коде, основываясь на том, как она проявляется. Поэтому я попробую обобщить разную информацию об ошибках и о том, как они проявляются.

Но для начала – ещё одно лирическое отступление на несколько страниц.

Можно лечить ошибки, а можно их предупреждать. Лучше заранее представлять себе, какие потенциальные ошибки могут возникнуть в коде и страховаться от них. Приведённая ниже классификация поможет лучше ориентироваться в причинах возникновения проблем в коде и в способах их диагностики. Эта классификация опирается не на сами ошибки (я потом тоже буду рассматривать разные варианты ошибок и их проявлений), а на ситуации, которые предшествуют появлению ошибки. Я называю эти ситуации “нестабильностями”.

Все возникающие в программе нестабильности можно разделить на 3 главных типа. Это:

  1. Нестабильности по входным данным.
  2. Нестабильности по внешним вызовам.
  3. Внутренние нестабильности модуля.

Рассмотрим подробнее.

1. Нестабильность по входным данным. Проявляются в том случае, если аргументы, переданные в функцию, не соответствуют ожидаемым. Простейший пример такой нестабильности – strcpy(NULL, “Умри, сцуко!”). Более сложные примеры могут содержать нестабильности, которые проявляются не сразу – strcpy(malloc, “Умри, сцуко!”). Или например, нестабильность по входным данным, связанная с передачей адреса локальной переменной sz на стеке в функцию SQLBindCol() (этот пример рассмотрен в http://blog.gamedeff.com/?p=160).

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

Очень частой ситуацией появления нестабильности по входным данным является изменение поведения на граничных или на запрёщенных входных данных.  Допустим, программист реализует функцию, которая в ответ на 2 и 2 дает результат 4. При этом функция реализована через умножение аргументов. Очевидно, что на 2 и 2 поведение функции является хорошо отлаженным и работоспособным. Если же передать в функцию 3 и 1, результатом станет 3. Нули являются запрещёнными аргументами, хотя функция как-то работает и на них.

Теперь мы оптимизируем алгоритм и делаем реализацию через сложение. Для “стандартных” 2 и 2 ответ не меняется. А вот для 3 и 1 ситуация кардинально изменилась – теперь возвращается результат 4. Если где-то в программе по каким-то причинам вызывалась функция с пограничными результатами, то поведение программы в этом месте начнет сильно отличаться. Ну и если передавались нули, то программа вообще начнёт, например, падать.

Много отладок программ, которые заканчиваются моей любимой фразой “я не понимаю, как оно вообще тут работало” как раз и связано с тем, что программисты забывают о пограничных или запрещенных значениях аргументов и поведение функций начинает отличаться именно в этих ситуациях (а не в 2*2, которые являются “стандартными”).

2. Нестабильность по внешним вызовам. Проявляется в том случае, если программист осознанно (или неосознанно, например, через действия пользователя) нарушает порядок вызова функций в ту или иную библиотеку (или порядок вызова методов в объект). Простейший пример возникновения такой нестабильности был рассмотрен в http://blog.gamedeff.com/?p=167, где вызывались функции GDI+ после GdiplusShutdown(). В более сложных ситуациях модуль/объект может находиться в дюжине различных состояний, между которыми он переключается при помощи вызова разных функций.

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

Очень важным в понимании нестабильностей второго рода является тот момент, что надо отдельно выделить “нулевое состояние” объекта. Это то состояние, когда объект только конструируется или уничтожается. Следует помнить, что уничтожение объекта может происходить из любого состояния и оно обязано происходить корректно. Если у вас включен SEH, то ситуация с уничтожением объекта только усложнаяется.

Во всех случаях необходимо следить на состояниями. Если вы вызываете функцию, которая переводит объект из одного состояния в другое, и в процессе перехода происходит ошибка, необходимо корректно “откатить” все изменения, чтобы объект остался в исходном состоянии, а не завис в промежуточном виде между двумя стабильными стейтами.

Для внешних пользователей объект обязан предоставлять информационные функции, с помощью которых можно будет определить состояние системы. Требования к этим функциям я распишу позже.

3. Внутренняя нестабильность модуля. Это, как правило, либо собственные ошибки алгоритма, либо отсутствие правильной обработки возвращаемых результатов из вызываемых функций. К внутренней нестабильности относится, например, эта история, где ошибочный возврат из random() 4 месяца приводил к порче оперативной памяти.

Как правило, одной возникающей нестабильности недостаточно для проявления ошибки. Реальная ошибка в программе возникает только тогда, когда происходит несколько взаимосвязанных нестабильностей подряд. Например, передача очень больших или очень маленьких значений в аргументы функции может приводить к переполнениям внутри и как следствие – к неправильным результатам на выходе (смешивание нестабильностей первого и третьего рода). Неправильно проинициализированный экземпляр класса передается в обрабатывающую его функцию (для вызывающей функции – нестабильность третьего рода, для вызываемой функции – нестабильность первого рода, а сам экземпляр класса рискует нарваться на нестабильность второго рода). Отсутствие проверки на INVALID_HANDLE_VALUE в последовательности CreateFile() / ReadFile() – нестабильность третьего рода для самой функции (нет проверки на успешность открытия файла) и нестабильность первого рода для ReadFile() (входные данные).

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

Более подробно по различным состояниям систем и нестабильностям распишу ещё в отдельных постах. Каменты всячески приветствуются.

Приятного вам кодирования и поменьше отладки!