Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Скачиваний:
38
Добавлен:
23.03.2015
Размер:
884.22 Кб
Скачать

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.

Для каждого из них мы должны выполнить обращения к mem­ber, size и elements, а затем проверить результаты. В случае с mem­ber мы должны сделать обращения, для которых элемент принад­лежит набору, а в остальных — не принадлежит.

Очевидно, что этих случаев недостаточно. Например, операция 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*

Соседние файлы в папке POSIBNIK