Языки программирования - концепции и принципы

          

Если вы хотите связать его


Если вы хотите связать его с внешним if-оператором, нужно использовать скобки:

if(x1>y1){

       if (x2 > у2)

            statement_1; }

else

statement_2;

Вложенные if-операторы могут определять полное двоичное дерево выборов (рис. 6.2а) или любое произвольное поддерево. Во многих случаях тем не менее необходимо выбрать одну из последовательностей выходов (рис. 6.26).

Языки программирования - концепции и принципы


Если выбор делается на основе выражения, можно воспользоваться switch-оператором. Однако, если выбор делается на основе последовательности вы­ражений отношения, понадобится последовательность вложенных if-onepa-торов. В этом случае принято отступов не делать:

C

if (х > у) {



 } else if (x > z) {

} else if(y < z) {

} else {

...

}

Явный end if

   Синтаксис if-оператора в языке С (и Pascal) требует, чтобы каждый вариант выбора был одиночным оператором. Если вариант состоит из нескольких операторов, они должны быть объединены в отдельный составной (compound) оператор с помощью скобок ({,} в языке С и begin, end в Pascal). Проблема та­кого синтаксиса состоит в том, что если закрывающая скобка пропущена, то компиляция будет продолжена без извещения об ошибке в том месте, где она сделана. В лучшем случае отсутствие скобки будет отмечено в конце компиля­ции; а в худшем — количество скобок сбалансируется пропуском какой-либо открывающей скобки и ошибка станет скрытой ошибкой этапа выполнения.

    Эту проблему можно облегчить, явно завершая if-оператор. Пропуск за­крывающей скобки будет отмечен сразу же, как только другая конструкция (цикл или процедура) окажется завершенной другой скобкой. Синтаксис if-оператора языка Ada таков:

if expression then

    statement_list_1;

Ada

else

     statement_list_2;

end if;

    Недостаток этой конструкции в том, что в случае последовательности условий (рис. 6.26) получается запутанная последовательность из end if. Чтобы этого избежать, используется специальная конструкция elsif, которая представляет другое условие и оператор, но не другой if-оператор, так что не требуется ни­какого дополнительного завершения:


Обратите внимание, что вариант False


if x > у then

….

Ada

elsif x >z then

….

elsif у > z then



else



end if;

Реализация

Реализация if-оператора проста:

Языки программирования - концепции и принципы


   Обратите внимание, что вариант False немного эффективнее, чем вариант True, так как последний выполняет лишнюю команду перехода. На первый взгляд может показаться, что условие вида:

C

     if (!expression)

потребует дополнительную команду для отрицания значения. Однако компи­ляторы достаточно интеллектуальны для того, чтобы заменить изначальную команду jump_false на jump_true.

Укороченное и полное вычисления

   Предположим, что в условном операторе не простое выражение отношения, а составное:

Ada

if (х > у) and (у > z) and (z < 57) then...

   Есть два способа реализации этого оператора. Первый, называемый полным вычислением, вычисляет каждый из компонентов, затем берет булево произведение компонентов и делает переход согласно полученному результа­ту. Вторая реализация, называемая укороченным вычислением (short-circuit)*, вычисляет компоненты один за другим: как только попадется компонент со значением False, делается переход к False-варианту, так как все выражение, очевидно, имеет значение False. Аналогичная ситуация происходит, если со­ставное выражение является or-выражением: если какой-либо компонент имеет значение True, то, очевидно, значение всего выражения будет True.

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

  В языке Pascal оговорено полное вычисление, потому что первоначально он предназначался для компьютера с большим кэшем. Другие языки имеют два набора операций: один для полного вычисления булевых значений и дру­гой — для укороченного.

в Ada and используется для


Например, в Ada and используется для полностью вычисляемых булевых операций на булевых и модульных типах, в то время как and then определяет укороченное вычисление:

Ada

if (х > у) and then (у > z) and then (z < 57) then...

Точно так же or else — эквивалент укороченного вычисления для or.

   Язык С содержит три логических оператора: «!» (не), « &&» (и), и «||» (или). Поскольку в С нет настоящего типа Boolean, эти операторы работают с цело­численными операндами и результат определяется в соответствии с интерпре­тацией, описанной в разделе 4.4. Например, а && b равно единице, если оба операнда не нулевые. Как «&&», так и «||» используют укороченное вычисле­ние. Убедитесь, что вы не спутали эти операции с поразрядными операциями (раздел 5.8).

   Относительно стиля программирования можно сказать, что в языке Ada программисты должны выбрать один стиль (либо полное вычисление, либо укороченное) для всей программы, используя другой стиль только в крайнем случае; в языке С вычисления всегда укороченные.

   Укороченность вычисления существенна тогда, когда сама возможность вычислить отношение в составном выражении зависит от предыдущего отно­шения:

Ada

if (а /= 0) and then (b/a > 25) then .. .

Такая ситуация часто встречается при использовании указателей (гл. 8):

Ada

if (ptr /= null) and then (ptr.value = key) then . ..

6.3. Операторы цикла

 

Операторы цикла наиболее трудны для программирования: в них легко сде­лать ошибку, особенно на границах цикла, то есть при первом и последнем выполнении тела цикла. Кроме того, неэффективная программа чаще всего расходует большую часть времени в циклах, поэтому так важно понимать их реализацию. Структура цикла показана на рис. 6.3. Оператор цикла имеет точ­ку входа, последовательность операторов, которые составляют цикл, и одну

Языки программирования - концепции и принципы


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

и расположением условий выхода. Мы


Циклы различаются числом, типом и расположением условий выхода. Мы начнем с обсуждения циклов с произвольными условиями выхода, называ­емыми циклами while, а в следующем разделе обсудим частный случай — циклы for.

    Наиболее общий тип цикла имеет единственный выход в начале цикла, т.е. в точке входа. Он называется циклом while:

C

while (s[i]. data != key)

   Цикл while прост и надежен. Поскольку условие проверяется в начале цикла, мы знаем, что тело цикла будет полностью выполнено столько раз, сколько потребуется по условию. Если условие выхода сначала имеет значение False,то тело цикла не будет выполнено, и это упрощает программирование гранич­ных условий:

C

while (count > 0) process(s[count].data);

Если в массиве нет данных, выход из цикла произойдет немедленно.

    Во многих случаях, однако, выход естественно писать в конце цикла. Так обычно делают, когда нужно инициализировать переменную перед каждым выполнением. В языке Pascal есть оператор повторения repeat:

Pascal

repeat

     read(v);

      put_in_table(v);

until v = end_value;

    В языке Pascal repeat заканчивается, когда условие выхода принимает зна­чение True. He путайте его с циклом do в языке С, который заканчивается, когда условие выхода принимает значение False:

C

do{

     v = get();

     put_in_table(v);

    } while (v != end_value);

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

while not found do

Pascal

begin

(* Длинное вычисление *)

(* Обнаружена ошибка, выход *)

(* Длинное вычисление *)

end

Pascal, в котором не предусмотрен выход из середины цикла, использует сле­дующее неудовлетворительное решение: установить условие выхода и ис­пользовать if-оператор, чтобы пропустить оставшуюся часть цикла:


В Ada есть обычный цикл


while not found do

Pascal

begin

(* Длинное вычисление *)

    if error_detected then found := True

else

     begin

     (* Длинное вычисление *)

     end

end

В языке С можно использовать оператор break:

while (!found) {

C

         /* Длинное вычисление */

         if (error_detected()) break;

          /* Длинное вычисление */

                        }

В Ada есть обычный цикл while, а также оператор exit, с помощью которого можно выйти из цикла в любом месте; как правило, пара связанных операторов if и exit заменяется удобной конструкцией when:

while not Found loop

Ada

    -- Длинное вычисление

exit when error_detected;

 - Длинное вычисление

 end loop;

Операционная система или система, работающая в реальном масштабе време­ни, по замыслу, не должна завершать свою работу, поэтому необходим способ задания бесконечных циклов. В Ada это непосредственно выражается опера­тором loop без условия выхода:

Ada

loop



end loop;

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

while(1==1){

C



}

Реализация

Цикл while:

C

while (expression)

statement;

реализуется так:

L1: compute            R1.expr

       jump_zero        R1,L2                  Выйти из цикла, если false

       statement                                      Тело цикла

       jump                  L1                       Перейти на проверку завершения цикла L2:

Обратите внимание, что в реализации цикла while есть две команды перехода! Интересно, что если выход находится в конце цикла, то нужна только одна команда перехода:

do{

C

statement;

} while (expression);

компилируется в

L1:        statement

              compute         expr

              jump_nz         L1                       He ноль — это True


Хотя цикл while очень удобен


Хотя цикл while очень удобен с точки зрения читаемости программы, эф­фективность кода может быть увеличена путем замены его на цикл do. Для выхода из середины цикла требуются два перехода точно так же, как и для цикла while.

 

6.4. Цикл for

 

Очень часто мы знаем количество итераций цикла: это либо константа, извест­ная при написании программы, либо значение, вычисляемое перед началом цикла. Цикл со счетчиком можно запрограммировать следующим образом:

int i;                                                       /* Индекс цикла */

C

int low, high;                                        /* Границы цикла */

i = low;                                                /* Инициализация индекса */

while (i <= high) {                             /* Вычислить условие выхода */

          statement;

          i++:                                          /* Увеличить индекс */

};

Поскольку это общая парадигма, постольку для упрощения программирова­ния во всех (императивных) языках есть цикл for. Его синтаксис в языке С следующий:

int i;                                                  /* Индекс цикла */                                           

 int low, high;                                   /* Границы цикла */

C

for (i = low; i <= high; i++) {

     statement;

}

В Ada синтаксис аналогичный, за исключением того, что объявление и увели­чение переменной цикла неявные:

Low, High: Integer;

Ada

for I in Low .. High loop

statement;

end loop;

Ниже в этом разделе мы обсудим причины этих различий.

     Известно, что в циклах for легко сделать ошибки в значениях границ. Цикл выполняется для каждого из значений от low до high; таким образом, общее число итераций равно high - low +1. Однако, если значение low строго больше значения high, цикл будет выполнен ноль раз. Если вы хотите выполнить цикл точно Л/ раз, цикл for будет иметь вид:

Ada


за того, что для массивов


forlinl ..N loop...

и число итераций равно N -1 + 1 = N. В языке С из- за того, что для массивов индексы должны начинаться с нуля, цикл со счетчиком обычно записывается так:

C

for(i = 0;i<n;i++)...

Так как запись i < п означает то же самое, что и i <= (п - 1), цикл выполняется (п -1)-0+1 =п раз, как и требуется.

 

Обобщения в циклах for

    Несмотря на то, что все процедурные языки содержат циклы for, они значи­тельно отличаются по предоставляемым дополнительным возможностям. Две крайности — это Ada и С.

   В Ada исходным является положение, что цикл for должен использоваться только с фиксированным числом итераций и что это число можно вычислить перед началом цикла. Объясняется это следующим: 1) большинство реальных циклов простые, 2) другие конструкции легко запрограммировать в явном ви­де, и 3) циклы for и сами по себе достаточно трудны для тестирования и про­верки. В языке Ada нет даже классического обобщения: увеличения перемен­ной цикла на значения, отличные от 1 (или -1). Язык Algol позволяет написать итерации для последовательности нечетных чисел:

Algol

for I := 1 to N step 2 do ...

в то время как в Ada мы должны явно запрограммировать их вычисление:

for l in 1 .. (N + 1)/2 loop

Ada

|1 =2*|-1;



end loop;

В языке С все три элемента цикла for могут быть произвольными выражения­ми:

C

for(i=j*k;   (i<n)&&(j + k>m);    i + = 2*j)...

В описании С определено, что оператор

C

for (expression_1 ; expression_2; expression_3) statement;

эквивалентен конструкции

C

for(expression_1 ;

while (expression_2) {

             statement;

             expression_3;

}

    В языке Ada также допускается использование выражений для задания границ цикла, но они вычисляются только один раз при входе в цикл. То есть

Ada

for I in expression_1 ..

Если тело цикла изменяет значение


expression_2 loop

       statement;

end loop;

эквивалентно

I = expression_1;

Ada

Temp = expression_2;

while (I < Temp) loop

       statement;

        I: = I + 1;

end loop;

Если тело цикла изменяет значение переменных, используемых при вычис­лении выражения expression_2, то верхняя граница цикла в Ada изменяться не будет. Сравните это с данным выше описанием цикла for в языке С, кото­рый заново вычисляет значение выражения expression_2 на каждой итерации.

   Обобщения в языке С — нечто большее, чем просто «синтаксический сахар», поскольку операторы внутри цикла, изменяющие выражения expres-sion_2 и expression_3, могут вызывать побочные эффекты. Побочных эффек­тов следует избегать по следующим причинам.

• Побочные эффекты затрудняют полную проверку и тестирование цикла.

• Побочные эффекты неблагоприятно воздействуют на читаемость и под­держку программы.

•Побочные эффекты делают цикл гораздо менее эффективным, потому что выражения expression_2 и expression_3 нужно заново вычислять на каждой итерации. Если побочных эффектов нет, оптимизирующий ком­пилятор может вынести эти вычисления за границу цикла.

Реализация

Циклы for — наиболее часто встречающиеся источники неэффективности в программах, потому что небольшие различия в языках или небольшие изме­нения в использовании оператора могут иметь серьезные последствия. Во многих случаях оптимизатор в состоянии решить эти проблемы, но лучше их понимать и избегать, чем доверяться оптимизатору. В этом разделе мы более подробно опишем реализацию на уровне регистров.

   В языке Ada цикл

Ada

for I in expression_1 .. expression_2 loop

       statement;

end loop;

компилируется в

       compute                R1,expr_1

       store                      R1,l                    Нижняя граница индексации

       compute                R2,expr_2

       store                      R2,High             Верхняя граница индексации


это закрепление регистра за индексной


L1:  load                      R1,l                    Загрузить индекс

        load                      R2,High             Загрузить верхнюю границу

        jump_gt               R1,R2,L2           Завершить цикл, если больше

        statement                                        Тело цикла

        load                     R1,l                    Увеличить индекс

        incr                      R1

        store                    R1,l

        jump                    L1

L2:

Очевидная оптимизация — это закрепление регистра за индексной перемен­ной I и, если возможно, еще одного регистра за High:

       compute                R1 ,ехрг_1          Нижняя граница в регистре

       compute                R2,expr_2           Верхняя граница в регистре

L1:  jump_gt                 R1,R2,L2           Завершить цикл, если больше

       statement

        incr R1                                             Увеличить индексный регистр

       jump L1

 L2:

Рассмотрим теперь простой цикл в языке С:

C

for (i = expression_1 ; expression_2; i++)

 statement;

Это компилируется в

          compute        R1,expr_1

          store              R1,i                        Нижняя граница индексации

L1:    compute        R2,expr_2               Верхняя граница внутри цикла!

          jump_gt        R1,R2,L2                Завершить цикл, если больше

          statement                                      Тело цикла

          load              R1,i                          Увеличить индекс

          incr               R1

          store             R1,i

          jump             L1

L2:

     Обратите внимание, что выражение expression_2, которое может быть очень сложным, теперь вычисляется внутри цикла. Кроме того, выражение expres-sion_2 обязательно использует значение индексной переменной i, которая из­меняется при каждой итерации. Таким образом, оптимизатор должен уметь выделить неизменяющуюся часть вычисления выражения expression_2, что­бы вынести ее из цикла.


Можно ли хранить индексную переменную


     Можно ли хранить индексную переменную только в регистре для увеличения эффективности? Ответ зависит от двух свойств цикла. В Ada индексная пере­менная считается константой и не может изменяться программистом. В языке С индексная переменная — это обычная переменная; она может храниться в реги­стре только в том случае, когда абсолютно исключено изменение ее текущего значения где-либо вне цикла. Никогда не используйте глобальную переменную в качестве индексной переменной, потому что другая процедура может прочи­тать или изменить ее значение:

C

int i;

void p2(void) {

    i = i + 5;

}

void p1(void) {

       for (i=0; i<100; i++)                            /* Глобальная индексная переменная */

               p2();                                             /* Побочный эффект изменит индекс*/

}

Второе свойство, от которого зависит оптимизация цикла, — потенциальная возможность использования индексной переменной за пределами цикла. В Ada индексная переменная неявно объявляется for-оператором и недоступна за пределами цикла. Таким образом, независимо от того, как осуществляется выход из цикла, мы не должны сохранять значение регистра. Рассмотрим сле­дующий цикл поиска значения key в массиве а:

C

inta[100];

int i, key;

key = get_key();

for(i = 0;i< 100; i++)

      if (a[i] == key) break;

process(i);

   

     Переменная i должна содержать правильное значение независимо от спо­соба, которым был сделан выход из цикла. Это может вызывать затруднения при попытке оптимизировать код. Обратите внимание, что в Ada требуется явное кодирование для достижения того же самого результата, потому что ин­дексная переменная не существует вне области цикла:

Ada

Found: Integer := False;

for I in 1 ..100 loop

      if A(l) = Key then

          Found = I;

          exit;

      end if;

end loop;

Определение области действия индексов цикла в языке C++ с годами меня­лось, но конечное определение такое же, как в Ada: индекс не существует вне области цикла:


Индексная переменная является локальной для


for(int i=0;i<100;i++){

C++

                    // Индексная переменная является локальной для цикла

}

На самом деле в любом управляемом условием операторе (включая, if- и switch-операторы) можно задать в условии несколько объявлений; область их действия будет ограничена управляющим оператором. Это свойство может способствовать читаемости и надежности программы, предотвращая непред­намеренное использование временного имени.

6.5. «Часовые»

 

     Следующий раздел не касается языков программирования как таковых; ско­рее, он предназначен для того, чтобы показать, что программу можно улуч­шить за счет более совершенных алгоритмов и методов программирования, не прибегая к «игре» на языковых частностях. Этот раздел включен в книгу, по­тому что тема выхода из цикла при последовательном переборе является пред­метом интенсивных дебатов, однако существует и другой алгоритм, который является одновременно ясным, надежным и эффективным.

    В последнем примере предыдущего раздела (поиск в массиве) есть три ко­манды перехода в каждой итерации цикла: условный переход цикла for, услов­ный переход if-оператора и переход от конца цикла обратно к началу. Пробле­ма поиска в данном случае состоит в том, что мы проверяем сразу два условия: найдено ли значение key и достигнут ли конец массива? Используя «часового» (sentinel) *, мы можем два условия заменить одним. Идея состоит в том, чтобы ввести в начале массива дополнительно еще один элемент («часового») и хра­нить в нем эталонное значение key, которое нужно найти в массиве (рис. 6.4).

Языки программирования - концепции и принципы


Поскольку мы обязательно найдем key либо как элемент массива, либо как искусственно введенный элемент, постольку достаточно проверять только од­но условие внутри цикла:

Ada

type А_Туре is array(0 .. 100) of Integer;

                                          -- Дополнительное место в нулевой позиции для «часового»


Если при возврате управления из


 function Find_Key(A: A_Type; Key: Integer)

             return Integer is

          I:  Integer := 100;                                      -- Поиск с конца

 begin

       A(0) := Key;                                                -- Установить «часового»

       while A(l) /= Key loop

       I:=I-1;

       end loop;              

        return I;         

 end Find_Key;

  Если при возврате управления из функции значение I равно нулю, то Key в

 массиве нет; в противном случае I содержит индекс найденного значения.

 Этот код более эффективен, цикл чрезвычайно прост и может быть легко про-

 верен.

6.6. Инварианты

 

    Формальное определение семантики операторов цикла базируется на кон­цепции инварианта: формулы, которая остается истинной после каждого вы­полнения тела цикла. Рассмотрим предельно упрощенную программу для вы-     числения целочисленного деления а на b с тем, чтобы получить результат у:

у = 0;

C

х = а;

while (х >- b) {                                                               /* Пока b «входит» в х, */

х -= b;                                                                            /* вычитание b означает, что */

у++;                                                                              /* результат должен быть увеличен */

}

и рассмотрим формулу:

a = yb +х

где курсивом обозначено значение соответствующей программной перемен­ной. После операторов инициализации она, конечно, будет правильной, по­скольку у = 0 и х = а. Кроме того, в конце программы формула определяет, что у есть результат целочисленного деления а/b при условии, что остаток х мень­ше делителя b.

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

(у + \)b + (х-b)=уb+b+х-b=уb+х=а

     Таким образом, выполнение тела цикла переводит программу из состояния, которое удовлетворяет инварианту, в другое состояние, которое по-прежнему удовлетворяет инварианту.


Теперь заметим: для того чтобы


     Теперь заметим: для того чтобы завершить цикл, булево условие в цикле while должно иметь значение False, то есть вычисление должно быть в таком состоянии, при котором --(х > b), что эквивалентно х < b. Объединив эту фор­мулу с инвариантом, мы показали, что программа действительно выполняет целочисленное деление.

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

     Это делается следующим образом. Так как во время выполнения програм­мы b является константой (и предполагается положительной!), нам нужно по­казать, что неоднократное уменьшение х на b должно, в конечном счете, при­вести к состоянию, в котором 0 < х < b. Но 1) поскольку х уменьшается неод­нократно, его значение не может бесконечно оставаться больше значения b; 2) из условия завершения цикла и из вычисления в теле цикла следует, что х никогда не станет отрицательным. Эти два факта доказывают, что цикл дол­жен завершиться.

Инварианты цикла в языке Eiffel

Язык Eiffel имеет в себе средства для задания контрольных утверждений вооб­ще (см. раздел 11.5) и инвариантов циклов в частности:

from

         у = 0; х = а;

invariant

Eiffel

         а = yb + х

variant

          х

until

           x< b

 loop

           x :=x-b;

           у:=у+1;

 end

    Конструкция from устанавливает начальные условия, конструкция until зада­ет условие для завершения цикла, а операторы между loop и end образуют те­ло цикла. Конструкция invariant определяет инвариант цикла, а конструкция variant определяет выражение, которое будет уменьшаться (но останется неот­рицательным) с каждой итерацией цикла. Правильность инварианта проверя­ется после каждого выполнения тела цикла.

 

 

 

 

 

 

 

 

 

 

 

6.7.

В первоначальном описании языка Fortran


Операторы goto

 

В первоначальном описании языка Fortran был только один структурирован­ный управляющий оператор: оператор do, аналогичный циклу for. Все осталь­ные передачи управления делались с помощью условных или безусловных пе­реходов на метки, т. е. с помощью операторов, которые называются goto:

                          if(a.eq.b)goto12

                          …

                          goto 5

Fortan

4                        …

                          …

12                      …

                          …

5                        …

                          if (x .gt. y) goto 4

    В 1968 г. Э. Дейкстра написал знаменитое письмо, озаглавленное «оператор goto следует считать вредным», с которого началась дискуссия о структур­ном программировании. Основной аргумент против goto состоит в том, чтс произвольные переходы не структуированы и создают «программу-спагет­ти», в которой возможные пути выполнения так переплетаются, что ее не­возможно понять и протестировать. Аргументом в пользу goto является то что в реальных программах часто требуются более общие управляющие структуры, чем те, которые предлагают структурированные операторы, и чтс принуждение программистов использовать их приводит к искусственному и сложному коду.

    Оглядываясь назад, можно сказать, что эти дебаты были чересчур эмоцио­нальны и затянуты, потому что основные принципы совсем просты и не тре­буют долгого обсуждения. Более того, в современные диалекты языка Fortrar добавлены более совершенные операторы управления с тем, чтобы оператор goto больше не доминировал.

    Можно доказать математически, что достаточно if- и while-операторов чтобы записать любую необходимую управляющую структуру. Кроме того эти операторы легко понять и использовать. Различные синтаксические рас­ширения типа циклов for вполне ясны и при правильном использовании не представляют никаких трудностей для понимания или сопровождения про­граммы.

Так почему же языки программирования


Так почему же языки программирования (включая Ada, при разра­ботке которого исходили из соображений надежности) сохраняют goto?

    Причина в том, что есть несколько вполне определенных ситуаций, где лучше использовать goto. Во-первых, многие циклы не могут завершаться в точке входа, как того требует цикл while. Попытка превратить все циклы в циклы while может затемнить суть дела. В современных языках гибкости привносимой операторами exit и break, достаточно и оператор goto для этой цели обычно не нужен. Однако goto все еще существует и иногда может быть полезным. Обратите внимание, что как язык С, так и Ada, ограничивают при­менение goto требованием, чтобы метка находилась в той же самой процедуре.

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

    В языке С нет никаких средств для обработки этой ситуации (не подходит даже goto по причине ограниченности рамками отдельной процедуры), поэто­му для обработки серьезных ошибок нужно использовать средства операцион­ной системы. В языках Ada, C++ и Eiffel есть специальные языковые конст­рукции, так называемые исключения (exseptions), см. гл. 11, которые непосред­ственно решают эту проблему. Таким образом, операторы goto в большин­стве случаев были вытеснены по мере совершенствования языков.

Назначаемые goto-операторы

   В языке Fortran есть конструкция, которая называется назначаемым (assigned) оператором goto. Можно определить метку-переменную и присваивать ей значение той или иной конкретной метки.

переменной фактической целевой точкой перехода


При переходе по метке- переменной фактической целевой точкой перехода является значение этой переменной:

              assign 5 to Label

                   ...

Fortan

              if (x .gt. y) assign 6 to Label

5                 ...

6                 ...

               goto Label

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

     Хотя в других языках не существует назначаемого goto, такую конструк­цию совсем просто смоделировать, определяя много небольших подпрограмм и манипулируя передачами указателей на эти подпрограммы. Соотносить конкретный вызов с присвоением указателю значения, связывающего его с той или иной подпрограммой, достаточно трудно. Поэтому указатели на подпрограммы следует применять лишь в случаях хорошей структури­рованности, например в таблицах указателей на функции в интерпретаторах или в механизмах обратного вызова.

6.8. Упражнения

 

1. Реализует ваш компилятор все case-/switch-операторы одинаково, или он пытается выбирать оптимальную реализацию для каждого оператора?

2. Смоделируйте оператор repeat языка Pascal в Ada и С.

3. Первоначально в языке Fortran было определено, что цикл выполняет­ся, по крайней мере, один раз, даже если значение low больше, чем зна­чение high! Чем могло быть мотивировано такое решение?

4. Последовательный поиск в языке С:

C

         while (s[i].data != key)

                 i++;

       

 можно записать как

C

while (s[i++].data != key)

;                                                                     /* Пустой оператор */

В чем различие между двумя вариантами вычислений?

5. Предположим, что в языке Ada переменная индекса может существовать за рамками цикла. Покажите, как бы это воздействовало на оптимиза­цию цикла.


Сравните сгенерированный для поиска код,


6. Сравните сгенерированный для поиска код, реализованный с помощью операторов break или exit, с кодом, сгенерированным для поиска с «часовым».

7. Напишите программу поиска с «часовым», используя do-while вместо while. Будет ли это эффективнее?

8. Почему мы помещали «часового» в начало массива, а не в конец?

9. (Шолтен) В игре Го используют камни двух цветов, черные и белые. Предположим, что у вас в коробке неизвестная смесь камней, и вы вы­полняете следующий алгоритм:

while Stones_Left_in_Can loop                    -- пока есть камни в коробке              

Ada

       Remove_Two_Stones(S1, S2);               -- вынуть два камня

       if Color(S1 )=Color(S2) then

          Add_Black_Stone;                                --добавить черный камень

       else

          Add_White_Stone;                                 -- добавить белый камень

       end if;

end loop;

    Найдите переменную, значение которой уменьшается, оставаясь неотрицательным, и тем самым покажите, что цикл заканчивается. Мо­жете ли вы что-нибудь сказать относительно цвета последнего камня? (Подсказка: напишите инвариант цикла для числа белых камней).

 

Глава 7

 

Подпрограммы

 

 

 

 

7.1. Подпрограммы: процедуры и функции

 

Подпрограмма — это сегмент программы, к которому можно обратиться из любого места внутри программы. Подпрограммы используются по разным причинам:

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


это логическая единица декомпозиции программы.


• Подпрограмма — это логическая единица декомпозиции программы. Да­же если сегмент выполняется только один раз, полезно оформить его в виде подпрограммы с целью тестирования, документирования и улучше­ния читаемости программы.

• Подпрограмму также можно использовать как физическую единицу де­композиции программы, т. е. как единицу компиляции. В языке Fortran подпрограмма (subroutine) — это единственная единица и декомпозиции, и компиляции. В современных языках физической единицей декомпозиции является модуль, представляющий собой группу объявлений и подпрограмм (см. гл. 13).

Подпрограмма состоит из:

• объявления, которое задает интерфейс с подпрограммой; это объявление включает имя подпрограммы, список параметров (если есть) и тип воз­вращаемого значения (если есть);

• локальных объявлений, которые действуют только внутри тела подпро­граммы;

• последовательности выполняемых операторов.

   Локальные объявления и выполняемые операторы образуют тело под­программы.

Подпрограммы, которые возвращают значение, называются функциями (functions), а те, что не возвращают, — процедурами (procedures). Язык С не име­ет отдельного синтаксиса для процедур; вместо этого следует написать функ­цию, которая возвращает тип void, т.е. тип без значения:

C

void proc(int a, float b);

Такая функция имеет те же свойства, что и процедура в других языках, поэто­му мы используем термин «процедура» и при обсуждении языка С.

   

    Обращение к процедуре задается оператором вызова процедуры call. В языке Fortran он имеет специальный синтаксис:

C

call proc(x,y)

тогда как в других языках просто пишется имя процедуры с фактическими па­раметрами:

C

ргос(х.у);

   Семантика вызова процедуры следующая: приостанавливается текущая по­следовательность команд; выполняется последовательность команд внутри тела процедуры; после завершения тела процедуры выполнение продолжает­ся с первой команды, следующей за вызовом процедуры.

и их области действия, что


Это описание игно­рирует передачу параметров и их области действия, что будет объектом де­тального рассмотрения в следующих разделах.

   Так как функция возвращает значение, объявление функции должно опре­делять тип возвращаемого значения. В языке С тип функции задается в объяв­лении функции перед ее именем:

C

int func(int a, float b);

тогда как в языке Ada используется другой синтаксис:

Ada

function Func(A: Integer; В: Float) return Integer;

Вызов функции является не оператором, а элементом выражения:

C

a = x + func(r,s) + y;

    Тип результата функции не должен противоречить типу, ожидаемому в выра­жении. Обратите внимание, что в языке С во многих случаях делаются неяв­ные преобразования типов, тогда как в Ada тип результата должен точно соот­ветствовать контексту. По смыслу вызов функции аналогичен вызову проце­дуры: приостанавливается вычисление выражения; выполняются команды тела функции; затем возвращенное значение используется для продолжения вычисления выражения.

    Термин «функция» фактически совершенно не соответствует тому контек­сту, в котором он употребляется в обычных языках программирования. В математике функция — всего лишь отображение одного набора значений на дру­гой. Если использовать техническую терминологию, то математическая фун­кция не имеет побочного эффекта, потому что ее «вычисление» прозрачно в точке, в которой делается «вызов». Если есть значение 3.6, и вы запрашива­ете значение sin(3.6), то вы будете получать один и тот же результат всякий раз, когда в уравнении встретится эта функция. В программировании функ­ция может выполнять произвольное вычисление, включая ввод-вывод или изменение глобальных структур данных:

int x,y,z;

C

intfunc(void)

{

          у = get();                                /* Изменяет глобальную переменную */

          return x*y;                             /* Значение зависит от глобальной переменной */


Если оптимизатор изменил порядок вычисления


z = х + func(void) + у;

Если оптимизатор изменил порядок вычисления так, что х + у вычисляется перед вызовом функции, то получится другой результат, потому что функция изменяет значение у.

    Поскольку все подпрограммы в С — функции, в программировании на языке С широко используются возвращаемые значения и в «невычисли­тельных» случаях, например в подпрограммах ввода-вывода. Это допустимо при условии, что понятны возможные трудности, связанные с зависимостью от порядка и оптимизацией. Исследование языков программирования приве­ло к разработке интереснейших языков, которые основаны на математически правильном понятии функции (см. гл. 16).

7.2. Параметры

 

    В предыдущем разделе мы определили подпрограммы как сегменты кода, ко­торые можно неоднократно вызывать. Практически всегда при вызове требу­ется выполнять код тела подпрограммы для новых данных. Способ повлиять на выполнение тела подпрограммы состоит в том, чтобы «передать» ей необ­ходимые данные. Данные передаются подпрограмме в виде последовательно­сти значений, называемых параметрами. Это понятие взято из математики, где для функции задается последовательность аргументов: sin (2piК). Есть два понятия, которые следует четко различать:

• Формальный параметр — это объявление, которое находится в объявле­нии подпрограммы. Вычисление в теле подпрограммы пишется в.терми-нах формальных параметров.

• Фактический параметр — это значение, которое вызывающая программа передает подпрограмме.

В следующем примере:

int i,,j;

char а;

void p(int a, char b)

C

{

        i = a + (int) b;

}

P(i,a);

P(i+j, 'x');

формальными параметрами подпрограммы р являются а и b, в то время как фактические параметры при первом вызове — это i и а, а при втором вызове — i + j и 'х'.

    На этом примере можно отметить несколько важных моментов. Во-пер­вых, так как фактические параметры являются значениями, то они могут быть константами или выражениями, а не только переменными.

менная используется как параметр, на


Даже когда пере­ менная используется как параметр, на самом деле подразумевается «текущее значение, хранящееся в переменной». Во-вторых, пространство имен у раз­ных подпрограмм разное. Тот факт, что первый формальный параметр назы­вается а, не имеет отношения к остальной части программы, и этот параметр может быть переименован, при условии, конечно, что будут переименованы все вхождения формального параметра в теле подпрограммы. Переменная а, объявленная вне подпрограммы, полностью независима от переменной с та­ким же именем, объявленной внутри подпрограммы. В разделе 7.7 мы более подробно рассмотрим связь между переменными, объявленными в разных подпрограммах.

 

 

 

Установление соответствия параметров

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

Ada

procedure Proc(First: Integer; Second: Character);

 Proc(24, 'X');

   Однако в языке Ada при вызове возможно использовать установление соответствия по имени, когда каждому фактическому параметру предшеству­ет имя формального параметра. Следовать порядку объявления параметров при этом не обязательно:

Ada

Ada Proc(Second => 'X', First => 24);

Обычно этот вариант используется вместе с параметрами по умолчанию, при­чем параметры, которые не написаны явно, получают значения по умолча­нию, заданные в объявлении подпрограммы:

Ada

procedure Proc(First: Integer := 0; Second: Character := '*');

Proc(Second => 'X');

Соответствие по имени и параметры по умолчанию обычно используются в командных языках операционных систем, где каждая команда может иметь множество параметров и обычно необходимо явно изменить только некото­рые из них. Однако этот стиль программирования таит в себе ряд опасностей. Использование параметров по умолчанию может сделать программу труд­ной для чтения, потому что синтаксически отличающиеся обращения факти­чески вызывают одну и ту же подпрограмму.

ся проблематичным, потому что при


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

Ada

X:=Proc_1 (Y) + Proc_2(Z);

Однако если вы используете именованные параметры, то, возможно, вам при­дется сильно изменить свою программу, чтобы установить соответствие но­вым именам параметров:

Ada

X := Proc_1(Parm => Y) + Proc_2(Parm => Z);

7.3. Передача параметров подпрограмме

 

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

    Давайте начнем с данного выше определения: значение фактического па­раметра передается формальному параметру. Формальный параметр — это просто переменная, которая объявлена внутри подпрограммы, поэтому, оче­видно, нужно копировать значение фактического параметра в то место памя­ти, которое выделено для формального параметра. Этот механизм называется

Языки программирования - концепции и принципы


«семантикой copy-in» («копирование в») или «вызовом по значению» (call-by-value). На рисунке 7.1 показана семантика copy-in для процедуры:

procedure Proc(F: in Integer) is

begin

Ada

     ... ;

end Proc;

 и вызова:

Ada

Proc(2+3*4);

Преимущества семантики copy-in:

• Copy-in является самым надежным механизмом передачи параметров. Поскольку передается только копия фактического параметра, подпро­грамма никак не может испортить фактический параметр, который, не­сомненно, «принадлежит» вызывающей программе. Если подпрограмма изменяет формальный параметр, изменяется только копия, а не ориги­нал.


Фактические параметры могут быть константами,


• Фактические параметры могут быть константами, переменными или вы­ражениями.

• Механизм copy-in может быть очень эффективным, потому что началь­ные затраты на копирование делаются один раз, а все остальные обраще­ния к формальному параметру на самом деле являются обращениями к локальной копии. Как мы увидим в разделе 7.7, обращение к локальным переменным чрезвычайно эффективно.

    Если семантика copy-in настолько хороша, то почему существуют другие ме­ханизмы? дело в том, что часто мы хотим изменить фактический параметр, несмотря на тот факт, что такое изменение «небезопасно»:

• Функция возвращает только один результат, но, если результат вычис­ления достаточно сложен, может возникнуть желание вернуть несколь­ко значений. Чтобы сделать это, необходимо задать в процедуре не­сколько фактических параметров, которым могут быть присвоены ре­зультаты вычисления. Обратите внимание, что этого часто можно избе­жать, определив функцию, которая возвращает в качестве результата за­пись.

• Кроме того, цель выполнения подпрограммы может состоять в моди­фикации данных, которые ей передаются, а не в их вычислении. Обычно это происходит, когда подпрограмма обрабатывает структуру данных. Например, подпрограмма, сортирующая массив, не вычисляет значение; ее цель состоит только в том, чтобы изменить фактический параметр. Нет никакого смысла сортировать копию массива!

• Параметр может быть настолько большим, что копировать его неэффек­тивно. Если copy-in используется для массива из 50000 целых чисел, мо­жет просто не хватить памяти, чтобы сделать копию, или затраты на ко­пирование будут чересчур большими.

   Первые две ситуации легко разрешить с помощью семантики copy-out («копирование из»). Фактический параметр должен быть переменной, а под­программе передается адрес фактического параметра, который она сохраняет. Для формального параметра используется временная локальная переменная, и значение должно быть присвоено формальному параметру, по крайней ме­ре, один раз во время выполнения подпрограммы.

зывает сохраненный адрес. На рисунке


Когда выполнение под­программы завершено, значение копируется в переменную, на которою ука­ зывает сохраненный адрес. На рисунке 7.2 показана семантика copy-out для следующей подпрограммы:

procedure Proc(F: out Integer) is

begin

Ada

     F := 2+3*4;                                          -- Присвоение параметру

end Proc;

A: Integer;

Proc(A);                                                     -- Вызов процедуры с переменной

Когда нужно модифицировать фактический параметр, как, например, в sort, можно использовать семантику copy-in/out фактический параметр копирует-

Языки программирования - концепции и принципы


ся в подпрограмму, когда она вызывается, а результирующее значение копи­руется обратно после ее завершения.

Однако механизмы передачи параметров на основе копирования не могут решить проблему эффективности, связанную с «большими» параметрами. Ре­шение, которое известно как «вызов по ссылке» (call-by-reference) или «семан­тика ссылки» (reference cemantics), состоит в том, чтобы передать адрес факти­ческого параметра и обращаться к параметру косвенно (см. рис. 7.3). Вызов подпрограммы эффективен, потому что для каждого параметра передается только указатель небольшого, фиксированного размера; однако обращение к параметру может оказаться неэффективным из-за косвенности.

Чтобы получить доступ к фактическому параметру, нужно загрузить его ад­рес, а затем выполнить дополнительную команду для загрузки значения. Обра­тите внимание, что при использовании семантики ссылки (или copy-out), фактический параметр должен быть переменной, а не выражением, так как ему будет присвоено значение.

Другая проблема, связанная с вызовом по ссылке, состоит в том, что может возникнуть совмещение имен (aliasing), т. е. может возникнуть ситуация, в ко­торой одна и та же переменная известна под несколькими именами.

Языки программирования - концепции и принципы


В следующем примере внутри функции f переменная global получает алиас (т. е. альтернативное имя) *parm:

C

int global = 4;

 inta[10];


В этом примере, если выражение


int f(int *parm)

 {

          *parm = 5:                                        /* Та же переменная, что и "global" */

            return 6;

}

х = a[global] + f(&global);

   В этом примере, если выражение вычисляется в том порядке, в котором оно записано, его значение равно а[4] + 6, но из-за совмещения имен значение выражения может быть 6 + а[5], если компилятор при вычислении выражения выберет порядок, при котором вызов функции предшествует индексации массива. Совмещение имен часто приводит к непереносимости программ.

   Реальный недостаток «вызова по ссылке» состоит в том, что этот механизм по сути своей ненадежен. Предположим, что по некоторым причинам под­программа считает, что фактический параметр — массив, тогда как реально это всего лишь одно целое число. Это может привести к тому, что будет затер­та некоторая произвольная область памяти, так как подпрограмма работает с фактическим параметром, а не просто с локальной копией. Этот тип ошибки встречается очень часто, потому что подпрограмма обычно пишется не тем программистом, который разрабатывает вызывающую программу, и всегда возможно некоторое недопонимание.

   Безопасность передачи параметров можно повысить с помощью строгого контроля соответствия типов, который гарантирует, что типы формальных и фактических параметров совместимы. Однако все еще остается возможность недопонимания между тем программистом, кто написал подпрограмму, и тем, чьи данные модифицируются. Таким образом, мы имеем превосходный механизм передачи параметров, который не всегда достаточно эффективен (семантика copy-in), а также необходимые, но ненадежные механизмы (семантика copy-out и семантика ссылки). Выбор усложняется ограничения­ми, которые накладывают на программиста различные языки программиро­вания. Теперь мы подробно опишем механизмы передачи параметров для не­скольких языков.

Параметры в языках С и C++

В языке С есть только один механизм передачи параметров — copy-in:


Чтобы получить функциональные возможности семантики


int i = 4;                                                                  /* Глобальная переменная */                 

C

void proc(int i, float f)

{

i=i+(int) f;                                                           /* Локальная переменная "i" */

}

proc(j, 45.0);                                                      /* Вызов функции */

В ргос изменяемая переменная i является локальной копией, а не глобальной переменной i.

Чтобы получить функциональные возможности семантики ссылки или copy-out, пишущий на С программист должен прибегать к явному использо­ванию указателей:

int i = 4; /* Глобальная переменная */                          [с]

void proc(int *i, float f)

{

*i = *i+ (int) f; /* Косвенный доступ */

}

proc(&i, 45.0); /* Понадобилась операция получения адреса */

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

    В языке C++ этот недостаток устранен, поскольку в нем есть возможность задавать параметры специального ссылочного типа (reference parameters):

int i = 4;                                             // Глобальная переменная

C++

void proc(int & i, float f)

{

       i = i + (int) f;                             // Доступ по ссылке

}

proc(i, 45.0);                                     // He нужна операция получения адреса

   Обратите внимание на естественность стиля программирования, при ко­тором нет неестественного использования указателей. Это усовершенствова­ние механизма передачи параметров настолько важно, что оправдывает использование C++ в качестве замены С.

   Вам часто придется применять указатели в С или ссылки в C++ для пере­дачи больших структур данных. Конечно, в отличие от копирования парамет­ров (copy-in), существует опасность случайного изменения фактического па­раметра.

Можно задать для параметра доступ


Можно задать для параметра доступ только для чтения, объявив его константой:

void proc(const Car_Data & d)

{

     d.fuel = 25;                                          // Ошибка, нельзя изменять константу

}

    Объявления const следует использовать возможно шире, как для того, чтобы сделать смысл параметров более прозрачным для читателей программы, так и для того, чтобы отлавливать возможные ошибки.

    Другая проблема, связанная с параметрами в языке С, состоит в том, что массивы не могут быть параметрами. Если нужно передать массив, передает­ся адрес первого элемента массива, а процедура отвечает за правильный до­ступ к массиву. Для удобства имя массива в качестве параметра автоматически рассматривается как указатель на первый элемент:

intb[50];                                           /* Переменная типа массив */                           

C

void proc(int a[ ])                            /* "Параметр-массив" */

{

а[100] = а[200];                             /* Сколько элементов? */

 }

proc(&b[0]);                                 /* Адрес первого элемента */

proc(b);                                         /* Адрес первого элемента */

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

int i;

void proc(int a[ ]);                     /* "Параметр-массив" */

proc(&i);                                   /* Допустим любой указатель на целое число!! */

Наконец, в языке С контроль соответствия типов никак не действует между файлами, поэтому можно в одном файле поместить

 

C

[С] void proc(float f) { ...}                           /* Описание процедуры */    

                        

а в другом файле —

C

void proc(int i);                                           /* Объявление процедуры */                           ргос(100);


требует выполнения контроля соответствия типов


а затем месяцами искать ошибку.

    Язык C++ требует выполнения контроля соответствия типов для парамет­ров. Однако он не требует, чтобы реализации включали библиотечные средст­ва, как в Ada (см. раздел 13.3), которые могут гарантировать контроль соответ­ствия типов для независимо компилируемых файлов. Компиляторы C++ вы­полняют контроль соответствия типов вместе с компоновщиком: типы пара­метров шифруются во внешнем имени подпрограммы (процесс называется name mangling), а компоновщик следит за тем, чтобы связывание вызовов с программами делалось только в случае корректной сигнатуры параметров. К сожалению, этот метод не может охватывать все возможные случаи несоответ­ствия типов.

 

 

 

 

 

 

 

 

 

 

 

 

Параметры в языке Pascal

В языке Pascal параметры передаются по значению, если явно не задана пере­дача по ссылке:

Pascal

procedure proc(P_lnput: Integer; var P_0utput: Integer);

Ключевое слово var указывает, что параметр вызывается по ссылке, в про­тивном случае используется вызов по значению, даже если параметр очень большой. Параметры могут быть любого типа, включая массивы, записи или другие сложные структуры данных. Единственное ограничение состоит в том, что тип результата функции должен быть скалярным. Типы фактиче­ских параметров проверяются на соответствие типам формальных парамет­ров.

   Как мы обсуждали в разделе 5.3, в языке Pascal есть серьезная проблема, связанная с тем, что границы массива рассматриваются как часть типа. Для решенения этой проблемы стандарт Pascal определяет совместимые парамет­ры массива (conformant array parameters).

 

Параметры в языке Ada

В языке Ada принят новый подход к передаче параметров. Она определяется в терминах предполагаемого использования, а не в терминах механизма реали­зации. Для каждого параметра нужно явно выбрать один из трех возможных режимов.


Параметр можно читать, но не


in      — Параметр можно читать, но не писать

              (значение по умолчанию).

out    — Параметр можно писать, но не читать.

in out — Параметр можно как читать, так и писать.

Например:

Ada

procedure Put_Key(Key: in Key_Type);

procedure Get_Key(Key: out Key_Type);

procedure Sort_Keys(Keys: in out Key_Array);

В первой процедуре параметр Key должен читаться с тем, чтобы его можно было «отправить» (Put) в структуру данных (или на устройство вывода). Во второй значение получено (Get) из структуры данных, а после завершения процедуры значение присваивается параметру. Массив Keys, который нужно отсортировать, должен быть передан как in out, потому что сортировка вклю­чает и чтение, и запись данных массива.

   Для функций в языке Ada разрешена передача параметров только в режи­ме in. Это не делает функции Ada функциями без побочных эффектов, потому что нет никаких ограничений на доступ к глобальным переменным; но это может помочь оптимизатору увеличить эффективность вычисления выраже­ния.

   Несмотря на то что режимы определены не в терминах механизмов реали­зации, язык Ада определяет некоторые требования по реализации. Парамет­ры элементарного типа (числа, перечисления и указатели) должны переда­ваться соответствующим копированием: copy-in для in-параметров, copy-out для out-параметров и copy-in/out для in-out-параметров. Реализация режимов для составных параметров (массивов и записей) не определена, и компилятор может выбрать любой механизм. Это приводит к тому, что правильность про­граммы в Ada может зависеть от выбранного механизма реализации, поэтому такие программы непереносимы.

   Между формальными и фактическими параметрами делается строгий кон­троль соответствия типов. Тип фактического параметра должен быть таким же, как и у формального; неявное преобразование типов никогда не выполня­ется. Однако, как мы обсуждали в разделе 5.3, подтипы не обязаны быть иден­тичными, пока они совместимы; это позволяет передавать произвольный массив формальному неограниченному параметру.


Мы вкратце коснемся передачи параметров


Параметры в языке Fortran

Мы вкратце коснемся передачи параметров в языке Fortran, потому что здесь возможны эффектные ошибки. Fortran может передавать только скалярные значения; интерпретация формального параметра, как массива, выполняется вызванной подпрограммой. Для всех параметров используется передача пара­метра по ссылке. Более того, каждая подпрограмма компилируется независи­мо, и не делается никакой проверки на совместимость между объявлением подпрограммы и ее вызовом.

   В языке определено, что если делается присваивание формальному пара­метру, то фактический параметр должен быть переменной, но из-за независи­мой компиляции это правило не может быть проверено компилятором. Рас­смотрим следующий пример:

Subroutine Sub(X, Y)

Fortran

Real X,Y

X=Y

End

Call Sub(-1.0,4.6)

У подпрограммы два параметра типа Real. Поскольку используется семанти­ка ссылки, Sub получает указатели на два фактических параметра, и присваи­вание выполняется непосредственно для фактических параметров (см. рис. 7.4). В результате область памяти, где хранится значение -1,0, изменяется! Без преувеличения можно сказать, что выявить и устранить эту ошибку буквально

Языки программирования - концепции и принципы


нет никаких средств, так как отладчики позволяют проверять и отслеживать только переменные, но не константы. Как показывает практика, правильное соответствие фактических и формальных параметров — краеугольный камень надежного программирования.

7.4. Блочная структура

 

Блок — это объект, состоящий из объявлений и выполняемых операторов. Аналогичное определение было дано для тела подпрограммы, и точнее будет сказать, что тело подпрограммы — это блок. Блоки вообще и процедуры в ча­стности могут быть вложены один в другой. В этом разделе будут обсуждаться взаимосвязи вложенных блоков.

    Блочная структура была сначала определена в языке Algol, который включает как процедуры, так и неименованные блоки.

В языке Pascal есть вложенные


В языке Pascal есть вложенные процедуры, но нет неименованных блоков; в С есть неимено­ванные блоки, но нет вложенных процедур; a Ada поддерживает и то, и дру­гое.

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

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

   Ниже приведен пример полной Ada-программы:

procedure Mam is                                                                                      

      Global: Integer;

       procedure Proc(Parm: in Integer) is

Ada

              Local: Integer;

       begin

               Global := Local + Parm;

       end Proc;

begin -- Main

    Global := 5;

     Proc(7);

     Proc(8);

end Main;

Ada-программа — это библиотечная процедура, то есть процедура, которая не включена внутрь никакого другого объекта и, следовательно, может хра­ниться в Ada-библиотеке. Процедура начинается с объявления процедуры Main, которое служит описанием интерфейса процедуры, в данном случае внешним именем программы. Внутри библиотечной процедуры есть два объ­явления: переменной Global и процедуры Ргос. После объявлений располага­ется последовательность исполняемых операторов главной процедуры. Дру­гими словами, процедура Main состоит из объявления процедуры и блока. Точно так же локальная процедура Ргос состоит из объявления процедуры (имени процедуры и параметров) и блока, содержащего объявления перемен­ных и исполняемые операторы.

процедура локальная для Main или


Говорят, что Ргос — процедура локальная для Main или вложенная внутри Main.

   С каждым объявлением связаны три свойства.

 

Область действия. Область действия переменной — это сегмент програм­мы, в котором она определена.

 

Видимость. Переменная видима внутри некоторого подсегмента области действия, если к ней можно непосредственно обращаться по имени.

Время жизни. Время жизни переменной — это период выполнения про­граммы, в течение которого переменной выделена память.

   Обратите внимание, что время жизни — динамическая характеристика по­ведения программы при выполнении, в то время как область действия и види­мость касаются исключительно статического текста программы.

   Продемонстрируем эти абстрактные определения на приведенном выше примере. Область действия переменной начинается в точке объявления и за­канчивается в конце блока, в котором она определена. Область действия пе­ременной Global включает всю программу, тогда как область действия пере­менной Local ограничена отдельной процедурой. Формальный параметр Раrm рассматривается как локальная переменная, и его область действия также ог­раничена процедурой.

   Видимость каждой переменной в этом примере идентична ее области дей­ствия; к каждой переменной можно непосредственно обращаться во всей ее области действия. Поскольку область действия и видимость переменной Local ограничены локальной процедурой, следующая запись недопустима:

Ada

begin — Main

Global := Local + 5;                                       -- Local здесь вне области действия

end Main;

Однако область действия переменной Global включает локальную проце­дуру, поэтому обращение внутри процедуры корректно:

procedure Proc(Parm: in Integer) is

     Local: Integer;

begin

      Global := Local + Parm;                         --Global здесь в области действия

end Proc;

Время жизни переменной — от начала выполнения ее блока до конца выпол­нения этого блока.

ная Global существует на протяжении


Блок процедуры Main — вся программа, поэтому перемен­ ная Global существует на протяжении выполнения программы. Такая пере­менная называется статической: после того как ей отведена память, она су­ществует до конца программы. Локальная переменная имеет два времени жизни, соответствующие двум вызовам локальной процедуры. Так как эти интервалы не перекрываются, переменной каждый раз можно выделять но­вое место памяти. Локальные переменные называются автоматическими, по­тому что память для них автоматически выделяется при вызове процедуры (при входе в блок) и освобождается при возврате из процедуры (при выходе из блока).

 

 

 

 

 

 

 

 

 

 

 

 

 

Скрытые имена

Предположим, что имя переменной, которое используется в главной про­грамме, повторяется в объявлении в локальной процедуре:

procedure Mam is

        Global: Integer;

        V: Integer;                                                      -- Объявление в Main

procedure Proc(Parm: in Integer) is

        Local: Integer;

        V: Integer;                                                      -- Объявление в Proc

begin

      Global := Local + Parm + V;                            -- Какое именно V используется?

end Proc;

begin -- Main

      Global := Global + V;                                        -- Какое именно V используется?

end Main;

     В этом случае говорят, что локальное объявление скрывает (или перекрывает) глобальное объявление. Внутри процедуры любая ссылка на V является ссылкой на локально объявленную переменную. С технической точки зрения область действия глобальной переменной V простирается от точки объявления до конца Main, но она невидима в локальной процедуре Ргос.

   Скрытие имен переменных внутренними объявлениями удобно тем, что программист может многократно использовать естественные имена типа Current_Key и не должен изобретать странно звучащие имена.

Кроме того, всегда можно добавить


Кроме того, всегда можно добавить глобальную переменную, не беспокоясь о том, что ее имя совпадет с именем какой-нибудь локальной переменной, которое ис­пользуется одним из программистов вашей группы. Недостаток же состоит в том, что имя переменной могло быть случайно перекрыто, особенно если ис­пользуются большие включаемые файлы для централизации глобальных объ­явлений, поэтому, вероятно, лучше избегать перекрытия имен переменных. Однако нет никаких возражений против многократного использования име­ни в разных областях действия, так как нельзя получить доступ к обеим пере­менным одновременно независимо от того, являются имена одинаковыми или разными:

procedure Main is

Ada

   procedure Proc_1 is

               Index: Integer;                                       -- Одна область действия

               …

   endProc_1;  

     procedure Proc_2 is

         Index: Integer;                                             -- Неперекрывающаяся область действия

             …

         end Proc_2;

begin – Main



end Main;

Глубина вложения

Принципиальных ограничений на глубину вложения нет, но ее может произ­вольно ограничивать компилятор. Область действия и видимость определя­ются правилами, данными выше: область действия переменной — от точки ее объявления до конца блока, а видимость — такая же, если только не скрыта внутренним объявлением. Например:

procedure Main is

Ada

     Global: Integer;

     procedure Level_1 is

      Local: Integer;                                      -- Внешнее объявление Local

procedure Level_2 is

            Local: Integer;                                --Внутреннее объявление Local

       begin -- Level_2

            Local := Global;                             -- Внутренняя Local скрывает внешнюю Local

       end Level_2;

begin -- Level_1

     Local := Global;                                     -- Только внешняя Local в области действия


Область действия переменной Local, определенной


     Level_2:

end Level_1;

begin -- Main

    Level_1;

    Level_2;                                                   -- Ошибка, процедура вне области действия

end Main;

Область действия переменной Local, определенной в процедуре Level_1, про­стирается до конца процедуры, но она скрыта внутри процедуры Level_2 объ­явлением того же самого имени.

    Считается, что сами объявления процедуры имеют область действия и ви­димость, подобную объявлениям переменных. Таким образом, область дейст­вия Level_2 распространяется от ее объявления в Level_1 до конца Level_1. Это означает, что Level_1 может вызывать Level_2, даже если она не может обра­щаться к переменным внутри Level_2. С другой стороны, Main не может не­посредственно вызывать Level_2, так как она не может обращаться к объявле­ниям, которые являются локальными для Level_1.

    Обратите внимание на возможность запутаться из-за того, что обращение к переменной Local в теле процедуры Level_1 отстоит от объявления этой переменной дальше по тексту программы, чем объявление Local, заключенной внутри процедуры Level_2. В случае многочисленных локальных процедур найти правильное объявление бывает трудно. Чтобы избежать путаницы, луч­ше всего ограничить глубину вложения двумя или тремя уровнями от уровня главной программы.

Преимущества и недостатки блочной структуры

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

procedure Proc(...) is

                 -- Большое количество объявлений

begin

    -- Длинное вычисление 1

Ada

if N < 0 then

               -- Длинное вычисление 2, вариант 1


В этом примере мы хотели


elsif N = 0 then

               -- Длинное вычисление 2, вариант 2

else

               -- Длинное вычисление 2, вариант 3

end if;

      -- Длинное вычисление 3

end Proc;

В этом примере мы хотели бы не записывать три раза Длинное вычисление 2, а оформить его как дополнительную процедуру с одним параметром:

procedure Proc(...) is

           -- Большое количество объявлений

          procedure Long_2(l: in Integer) is

          begin

                 -- Здесь действуют объявления Proc

Ada

          end Long_2;

begin

     -- Длинное вычисление 1

if N<0thenl_ong_2(1);

elsif N = 0 then Long_2(2);

else Long_2(3);

end if;

     -- Длинное вычисление З

end Proc;

Однако было бы чрезвычайно трудно сделать Long_2 независимой процеду­рой, потому что пришлось бы передавать десятки параметров, чтобы она мог­ла обращаться к локальным переменным. Если Long_2 — вложенная процеду­ра, то нужен только один параметр, а к другим объявлениям можно непосред­ственно обращаться в соответствии с обычными правилами для области дей­ствия и видимости.

    Недостатки блочной структуры становятся очевидными, когда вы пыта­етесь запрограммировать большую систему на таком языке, как стандарт Pascal, в котором нет других средств декомпозиции программы.

• Небольшие процедуры получают чрезмерную «поддержку». Предполо­жим, что процедура, преобразующая десятичные цифры в шестнадцате-ричные, используется во многих глубоко вложенных процедурах. Такаясервисная процедура должна быть определена в некотором общем пред­шествующем элементе. На практике в больших программах с блочной структурой проявляется тенденция появления большого числа неболь­ших сервисных процедур, описанных на самом высоком уровне объявле­ний. Это делает текст программы неудобным для работы, потому что нужную программу бывает просто трудно разыскать.

• Защита данных скомпрометирована. Любая процедура, даже та, объявле­ние которой в структуре глубоко вложено, может иметь доступ к глобаль­ным переменным.

В большой программе, разрабатываемой группой


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

    Эти проблемы настолько серьезны, что каждая коммерческая реализация Pascal определяет (нестандартную) структуру модуля, чтобы иметь возмож­ность создавать большие проекты. В главе 13 мы подробно обсудим конструк­ции, которые применяются для декомпозиции программы в таких современ­ных языках, как Ada и C++. Однако блочная структура остается важным инс­трументом детализированного программирования отдельных модулей.

    Понимать блочную структуру важно также и потому, что языки програм­мирования реализованы с использованием стековой архитектуры, которая непосредственно поддерживает блочную структуру (см. раздел 7.6).

 

7.5. Рекурсия

 

Чаще всего (процедурное) программирование использует итерации, то есть циклы; однако рекурсия — описание объекта или вычисления в терминах са­мого себя — является более простым математическим понятием, а также мощ­ной, но мало используемой техникой программирования. Здесь мы рассмот­рим, как программировать рекурсивные подпрограммы.

   Наиболее простой пример рекурсии — функция, вычисляющая фактори­ал. Математически она определяется как:

0! = 1

n! = п х (п - 1)!

Это определение сразу же переводится в программу, которая использует рекурсивную функцию:

int factorial(int n)

C

{

if (n == 0) return 1 ;

else return n * factorial(n - 1);

}

Какие свойства необходимы для поддержки рекурсии?

• Компилятор должен выдавать чистый код. Так как при каждом обраще­нии к функции factorial используется одна и та же последовательность машинных команд, код не должен изменять сам себя.

• Должна существовать возможность выделять во время выполнения про­извольное число ячеек памяти для параметров и локальных переменных.


Первое требование выполняется всеми современными


  Первое требование выполняется всеми современными компиляторами. Самоизменяющийся код — наследие более старых стилей программирования и используется редко. Обратите внимание, что если программа предназначена для размещения в постоянной памяти (ROM), то она не может изменяться по определению.

  Второе требование определяется временем жизни локальных переменных. В примере время жизни формального параметра n — с момента, когда проце­дура вызвана, до ее завершения. Но до завершения процедуры делается еще один вызов, и этот вызов требует, чтобы была выделена память для нового формального параметра. Чтобы вычислять factorial(4), выделяется память для 4, затем 3 и т. д., всего пять ячеек. Нельзя выделить память перед выполнени­ем, потому что ее количество зависит от параметра функции во время выпол­нения. В разделе 7.6 показано, как это требование выделения памяти непо­средственно поддерживается стековой архитектурой.

  

   Большинство программистов обратит внимание, что функцию, вычисляю­щую факториал, можно написать так же легко и намного эффективнее с по­мощью итерации:

int factorial(int n)

{

C

       int i = n;

       result = 1;

       while (i != 0) {

             result = result * i;

                    i--;

}

               return result;

}

Так почему же используют рекурсию? Дело в том, что многие алгоритмы мож­но изящно и надежно написать с помощью рекурсии, в то время как итераци­онное решение трудно запрограммировать и легко сделать ошибки. Приме­ром служат алгоритм быстрой сортировки и алгоритмы обработки дре­вовидных структур данных. Языковые понятия, рассматриваемые в гл. 16 и 17 (функциональное и логическое программирование), опираются исключительно на рекурсию, а не на итерацию. Даже для обычных языков типа С и Ada рекурсию, вероятно, следует использовать более часто, чем это делается, из-за краткости и ясности программ, которые получаются в результате.


это структура данных, которая принимает


7.6. Стековая архитектура

 

Стек — это структура данных, которая принимает и выдает данные в порядке LIFO — Last-In, First-Out (последним пришел, первым вышел). Конструкции LIFO существуют в реальном мире, например стопка тарелок в кафетерии или пачка газет в магазине. Стек может быть реализован с помощью массива или списка (см. рис. 7.5). Преимущество списка в том, что он не имеет границ, а его размер ограничен только общим объемом доступной памяти. Массивы же намного эффективнее и неявно используются при реализации языков про­граммирования.

Языки программирования - концепции и принципы


    Кроме массива (или списка) в состав стека входит еще один элемент — указатель вершины стека (top-of-stack pointer). Это индекс первой доступной пустой позиции в стеке. Вначале переменная top будет указывать на первую позицию в стеке. На стеке допустимы две операции — push (поместить в стек) и pop (извлечь из стека), push — это процедура, получающая элемент как па­раметр, который она помещает в вершину стека, увеличивая указатель вершины стека top. pop — это функция, которая возвращает верхний элемент стека, уменьшая top, чтобы указать, что эта позиция стала новой пустой пози­цией.

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

C

#define Stack_Size 100

int stack[Stack_Size];

int top = 0;

void push(int element)

{

              if (top == Stack_Size)                                     /* Переполнение стека, предпримите

                                                                                        что-нибудь! * I

else stack[top++] = element;

 }

int pop(void)

{

if (top == 0)                                                            /* Выход за нижнюю границу стека,

                                                                                 предпримите то-нибудь! */

else return stack[--top];

 }

Выход за нижнюю границу стека произойдет, когда мы попытаемся извлечь элемент из пустого стека, а переполнение стека возникнет при попытке поме­стить элемент в полный стек.

Выход за нижнюю границу стека


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

 

 

Выделение памяти в стеке

Как используется стек при реализации языка программирования? Стек нужен для хранения информации, касающейся вызова процедуры, включая локаль­ные переменные и параметры, для которых память автоматически выделяется после входа в процедуру и освобождается после выхода. Стек является подхо­дящей структурой данных потому, что входы и выходы в процедуры делаются в порядке LIFO, а все нужные данные принадлежат процедуре, которая в це­почке вызовов встречается раньше.

Рассмотрим программу с локальными процедурами:

procedure Main is

     G: Integer;

Ada

      procedure Proc_1 is

             L1: Integer;

      begin ... end Proc_1 ;

      procedure Proc_2 is

            L2: Integer;

       begin... end Proc_2;

begin

     Proc_1;

     Proc_2;

end Main;

Языки программирования - концепции и принципы


Когда начинает выполняться Main, должна быть выделена память для G. Ког­да вызывается Ргос_1, должна быть выделена дополнительная память для L1 без освобождения памяти для G (см. рис. 7.6а). Память для L1 освобождается перед выделением памяти для L2, так как Ргос_1 завершается до вызова Ргос_2 (см. рис. 7.66). Вообще, независимо оттого, каким образом процедуры вызывают друг друга, первый элемент памяти, который освобождается, явля­ется последним занятым элементом, поэтому память для переменных и пара­метров может отводиться в стеке.

    Рассмотрим теперь вложенные процедуры:

procedure Main is

     G: Integer;

Ada

      procedure Proc_1 (P1: Integer) is

          L1: Integer;

      procedure Proc_2(P2: Integer) is

           L2: Integer;

      begin

          L2 := L1 + G + P2;


Ргос_2 может вызываться только из


      end Proc_2;

   begin -- Proc_1

          Proc_2(P1);

     end Proc_1;

begin -- Main

         Proc_1 (G);

end Main;

Языки программирования - концепции и принципы


Ргос_2 может вызываться только из Ргос_1. Это означает, что Ргос_1 еще не завершилась, ее память не освобождена, и место, выделенное для L1, должно все еще оставаться занятым (см. рис. 7.7). Конечно, Ргос_2 завершается рань­ше Ргос_1, которая в свою очередь завершается раньше Main, поэтому память может быть освобождена с помощью операции pop.

Записи активации

Фактически стек используется для поддержки всего вызова процедуры, а не только для размещения локальных переменных. Сегмент стека, связанный с каждой процедурой, называется записью активации (activation record) для процедуры. Вкратце, вызов процедуры реализуется следующим образом (см. рис. 7.8):

Языки программирования - концепции и принципы


1. В стек помещаются фактические параметры. К ним можно обращаться по смещению от начала записи активации.

2. В стек помещается адрес возврата RA (return address). Адрес возврата — это адрес оператора, следующего за вызовом процедуры.

3. Индекс вершины стека увеличивается на общий объем памяти, требуе­мой для хранения локальных переменных.

4. Выполняется переход к коду процедуры.

После завершения процедуры перечисленные шаги выполняются в обрат­ном порядке:

1. Индекс вершины стека уменьшается на величину объема памяти, выде­ленной для локальных переменных.

2. Адрес возврата извлекается из стека и используется для восстановления указателя команд.

3. Индекс вершины стека уменьшается на величину объема памяти, выде­ленной для фактических параметров.

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


В классическом стеке единственно допустимые


 

 

Доступ к значениям в стеке

В классическом стеке единственно допустимые операции — это push и pop. «Рабочий» стек, который мы описали, — более сложная структура, потому что мы хотим иметь эффективный доступ не только к самому последнему значе­нию, помещенному в стек, но и ко всем локальным переменным и ко всем па­раметрам. В частности, необходимо иметь возможность обращаться к этим данным относительно индекса вершины стека:

C

stack[top -25];

Однако стек может содержать и другие данные помимо тех, что связаны с вы­зовом процедуры (например, временные переменные, см. раздел 4.7), поэто­му обычно поддерживается еще дополнительный индекс, так называемый указатель дна (bottom pointer), который указывает на начало записи активации (см. раздел 7.7). Даже если индекс вершины стека изменится во время выпол­нения процедуры, ко всем данным в записи активации можно обращаться по фиксированным смещениям от указателя дна стека.

 

 

Параметры

Существуют два способа реализации передачи параметров. Более простым яв­ляется помещение в стек самих параметров (либо значений, либо ссылок). Этот способ используется в языках Pascal и Ada, потому что в этих языках но­мер и тип каждого параметра известны на этапе компиляции. По этой инфор­мации смещение каждого параметра относительно начала записи активации может быть вычислено во время компиляции, и к каждому параметру можно обращаться по этому фиксированному смещению от указателя дна стека:

 

load                          R1 ,bottom_pointer                 Указатель дна

add                           R1 ,#offset-of-parameter          + смещение

load                          R2,(R1)                                    Загрузить значение, адрес которого

                                                                                   находится в R1

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

При выходе из подпрограммы выполняется


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

   При использовании этого метода в языке С возникает проблема, связан­ная с тем, что С разрешает иметь в процедуре переменное число парамет­ров:

C

void proc(int num_args,...);

Так как количество параметров подпрограмме неизвестно, она не может очи­стить стек. Ответственность за очистку стека, таким образом, перекладывает­ся на вызыватель, который знает, сколько параметров было передано. Это приводит к некоторому перерасходу памяти, потому что код очистки стека теперь свой при каждом вызове вместо того, чтобы быть общим для всех вы­зовов.

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

    Обратите внимание, что иногда нельзя сохранить параметр непосредст­венно в стеке. Как вы помните, формальный параметр в языке Ada может иметь неограниченный тип массива, границы которого неизвестны во время компиляции:

Ada

procedure Proc(S: in String);

Таким образом, фактический параметр не может быть помещен непосредст­венно в стек. Вместо него в стек помещается дескриптор массива (dope vector) (см. рис. 5.4), который содержит указатель на массив.

 

 

 

 

Рекурсия

Архитектура стека непосредственно поддерживает рекурсию, поскольку каж­дый вызов процедуры автоматически размещает новую копию локальных пе­ременных и параметров. Например, при каждом рекурсивном вызове функ­ции факториала требуется одно слово памяти для параметра и одно слово па­мяти для адреса возврата.

что издержки на рекурсию больше,


То, что издержки на рекурсию больше, чем на ите­рацию, связано с дополнительными командами, затрачиваемыми на вход в процедуру и выход из нее. Некоторые компиляторы пытаются выполнить оп­тимизацию, называемую оптимизацией хвостовой рекурсии (tail-recursion) или оптимизацией последнего вызова (last-call). Если единственный рекурсивный вызов в процедуре — последний оператор процедуры, то можно автома­тически перевести рекурсию в итерацию.

Размер стека

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

   Однако при применении рекурсии размер стека во время выполнения тео­ретически неограничен:

C

i = get(); 

j = factorial(i);

В упражнениях приведена функция Акерманна, которая гарантированно пе­реполнит любой стек! Но на практике обычно нетрудно оценить размер сте­ка, даже когда используется рекурсия. Предположим, что размер записи ак­тивации приблизительно равен 10, а глубина рекурсии не больше несколь­ких сотен. Добавления к стеку лишних 10 Кбайт более чем достаточно.

    Читатели, которые изучали структуры данных, знают, что рекурсией удобно пользоваться при работе с древовидными структурами в таких алго­ритмах, как быстрая сортировка и приоритетные очереди. Глубина рекур­сии в алгоритмах обработки древовидных структур данных — приблизи­тельно Iog2 от размера структуры. Для реальных программ глубина рекур­сии не превышает 10 или 20, поэтому опасность переполнения стека очень невелика.

    Независимо от того, используется рекурсия или нет, сама природа рассматриваемых систем такова, что потенциально возможное перепол­нение стека должно как-то обрабатываться.

и при критических обстоятельствах разрушаться,


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

7.7. Еще о стековой архитектуре

 

 

Доступ к переменным на промежуточных уровнях

 

Мы обсудили, как можно эффективно обращаться к локальным переменным по фиксированным смещениям от указателя дна, указывающего на запись ак­тивации. К глобальным данным, т. е. данным, объявленным в главной про­грамме, также можно обращаться эффективно. Это легко увидеть, если рас­сматривать глобальные данные как локальные для главной процедуры. Па­мять для глобальных данных распределяется при входе в главную процедуру, т. е. в начале программы. Так как их размещение известно на этапе компиля­ции, точнее, при компоновке, то действительный адрес каждого элемента из­вестен или непосредственно, или как смещение от фиксированной позиции. На практике глобальные данные обычно распределяются независимо (см. раздел 8.3), но в любом случае адреса фиксированы.

      Труднее обратиться к переменным на промежуточных уровнях вложения.

procedure Main is

       G: Integer,

        procedure Proc_1 is

           L1: Integer;

Ada

                procedure Proc_2 is

                       L2: Integer;

                begin L2 := L1 + G; end Proc_2;

                    procedure Proc_3 is

                           L3: Integer;

                    begin L3 := L1 + G; Proc_2; end Proc_3;

         

           begin -- Proc_1

                Proc_3;

           end Proc_1;

     begin — Main

        Proc_1;

     end Main;

Мы видели, что доступ к локальной переменной L3 и глобальной переменной G является простым и эффективным, но как можно обращаться к L1 в Ргос_3? Ответ прост: значение указателя дна сохраняется при входе в процедуру и используется как указатель на запись активации объемлющей процедуры Ргос_1.

и может быть немедленно загружен,


Указатель дна хранится в известном месте и может быть немедленно загружен, поэтому дополнительные затраты потребуются только на косвен­ную адресацию.

   
Языки программирования - концепции и принципы


      При более глубоком вложении каждая запись активации содержит указа­тель на предыдущую запись активации. Эти указатели на записи активации образуют динамическую цепочку (см. рис. 7.9). Чтобы обратиться к вышележа­щей переменной (вложенной менее глубоко), необходимо «подняться» по ди­намической цепочке. Связанные с этим затраты снижают эффективность работы с переменными промежуточных уровней при большой глубине вло­женности. Обращение непосредственно к предыдущему уровню требует толь­ко одной косвенной адресации, и эпизодическое глубокое обращение тоже не должно вызывать никаких проблем, но в циклы не следует включать операто­ры, которые далеко возвращаются по цепочке.

Вызов вышележащих процедур

   Доступ к промежуточным переменным фактически еще сложнее, потому что процедуре разрешено вызывать другие процедуры, которые имеют такой же или более низкий уровень вложения. В приведенном примере Ргос_3 вызыва­ет Ргос_2. В записи активации для Ргос_2 хранится указатель дна для проце­дуры Ргос_3 так, что его можно восстановить, но переменные Ргос_3 недо­ступны в Ргос_2 в соответствии с правилами области действия.

    Так или иначе, программа должна быть в состоянии идентифицировать статическую цепочку, т.е. цепочку записей активации, которая определяет статический контекст процедур согласно правилам области действия, в про­тивоположность динамической цепочке вызовов процедур во время выполне­ния. В качестве крайнего случая рассмотрим рекурсивную процедуру: в дина­мической цепочке могут быть десятки записей активации (по одной для каж­дого рекурсивного вызова), но статическая цепочка будет состоять только из текущей записи и записи для главной процедуры.

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

в примере имеет нулевой уровень,


Если главная программа в примере имеет нулевой уровень, то обе процедуры Ргос_2 и Ргос_3 находятся на уровне 2. При продвижении вверх по динамической цепочке уровень вло­женности должен уменьшаться на единицу, чтобы его можно было рассматривать как часть статической цепочки; таким образом, запись для Ргос_3 про­пускается, и следующая запись, уже запись для Ргос_1 на уровне 1, использу­ется, чтобы получить индекс дна.

Языки программирования - концепции и принципы


Другое решение состоит в том, чтобы явно включить статическую цепоч­ку в стек. На рисунке 7.10 показана статическая цепочка сразу после вызова Ргос_2 из Ргос_3 . Перед вызовом статическая цепочка точно такая же, как ди­намическая, а после вызова она стала короче динамической и содержит толь­ко главную процедуру и Ргос_1.

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

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

7.8.  Реализация на процессоре Intel 8086

 


Чтобы дать более конкретное представление


Чтобы дать более конкретное представление о реализации идей стековой архитектуры, рассмотрим вход в процедуру и выход из нее на уровне машинных команд для процессора серии Intel 8086. В качестве примера возьмем:

procedure Main is

       Global: Integer;

        procedure Proc(Parm: in Integer) is

                 Local'1, Local2: Integer;

        begin

Ada

                 Local2 := Global + Farm + Local 1 ;

        end Proc;

begin

       Proc(15);

end Main;

Процессор 8086 имеет встроенные команды push и pop, в которых подразуме­вается, что стек растет от старших адресов к младшим. Для стековых операций выделены два регистра: регистр sp, который указывает на «верхний» элемент в стеке, и регистр bр, который является указателем дна и идентифицирует ме­стоположение начала записи активации.

    При вызове процедуры в стек помещается параметр и выполняется коман­да вызова (call):

mov                 ax, #15                            Загрузить значение параметра

push                ax                                    Сохранить параметр в стеке

call                  Proc                                 Вызвать процедуру

На рисунке 7.11 показан стек после выполнения этих команд — параметр и адрес возврата помещены в стек.

Языки программирования - концепции и принципы


    Следующие команды являются частью кода процедуры и выполняются при входе в процедуру; они сохраняют старый указатель дна (динамическая связь), устанавливают новый указатель дна и выделяют память для локальных переменных, уменьшая указатель стека:

push             bp                         Сохранить старый динамический указатель

mov              bp, sp                    Установить новый динамический указатель

sub               sp,#4                      Выделить место для локальных переменных

Получившийся в результате стек показан на рис. 7.12.

Языки программирования - концепции и принципы


Теперь можно выполнить тело процедуры:

mov                     ax,ds:[38]                        Загрузить переменную Global


к глобальным переменным делается через


add                      ax,[bp+06]                       Прибавить параметр Parm

add                      ax,[bp-02]                        Прибавить переменную Local 1

mov                     ax,[bp]                             Сохранить в переменной Local2

Обращение к глобальным переменным делается через смещения относи­тельно специальной области памяти, на которою указывает регистр ds (сегмент данных). К параметру Parm, который располагается в стеке «ни­же» начала записи активации, обращаются при положительном смещении относительно bp. К локальным переменным, которые в стеке располагают­ся «выше», обращаются при отрицательном смещении относительно bp. Важно обратить внимание, что поскольку процессор 8086 имеет регистры и способы адресации, разработанные для обычных вычислений с исполь­зованием стека, то ко всем этим переменным можно обращаться одной ко­мандой.

   При выходе из процедуры должны быть ликвидированы все изменения, сделанные при входе в процедуру:

mov       sp,bp                                 Очистить все локальные переменные

pop        bp                                      Восстановить старый динамический указатель

ret          2                                        Вернуться и освободить память параметров

Указатель вершины стека принимает значение указателя дна и таким образом действительно освобождает память, выделенную для локальных переменных. Затем старый динамический указатель выталкивается (pop) из стека, и bр те­перь указывает на предыдущую запись активации. Остается только выйти из процедуры, используя адрес возврата, и освободить память, выделенную для параметров. Команда ret выполняет обе эти задачи; операнд команды указы­вает, сколько байтов памяти, выделенных для параметра, необходимо вытол­кнуть из стека.

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


Использует ли ваш компилятор Ada


7.9. Упражнения

 

1. Использует ли ваш компилятор Ada значения или ссылки для передачи массивов и записей?

2. Покажите, как реализуется оптимизация последнего вызова процедуры при рекурсиях. Можно ли выполнить эту оптимизацию для функции факториала?

3. Функция Маккарти определяется следующей рекурсивной функцией:

function M(l: Integer) return Integer is

Ada

begin                                                                                                           

          if I > 100 then return 1-10;

          else return M(M(I + 11));

end M;

а) Напишите программу для функции Маккарти и вычислите M(l) для 80</<110.

 

б) Смоделируйте вручную вычисление для М(91), показав рост стека.

в) Напишите итерационную программу для функции Маккарти.

4. Функция Акерманна определяется следующей рекурсивной функцией:

function A(M, N: Natural) return Natural is

Ada

begin                                                                                                      

     if M = 0 then return N + 1 ;

     elsif N = 0 then return A(M -1,1);

     else return A(M - 1, A(M, N-1));

end A;

а) Напишите программу для функции Акерманна и проверьте, что А(0,0)=1, А(1,1 )=3, А(2,2)=7, А(3,3)=61.

б) Смоделируйте вручную вычисление для А(2,2)=7, проследив за ростом стека.

в) Попытайтесь вычислить А(4,4) и опишите, что при этом происходит. По­пробуйте выполнить вычисление, используя несколько компиляторов. Не забудьте перед этим сохранить свои файлы!

г) Напишите нерекурсивную программу для функции Акерманна.

5. Как получить доступ к переменным промежуточной области действия на процессоре 8086?

6. Существует механизм передачи параметров, называемый вызовом по имени (call-by-name), в котором при каждом обращении к формальному параметру происходит перевычисление фактического параметра. Этот механизм впервые использовался в языке Algol, но его нет в большинст­ве обычных языков программирования.