- •4.9.1. Изменяемость
- •4.9.2. Классы операций
- •4.9.3. Полнота
- •4.9.5. Операции egual, similar и copy
- •5. Исключительные ситуации
- •6.2.1. Сигнализация об исключительных ситуациях
- •5.2.2. Обработка исключительных ситуаций
- •5.2.3. Предложение resignal
- •6.2.4. Необрабатываемые исключительные ситуации
- •6.2.3. Предложение resignal
- •5.2.4. Необрабатываемые исключительные ситуации
- •6.2.1. Реализация итераторов
- •6.2.2. Использование итераторов
- •7.2. Абстракция данных
- •7.4. Генераторы
- •9.1.3. Пример
9.1.3. Пример
Для иллюстрации вышесказанного рассмотрим простую процедуру, проверяющую, является ли символьная строка палиндромом. Палиндромом называется строка, которая читается одинаково в обоих направлениях (например, слово deed). На рис. 9.2 приведена спецификация и реализация этой процедуры. Просматривая спецификацию, мы можем видеть, что необходимы тесты, возвращающие как значения true, так и значения false. Помимо этого, мы должны включить в качестве граничного условия нулевую строку. Проверочный набор может быть, например, следующим;
palindrome = proc (s: string) returns (bool)
effects Возвращает значение true, если s прочитывается одинаково в обоих направлениях. В противном случае возвращается значение false. Например, "deed" и " " являются палиндромами. palindrome == proc (s: string) returns (bool) low: int :== I high: int := string$size (s) while high > low do
if s [lowl— = s [high} then return (false) and low ;== low+ I high := high — I end return (true) end palindrome
Рис. 9.2. Процедура palindrome.
"" "deed" "ceed"
Анализ программы показывает, что мы должны проверить также следующие случаи: 1) цикл не выполняется; 2) после первого прохода возвращается значение false; 3) после первого прохода возвращается значение true; 4) после второго прохода возвращается значение false; 5) после второго прохода возвращается значение true.
Случаи 1), 4) и 5) уже учтены. Для случаев 2) и 3) мы должны рассмотреть ситуации "аЬ" и "аа". К этому моменту мы можем спросить себя, не пропущен ли какой-нибудь случай, и обратить внимание на то, что все проверочные строки содержат четное число символов. Следовательно, необходимо добавить в тест также и строки с нечетным числом символов. Эти строки должны включать в себя строку длиной 1 для проверки граничного случая. Наконец, мы должны разумным образом организовать последовательность тестов так, чтобы самый короткий из них выполнялся первым. Такое упорядочивание облегчает процедуру нахождения оши-, бок (см. разд. 9.4).
178 Глава 9
9.1.4. Тестирование итераторов
Составление тестов для итераторов эквивалентно составлению тестов для процедур. Единственное любопытное отличие заключается в том, что маршруты в спецификациях итераторов сходны с маршрутами для циклов. Другими словами, мы должны быть уверены в том, что в набор тестов включены случаи, когда итерация выполняется дважды и трижды, а также, если это возможно, не выполняется ни разу. Например, рассмотрим итератор primes:
primes = iter (n: int) yields (int)
effects Если n^ 2, то выдаются все простые числа, меньшие или равные n; в противном случае не выдается ничего.
Тесты должны включать случаи для n, равного 1, 2 и 3. Необходимость в большем числе тестов определяется на основе реализации итератора.
9.1.6. Тестирование типов данных
При тестировании типов данных мы, как обычно, создаем тесты на основе анализа спецификации и реализации каждой из операций. Теперь мы должны проверить операции не по отдельности, а целыми группами, поскольку некоторые операции (конструкторы и примитивные конструкторы) создают объекты, используемые при тестировании других операций. Например, в операциях intset конструкторы create, insert и delete должны быть использованы для генерации аргументов для других операций и друг для друга. (Спецификация для intset повторена на рис. 9.3.) intset = data type is create, insert, delete, member, size, elements Назначение
Наборы intset представляют собой неограниченные математические наборы целых чисел. Эти наборы изменяемы: операции insert и delete добавляют и удаляют из набора числа. Операции create == proc ( ) returns (intset)
effects Возвращает новый пустой набор intset. insert == proc (s: intset, x: int)
modifies s effects Добавляет x к элементам s; после завершения операции, spost ==
= s (J {x), где Spost ^ть набор значений в s при возврате из intset. delete s= proc (s: intset, x: int)
modifies s
effects Удаляет x из s (т.е. Spogi == s — (x})« member == proc (s: intset, x; int) returns (tool) ; effects Возвращает (x ^ s). 'size = proc (s: intset) returns (int)
effects Возвращает число элементов в s. elements = iter (s: intset) yields (int) requires s не модифицируется телом цикла.
effects Создает влементы s, по одному и в произвольном порядке. vend Intset Рис. 9,3. Спецификация intset.
Уестирование и отладка
Мы начнем с просмотра маршрутов в спецификациях. Мар-^щруты в спецификациях операций member и elements очевидны. Для операции member мы должны включить случаи, которые выдают в качестве результата true и false. Поскольку elements является итератором, мы должны просмотреть по крайней мере маршруты длиной нуль, единица и двойка. Следовательно, нам необходимы наборы intset, содержащие нуль, один и два элемента. Пустой набор intset и одноэлементный набор intset должны также проверять граничные условия. Итак, мы можем начать со следующих наборов: 1) пустой набор intset, получаемый обращением к intset$create ( ); 2) одноэлементный набор intset, полученный размещением числа 3 в пустом наборе intset; 3) двухэлементный набор intset, получаемый размещением в пустом наборе чисел 3 и 4.
Для каждого из них мы должны выполнить обращения к member, size и elements, а затем проверить результаты. В случае с member мы должны сделать обращения, для которых элемент принадлежит набору, а в остальных — не принадлежит.
Очевидно, что этих случаев недостаточно. Например, операция delete вовсе не проверяется, а в других спецификациях имеются неучтенные нами маршруты. Эти маршруты неким образом скрыты в наших неформальных спецификациях. При использовании формальных спецификаций они стали бы более очевидны.
При размещении элемента, уже принадлежащего набору, размер набора intset остается прежним, следовательно, необходимо проанализировать случай, когда мы дважды помещаем в набор intset один и тот же элемент. Аналогичным образом, размер набора intset уменьшается в том случае, если имеющийся в нем элемент удаляется. Следовательно, необходимо учесть ситуацию, в которой элемент удаляется после его размещения и удаляется элемент, не принадлежащий набору. Мы можем добавить следующие дополнительные наборы: 1) набор, полученный из пустого набора двукратным размещением в нем числа 3; 2) набор, полученный размещением числа 3 и его последующим удалением; 3) набор, полученный размещением числа 3 и удалением числа 4.
Для нахождения скрытых маршрутов необходимо отдельно выделить маршруты для конструкторов. Операция insert должна работать правильно вне зависимости от того, принадлежал ли уже вновь размещаемый элемент набору или нет. Это относится также и к операции delete. Такой подход дает три только что рассмотренных случая.
Разумеется, мы должны дополнительно рассмотреть маршруты в реализациях операций. Рассмотренные случаи покрывают реализации, использующие массив без дублирующих элементов (рис. 9.4). Единственная возможная проблема связана с содержащей цикл операцией member. Для учета всех маршрутов в этом
м
180 Глава 9
цикле мы должны проверить случай с двухэлементным массивом, когда искомый элемент отсутствует, является первым или же вторым. (Эти случаи невозможно получить анализом только спецификации. На уровне спецификации рассматривается только принадлежность элемента массиву. Позиция его в массиве интереса не представляет.) Аналогично в операции delete мы должны быть в состоянии удалить как первый, так и второй элементы массива. При этом мы учитываем возможность потенциальной ошибки, при которой последний элемент массива удаляется неправильно. В рассматриваемой реализации операции delete перед сжатием массива последний его элемент пересылается в позицию удаленного элемента. Если мы выполним эти действия в обратном порядке, то в том случае, когда удаляемый элемент находился на верхней границе массива, программа работать не будет.
intset = cluster is create, insert, delete, member, size, elements rep == array [int]
create =r proc ( ) returns (cvt) return (rep$new ( )) end create
insert == proc (s: intset, x: int)
it— member (s, x) then rep$addh (down (s), x) end end insert
delete = proc (s: cvt, x: int) for j: int in rep$indexes (s) do it s [j] = x then s [j] :== rep$top (s)
rep$remh (s) end end end delete
member = proc (s: cvt, x: int) returns (bool) for y: int in rep$elements (s) do if у = x then return (true) end end return (false) end member
size == proc (s; cvt) returns (int) return (rep$size (s)) end size
elements = iter (s: cvt) yields (int) for y: in rep$elements (s) do yield (y) end end elements
end intset Рис. 9.4. Реализация целочисленного набора intset.
Тестирование и отладка
9.2. Индивидуальное и интегральное тестирование
Тестирование обычно разбивается на две фазы. В процессе индивидуального тестирования устанавливается правильность работы каждого модуля отдельно от остальных. При интегральном тестировании проверяется одновременная работа всех модулей, т. е. всей программы.
Интегральное тестирование обычно более сложно, чем индивидуальное. Во-первых, охарактеризовать предполагаемое поведение программы целиком обычно более затруднительно, чем каждого отдельно взятого ее модуля. Во-вторых, При интегральном тестировании объем работы обычно резко возрастает. В частности, значительно увеличивается время выполнения теста. Наконец, в этих двух методах существенно отличаются друг от друга роли спецификаций.
В отличие от интегрального тестирования при индивидуальном тестировании, как правило, предполагается, что спецификация составлена правильно. При возникновении ошибки в процессе индивидуального тестирования мы делаем заключение, что неправильно работает сам модуль. При оценке всей программы мы должны учитывать тот факт, что наиболее серьезными ошибками обычно являются ошибки спецификации. В таких случаях каждый модуль выполняет возложенную на него функцию, однако сама программа — нет. Это в большинстве случаев свидетельствует об ошибках в спецификации. В такой ситуации тестируемый своими создателями модуль обычно функционирует правильно, однако при этом не соответствует требованиям тех, кто составлял модули, вызывающие данный. На этапе интегрального тестирования это чрезвычайно затрудняет поиск ошибок.
Рассмотрим программу, реализованную модулем Р, который вызывает модуль Q. При индивидуальной проверке Р и Q были проверены отдельно. (Как уже говорилось, для индивидуальной их проверки достаточно смоделировать поведение другого модуля.) Затем мы выполняем совместный тест, проверяя, удовлетворяют ли они спецификации для Р. При этом используются тесты модуля Р. Предположим, что была обнаружена ошибка. Возможны два случая.
1. Модуль Q проверяется с входными значениями, которые не были учтены при индивидуальной проверке.
2. Модуль Q ведет себя не в соответствии с предположением, выдвинутым при тестировании Р.
При работе с модулями, подобными Р и Q, возникает желание сразу проверить их работу совместно, не тестируя каждый в отдельности. Иногда совместные тесты более оправданны, но обычно лучше проводить индивидуальную проверку. Например, для программы, приведенной на рис. 9.1, мы беспокоимся о проверке
182 Глава 9
лишь четырех маршрутов; различные маршруты получения результата процедурой pred в данном случае не анализируются. Однако проверка процедуры предполагает много различных тестов. Объединение всех этих вариантов проверки имеет много недостатков. Число тестов и время их выполнения возрастает) а при изменении в реализации любого из этих модулей необходимо пересматривать всю процедуру тестирования. Более эффективна индивидуальная проверка каждого модуля.
9.3. Средства тестирования
Процесс отладки всегда желательно максимально автоматизировать. Обычно мы не можем автоматизировать генерацию проверочных данных. Генерация соответствующих данных для проверки программы является неалгоритмическим процессом, требующим тщательного анализа. Далее, задача автоматизации принятия решения о том, какие выходные данные для произвольного входного набора считать удовлетворительными, обычно также трудна и так же подвержена ошибкам, как и процесс написания самой тестируемой программы.
Однако мы можем автоматизировать процесс вызова программы с заранее определенной последовательностью входных данных и дальнейшую проверку получаемых результатов с заданной проверочной последовательностью. Выполняющий это механизм называется драйвером. Драйвер вызывает тестируемую программу и следит за ее выполнением. Более конкретно, это предполагает:
1. Установку окружения, необходимого для вызова проверяемого модуля. В некоторых языках (но не в языке CLU) это предполагает создание и инициализирование глобальных переменных.
2. Выполнение ряда вызовов. Аргументы для этих вызовов могут быть считаны из файла или встроены в программу драйвера. Если аргументы считываются из файла, то, если это возможно, они должны быть проверены на соответствие. 3. Сохранение результатов и проверку их на соответствие. К наиболее распространенному способу проверки соответствия результатов предполагаемым является сравнение их с контрольной последовательностью значений, которая была помещена в файл. Однако иногда более удобно написать программу) которая сравнивает результаты непосредственно с входными значениями. Например, если программа должна находить корни полинома, то достаточно просто написать программу, проверяющую, являются ли в действительности полученные значения корнями данного полинома. Аналогично, результат работы программы sqrt легко проверить непосредственным вычислением.
^Тестирование и отладка
1Драйвер, проверяющий реализацию программы sqrt, показан 1иа рис. 9.5.
% считать следующие файлы в качестве входных; % file_of_tests, bad.tests, correct.results, incoirect.results
for % каждого теста в file_of_tests
do it test.square < 0 [ test.epsilon < .00001 I test.epsilon »> .001 then % добавить test к bad Jests else result := sqrt (test,square, test.epsilon) if real$abs (square — result * result) < == epsilon then % добавить (test, result) к correct_results else % добавить (test, result) к incorrect results end end end Рис. 9.5. Драйвер для программы sqrt.
При тестировании помимо драйверов также используются заглушки. Драйвер имитирует части программы, к которым обращается тестируемый модуль. Заглушки имитируют части программы, вызываемые тестируемой программой. Заглушка должна 1) проверять корректность окружения, создаваемого вызывающей программой; 2) проверять правильность аргументов, передаваемых пользователем; 3) модифицировать аргументы и окружение и возвращать значения, позволяя вызывающей программе продолжить свою работу. Лучше всего, если эти процессы соответствуют спецификации модуля, имитируемого данной заглушкой. К сожалению, это не всегда возможно. Иногда «правильное» значение может быть получено только самой имитируемой программой. В таких случаях мы должны остановиться на каком-нибудь «приемлемом» значении.
(Если все взаимодействия между модулями осуществляются через аргументы и результаты, то нет необходимости проверять или модифицировать аргумент.)
Очевидно, что драйверы требуются при тестировании тех модулей, для которых вызывающие их модули еще не были созданы. Заглушки используются для проверки тех модулей, для которых еще не были созданы вызываемые ими модули. Оба способа необходимы для проверки отдельных программных единиц, когда мы хотим максимально изолировать проверяемую единицу от остальных частей программы,
На практике заглушки и драйверы принято использовать для тестирования в интерактивном режиме. Самая простая реализация заглушки может просто печатать аргументы, с которыми она была вызвана, и выдавать запрос к пользователю на ввод значений, которые должны быть ею возвращены. Аналогично в случае использования простейшего драйвера проверка правильности результатов возлагается на производящего тестирование человека. Хотя такие драйверы и заглушки легко реализуются, их следует
184 Глава 9
ирование и отладка
всячески избегать. Они более склонны к ошибкам, чем автоматизированные драйверы и заглушки, затрудняют создание хорошего набора тестировочных данных, а также усложняют воспроизводимость тестов. Воспроизводимость тестов является особенно важной. Рассмотрим одну из типичных ситуаций, возникающих при тестировании.
1. Программа проверяется для входных значений с 1 до п и при этом ошибок не обнаруживается.
2. Тестирование программы с п + 1 значением приводит к обнаружению ошибки.
3. Отладка позволяет внести исправления, после которых программа работает с п + 1 значением правильно.
4. Тестирование продолжается со значения п + 2. Такая последовательность довольно неразумна, поскольку существует немалая вероятность того, что внесенные в программу изменения, заставившие работать ее правильно со значением п + 1, могут в то же время привести к ошибочным результатам для значений с 1 по п. При внесении любых, даже небольших изменений необходимо убедиться в том, что программа по-прежнему правильно выполняет выполнявшиеся ранее тесты. Такой метод называется регрессивным тестированием. Регрессивное тестирование удобно только в тех случаях, когда имеются средства, позволяющие без больших затрат перезапустить старые тесты.
Драйверы и заглушки должны создаваться с большой осторожностью. Все возможные ошибки хотелось бы приписать тестируемой программе. Однако при неаккуратном написании драйверов и заглушек они могут внести и свои собственные ошибки. В этом случае программист может тратить на тестирование и отладку тестировочных программ массу времени.
9.4. Отладка
Тестирование позволяет нам устаноаить наличие ошибки в программе, однако знание симптома еще далеко от установления действительной причины. С того момента, как нам становится известным наличие ошибки, особую важность в процессе отладки приобретает тактика ее локализации и устранения. Отличия в степени эффективности отладки программ различными людьми слишком велики для того, чтобы мы могли предложить некоторый магический способ, облегчающий этот процесс. Большая часть того, что мы могли бы сказать по этому поводу, опирается на обычный здравый смысл.
Отладка представляет собой процесс понимания и исправления ошибок. При отладке мы стараемся локализовать проблему, проводя различные простые проверки, регистрирующие ошибку, и, просматривая промежуточные значения, выделить часть про-
_ммы, содержащую ошибку. По мере сбора фактов, подтвер-ающих наличие ошибки, формулируется гипотеза и делается пытка подтвердить ее дальнейшим тестированием. Когда нам кется, что причина ошибки установлена, мы просматриваем утветствующий кусок программы, желая найти ошибку и ис-равить ее. В гл. II рассматриваются способы анализа программ, ^легчающие данный процесс.
Ошибки обязаны своим существованием самому программисту. сегда имейте в виду, что отладка отнимает гораздо больше вре-ени, чем программирование. Задача написания изначально эрректной программы вполне оправданна. Поэтому перед запуском программы внимательно просмотрите ее текст и .убедитесь 1 том, что вы хорошо понимаете те действия, которые данная Программа должна выполнять. Независимо от ваших способно-п-ей и усилий вероятность того, что программа сразу же станет работать правильно, достаточно мала. По этой причине вы должны вроектировать, записывать и документировать ваши программы Ьтаким образом, чтобы максимально облегчить процесс тестиро-1вания и отладки. Одним из способов является создание относи-1тельно небольших модулей, которые могут быть проверены неза-1висимо от оставшейся части программы. В большой степени это ряожет быть достигнуто в том случае, если вы будете придержи-1ваться рассмотренных ранее соображений. Создавайте структуры ^данных и связывайте с каждой из них инвариант представления 1с максимальными ограничениями. Для каждой из процедур при-г водите связные и понятные спецификации. В этом случае к мо-1рменту их тестирования вы будете четко знать, каковы должны быть ^входные значения и какой результат должен быть получен в от-lbct на каждое возможное входное значение. 1~ Помимо общей стратегии тестирования необходим также тща-
•_ тельно разработанный план поэтапной отладки. Перед началом
работы 'примите окончательное решение о том, что вы хотите сде-глать и как вы собираетесь это осуществить. Вы должны знать, f каковы должны быть исходные данные и какой результат вы собираетесь получить. Если вы не обдумали вопрос о входных данных достаточно тщательно, то весьма вероятно, что вы потратите массу времени на бесполезную работу.
Неплохую схему отладки предлагает так называемый научный подход. Сущность его заключается в следующем.
-. 1. Начинайте с изучения уже доступных данных.
•' 2. Сформулируйте гипотезу, которая корректна для таких .Данных.
3. Проведите несколько раз эксперимент, который в состоянии подтвердить гипотезу.
. Рассмотрим программу, принимающую на входе положитель-:ное целое число и возвращающую значение true, если число
186 Глава 9
пирование и отладка
является простым и false в противном случае. При тестировании в качестве первого значения мы используем число 2 и наша программа возвратит правильный ответ — false. Затем мы пробуем число 3 и программа возвратит неверный результат — false. Итак, имеется два результата, на основании которых мы попытаемся сформулировать гипотезу. К одной из возможных и приятных для нас гипотез относится ситуация, при которой после первого теста нам каким-либо образом не удалось реинициализи-ровать программу. Это ведет к тому, что программа всегда работает правильно при первом проходе и всегда неправильно — при последующих. (При работе с языком CLU такая ошибка маловероятна.) Для проверки этой гипотезы мы можем проверить нашу программу с теми же аргументами, однако следует изменить порядок их следования, т. е. сначала мы подставим число 3, а затем — 2. Перед запуском тестов необходимо решить, какой результат подтвердит нашу гипотезу и какой — опровергнет.
1. Результаты, подтверждающие гипотезу (true, false).
2. Результаты, отвергающие гипотезу (false, true), (false,
alse), (true, true)
Пусть в процессе эксперимента программа возвратила сначала значение false, а затем true. Мы сразу же отбрасываем нашу первую гипотезу и выдвигаем другую, например, что программа выполняется неправильно со всеми нечетными' простыми числами.
При отладке очень полезно для начала отыскать такое входное значение, при котором наличие ошибки сразу регистрируется. Это значение не обязательно должно совпадать со значением, позволяющим зарегистрировать ошибку на этапе тестирования. Часто возможно использовать более простое значение, достаточное для того, чтобы выйти на ошибку. Хорошим способом является разделение тестировочных данных на подгруппы и проверка программы с различными подмножествами из этого набора.
Например, предположим, что мы тестируем процедуру palin-drome, приведенную на рис. 9.2, и обнаруживаем, что выполнение ее со знаменитым палиндромом Наполеона «able was I ere I saw elba» приводит к ошибке. Это довольно длинный палиндром, поэтому желательно подобрать более короткий, также выдающий отрицательный результат. Мы начнем с того, что возьмем палиндром, состоящий из одной буквы «г», расположенной в центре исходной фразы, а затем проверим, распознает ли его программа. Если программа не распознает палиндром из одной буквы, то можно предположить, что не распознаются все палиндромы с нечетным числом символов. Если палиндром «г» будет распознан, то мы можем затем попробовать палиндром «ere», исходя из гипотезы, что программа не работает со всеми палиндромами с нечетным числом символов, если это число больше единицы. Если
при случае с «ere» ошибка также не возникнет, то мы, вероятно, ожем попробовать «I ere 1». Предположим, что в этом случае Программа выполнится неправильно. Напрашиваются два пред-доложения: либо ошибка связана с символами пробела, либо 1 использованием букв верхнего регистра. Мы должны теперь Проверить это с кратчайшими палиндромами, например с «» и «1». 1 После того как мы найдем простое входное значение, вызы-1вающее ошибку, мы можем использовать ее для поиска места ^возникновения этой ошибки. Обычно нахождение входных данных, ввызывающих ошибку, является достаточным для определения ее .местоположения. Однако если это не так, то необходимо сузить .область поиска, просматривая для этого промежуточные результаты.
В данном случае наша цель заключается в выделении частей программы, которые не относятся к возникшей проблеме, и последующем подробном анализе оставшихся частей. Мы делаем это при помощи трассировки программы, т. е. запуская ее и анализируя значения отдельных переменных в различных точках. Если программа состоит из нескольких модулей, то наша первая цель — обнаружить, какой модуль служит источником ошибки. Мы делаем это, анализируя все вызовы и возвраты из процедур. (Для итераторов анализируются значения в каждом проходе.) Для каждого вызова производится проверка правильности аргументов. Аргументы должны удовлетворять требованиям, указанным в предложении requires вызываемой процедуры, а также учитывать результаты предыдущих трассировок. Если аргументы не соответствуют требуемым, то ошибку следует искать в вызывающем модуле. В противном случае мы должны выяснить, соответствуют ли получаемые результаты указанным аргументам. Если это не так, то ошибка принадлежит вызываемой процедуре.
Локализация проблемы сведением ее к одной процедуре или циклу часто оказывается достаточной, поскольку мы можем обнаружить ошибку просмотром текста соответствующего модуля. Однако часто полезно бывает выделить в модуле только часть, содержащую ошибку. Для этого мы продолжаем проводить трассировку и анализ локальных переменных данного модуля. В этом случае необходимо обнаружить первое свидетельство неправильного выполнения. В частности, важно проверить соответствие промежуточных результатов значениям, полученным до начала трассировки. Необходимо четко представлять себе, какими должны быть промежуточные результаты.
Рассмотрим неверно составленную реализацию процедуры palindrome:
palindrome = proc (s: string) returns (bool) low: int := I high int := string$size (s)
188 Глава 9
ирование и отладка
while high^>low do
if s [lowF^" = s [high] then return (false) end low := low+ I
if high >> low + I then high :== high — I end end return (true) end palindrome
Эта реализация работает неверно с палиндромами, содержащими нечетное число символов, которые при этом имеют длину более одного символа. Следовательно, мы должны проверить ее со строкой «ere». Предположим, что в начале инициализации мы ожидаем, что переменные low и high равны значениям верхней и нижней границ массива и что после каждой итерации значение переменной low возрастает, а переменной high — уменьшается. В конце инициализации low == 1 и high == 3, как и ожидалось. Однако в конце первой итерации мы замечаем, что low = 2, a high == 3, чего не должно быть. В этом месте мы должны суметь обнаружить ошибку.
Если у вас есть доступ к .хорошему отладчику, то анализировать промежуточные результаты относительно легко, например, сообщая отладчику, трассировку каких переменных вы хотите осуществить. Если это не так, то имеет смысл написать значительный объем программ, единственная цель которых — помочь вам при анализе промежуточных результатов. В любом случае для каждого из типов должна быть написана программа, реализующая отображение объектов в абстрактной форме. Например, при отладке полиномов гораздо легче понять, что происходит, если полипом отображается в виде строки
"3+х**5" а не массива II: 3,0,0,0,1]
Эта программа может вызываться либо пользователем интерактивного отладчика, либо оператором print. Также полезно иметь обратную программу, принимающую на входе абстрактное представление объекта и выдающую соответствующий объект нужного типа. (В языке CLU мы придерживаемся соглашения, по которому процедура, подготавливающая данные для последующего отображения их на терминале, называется распаковкой, а обратная ей — упаковкой.)
При планировании сеансов отладки помните, что ошибка, вероятнее всего, находится не в том месте, в котором вам кажется. Если бы это было так, то вы бы ее уже нашли. Не следует цепляться за какое-либо фиксированное предположение о том, где расположена ошибка. Первое свидетельство о наличии ошибки может проявиться в месте, весьма далеком от ее реального место-
рождения. Если вам кажется, что вы знаете, какая из процедур держит ошибку, и вы уже потратили значительное количество ямени на анализ этой процедуры, то вероятнее всего, что вы абаетесь. Не замыкайтесь на чем-либо одном. Проанализируйте д мыслей, который привел вас к данной процедуре, и спросите бя, не просмотрели ли вы чего-либо.
1 Существует еще один подход к проблеме обнаружения ошибки, „ключающийся в выделении частей программы без ошибок. праздо легче выделить те места, где ошибки скорее всего отсут-»уют. Часто попытка продемонстрировать, что в некотором Jecre программы ошибка скорее всего отсутствует, приводит ^обнаружению ее именно в этом месте. В любом случае последо-ттельное исключение возможных мест ошибки часто является 1илучшим способом ее обнаружения.
В попытке сокращения числа возможных мест ошибки обра-.айте тщательное внимание как на программу, так и на входные анные. Каждый программист тратил время на обнаружение ,шибки, в то время как проблема заключалась во входных данных. ^ак уже говорилось, вы должны писать драйверы и заглушки пень аккуратно, избегая по возможности все вероятные ошибки. Внимательный анализ входных данных является хорошей демонстрацией принципа «начинайте с простого». Большинство "пибок довольно грубы. К простым, наиболее часто встреча-щимся ошибкам относятся следующие:
1. Перемена порядка следования входных аргументов.
2. Отсутствие инициализации переменной.
3. Отсутствие повторной инициализации переменной при по-1 игорном прохождении сегмента программы. 1. 4. Копирование только верхнего уровня структуры данных 1;вместо предполагаемого полного. 1 5. Ошибки при расстановке скобок в выражениях. 1 Помните, что рассуждения в процессе создания программы ^отличны от рассуждений при ее отладке. Наличие ошибки яв-„ляется свидетельством того, что ваш ход мыслей оказался оши-..бочным. Легко убедить себя в том, что процедура не содержит .ошибки, используя те же рассуждения, которые привели к ее ; появлению. В таком случае полезно обратиться к кому-либо за помощью. Обращение за помощью не является свидетельством •поражения. Напротив, это считается хорошим приемом. Попытайтесь объяснить свою проблему кому-нибудь еще. Очень , часто лишь одна попытка объяснить кому-либо ход своих мыслей позволяет обнаружить источник ошибки. Если этого и не произойдет, то услышанная новая точка зрения почти наверняка предотвратит вас от дальнейших заблуждений.
Одна из труднейших проблем заключается в решении о том, ^что считать само собой разумеющимся. Наивно полагать, что само
190 Глава 9
пирование и отладка
собой разумеющимся ничего принимать не надо, поскольку такая позиция весьма непродуктивна. Ошибка, обнаруженная тестом, может быть в одном из модулей программы, компиляторе, использованном для компиляции программы, операционной системе, аппаратной части, электропроводке в здании и т. д. Однако наиболее вероятно, что ошибка была сделана в вашей программе. Начните с предположения, что все остальное работает правильно, и ищите ошибку в своей программе. Если спустя разумное время вам это сделать не удастся, то начните просмотр модулей, к которым ваша программа обращается. Если и там ошибка не будет обнаружена, то выясните, не переменялись ли в недавнем прошлом компилятор или операционная система. Если это не так, то не следует спешить искать в них ошибки.
Если вы регистрируете ошибку, источник которой не обнаруживается, то убедитесь в том, что вы используете корректный исходный текст программ. При объединении вместе больших систем почти всегда используется раздельная компиляция. Это может легко привести к ситуации, когда объектный код, содержащий ошибку, не соответствует исходному тексту программы, в котором вы пытаетесь ее отыскать. Особенно неприятный вариант возникает в том случае, когда компилятор или операционная система были изменены. Если поведение вашей программы не изменилось, а вы абсолютно уверены в том, что сами никаких изменений не вносили, то проверьте, не используете ли вы модуль, скомпилированный на другой версии операционной системы.
Если вы перепробовали все, что вам могло прийти в голову, но тем не менее не обнаружили ошибку, отложите задачу. Задача программиста состоит в том, чтобы быстрее закончить задачу (включая документацию и тестирование). Цель не заключается в том, чтобы быстрее найти некоторую ошибку. Навязчивый поиск какой-нибудь одной ошибки почти всегда непродуктивен. Если вы пытаетесь отладить то, на что вы уже потратили немало времени, то в лучшем случае вы будете работать неэффективно. В худшем же вы сделаете ошибки, например внесете в программу неверные изменения или удалите какой-нибудь важный файл.
После того как вы обнаружили ошибку, попытайтесь понять, почему вы ошиблись в этом месте программы. Была ли это опечатка, или незнание языка программирования, или это указывает на какую-то логическую проблему? Уяснение причины ошибки поможет вам исправить программу. Это также может помочь обнаружить другие ошибки и не допустить ряда ошибок в будущем.
Наконец, когда вам кажется, что вы нашли ошибку и знаете причину ее возникновения, то не будьте слишком поспешны в ее устранении. Убедитесь в том, что обнаруженная ошибка действительно порождает отмеченные вами симптомы. Если вы уже потра-
ди много времени, наблюдая за поведением программы, то, зможно, окажется более разумным продолжить этот процесс, исправляя пока ошибки, поскольку это приведет к изменениям 1 поведении программы. В отличие от поочередного исправления ^правление сразу нескольких ошибок не только легче осуществить, но и приведет к созданию более четкой и эффективной Программы.
fe Если вы решили сделать изменения, то должны просмотреть все ветвления в программе. Убедитесь в том, что внесенное изменение действительно решит проблему, а не породит ряд новых. рСамыми труднообнаруживаемыми ошибками являются те, которые ^•носятся в процессе исправления других. Это обусловлено тем, "что в процессе таких исправлений мы не столь систематичны,
как в процессе составления программы. Мы пытаемся произвести .локальные изменения там, где необходим глобальный подход. Часто более эффективно переписать процедуру заново, чем исправлять старую.
9.5. Защитное программирование
Для борьбы с будущими ошибками рекомендуется программировать с защитой. Каждый хороший программист должен учитывать возможность появления ошибок. Предположим, что ваша программа будет вызвана с неправильными данными, что файлы, предполагающиеся открытыми, окажутся закрытыми, и т. д. Составляйте свою программу таким образом, чтобы подобные ошибки сразу же дали о себе знать. В языке CLLI имеется большинство общепринятых защитных средств подобного рода. Более того, в языке CLLJ производится полная проверка типов данных, контроль на выход за границы массивов и проверка на арифметическое переполнение и потерю значимости как на этапе компиляции, так и на этапе выполнения. К двум защитным методам, которые отсутствуют в языке CLU, относятся проверка выполнения требований процедур и инвариантов представления, а также полное тестирование всех условных выражений. Нарушение инварианта представления или требований процедуры часто является первой причиной ошибки. Если в программу включены средства неявной проверки этих требований, то первый заметный симптом ошибки будет обнаружен значительно раньше. Рассмотрим, например, процедуру со следующей спецификацией:
in_ range == proc(x, у: int, a: elem.array, e; elem) returns (b: bool)
requires у ,5 x.
effects Возвращается значение true, если e есть элемент из a hi, .,., а [у], т.е. b= дг [x^z^y & a [z ] =e ]
192 Глава. 9
^.Тестирование и отладка
Предположим, что в программе, вызывающей эту процедуру, перепутан порядок следования входных аргументов. В этом случае процедура in_range, вероятнее всего, возвратит значение false вне зависимости от того, присутствует ли е в массиве а или нет. Первый заметный симптом такого неправильного обращения может показаться не связанным с этим обращением. В худшем случае ошибка никогда не будет обнаружена и программа, вызывающая процедуру in_range, просто возвратит неправильный результат. Однако если в случае х > у процедура in_range выдаст сообщение об ошибке, то последняя будет обнаружена немедленно.
Аналогичные ошибки могут возникать при отсутствии полной проверки условных выражений. Например, предположим, что процедура receive передает в сообщении посланную по сети строку и для данного обращения к ней имеют смысл только значения «deliver» и «examine». Реализация
s := receive ( )
if s =^ "deliver" then % выполнить запрос на передачу elseif s = "examine" then % выполнить анализ else signal failure ("unexpected request:" fls) end
гораздо лучше приведенной ниже
s := receive ( ) if s == "deliver"
then % выполнить запрос на передачу else % выполнить анализ end
Если инвариант представления новый или был модифицирован, то он должен проверяться в начале каждой операции и перед возвращением. Для облегчения этой операции в кластер должна входить программа, выполняющая соответствующую проверку. Эта программа должна завершаться успешно, если инвариант представления удовлетворен, и выдавать сигнал ошибки — в противном случае.
Инварианты представления и требования служат двумя примерами утверждений о промежуточных стадиях вычислений. В гл. II мы рассмотрим еще несколько видов утверждений. Часто полезно создавать программы, проверяющие эти утверждения. Защитное программирование обычно предполагает дополнительные затраты машинного времени и рабочего времени программиста. Как правило, дополнительный труд программиста не является в данном случае принципиальным пунктом, поскольку защитное программирование почти всегда сокращает общий объем работы программиста над проектом. С увеличением времени работы часто приходится считаться. Для программ, у которых одним изтребо-
1.ваний является производительность, отдельные методы защитного ^программирования могут оказаться неприемлемыми. Если, например, аппаратная часть не регистрирует арифметическое переполнение, то программное выполнение этой операции может вдвое увеличить стоимость выполнения арифметических операций. В частности, процедура двоичного поиска не может позволить себе проверку на упорядоченность каждого входного массива.
Если имеется подозрение, что использовать защитное программирование в рабочей версии программы довольно дорого, то рекомендуется использовать его хотя бы на этапе создания программы. Удаление из текста программ, обнаруживающих ошибки, гораздо легче, чем вставка их в процессе отладки. Удаление из программы средств защиты должно быть тщательно продумано. Если это возможно, они должны быть оставлены в окончательной версии. Почти всегда очевидно, что когда программа попадет к пользователям, она еще будет содержать некоторые ошибки, а в процессе модификации к ним добавятся новые. Важно, чтобы эти ошибки могли быть обнаружены и исправлены максимально быстро. Фактическая экономическая стоимость необнаруженной ошибки в программе может превысить стоимость всех проверок на этапе отладки. В любом случае лучше сохранить все возможные «недорогие» проверки.
Заключение
В данной главе были рассмотрены различные вопросы тестирования и отладки. Тестирование представляет собой метод проверки корректности программы. Мы описали способ построения различных вариантов тестирования, основанный на методичном рассмотрении спецификации и реализации модуля. Тесты затем должны быть выполнены под управлением драйвера, который проверяет результаты для каждого из случаев. Драйвер либо сам генерирует входные данные, либо получает их из файла и либо проверяет результаты вычислений, либо сравнивает их с данными из другого файла. Если тестируется отдельный модуль, то модули, расположенные на нижнем уровне, должны быть заменены эмулирующими их заглушками. Позднее, на этапе интегральной проверки, заглушки заменяются реальными программами.
Тестирование позволяет обнаружить наличие ошибки. Отладка представляет собой процесс, понимания причины ошибки и последующего ее исправления. При отладке мы пытаемся сузить область проблемы, отыскивая более простые варианты тестов, которые выявляют ошибку, а затем просматриваем промежуточные величины, отыскивая участок программы, содержащий ошибку. После сбора различных свидетельств об ошибке мы формулируем гипотезы и пытаемся подтвердить их дальнейшим тестированием. 7 дисков b.i Гатэг Дж.
194 Глава 9
Когда нам кажется, что причина ошибки установлена, мы изучаем соответствующий участок программы и исправляем ошибку.
Отладка может быть значительно облегчена, если использовать защитное программирование. Оно предполагает использование в программе различных проверок, позволяющих обнаружить вероятную ошибку. В частности, мы должны убедиться в том, что требования в предложении requires удовлетворяются. Также полезно проверить инвариант представления. Эти проверки желательно по возможности сохранить в рабочей версии программы.
Методичность при тестировании, отладке и защитном программировании значительно облегчает работу программиста. Эта работа окупает себя также и при последующей модификации программы.
Дополнительная литература
Beiser, В., 1983. Software Testing Techniques. New York: Van Nostrand Rein-hold.
Goodenough, John., and Susan L. Gerhart, 1975. Toward a theory of test data selection. IEEE Transactions on Software Engineering SE-I (2): 156—173.
Howden, William E., 1981. A survey of dynamic analysis methods. In Tutorial-. Software Testing and Validation Techniques (New York: IEEE Computer Society Press), pp. 209—231.
Weinberg, Gerald, M., 1971. The Psychology of Computer Programming. New York: Van Nostrand Reinhold.
Упражнения
9.1. Разработайте набор тестов для процедуры merge, используя спецификацию, приведенную на рис, 3.5, и реализацию, приведенную на рис. 3.6. Проделайте то же самое для процедур merge.sort и sort. Напишите драйвер для процедуры merge.
9.2. Разработайте набор тестов для процедуры poly (рис. 4.3 и 4.7). Напишите для этой задачи драйвер.
9.3. Разработайте набор тестов и драйвер для процедуры permutations (рис. 6.9).
9.4. В упражнении 5 гл. 4 наборы intset были реализованы при помощи упорядоченных списков olist. Подумайте, заглушки какого типа вы использовали бы для списков olist при проверке реализации intset.
9.5. Структурированная по блокам таблица символов в упр. 6 гл. 6 может быть реализована с помощью блоков и карт. Сделайте набросок такой реализации. Затем рассмотрите, как при тестировании syrntab вы могли бы использовать заглушки для этих двух абстракций.
9.6. Напишите итератор, выдающий числа Фибоначчи. (Число Фибоначчи представляет собой сумму двух предыдущих чисел Фибоначчи, а первое число Фибоначчи есть 0. Например, первые семь чисел Фибоначчи есть 0, 1, 2, 3, 5 и 8.) Перед отладкой определите тестовые наборы. Затем отладьте вашу программу и сообщите, насколько успешными оказались тесты.
9.7. Разработайте для себя профильный файл ошибок. Заведите журнал, в который записывайте ошибки в ваших программах. Для каждой ошибки записывайте ее причину и ищите закономерности.
Написание формальных Спецификаций
Большинство спецификаций в данной книге написаны квази-зрмально, без использования строгих правил. Фактически они Представляют собой сильно стилизованные комментарии. Мы 1выбрали неформальные спецификации, исходя из следующих воображений.
^ 1. Хотя формальные спецификации и принадлежат к много-^обещающим областям в исследованиях методов программирования, ^выгода от их применения при разработке различного программного обеспечения еще должна быть продемонстрирована.
2. Овладение техникой написания формальных спецификаций требует времени. Мы не думаем, что в течение односеместро-вого курса студенты могут освоить формальные спецификации вместе с другими материалами из этой книги.
3. Написание формальных спецификаций затруднено также и тем, что для этого требуется некоторая машинная поддержка. По крайней мере необходимы средства проверки синтаксиса и типа. Большинство читателей данной книги такими средствами не обладают.
В данной главе мы рассмотрим язык, используемый для написания формальных спецификаций. Мы убеждены, что студенты должны быть знакомы с формальными спецификациями хотя бы на уровне книги и что это знание окажет большую помощь при написании неформальных спецификаций.
Вспомните, что наши неформальные спецификации абстракций данных включают раздел, в котором дается интуитивное описание определяемого типа. Обычно это описание ссылается на некоторую область, с которой пользователи предположительно знакомы. Например, мы определяем набор intset в терминах математических наборов и poly в терминах полиномов над полем целых чисел. Проблема, связанная с использованием неформальных спецификаций, заключается в том, что такие дополнительные области никогда не определяются четко. Если читатели имеют интуитивное понимание материала из затронутой области и это их представление соответствует рассматриваемому, то они будут в состоянии понять спецификации, в противном случае — нет. Что еще хуже, и это говорилось вгл,8,—- нет способа узнать, интерпретирует ли 7*