Тестируйте при написании кода

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

Тестируйте граничные условия кода. Одним из важнейших методов тестирования является тестирование граничных условий: каждый раз, написав небольшой кусок кода, например цикл или условное выражение, проверьте, что тело цикла повторится нужное количество раз, а условное выражение правильно разветвляет вычисление. Этот процесс называется тестированием граничных условий потому, что вы проверяете крайние, экстремальные значения алгоритма или данных, такие как пустой ввод, единственный введенный элемент, полностью заполненный массив и т. п. Основная идея состоит в том, что большинство ошибок возникает как раз на границах — при каких-то экстремальных значениях. Если какой-то блок кода содержит ошибку, то, скорее всего, эта ошибка происходит на границе, и наоборот — если при экстремальных значениях код работает корректно, то он практически наверняка будет работать корректно и повсюду.

Приводимый фрагмент кода, моделирующий f gets, считывает симво-'j лы, пока не найдет символ перевода строки или не заполнит буфер:


-

Представьте себе, что вы только что написали этот цикл. Теперь мысленно выполните за него обработку строки. Первое граничное условие, которое надо проверить, очевидно: пустая строка. Если представить строку, содержащую единственный символ перевода строки, то нетрудно убедиться, что цикл остановится на пертой итерации со значением i, равным 0, так что в последней строке i будет уменьшено до -1 и, следовательно, запишет нулевой байт в элемент s[-1 ], который находится вне границ массива. Итак, проверка первого же граничного условия обнаружила ошибку.

Если переписать цикл так, чтобы он использовал идиоматическую форму заполнения массива вводимыми символами, он будет выглядеть следующим образом:



Повторив в уме первый reef, мы удостоверимся, что теперь строка, содержащая только символ перевода строки, обрабатывается корректно: i равно 0, первый же введенный символ прерывает работу цикла, а '\0- сохраняется в s[0]. Проверив схожим образом варианты с вводом одного и двух символов, замыкаемых символом перевода строки, мы убедимся, что цикл работает корректно вблизи нижней границы ввода.

Теперь надо проверить и другие граничные условия. Ситуации, когда во вводе содержится очень длинная строка или не содержится символов перевода строки, предусмотрены кодом — на этот случай существует ограничение i значением МАХ-1. Однако что будет, если ввод абсолютно пуст (в нем нет вообще ни одного символа) и первый же вызов getchar возвратит значение EOF? Надо добавить проверку и для такого случая:



Проверка граничных условий может обнаружить много ошибок, но, конечно, не все. Мы еще вернемся к рассмотренному примеру в главе 8, где покажем, что в нем осталась еще ошибка переносимости.

Следующим шагом будет проверка ввода около другой границы, когда массив почти заполнен, полностью заполнен и наконец переполнен, особенно если как раз в этот момент и встречается символ перевода строки. Мы не будем расписывать здесь все детали этих тестов; выполните их самостоятельно, — это очень хорошее упражнение. Задумавшись о всевозможных граничных условиях, нам придется решить, что делать в случае, если буфер заполнится до того, как во вводе встретится ' \п '; этот пробел в спецификации должен быть ликвидирован на ранней стадии написания программы.

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

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



Что будет делать эта функция, если n будет равно 0? Массив, не содержащий элементов, — вполне осмысленный элемент программы, а вот среднее значение его элементов не имеет никакого смысла. Должна ли функция позволять системе отлавливать деление на 0? Прерывать функцию? Сообщать об ошибке? Без предупреждения возвращать какое-нибудь нейтральное значение? А что если n вообще отрицательно, что абсолютно бессмысленно, но не невозможно? Как мы уже говорили в главе 4, нам представляется правильным в случае, если n меньше либо равно нулю, возвращать 0 в качестве среднего значения:

 return n <= 0 ? 0.0

но однозначно правильного ответа здесь не*существует.

Имеется, правда, гарантированно неверное мнение — игнорировать проблему. В ноябре 1998 в журнале Scientific American был описан инцидент, произошедший на борту американского ракетного крейсера Yorktown. Член команды по ошибке вместо значимого числа ввел 0, что привело к ошибке деления на нуль; ошибка разрослась и в конце концов силовая установка корабля оказалась выведена из строя. Несколько часов Yorktown дрейфовал по воле волн — а все из-за того, что в программе не была осуществлена проверка диапазона вводимых значений.

Используйте утверждения. В С и C++ существует возможность использования специального щ механизма утверждений (assertions) (в <assert. h>), который позволяет включать в программу проверку пред- и постусловий. Поскольку невыполненное утверждение прерывает работу программы, используют их, как правило, в ситуациях, когда сбой на самом деле не ожидается, а при его возникновении нет возможности продолжить работу нормально. Только что рассмотренный пример можно было бы дополнить утверждением, вставленным перед началом цикла:

assert(n > 0);

Если утверждение не выполнено, программа прерывается; сопровождается это выдачей стандартного сообщения:

Assertion failed: n > О, 
file avgtest.с, line 7
Abort(crash)

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

Используйте подход защитного программирования. Полезно вставлять некоторый код для обработки (хотя бы просто предупреждения пользователю) случаев, которых "не может быть никогда", то есть ситуаций, которые теоретически не должны случиться, но все же имеют место (например, из-за сбоя где-то в другом участке программы). Хороший пример — добавление проверки на нулевой или отрицательный размер массива в функцию avg. Еще одним примером может стать программа, выставляющая оценки по американской системе; очевидно, что отрицательных или очень больших значений появиться в ней не может, но лучше все же это проверить:

Это пример защитного программирования (defensive programming), при котором вы убеждаетесь в том, что программа защищена от неправильного использования или некорректных данных. Пустые указатели, индексы вне диапазона, деление на ноль и другие ошибки можно обнаружить на ранних стадиях жизни программы или нейтрализовать. Если бы все программисты применяли принципы защитного программирования, с Yorktown ничего бы не произошло, что бы там ни вводил оператор.

Проверяйте коды возврата функций. Одним из приемов защиты, которым программисты почему-то незаслуженно пренебрегают, является проверка возвращаемого значения библиотечных функций и системных вызовов. Значения, возвращаемые функциями, обслуживающими ввод, такими как f read и fscant, надо всегда проверять. Также обязательно надо проверять и возвращаемые значения вызовов открытий файлов типа f open. Если чтение или открытие файла по каким-то причи- i нам не выполняется, не может быть и речи о нормальном продолжении работы программы.

Проверка возвращаемого значения функций вывода типа f p rintf или fwrite поможет поймать ошибки, происходящие при попытке записи в файл, когда свободного места на диске не осталось. Также полезно на всякий случай проверить значение, возвращаемое fclose, — если при выполнении произошла какая-нибудь ошибка, эта функция возвратит EOF, в противном случае возвращается ноль.



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

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

Упражнение 6-1

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

1. Этот код должен вычислять факториалы:




2. Этот отрывок должен распечатывать символы строки, каждый в отдельной строке:


3. Предполагается, что эта функция будет копировать строку из одного места в другое (из источника s гс в приемник dest):

4. Еще один пример копирования строк — на этот раз копируется n сим- 1 волов из s в t:



5. Сравнение чисел:




6. Проверка класса символа:



Упражнение 6-2

Мы пишем эту книгу в конце 1998 года, поэтому призрак проблемы 2000 года неотступно стоит перед нами как самая глобальная ошибка граничных условий.

1. Какие даты вы используете для поверки программы на работоспособность в 2000 году? Предположим, что выполнять тесты очень дорого, в каком порядке вы будете их осуществлять после ввода даты 1 января 2000 года?

2. Как вы будете тестировать стандартную функцию ctime, которая возвращает строковое представление даты в такой форме:

Fri Dec 31 23:58:27 EST 1999\n\0

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

3. Опишите, как вы будете тестировать программу-календарь, которая генерирует вывод в таком виде:



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