Андрей Колесов

Несмотря на очевидный прогресс инструментов программирования, методология разработки базируется на принципах, сформулированных еще в 60-70-х годах прошлого столетия. Из всех публикаций того времени мне хотелось бы выделить две фундаментальные книги (были, конечно, и другие), на которые я буду ссылаться далее.

  1. Э. Дейкстра. "Заметки по структурному программированию". В сборнике "Структурное программирование". - М.: "Мир", 1975.
  2. Э. Йордан. "Структурное проектирование и конструирование программ". - М.: "Мир", 1979.

Многие современные дискуссии разработчиков (выбор языков, управление проектами и пр.) до боли напоминают обсуждения тех времен. Однако есть одно отличие: тогда широкая общественность через книги и журналы могла познакомиться с мнением двух-трех десятков "доцентов с кандидатами". Сейчас через Интернет и массу компьютерных изданий свои соображения может обнародовать практически любой из многомиллионной армии программистов. Конечно, это здорово, но есть и некоторые опасности...

Казалось бы, статья Андрея Калинина "Разумный goto" ("BYTE/Россия" № 4'2001) как раз демонстрирует преемственность программистских проблем, предлагая читателям "еще одно мнение в споре о том, стоит ли использовать оператор goto". В этой связи выскажу мнение: все вопросы использования или не-использования goto были решены еще в 70-е годы. Считаю полезным, однако, высказать свои соображения по поводу данной публикации.

Переходите дорогу только в установленных местах

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

  1. Использование goto в программе автоматически создает серьезную потенциальную угрозу ее устойчивости и надежности.
  2. Современные средства программирования позволяют обходиться без его применения, и это не приводит к сколько-нибудь заметной потере в скорости выполнения кода.
  3. Большинство языков допускают применение goto, и, возможно, существуют ситуации, когда его использование оправданно, но разработчик всегда должен помнить о правиле "семь раз отмерь - один раз отрежь".

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

Тут уместна такая аналогия: действительно, можно сэкономить время, если перебегать Тверскую улицу в Москве, минуя пешеходный переход. Но многие ли из тех, кто так делает, смогут посоветовать своим детям: "Делай, как я"?

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

Программирование - наука или искусство? Технология!

Ключевая идея г-на Калинина, с которой я хотел бы поспорить, сформулирована в конце его статьи: "…я видел исходные тексты, в которых безусловные переходы используются далеко не лучшим способом. Но в этом виновата не языковая конструкция, а программист, который ее так использовал". В этой связи на память приходит цитата из "Швейка": "А если где кого убили, то так тому и надо - не будь дураком и не давай себя убивать".

70-е годы мне представляются переломным моментом в истории программирования, потому что именно тогда в дискуссии на тему "Что такое программирование - наука или искусство?" был найден правильный ответ: "технология". А главная идея любой технологии - создание таких условий, когда производственный процесс минимально зависит от субъективных факторов. В частности, это означает, что современные средства программирования должны обеспечивать максимальную защиту от возможных ошибок разработчика.

Тут можно провести аналогию с развитием методов управления автотранспортом. Сначала безопасность обеспечивалась за счет разработки правил движения. Затем появилась система разметки дорог и регулирования перекрестков. И, наконец, стали строиться транспортные развязки, которые в принципе предотвращают пересечение потоков машин и пешеходов. Впрочем, используемые средства должны определяться характером решаемой задачи: для проселочной дороги вполне достаточно соблюдения простого правила - "смотри под ноги и по сторонам".

Эффективность программ - что это такое?

Андрей Калинин оправдывает использование "запрещенных" методов необходимостью повышения эффективности программ. Однако, что характерно, он ничего не говорит о том, что понимается под эффективностью.

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

For i = 1 To 10
  a(i) = 0
Next i

на

a(1) = 0
...
a(10) = 0

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

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

Конечно же, я готов согласиться с г-ном Калининым, что для аварийного выхода из последовательности вложенных циклов, наверное (почему "наверное", мы разберемся позднее), удобнее использовать безусловный переход. Хотя столь же легко можно обойтись и без него, выделив эту конструкцию в виде процедуры:

Sub MyProc
  For i = 1 To 10
    ...  ' тут может быть много вложенных циклов
    Exit Sub  ' аварийный выход
  Next i
End Sub

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

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

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

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

Но дело-то даже не в этом. Ведь, обсуждая вопрос, что такое "хорошая" программа, еще 30 лет назад специалисты пришли к однозначному выводу, что эффективность - это лишь один из показателей, хотя и важный, но далеко не самый главный. Кроме скорости и компактности кода нужно учитывать трудозатраты на программирование и сопровождение, возможность модификации приложения, обеспечение коллективной разработки и т. п. Не говоря уж о том, что программа должна работать в соответствии с заданными спецификациями, а проект нужно реализовывать в исторически приемлемые сроки.

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

Структурное программирование - основа индустриальной технологии

Необходимость разработки методов структурного программирования на рубеже 60-70-х годов была вызвана потребностями компьютерной индустрии. Объем программных проектов быстро возрастал. В этой связи хотелось бы напомнить, что одним из крупнейших программных проектов до сегодняшних дней остается создание операционной системы IBM OS/360, первый вариант которой разрабатывался в 1963-1966 гг. В нем участвовали сотни программистов, а общая трудоемкость оценивалась в 5 тыс. человеко-лет.

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

Существует много историй (составляющих фольклор вычислительной науки) о Дейкстре, Йордане, Кнуте и других отцах-основателях современных технологий программирования. Например, Дейкстре принадлежит высказывание, что если знание FORTRAN можно сравнить с младенческим расстройством, то PL-1 - это определенно смертельная болезнь (конечно, речь идет о стандарте FORTRAN 66.) А вот цитата из книги Йордана: "Программисту можно простить многие прегрешения, но ни одному из них, как бы он ни был умен и опытен, нельзя простить программу, не оформленную и не содержащую комментариев".

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

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

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

В те времена огромный объем программирования был связан с использованием ассемблеров, т.е. машинно-ориентированных языков, а единственные средства управления логикой фактически ограничивались двумя операторами - условного (If <условие> goto) и безусловного (goto) перехода. Синтаксис языков высокого уровня также был недостаточно хорош, чтобы отказаться от goto.

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

Вообще говоря, главное в структурном программировании - грамотное составление правильной логической схемы программы, реализация которой языковыми средствами - дело вторичное. И уж во всяком случае структуризация кода не сводится лишь к исключению goto. Доказывая эти положения, Йордан привел программные примеры реализации нужного набора логических конструкций (с использованием goto!) на пяти ведущих тогда языках: Assembler IBM 360, FORTRAN, COBOL, ALGOL и PL-1.

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

Кстати, со всеми этими проблемами управления процессом разработки мне, тогда совершенно желторотому программисту, самому пришлось столкнуться в середине 70-х годов. Наша команда из пяти человек (все такие же "чайники") выполняла трехлетний проект разработки ПО для системы реального времени, конечно же, с использованием ассемблера.

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

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

В чем суть структурного программирования

Идея тут достаточно проста: программа должна представлять собой множество вложенных блоков (или иначе - блоков, объединенных в виде иерархической древовидной структуры), каждый из которых имеет один вход и один выход. Чтобы нагляднее представить это, откройте, например, дерево каталогов в Проводнике Windows и представьте себе, что значок каталога соответствует такому блоку, а иконка файла - вычисляемому оператору. При этом передача управления между блоками и операторами на каждом уровне дерева выполняется последовательно. (По большому счету событийная модель программ организована точно так же, просто роль связующего блока верхнего уровня выполняет ядро системы.)

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

Ответ на это был дан в классической работе Бома и Джакопини, опубликованной в 1965 г., в которой было доказано (без всяких "нам кажется"!), что любую программу можно построить, используя лишь три основных типа блоков:

  • функциональный блок - отдельный линейный оператор или их последовательность;
  • обобщенный цикл - конструкция типа Do <условие>...Loop (проверка в начале цикла!);
  • принятие двоичного решения - If <условие> Then...Else.

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

Однако на практике оказалось, что хотя этих управляющих блоков и достаточно для построения программы, но для более эффективной работы желательно иметь некоторые "расширенные" варианты. Таким образом, появились конструкции Do... Loop <условие>, For...Next и Select Case. Кроме того, для циклов и функциональных блоков желательно иметь операторы досрочного выхода из блока - Exit Do, Exit For и Exit Sub/Function. И никаких goto и меток операторов.

Суха теория, а древо жизни вечно зеленеет

Теперь я попробую показать, что методы структурного программирования нужно применять не по причине религиозных убеждений или моды, а для решения вполне конкретных задач. Рассмотрим пример, приведенный в начале статьи о "разумном goto":

  i = 5
  GoTo Label
  ...
  For i = 1 To 10
    ... <Block1>
Label:
    ... <Block2>
  Next

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

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

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

If <условие> Then
  <Block1 при i = 5>
  Start = 6
Else
  Start = 1
End If

For i = Start To 10
    ... <Block1>
    ... <Block2>
Next

Слишком длинно? Это только кажется - когда вы напишете реальный код, то даже не заметите три дополнительные строчки. Еще раз подчеркну, что речь идет о некоторой весьма экзотической логической конструкции.

Однако давайте из соображений эффективности выберем вариант Калинина. Тщательно протестируем его и убедимся в его работоспособности. Какие же опасности нас подстерегают?

Да, программа сейчас работает. А вы уверены, что она будет работать, если вы будете использовать новую версию компилятора или установите какой-нибудь Pentium 5? Поясню свой вопрос - вы точно знаете, в какой машинный код преобразуется конструкция For...Next?

Ведь вполне вероятно, мы скоро обнаружим, что любые операторные скобки программного блока будут обрабатываться через стек, как это сегодня происходит при вызове процедуры, а индекс в For...Next будет считаться локальной переменной данного блока. Кстати, архитектура очень известной в 70-е годы серии машин Burroughs была реализована именно таким образом: там подобные вхождения внутрь блока были запрещены на аппаратном уровне.

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

MyProc proc
       push bp
        ...
Label:    ; мы не выполнили формирование стека
       ...
       pop bp
       ret
MyProc endp

Нет нужды говорить о том, что прямая передача управления jmp Label из другой процедуры (вполне допустимая с точки зрения языка операция) приведет к краху системы.

И это отнюдь не гипотетическая угроза. Давайте посмотрим на такую конструкцию:

Do
  Dim nTotal As Long
  nTotal = nTotal + 1
Loop Until nTotal > 100
MsgBox nTotal    ' вывод сообщения

Так вот, в нынешней версии Visual Basic 6 в результате выполнения данного кода вы получите "101". А запустив программу в Visual Basic.NET, увидите сообщение об ошибке - "переменная nTotal не определена". Это произойдет потому, что в новой версии Visual Basic переменные, объявленные внутри блока, будут считаться локальными для данной конструкции.

Кстати, вот еще один пример быстрого, но некорректного кода:

For i = 1 To 10
  ...
Next i ' далее продолжаем обработку с текущим значением i

Все это очень здорово, но кто может гарантированно сказать, чему будет равно значение i при выходе из цикла - 10 или 11?

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

nEnd = SomeFunction(...)
For i = 1 To nEnd

Казалось бы, корректировка очень тривиальная, а программа перестала работать: при переходе внутрь блока через метку цикл либо вообще не будет выполняться, либо будет выполняться бесконечно (опять-таки в зависимости от компилятора). Даже сам автор кода может легко допустить такую ошибку, поскольку пару месяцев спустя он уже забудет о своих маленьких хитростях. Если же автор уехал на месяц в отпуск, а исправить код нужно срочно, то его коллега почти со 100%-ной гарантией приведет программу в нерабочее состояние.

***

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

 
Утверждения статьи "Разумный goto" Комментарий
Но было бы неверным считать, что операторы ветвления полностью заменяют goto Современные языки программирования позволяют полностью исключить goto. Это доказано теоретически и подтверждается практикой разработки
Зачем нам чистота программного кода, если она достигнута за счет превращения получившейся программы в бесполезного, с трудом ворочающегося мастодонта? Именно чистота кода (а точнее - выполнение технологических требований) обеспечивает создание полезных и эффективных приложений. Мастодонты получаются как раз в случае нарушения технологии. Не нужно оправдывать неумение писать эффективный код отсутствием goto
...никому доподлинно не известно, что лучше: писать программы, которые новички смогут читать как художественную прозу (не получая при этом никакого опыта в программировании), либо вообще не принимать во внимание то, что даже опытный программист не сразу поймет, зачем нужна та или иная строчка Это не известно только тем, что не изучал программирование (не нужно путать освоение синтаксиса языка с изучением методов программирования). В одной книге еще 70-х годов содержится четкая рекомендация: если программист пишет непонятный код, то для пользы дела лучше всего такого программиста уволить
Большая часть правил имеет своей целью усреднить программистов: ...заставить опытных программистов не смущать новичков "неправильными приемами Главный признак опыта заключается именно в понимании необходимости правил. Один из отцов структурного программирования профессор Э. Дейкстра еще в 1965 г. заявил, что "квалификация программиста обратно пропорциональна числу операторов goto в его программах (в те времена языки программирования не содержали конструкций, позволяющих полностью исключить goto)
Мне кажется, нельзя исходить из того, что некоторая конструкция языка дает возможность программисту совершить ошибку Технология программирования требует исходить именно из этого. Данный тезис имеет научное доказательство
Как бы ни были плохи эти конструкции сами по себе (с точки зрения их понятности для досужего читателя исходных текстов), их все равно приходится использовать. Понятность кода нужна самому разработчику, а не "досужим читателям. И совсем непонятно, зачем нужно использовать плохие конструкции
В этом виновата не языковая конструкция, а программист, который ее так использовал Понятное дело: "компьютер в руках дилетанта - кусок железа. Требования к квалификации программиста никто не отменял. Но главная идея технологии - максимально обезопасить системы от возможных ошибок разработчика