- •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. Пример
7.4. Генераторы
В языке Паскаль имеется оператор for, который позволяет выполнить итерацию в некотором поддиапазоне типов скалярных величин (за исключением типа real), но в нем совсем нет механизма для реализации итераторов, определенных пользователем.
.Использование языка Паскаль
Как отмечено в гл. 6, это может вызвать неудобства. Например, чтобы суммировать элементы в очереди целых чисел intqueue, мы могли бы написать программу, аналогичную программе, приведенной на рис. 7.9. Эта реализация была бы несколько менее неуклюжа, если бы мы к очереди целых чисел intqueue добавили функцию q.copy, но все равно она была бы неудовлетворительной. В этой программе имеются потери и по времени, и по пространству. По крайней мере они состоят из копирования всей очереди.
sum := О t := q.new; while not (qJsempty (q)) do
begin
i := q_remfirst (q); sum := sum + i; q_append (t, i) end;
while not (isempty (t)) do q_append (q, q_remfirst (t));
Рис. 7.9. Суммирование элементов очереди без использования итератора,
Чтобы избежать таких потерь, мы создадим тип некоторого специального вида, называемый генератором. Генераторы имитируют работу итераторов. У каждого генератора есть три функции и одна процедура. Эти три функции создают объект генератора, получают следующий элемент и проверяют, все ли элементы были выданы. Процедура уничтожает объект генератора.
Например, для очереди целых чисел мы могли бы создать генератор qelems. Функция qelems.create выполняет некоторую работу, которая соответствует той инициализации, которая делается до выполнения первого оператора yield в итераторе языка CLU. Эта функция возвращает некоторый объект с типом qelems. Вторая функция qelems_next выдает каждый раз один элемент при обращении к ней и модифицирует объект генератора qelems, чтобы указать, что этот элемент уже был выдан. Последняя функция qelems.done используется для определения того, были ли выданы все элементы. Процедура qelems, destroy освобождает пространство, которое было выделено при работе функций qe-lems.create и qelems_next.
Спецификация генератора qelems приведена на рис. 7.10. Обе функции qelems_done и qelems_next требуют, «чтобы очередь, используемая для создания элементов qi, не модифицировалась после того, как создан элемент qi». Это аналогично общему требованию предложения requires итераторов языка CLU, в которых требуется, чтобы объект, по которому совершается итерация, не модифицировался в теле данного цикла.
162 Глава 7
qelems = generator type is qelems_create, qelems_done,
qelerns-next, qelems_destroy
Описание
Генераторы qelems используются для выполнения итераций по элементам очередей целых чисел intqueue. Реализация может зависеть от представления очереди целых чисел intqueue.
Операции
function qelems_create (qJntqueue): qelems
effects Возвращается некоторый объект qelems, который может быть использован для выдачи всех элементов в очереди q. function qelems_done (qi: qelems): boolean
requires Чтобы очередь целых чисел intqueue, используемая для создания элементов qi, не модифицировалась после того, как создан элемент qi.
effects Возвращается значение true, если все элементы в очереди целых чисел intqueue, используемой для создания элементов qi, были «выданы» после того, как был создан элемент qi. В противном случае возвращается значение false. function qelems.next (qi: qelems): integer
requires Чтобы очередь целых чисел intqueue, используемая для создания элементов qi, не модифицировалась после того, как создан элемент qi, и чтобы существовал некоторый элемент в очереди целых чисел intqueue, который еще не был выдан. modifies oi
effects Еозгращается некоторый элемент в очереди чисел intqueiie, используемый для создания элемента qi. Этот элемент не был выдан после того, как был создан элемент qi. Элемент qi модифицируется для того, чтобы отметить тот факт, что данный элемент был «выдан». proctewe qelems_destroy (qi: qelems) modifies qi
effects Освобождается вся память из неупорядоченного массива, занимаемая элементом qi. end qelems
Рис. 7,i0. Спецификация типа данных генератор для очередей.
Используя тип данных qelems, мы можем заново реализовать суммирование следующим образом:
sum := О
qi := qelems_create (q); tt-hi'le not (qelems. done (qi)) do
sum := sum + qelems. next (qi); qelems- destroy (qi)
Другие циклы реализуются аналогичным образом. На рис. 7. II приводится реализация операций над типом данных qelems. Эта реализация совместима с реализацией очереди целых чисел intqueue, приведенной на рис. 7.2. На самом деле осуществляется доступ к представлению очереди целых чисел intqueue. Такой доступ типичен для генераторов, использующих такие совокупносчи, как очереди целых чисел intqueue. Они в действительности являются частью данного типа совокупности, как итераторы являются частью языка CLU. Отметим, что пред-
Использование языка Паскаль
qelems = {qelemsJ-ep; qelems_rep == ^intqueue_elem; function qelems_create (q: intqlieue): qelems; var qi: qelems; begin new (qi); qit := qf; qelems .create :== qi end {функция qelems_create}; lunction qelems .done (qi: qelems): boolean; begin
qelems_done := (qi^ == nil) end {функция qelems_done}; function qelems_next (qi: qelems): integer; begin if q^ = nil then failure ("К функции qelems_next обратились с пустой очередью
генератора") else begin qelems_next := qi-("l-.val; ФТ ••= qiTt-next end
end {функция qelems_next}; procedure qelems_destroy (qi: qelems); begin
dispose (qi); end {процедура qelems_destroy};
Рис. 7.11. Реализация генератора для типа данных очередь целых чисел intqueue.
ставление имеет дополнительный уровень косвенности дла того, чтобы гарантировать, что совместимое использование работает правильно.
7.5. Полный пример
В этом разделе приводится конкретная иллюстрация того, как подготовить программу на языке Паскаль, используя подход, который мы описали. Предположим, что у нас есть некоторая библиотека абстракций, в которую включен тип данных intbintree, приведенный на рис. 7.12, а мы хотим реализовать некоторую функцию, которая организует поиск по бинарному дереву целых чисел методом в ширину.
Поиск в ширину обычно реализуется с использованием очереди деревьев. Сначала помещаем в пустую очередь то дерево, по которому будет выполняться поиск. Затем проверяем, содержит ли корневой узел этого дерева тот элемент, который мы ищем. Если он не содержит его, удаляем из очереди это дерево и помещаем в очередь его правое и левое поддеревья. Затем этот процесс повторяется для каждого из этих деревьев. Если в конце
154 Глава 7
Использогание языка Паскале
intbintree == data type is bt_new, bt_isempty, bt_append_left, bt_append_right, bt.left, bt_right, bt_rootval, bt_destroy
Описание
Бинарные деревья целых чисел intbintree используются для хранения значений типа целые числа. Элемент может присутствовать в нескольких узлах такого дерева. И промежуточные узлы, и листья имеют значения. Бинарные деревья целых чисел intbintree изменяемы.
Операции
function bt_new; intbintree
effects Возвращается .пустое бинарное дерево. function btJsempty (bt: intbintree): boolean effects Возвращается значение true, если дерево bt пусто, и значение
false — в противном случае.
procedure bt_append_left (bt: intbintree; е: integer)
modifies bt effects Добавляется некоторый узел со значением е как самый левый
узел дерева bt.
procedure bt.append_right (bt: intbintree; е: integer)
modifies bt effects Добавляется некоторый узел со значением е как самый правый
узел дерева bt.
function btJeft (bt: intbintree): intbintree requires Чтобы дерево bt было не пустым.
effects Возвращается поддерево дерева bt, чей корень является левым ближайшим потомком корня дерева bt. Это поддерево может быть пустым.
function bt_right (bt: intbintree): intbintree requires Чтобы дерево bt было не пустым,
effects Возвращается поддерево дерева bt, чей корень является правым ближайшим потомком корня дерева bt. Это поддерево может быть пустым.
function bt_rootval (bt: intbintree): integer requires Чтобы дерево bt было не пустым. effects Возвращается значение корневого узла дерева bt. .procedure bt_destroy (bt; intbintree)
modifies bt
effects Освобождается вся память неупорядоченного массива, занимаемая деревом bt. end intbintree
Рис. 7.12. Спецификация бинарного дерева целых чисел intbintree.
мы получим пустую очередь, то в дереве нужного элемента не было.
На рис. 7.13 приведена реализация функции breadth.first. search. При этом предполагается, что была выполнена конкретная реализация абстракции queue [etype] при помощи замены etype на intbintree и задания имени btq получившемуся типу. Отметим, что функция breadth. firsLsearch использует процедуру btq.destroy для освобождения пространства, занимаемого создаваемой очередью. Если это не будет сделано, то память, занимаемая элементами локальной переменной q, была бы поте-
lunction breadth Jiist ^search (bt: intbiiitree; е: integer.): boolean; var q: btq; t: intbintree; found: boolean; begin found ;= false; if not (btJsempty (bt)) then begin q := btq_new; btq_append (q, btq);
while (not (btqJsempty (q)) and not (found)) do begin t := btq_re;nfirst (q); if bt.rootval (t) == e then found :== true else begin if not (btJsempty (bt.left (t))) then btq_append (q, btJeft (t)); if not (btJsempty (bt_right (t))) then btq_append (q, bt_right (t))! end end btq .destroy (q) end;
breadthJirst-search :== found end {функция breadthJirst_search}
Рис. 7.13. Реализация функции breadth, first, search,
ряна. С другой стороны, нет обращения к процедуре bt.destroy (t). Прежде чем выйти из операции, мы должны вызвать процедуру destroy для освобождения тех объектов, которые были выделены динамически в процессе работы этой функции. Хотя функция breadth.first. search делает объявление локальной переме « ной типа бинарного дерева целых чисел intbintree, она не создает ни одного объекта данного типа.
Теперь у нао есть все части, необходимые для получения функции поиска в ширину, которую можно было бы вставить в большую программу. Способ, которым мы объединим эти части, зависит от используемого языка Паскаль. Во многих диалектах языка Паскаль (включая и стандартный язык Паскаль) порядок объявления переменных внутри блока имеет ограничения — сперва делаются все объявления const. затем все объявления type, далее все объявления var и, наконец, все объявления операций. Из этого еледует, что объявление представления некоторого абстрактного типа не будет соседствовать с реализациями операций над данным типом.
Далее мы предполагаем, что используем язык Паскаль, в котором снято это ограничение и допускается любой порядок объявлений. Если для языка Паскаль, у которого более строгие ограничения, надо затратить большие усилия по программированию,
156 Глава 7
то, вероятно, выгодно написать некоторую программу, которая переводит программы, написамые как здесь без ограничений, в программы, соответствующие данному компилятору. Такая программа сама может быть быстро написана на языке Паскаль.
В языке Паскаль требуется, щобы все идентификаторы были объявлены до того, как они используются. Для написания взаимно рекурсивных операций исильзуют^я формальные объявления. Формальное объявление задает заголовок некоторой операции, не задавая ее тела. Обвдц формат программы на языке Паскаль, соответствующий ограничениям на объявление, а затем на использование, всегда будет следующим: 1) реализация процедуры failure; 2) реализации всех абстрактных типов, которые будут использоваться в данной программе, включая и генераторы; 3) реализации всех операций, которые не являются частью не-когарого абстрактного тела, а также объявления констант, типов и переменных и формальныесбъявления; 4) тело данной программы.
Структура программы, соответствующей нашему примеру, была бы следующей: 1) реализация процедуры failure; 2) реализация бинарного дерева целых чисе.т intbintree; 3) реализация очереди queue [intbintree]; 4) реализация функции breadth.first. serch; 5) другие объявления и тцо программы.
76. Обзор главы
Подход к программированию, рассматриваемый бэтой книге, в большой степени независим от используемого языка программирования. Мы предполагаем, что, хотя выбор языка программирования для реализации программы окажет некоторое влияние на проектирование самой программы, это влияние не будет доминирующим. В частности, нет необходимости ограничивать типы йостракций, используемые при проектировании, теми типами, ко-тсрые непосредственно поддерживаются в данном языке программирования.
В нашем случае мы обратились к языку Паскаль — языку, который имеет относительно примитивные механизмы абстракций. Мы определили, как надо модифицировать абстракции процедур, итераций и данных, чтобы соответствовать языку Паскаль, и предложили соглашения по программированию, которые могут быть использованы для реализации абстракций данных, генераторов, полиморфных абстракций и обработки исключительных ситуаций па языке Паскаль. Наш подход 8 языку Паскаль может быть относительно просто адаптирован для других языков программирования (в течение нескольких лет мы читали курс, в котором использовался язык ПЛ/1). Мы рассмотрим такую адаптацию ч гл. 15.
Использование языка Паскаль
Дополнительная литература
Garland, Stephen J. 1986. Introduction to Computer Science with Applications in Pascal. Mass.: Addison-Wesley Publishing Co.
Упражнения
7.1. Реализуйте тип бинарного дерева целых чисел intbintree, заданный на рис. 7.12.
7.2. Специфицируйте некоторую абстракцию bintree [type], основанную на абстракции бинарного дерева целых чисел intbintree, заданную на рис. 7,12.
7.3. Реализуйте очередь целых чисел intqueue (рис. 7.1) так, чтобы доступ к первому и последнему элементам и их удаление могли быть сделаны за постоянное время. Надо ли изменять реализацию функции breadth-first-search для того, чтобы использовать преимущества этой лучшей реализации очереди целых чисел inttjueue?
7.4. Специфицируйте и реализуйте некоторую абстракцию обобщенного дерева, т. е. такого дерева, в котором может быть любое конечное число ветвей у каждого узла. (Указание: в своей реализации вы можете рассмотреть для каждого узла использование очереди его детей.)
7.5. Модифицируйте функцию breadth-first-search (рис. 7.13) так, чтобы она работала с обобщенным деревом, которое специфицировано в результате Вашего ответа на упражнение 7.4.
7.6. 'Специфицируйте и реализуйте некоторую абстракцию стека и используйте ее для реализации некоторой функции, которая выполняет поиск в ширину.
7.7. Специфицируйте и реализуйте некоторый генератор, который возвращает все листья в бинарном дереве целых чисел intbintree (рис. 7.12).
7.8. Что реализовано в программе, приведенной на рис. 7,14? (Предполагается, что сделаны спецификации, приведенные на рис, 7.1 и 7.10.)
program test: var i, sum: integer; q: intqueue; qi, qil: qelems; begin q := q-new;
for i := I to 3 do q.append (q, i); sum :== 0 qi := qelems_create (q); while not (qelems-done (qi)) do begin qil '.= qelems-create (q); while not (qelems-done (qil)) do
sum := sum + qelems-next (qil); qelems-destroy (qil); sum :== sum+ qelems_next (qi); end; qelems-destroy (qi); q .destroy (q); write Ln (sum) end {программа test)
Рис. 7.14. Программа на языке Паскаль»
8. Спецификации
В данной книге мы указывали важность.спецификаций на всех этапах разработки программного обеспеченияГПри этом подчеркивался тот факт, что корректное^прлменение спецификаций является ключевым моментом на пуйГ к эффективному программированию. Без спецификаций абстракции слишком сложны для того, чтобы оказаться действительно полезными^ В данной главе мы рассмотрим смысл спецификаций и некоторые критерии, которые необходимо учитывать при их написании. Мы также рассмотрим два основных назначения спецификаций.
8.1. Спецификации и необходимые наборы спецификаций
Назначение спецификации состоит в определении поведения абстракции. Пользователи полагаются н^.это поведение, а разработчики должны обеспечить его. Говорят,'' что-реализация, обеспечивающая описываемое поведение, удовлетворяет заданной спецификации. ' ,
Определим/значение спецификации через набор' >ёсех программных модулей, ей удовлетворяющих. Назовем это необходимым набором спецификаторов для данной спецификации. В качестве примера рассмотрим спецификацию
р = ргос (у: int) returns (х: int)
requires у j> 0 effects x >• у '.' ''•^'",'"•...!^,'^•'•.•''^
Она-удовлетворяется любой процедурой, с именем р, которая при вызове с аргументом, большим нуля, возвращает значение, большее этого аргумента. Члены необходимого набора включают в себя:
р = ргос (у: int) returns (int) return (у + 1) end р р = ргос (у: int) returns (int) return (у * 2) end р р = ргос (у: int) returns (int) return (у + 3) end р
Подобно каждой спецификации, данной спецификации удовлетво» ряет неограниченное число программ.
Спецификг.ции
Важно пешмняь, что спецификация, ее набор спецификаторов и некоторый член этого набора представляют собой весьма отличные друг от друга вещи, так же отличающиеся друг от друга, . как отличается программа от набора всех выполняемых ею возможных вычислений и работы этой программы с каким-то одним набором значений."^
.2. Некоторые критерии) к спецификациям
применяемые
3 Хорошие спецификации могут иметь различные формы, однако все они имеют схожие атрибуты. В данном разделе рассматриваются три важных атрибута — ограниченность, обобщенность и простота.
(" 8.2.1. Ограниченность
Lf Имеется большое различие между знанием о пригодности некоторых членов набора спецификаторов, и знанием того, что подходящими являются все члены. Это аналогично отличию знания о возможности работы программы с некоторыми наборами входных значений, от знания возможности ее работы со всеми входными значениями. Мы будем говорить о подобном отличии при обсуждении тестирования программ (см. гл. 9).'./Хорошая спецификация должна быть ограничена настолько, чтобы позволить выявить любую реализацию, не устраивающую пользователей данной абстракции. Это требование лежит в основе почти всех применений спецификаций. '/'
В общем случае обсуждение того, является ли спецификация достаточно ограниченной, предполагает рассмотрение задач, возлагаемых на членов набора спецификаторов,^меется ряд распространенных ошибок, которые почти всегда приводят к неадекватно ограниченным абстракциям^? К одной из таких ошибок' относитс^неполное задание требований в предложении requires.^/ Например, на рис. 8.1 приведены три спецификации итератора elems для портфеля целых чисел. (Портфель (bag) представляет собой обычный набор данных за тем исключением, что элементы в нем могут повторяться. Например, портфель bag [int] может содержать, число 3 дважды. Портфели иногда называют мульти-наборами.) В первой спецификации не дается ответа на вопрос, что произойдет, если b изменится в цикле, использующем elems. Она, следовательно, допускает реализации с резко отличающимся поведением. Например, приведет ли изменение b к изменению значений, возвращаемых elems?
160 Глава S
elems = iter (b: bag [t]) yields (e: t) effects Поочередно выдает каждый элемент из Ь.
elems == iter (b: bag [t]) yields (e: t)
requires b не изменяется в цикле, использующем elems. effects Поочередно выдает каждый элемент из b.
elems = iter (b: bag [t]) yields (e: t)
requires b не изменяется в цикле, использующем elems. effects Выдает все элементы из b в произвольном порядке. Каждый элемент из b выдается столько же раз, сколько раз он встречается в b.
Рнс. 8.1. Три спецификации для elems.
Одним из способов решения данной проблемы является введение требования о том, чтобы переменная b не изменялась внутри использующего elems цикла. Это требование задано во второй спецификации. Эта спецификация может бьпь, а может и не быть достаточно ограниченной, поскольку она не накладывает ограничений на порядок, в котором возвращаются элементы. Лучше, если спецификация будет определять порядок или содержать фразу «в произвольном порядке». Кроме того, в специфшка-ции не указано, что делается в том случае, если какой-нибудь элемент встречается в b более одного раза. В ней даже не говорится явно, что elems возвращает только те элементы, которые содержатся в b. В третьей спецификации рассмотренные недостатки устранены.
К другим ошибкам относится отсутствие полного списка исключительных ситуаций и описания .поведения программы для граничных случаев. Например, пусть операция string^indexs получает в качестве аргументов строки si и s2, и, если si является подстрокой в s2, возвращает индекс вхождения в s2 первого символа из si. Например: &iring,$'ndexs ("ab", "abc"») == 2
Спецпфикаиия, которая содержит только эту информацию, не будет достаючно ограниченной, поскольку она не объясняет, что произойдет в том случае, если si не является подстрокой в s2, пли если она встречается в s2 несколько раз, или если si или s2 пусты. Спецификация на рис. 8.2 является достаточно полной.
Сказанное позволяет сделать вывод о том, что написание достаточно эффективных спецификацнй должно производиться с большой осторожностью. Полезную роль здесь может сыграть язык формальных спецификаций (формальные спецификации рассматриваются в гл. 10).
iiidexs = proc (si, s2: string) returns (i: int)
effects Если si входит в s2 как подстрока, то i есть последний индекс вхождения si в s2. i равно 0, если si не входит в s2, и рапно 1, если si есть пустая строка. indexs ("be", "abcbc") = 2 indexs (" ", "a") = I
Рис, 8.2, Спецификация операции strin^indexs,
Спецификации
^8.2.2. Обобщенность
Хорошаяуспецификация должна быть достаточно обобщенной, позволяя сократить до минимума число исключаемых, однако приемлемых программ.^Важность критерия обобщенности может быть не столь очевидна, как критерия ограниченности./Жела-тельно не только сократить ограничения на число приемлемых реализаций, но и обеспечить возможность использования наиболее удачных (более эффективных или «элегантных») из нихУНапример, спецификация
sqrt = proc (sq: real, e: real) returns (root: real)
requires sq^O & e;> .001. effects 0 ^ (root * root —sq) ^ e.
ограничивает разработчика алгоритмами, которые отыскивают приближения, больщ^е или равные фактическому значению квадратного корня;, Эт^Чотраничение может привести к неоправданному снижению эффективности.
Желательно создавать спецификации максимально обобщенными. Это привело нас к «дефинитивному» стилю, который, и использован в данной книге.у Такая, спецификация яйй^Ь^ечис-ля^ свойства, которыми должны обладать спецификаторы. Альтернативой дефинитивной спецификации является операционная спецификация^Вместо описания свойств спецификаторов операционная спецификация приводит способ конструирования их. Например,
search = proc (a: array [int], х: int) returns (i: int) signais (not.in) effects Анализирует a[low], a[low+ 1],...,поочгредно ii возвращает индекс элемента, равного х. Сигнализирует not-in, если ни один из элементов не равен х.
есть операционная спецификация операции serach, в то время как
search = proc (a: array [int], х: int) returns (i: int) signals (not-in) effects Возвращает i, такое, что a[i] = х; х. Сигнализирует not_in, если такое i отсутствует.
есть дефинитивная спецификация. Первая спецификация объясняет, как реализовать операцию search, а вторая просто описывает свойства, которыми должны обладать входные и выходные данные. Дефинитивная спецификация не только является более короткой, но и предоставляет разработчику большую свободу, позволяя ему выбрать способ анализа элементов массива в удобном для него порядке.
"У Операционные спецификации имеют ряд преимуществ.1^Что более существенно./они &равнительно легко составляются опытными программистами—главным образом по причине того, что их составление очень сильно напоминает сам процесс программи-
(> Листов Б., Гатэг Дж.
162 Глава 8
рования. Они обычно имеют больший размер, чем дефинитивные спецификации, и часто ведут к избыточности.У Например, операционная спецификация операции search указывает, какой индекс должен быть возвращен в том случае, если х встречается в массиве а более одного раза. В качестве другого примера можно рассмотреть попытку написать операционную спецификацию для процедуры извлечения квадратного корня.
Хорошей проверкой на обобщенность является анализ каждого требования спецификации в предложениях requires и effects и уточнение их реальной необходимости. Если это не так, то они должны быть исключены или ослаблены. Помимо этого, к любой части спецификации, являющейся более операционной, нежели дефинитивной, следует относиться g осторожностью.
8.2.3. Простота
Когда мы говорим о том, что делает программу «хорошей», то мы рассматриваем не только выполняемые ею вычисления, но и особенности самого текста программы, например, насколько удачно она разбита на модули и насколько хорошо составлены комментарии. Аналогично при анализе спецификации мы должны рассмотреть не только свойства набора спецификаторов, но и свойства самой спецификации, например, насколько она удобна для чтения.
Хорошая спецификация должна облегчать работу е другими людьми.УСпецификация может быть достаточно ограниченной и достаточно обобщенной^ т. е. иметь необходимый смыслы/однако этого недостаточно. Если этот смысл трудно понимаем читателями программы, то польза от такой спецификации весьма невелика.
Имеются два пути, ведущие к непониманию спецификации. Одни читатели могут изучать ее и прийти к тому, что они не понимают спецификацию. Например, читатель второй спецификации elems на рис. 8.1 может быть смущен тем фактом, что элемент встречается в портфеле более одного раза. Это не очень хорошо, однако гораздо менее опасно, чем в том случае, когда люди считают, что они поняли спецификацию, а в действительности это не так. В таком случае пользователь и разработчик понимают спецификацию каждый по-своему, что ведет к модулям, которые не могут работать совместно. Например, разработчик процедуры elems может решить выдавать каждый элемент столько раз, сколько раз он встречается в портфеле, а пользователь при этом считает, что элемец^выд^ется только один раз. „ Простота^' Шляется важным, однако аморфным критерием.'. Легко сказать, что хорошую спецификацию будет легче понять, и гораздо сложнее сказать, как достичь этого. Имеется множество факторов, оказывающих влияние на простоту, среди которых
Спецификации
W,'' \
раткость, избыточность и структурированность/являются, пожалуй, наиболее важными.
'-Наиболее краткое представление не всегда является наилучшим, однако на начальном этапе оно, как правило, является лучшим. Имеется ряд соображений, оправдывающих увеличение спецификации добавлением к ней избыточной информации или уровней структурированности, однако важно избегать ненужного многословия. Обычно по мере возрастания объема спецификации увеличивается вероятность появления в ней ошибок, а также появляется возможность ее неверного трактования. </ Важно не путать объем с полнотой^Спецификации, порожденные «потоком сознания», приводят к неполноте и громоздкости.. Созданные без учета критериев спецификации, подобно программам, имеют размеры, большие необходимыхх/Вместо внесения добавлений в спецификацию разумнее^отложить локальные изменения и\ изыскать пути подтверждения достоверности этой информации. Написание короткой полной спецификации занимает больше времени, чем написание длинной и полной, однако автор спецификации может оставить эту задачу читателям спецификации.
Любая содержащая избыточный терст спецификация является менее точной, чем она могла бы бытб^Избыточность не должна присутствовать без серьезных причин и может быть оправдана в двух ситуациях: в случае, если велика вероятность неправильного понимания спецификации, и в случае необходимости выявления ошибок. V ... .
Роль, выполняемая спецификацией, во многом схожа с ролью, выполняемой записной книжкой. Она '(предназначена не только для содержания информации, но и для эффективной работы с этой информацией. Избыточность может быть использована для снижения вероятности пропуска важных подробностей.УСтарая поговорка «скажи им, что ты хочешь им сказать, и скажи им, что ты им сказал» имеет некоторую педагогическую ценность. ^Идея состоит в том, чтобы^представить информациюнесколькими способами, внося избыточность без повторений. Рассмотрим, например, '' • , •.. ,
р = proc(sl, s2: set) returns (b: bool) . effects b имеет значение true, если si есть подмножество s2, и false —в противном случае.
р== proc(sl, s2: set) returns (b: bool)
effects b = yx [x ^ si имплицирует x ^ s2]. p = proc (si, s2: set) returns (b: bool)
effects b имеет значение true, если si есть подмножество s2, и false —в пр»« тивном случае, т.е. b = ух [х (- si имплицирует х ^ s2].
Первая спецификация кратка и вполне понятна большинству читателей. Однако некоторые из них могут задаться вопросом: 6*
164 Глава 8
было ли понятие «подмножество» выбрано достаточно продуманно или автор имел в виду соответствующее подмножество? Вторая спецификация, которую прочесть чуть сложнее, не оставляет никаких сомнений по этому вопросу. Возникает другой вопрос; почему составитель спецификации, полагая процедуру р проверкой на наличие подмножества, не указал это явно? Третья спецификация отвечает на оба этих вопроса.
Констатация одного и того же факта двумя различными способами дает возможность читателям лучше понять спецификацию. Это позволяет избежать неверного понимания и сокращает общее время изучения спецификации. С этой точки зрения удачным примером может служить спецификация процедуры indexes, приведенная на рис. 8.2.
Спецификация, описывающая одну и ту же вещь несколькими способами, облегчает понимание ее различными читателями. Очень часто критическая спецификация представляет собой концепцию с названием, мало знакомым большинству читателей. Рассмотрим, например,
pv = proc (inc, r: real, n: int) returns (value: real)
requires inc > 0 & r •> 0 & n :> 0.
effects Возвращает имеющееся значение годового дохода для inc за период n лет в проценте прироста без риска величиной г.
То есть value == inc+ (inc/(l +г)+...+ (inc/(l + г)"~'). Например, pv (100, .10,3) = 100+ 100/1.1 + 100/1.21
Для читателей, недостаточно хорошо знакомых с финансовыми вопросами, нелегко будет понять фразу «имеющееся значение». И с этой точки зрения вторая строка, «т. е.», представляет для них безусловный интерес. Вторая часть, «например», может быть использована читателями для подтверждения правильности своего понимания.
Если читатели извлекают пользу из подобной избыточности, то важно сделать так, чтобы вся подобная информация была каким-либо образом помечена с этой точки зрения. Хорошим способом является использование вводных слов и оборотов типа «например» или «т. е.».
Избыточность не сокращает число ошибок в спецификации. Она делает их более очевидными и дает возможность читателю их обнаружить. Рассмотрим, например,
too.cold = proc (temp: int) returns (b: bool)
effects b = true, если temp < 0 градусов по Фаренгейту; в противном случае b = false.
too.cold = proc (temp; int) returns (b: bool)
effects b = true, если temp < 0 градусов по Фаренгейту; в противном случае b = false. То есть b == true в точности в том случае, когда temp не больше, чем точка замерзания воды при нормальной температуре и давлении.
Спецификации
Первая спецификация не вызывает у читателя никаких подозрений, однако вторая явится для большинства предупредительным сигналом.
Одна из базовых проблем, относящихся к спецификации, заключается в том, что в процессе изучения читатель вносит в этот процесс свое представление о предмете, порождающее неоднозначность. Для исключения подобной неоднозначности может потребоваться введение весьма значительной избыточности. Рассмотрим, например,
billion = proc ( ) returns (b: int)
effects Возвращает целое число значением в один биллион. billion = proc ( ) returns (b: int) effects Возвращает целое число значением в один биллион, т. е. 10°.
Как американские, так и английские читатели найдут первую спецификацию абсолютно безошибочной. К сожалению, они интерпретируют ее совершенно по-разному, поскольку в Соединенных Штатах биллион есть 10", а в Великобритании биллион есть 10".
8.3. Почему именно спецификации?
Спецификации важны для достижения требуемой модульности программы. Абстракция используется для декомпозиции программы на модули. Однако взятая в отдельности абстракция является малопонятной. Без какого-либо описания мы не можем ни сказать, что она из себя представляет, ни отличить ее от одной из своих реализаций. В качестве такого описания и выступает спецификация.
Спецификация описывает соглашение между разработчиками и пользователями. Разработчик соглашается написать модуль, который относится к заданному набору спецификаторов. Пользователь соглашается не полагаться на знания о том, какой именно член набора используется, т. е. не предполагать ничего такого, что не было бы указано в спецификации. Такое соглашение позволяет разделить анализ реализации от собственно использования программы. Спецификации дают возможность создавать логические основы, позволяющие успешно «разделять и властвовать».
Спецификации, очевидно, полезны и для документации программы. Сам факт написания спецификации полезен также и потому, что он проливает свет на используемую абстракцию. Опыт показывает, что эта деятельность приносит столько же пользы, сколько приносит использование самого результата. Написание спецификации всегда позволяет нам узнать что-либо полезное об описываемом наборе спецификаторов. Это также всегда облегчает процесс выявления неоднозначностей, неполноты и недоопределенности. В некоторых случаях улучшение понима-
166 Глава 8
ния задачи является наиболее важным результатом данной ра-_ боты.
Цель состоит в написании спецификаций, которые являются одновременно достаточно полными и достаточно ограниченными. Таким образом, мы удаляем должное внимание поставленным требованиям, исключительным ситуациям и граничным условиям. Этот процесс предполагает постановку ряда вопросов относительно поведения абстракций, подобных тому, как поступать с индексами, если строка пуста. Смысл состоит в том, что постановка и ответ на такие вопросы заставляет нас более тщательно анализировать абстракцию и ее предполагаемое назначение.
Создание спецификации концентрирует внимание на том, какой должна быть сама программа. Она служит как бы механизмом генерации вопросов, ответ на которые должен быть дан в результате консультации с пользователями, а не с разработчиками. Обусловливая постановку этих вопросов на ранних стадиях разработки системы, спецификация позволяет нам улучшить понимание требований к системе и проекту до начала их реализации.
Как это будет показано в гл. 12 и 13, спецификации можно начать составлять сразу же после того, как будут приняты описываемые в них решения. Поскольку спецификации теряют значимость только в том случае, если становятся устаревшими соответствующие абстракции, то они эволюционируют до тех пор, пока эволюционирует сама программа. Серьезная ошибка считать, что процесс написания спецификаций является отдельной фазой создания программного обеспечения.
Однажды написанные, спецификации могут служить различным целям. Они одинаково полезны проектировщикам, разработчикам и лицам, сопровождающим математическое обеспечение. В течение фазы реализации программного обеспечения наличие хорошей спецификации помогает как реализующим заданный модуль, так и тем, кто использует затем этот модуль. Как уже говорилось, хорошая^^спецификация поддерживает баланс между ограниченностью и обобщенностью. Она сообщает разработчику, какие функции необходимо реализовать, однако не накладывает излишних ограничений на способ их реализации. Это дает разработчику максимальную свободу в написании модулей, согласованную в то же время с требованиями пользователей. Разумеется, спецификации существенны и для пользователей, которым не на что больше положиться при реализации своих модулей. Кроме спецификаций имеются только лишь тексты программ, а на текущий момент обычно неизвестно, какая часть программы претерпела изменения. В процессе тестирования спецификации предоставляют информацию, которая может быть использована для генерации тестировочных данных и построения заглушек, имитирующих работу данного модуля. (Мы обсудим это приме-
Смцификации
нение спецификации в гл. 9.) На этапе компоновки системы наличие хороших спецификаций позволяет сократить число и серьезность проблем, связанных с интерфейсами, за счет уменьшения числа различных неявных предположений об этих интерфейсах. При обнаружении ошибки спецификации позволяют выявить' их местоположение. Более того, они определяют ограничения, которые необходимо соблюсти при исправлении ошибки, что помогает избежать новых ошибок в процессе исправления старых.
Наконец, спецификация весьма полезна в процессе сопровождения программного обеспечения. Существование ясной и аккуратной документации является необходимым условием для успешного и эффективного сопровождения. Нам необходимо знать, что делает каждый модуль, а если он достаточно сложен, знать также то, как он это делает. Зачастую эти два аспекта документации сильно взаимосвязаны. Использование спецификации в качестве документации позволяет нам разделить эти две стороны, что облегчает анализ и возможные модификации. Например, модификация, при которой требуется только заново реализовать единственную абстракцию без изменения ее спецификации, гораздо легче реализуема, чем модификация, предполагающая изменение спецификации.
8.4. Заключение
В данной главе были рассмотрены спецификации и предложены критерии, используемые при их создании. Мы определяем смысл спецификации через набор программных модулей, ей удовлетворяющий. Такое определение учитывает интуитивное назначение спецификации, а именно: утверждает, что общего имеют между собой все допустимые реализации абстракции. Такая спецификация сообщает пользователям то, из чего они могут исходить, а проектировщикам — то, что они должны реализовать.
Хорошие спецификации должны быть ограниченными, обобщенными и простыми. Ограниченность и обобщенность предполагает наличие набора модулей, удовлетворяющих спецификации: не допускаются реализации, неприемлемые для пользователей абстракции, а предпочтительные реализации (например, более эффективные) должны быть выделены. Обобщенность гораздо легче реализовать в том случае, если спецификации написаны с использованием дефинитивного подхода, который только задает свойства набора спецификаторов. Операционный подход, объясняющий способ реализации абстракции, дает слишком ограниченные спецификации.
Простота подразумевает легкость понимания спецификации пользователями. При этом в начале работы над спецификацией
168 Глава 8
желательно стремиться к наибольшей краткости, а затем ввести некоторую избыточность, часто в форме примера. Избыточность также позволяет читателям проверить свое понимание спецификации.. Она также делает более очевидными ошибки, поскольку при избыточных описаниях они часто проявляются в виде противоречий. Для облегчения понимания спецификации вся избыточная информация должна быть каким-нибудь образом выделена.'
Спецификации имеют два основных назначения. Во-первых, процесс написания спецификации проливает свет на специфицируемую абстракцию — за счет фокусирования внимания на свойствах этой абстракции. Их роль может быть увеличена путем тщательного анализа свойств, которые могли остаться незамеченными, включая то, что должно быть указано в предложении requires относительно обработки исключительных ситуаций и поведения на границах. Иногда эта роль является главной ролью) выполняемой спецификацией, поскольку она выявляет проблемы в спецификациях, требующих дальнейшего анализа.
Спецификация используется также для документирования программ, на каждой стадии разработки и создания программного обеспечения. Разумеется, спецификация не покрывает всю необходимую документацию. Она описывает выполняемые модулем функции, однако для сложных модулей необходимо также описание принципов их работы. Если эти два вида документации четко разграничены, то модификация и сопровождение программного обеспечения значительно облегчаются.
Спецификации являются единственной расшифровкой абстракции и играют существенную роль в использованной нами методологии, поскольку без них абстракции были бы слишком расплывчатыми. Мы будем акцентировать на них свое внимание и в последующих главах.
Дополнительная литература
Parnas, David L,, 1977, The use of precise specifications in the development of software. In Proceedings of IFIP Congress 77, pp. 861—868.
Упражнения
8.1. Приведите четкую и краткую спецификацию абстракции портфеля целых чисел с операциями по созданию пустого портфеля, размещения и удаления из него элемента, проверки на принадлежность элемента к портфелю, установлению числа вхождений элемента в портфель и выборки элементов и портфеля.
8.2. Воспользовавшись спецификацией, рассмотренной в предыдущей главе, проанализируйте ее ограниченность, обобщенность и простоту. 8.3. Разумно ли ставить вопрос о корректности спецификации? Объясните. 8.4. Обсудите, как спецификации могут быть использованы в процессе компоновки системы. 8.5. Рассмотрите взаимосвязь абстракции, ее спецификации и реализации.
^.Тестирование и отладка
До сих пор речь шла о спецификации и реализации программы 'в совсем мало говорилось непосредственно о ее создании. Сейчас мы перейдем к связанным с этим вопросам, касающимся про-1-верки работоспособности программы.
1 Говоря о том, что созданная программа выполняется согласно (; нашим ожиданиям, мы воспользуемся понятием «проверка досто-- верности». Проверка достоверности обычно производится путем выполнения различных тестов и анализом ряда соображений, касающихся того, что мы считаем правильной работой программы. Назовем процессом отладки такой процесс, при котором производится выяснение причин, вследствие которых программа работает неправильно. Введем также понятие защитного программирования, обозначая этим создание программ, специально предназначенных для облегчения процессов проверки достоверности и отладки.
Перед подробным рассмотрением процесса проверки на достоверность необходимо обсудить, что мы должны от него ожидать. Наилучшим результатом окажется ситуация, при которой все пользователи программы будут удовлетворены ее работой. Эта цель недостижима. Такая гарантия предполагает знание того, что понимать под удовлетворенностью пользователей работой программы. Лучший результат, на который мы можем рассчиты-'вать, это гарантия того, что программа удовлетворяет своей спецификации. Большая часть работы тратится на выполнение именно этого условия.
Проверка достоверности может быть реализована двумя способами. Мы можем говорить о том, что программа будет работать со всеми возможными входными данными. Это предполагает тщательный анализ текста программы, что обычно называют верифи-цировац^ем. В гл. II мы рассмотрим ряд четких приембв' проверки программ. Как увидим далее, формальная проверка программу^ без^ привлечейия ЭВМ зачастую~чересчур__утомительна. К сожалению, на сегодняшний день существуют относительно примитивные средства проверки, и проверка программ в подавляющем большинстве представляет собой неформализованный процесс. Однако даже неформальная проверка может оказаться Довольно затруднительной.
170 Глава 9
пироеание и отладка
Альтернативой верификации служит тестирование. Легко можем убедиться в том, что для некоторого набора входных значений программа работает правильно, простой проверкой ее работы с этими значениями. Если набор возможных входных значений невелик, то возможна полная проверка (для каждого из входных значений). Но для большинства программ входной набор слишком велик (зачастую бесконечен) и полная проверка не представляется возможной. Однако грамотно подобранный набор проверочных значений может в большой степени укрепить нашу уверенность в том, что программа функционирует правильно. Хорошо произведенный тест позволяет выявить большинство ошибок в программах.
В данной главе мы сфокусируем свое внимание на тестировании как методе проверки достоверности программ. Рассмотрим, как выбирать соответствующие тесты и как организовать процесс тестирования. Обсудим также процесс отладки и защитное программирование.
9.1. Тестирование
,i Тестирование представляет собой процесс выполнения про-1 граммы для некоторого набора проверочных значений и сравне-^ ния полученных результатов с ожидаемыми. Цель_тести р о^ания заключается в выявлении возможных ошибок~Тестирование~не указывает^конкретное^местонахождепие ошибки, иго осуществляется при отладке. При тестировании программы мы анализируем взаимосвязь между входными и выходными значениями. При отладке программы мы также заинтересованы в подобной взаимосвязи, однако при этом также обращаем свое внимание на промежуточные результаты вычислений.
Успех тестирования заключается в выборе подходящего набора проверочных данных. Как уже говорилось, исчерпывающее тестирование для большинства программ осуществить невозможно. Например, если программа принимает на входе три целых числа, каждое из которых может изменяться в диапазоне от 1 до 1000, то полная проверка потребует выполнения программы миллион раз. Если каждый прогон программы занимает по времени одну секунду, то на полную проверку потребуется немногим более 31 года.
Что мы можем сделать, учитывая невозможность проверки всех вариантов? Нашей целью является нахождение сравнительно небольшого набора тестов, которые позволят нам получить ту же"йнформацию, что и при полной проверке для всех возможных допустимых значений. Например, предположим, что программа принимает в качестве аргумента целое число, работая одним методом со всеми четными числами, и другим — со всеми
.четными. В этом случае достаточно будет проверки этой про-раммы с каким-либо четным числом, каким-либо нечетным, а »кже с нулем.
M.I. Тестирование методом черного ящика
Тестовые значения выбираются с учетом спецификации и са-^мой реализации программы. При тестировании методом черного 'ящика мы выбираем проверочные да^ные^^сходя^из одлои^спе->.дификации, и не учитываем внутреннюю^структуру 'программы.' 1Т1кой подход р^аспространён во многих инженёрных"дйсципли-нах и имеет ряд существенных преимуществ. Основным преимуществом является то, что процедура тестирования не находится в прямой зависимости от проверяемой компоненты. Например, предположим, что автор программы сделал ошибочное предположение о том, что для некоторого класса входных значений программа использоваться не будет. Исходя из этого, он не включил в программу анализ подобной ситуации. Если данные для проверки были подобраны на основании анализа программы, то вследствие этого неверного предположения проверяющий мог быть легко введен в заблуждение. Вторым преимуществом тестирования такого рода является независимость его по отношению к изменениям в реализации. Проверочные данные не требуется изменять даже в том случае, если в программе были произведены значительные изменения. Наконец, преимуществом является также и то, что результаты тестирования могут быть проанализированы людьми, незнакомыми с внутренней структурой проверяемой программы.
Пути тестирования „спецификации. Хорошим способом реализации тестирования методом черного ящика является тестирование, основанное на анализе различных частей спецификации. Такими частями могут быть предложения requires и effects. Рассмотрим пример тестирования на основе анализа предложения requires. Пусть имеется следующая спецификация:
sqrt = proc (х: real, epsilon: real) returns (ans: real) ; requires х^з 0 & (.00001 < epsilon < .001) effects (х — epsilon ^ ans * ans ^ x + epsilon)
В приведенной спецификации предложение requires представляет собой конъюнкцию двух термов:
1. х>0
2. (.00001 < epsilon < .001).
Для выделения условий, при которых предложение requires будет удовлетворено, мы должны проверить парные комбинации путей, при которых должно удовлетворяться каждое из условий. Поскольку первое условие есть дизъюнкция двух примитивных
172 Глава 9
иртание и отладка
термов (х >- 0 есть сокращение от х =•= 0 1 х> 0), то оно может быть удовлетворено двумя способами. Это оставляет нам ситуации, при которых требования в предложении requires удовлетворяются:
1. х = 0 & .00001 < epsilon < .001.
2. х > 0 & .00001 < epsilon < .001.
Любой набор тестировочных данных для программы sqrt должен обязательно удовлетворять каждому из этих условий.
Сформировать набор данных, который будет проверять все условия, заданные в предложении requires спецификации, довольно затруднительно. Может оказаться затруднительным даже установление того, какие условия должны быть проверены. Например, в приведенной выше спецификации программы sqrt мы ' предполагаем, что программа иногда возвратит точный результат, иногда результат, чуть меньший, чем точное значение квадратного корня, а иногда — чуть больший. Программа же, которая всегда возвращает чуть большее или равное значение, будет вполне приемлемой. Мы окажемся не в состоянии подыскать данные, при которых возвращаемый результат чуть меньше ожидаемого, однако мы не можем знать этого без анализа текста программы. Фактически, без анализа текста программы мы не имеем представления о том, какие классы входных данных разбивают результаты на три категории.
Тем не менее мы должны внимательно проанализировать предложение effects и попытаться отыскать данные, которые ему удовлетворяют. Например, рассмотрим операцию intset$member:
member = proc (s: intset, х: int) returns (bool) effects Возвращает значение true, если х принадлежит s; в противном
случае возвращается значение false.
Предложение effects в этой спецификации представляет собой конъюнкцию: либо х принадлежит s, либо нет.
Часто тестирование, основанное на анализе предложения effects, проверяет функционирование программ обработки ошибок. Отсутствие сигнала о возникновении исключительноя ситуации при запрещенных значениях входных параметров является столь же серьезной ошибкой, что и неверная работа программы при нормальных значениях. Следовательно, данные теста должны проверять все возможные реакции программы. Рассмотрим, например, следующую спецификацию:
search^ proc (a: array lint], х: int) returns': int) signals (notJn) •-' effects Если х не принадлежит а, то возвращается i, такое, что a [i]=x;
в противном случае — сообщение not-in.
анной ситуации мы должны включить в проверку оба теста—. тай, когда х принадлежит а, и случай, когда х не принадле-: • а. Аналогично, если при выполнении программы sqrt возни-ают исключительные ситуации, а не значения, указанные в пред-ожении requires, то мы должны включить тесты, проверяющие акие ситуации.
Проверка граничных условий. Программа должна быть всегда Проверена с «типичными» входными значениями — например, Массивом или набором, содержащим несколько элементов, или целым числом, находящимся в границах между максимальным и 1минимальным значением, выдаваемым программой. Важно также ^проверить программу с нетипичными входными данными, которые Принято называть граничными.
t Анализ всех случаев, обусловленных предложением requires, ^Предполагает также проверку ряда граничных ситуаций — на-; пример, случай, при котором программа sqrt должна извлечь квадратный корень из нуля. Однако такой анализ не учитывает 'все граничные ситуации. Весьма важно проверить максимально возможное число таких ситуаций. Подобные проверки позволяют выявить два распространенных вида ошибок:
1. Логические ошибки, при которых обработка граничного условия не приводит к выходу на специальную подпрограмму его обработки, и
2. Отсутствие проверки условий, которые могут привести к возникновению исключительной ситуации в языке, расположенном на более низком уровне, или в аппаратной части (например, арифметическое переполнение).
Для составления тестов, проверяющих второй тип ошибки, хорошим приемом является использование проверочных данных, покрывающих все комбинации максимально и минимально допустимых значений всех тех числовых аргументов, изменение которых происходит в пределах некоторых границ. Например, тесты для программы sqrt должны включать случаи со значением для epsilon, очень близким к 0.001 и 0.00001. При работе со строковыми данными тесты должны включать пустую строку и строку из одного символа. Для массивов мы должны проверить пустой и односимвольный массивы.
Наложение ошибок. Другой вид граничного условия возникает в том,случае, когда один изменяемый объект связывается с двумя различными формальными параметрами, рассмотрим, например, процедуру
append, array -= proc (a I, a2: array [int]) modifies al и a2 effects Удаляет элементы из a2 и помещает их в конец al
К.
174 Глава 9
'естиромние и отладка
которая была реализована следующим образом)
append.array == proc (al, a2: array [int]) ai = array [int] while ai$size (a2) ^> 0 do ai$addh (al, a2 [ai$low(a2)]) ai$reml (a2) end end append, array
Любые проверочные значения, у которых al и a2 связаны с одним и тем же непустым массивом, приведут к возникновению в процедуре append.array серьезной ошибки.
9.1.2. Тестирование на основании текста программы
Приступая к проверке, лучше всего начать с проверки методом черного ящика. Однако при основательной проверке программы этот метод редко оказывается эффективным. Без просмотра текста самой программы невозможно сказать, какие проверки могут предоставить новую информацию. Следовательно, при использовании метода черного ящика неизвестно, какой объем всех возможных проверок будет осуществлен. Например, предположим, что некоторые выходные результаты программа выбирает из таблицы, а некоторые вычисляет. Если тест, основанный на методе черного ящика, включает только значения, которые программа должна отыскать в таблице, то информация о той части программы, которая ответственна за вычисления, получена не будет.
Хорошим способом реализации проверки методом черного ящика является выбо^^азличных^маршруюв^путей проверки. Главная цель в этом случае — создание теста, в котором каждый путь проверяются по крайней мере одним членом из набора. Мы говорим в этомслучае, что набор данных для теста является полномаршрутным. В гл. II мы рассмотрим прием подсчета путей тестирования программы. Сейчас мы будем исходить из неформальных аргументов. Рассмотрим программу
max_of_three == proc (х, у, z: int) returns (int) if x>y
then if x ^> z then return (х) else return (z) end else if у > z then return (y) else return (z) end end end max_oL three
Несмотря на тот факт, что существует п" входных значений, где п есть допускаемый языком программирования диапазон для целых чисел, для проверки данной программы имеется только четыре маршрута. Следовательно, концепция проверки всех маршрутов позволяет нам разбить проверочные данные на четыре класса. В первом из классов х больше, чем у и z. В другом — х
ьше, чем г, но меньше, чем у, и т.д. Представители этих че-)ех классов есть
3,2,4 1,2,1 1,2,3
Легко показать, что проверка всех маршрутов недостаточна для обнаружения всех ошибок. Рассмотрим программу
шах. of. three == proc (х, у, z: int) returns (int) return (х) end max. of. three
Набор для теста содержит только тройку 2,1,1
и для данной программы является полным. Использование такого теста приведет к ложному заключению, что программа написана правильно, поскольку данный тест не в состоянии обнаружить существующие ошибки. Проблема заключается в том, что стратегия проверки, основанная на проходе по всем маршрутам программы, не приводит к обнаружению отсутствующих маршрутов^ а пропуск маршрута является одной из распространенных ошибок программирования. Эта проблема являет собой специфический пример ранее упомянутого факта: ни один набор данных, основанный на анализе текста программы, не является исчерпывающим. Всегда в расчет должна приниматься и спецификация.
Другая потенциальная проблема, связанная со стратегией тестирования, основанной на выборе полномаршрутной проверки, заключается в наличии порой слишком большого числа маршрутов. Это делает ее непрактичной. Рассмотрим фрагмент программы на рис. 9.1. Как видно из последующего анализа, в этой программе имеется У"" маршрутов. Оператор if обусловливает выполнение одной или другой ветви программы, и оба этих маршрута приводят к выполнению следующего прохода цикла. Следовательно, для каждого прохода через i-ю итерацию имеется два прохода через (i + 1)-ю итерацию. Поскольку к первой итерации ведет только один маршрут, то число маршрутов по выходу из i-й итерации составляет 21. Следовательно, из 100-й итерации существует 2"° выхода.
j:=k
for i: int in int$fromJo (1, 100) do if pred (i *j) then j :== j + I end end
Рис. 9.1. Программа с большим числом маршрутов.
Проверка 2^ ситуаций вряд ли является реальной. В таких случаях мы останавливаемся на некотором приближении к полномаршрутному набору проверочных значений. Наиболее распространенным приближением является рассмотрение в качестве
176 Глава 9
эквивалента двух или более проходов через цикл и двух или более рекурсивных обращений к процедуре. Для получения набора проверочных данных для рассмотренной выше программы отыщем полномаршрутный набор значений для следующей программы»
j:=k
for i: int in int$from_to (1, 2) do if pred (i *j) then j :== j + I end end
В этой программе имеется только четыре маршрута. Полный набор данных для всех этих маршрутов может состоять, например, из следующих представителей:
1. pred (k) pred (2k + 2)
2. pred (k) ~ pred (2k + 2)
3. —pred (k) pred (2k)
4. —pred (k) — pred (2k)
Помимо этого, мы всегда включаем также случаи для каждой ветви условного выражения. Определим полномаршрутное тестирование для циклов и итераций следующим образом:
1. Для циклов с фиксированным числом проходов, как это было в предыдущем примере, будем использовать две итерации. Мы предпочтем сделать два прохода через цикл, поскольку к одной из распространенных ошибок относится отсутствие повторного задания начальных условий для цикла. Мы также должны убедиться в том, что ни в каком из случаев наши тесты не вызовут зацикливания.
2. Для циклов с переменным числом проходов используем в качестве значения этого числа нуль, единицу и двойку, и также включим тесты, предотвращающие зацикливание. Рассмотрим, например, следующий цикл:
while х S> 0 do
% некоторые действия end
Для такого цикла возможна ситуация, в которой не будет сделан ни один проход. Такой случай должен быть обязательно учтен в тестах, поскольку выполнение цикла в другой ситуации может быть причиной ошибки в программе.
3. Для рекурсивных процедур добавляются тесты для тех случаев, когда возврат из процедуры происходит без рекурсивных обращений, а также тесты, рассчитанные только на одно рекурсивное обращение.
Рассмотренное приближение к полномаршрутному тестированию, разумеется, далеко от идеала. Оно часто позволяет обнаружить ошибки, однако не дает никаких полных гарантий.
Тестирование и отладка