Конструирование программ - Язык Паскаль и начала программирования

Программирование: введение в профессию. 1: Азы программирования - 2016 год

Конструирование программ - Язык Паскаль и начала программирования

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

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

2.6.1. Концепция структурного программирования

Языки программирования, использовавшиеся в 1960-е годы, в основной своей массе позволяли без каких-либо ограничений в любой момент передавать управление в любое место программы — делать так называемые безусловные переходы. Такие переходы запутывали управляющую структуру программы до такой степени, что разобраться в получившемся тексте часто не мог сам его автор23.

Голландский учёный и программист Эдсгер Дейкстра в 1968 году в статье Go to statement considered harmful24 предложил для повышения ясности программ отказаться от практики неконтролируемых “прыжков” между разными местами кода. За два года до этого итальянцы Коррадо Бём и Джузеппе Якопини сформулировали и доказали теорему, обычно называемую сейчас теоремой структурного программирования; эта теорема гласит, что любой алгоритм, представленный блок-схемой, может быть преобразован к эквивалентному алгоритму (то есть такому, который на тех же входных словах выдаёт те же выходные слова) с использованием суперпозиции из всего трёх “элементарных конструкций”: прямого следования, при котором сначала выполняется одно действие, а за ним другое; неполного ветвления, при котором определённое действие выполняется или не выполняется в зависимости от истинности некоторого логического выражения; и цикла с предусловием, при котором действие повторяется до тех пор, пока некоторое логическое выражение остаётся истинным. На практике обычно добавляют также полное ветвление и цикл с постусловием; все эти базовые конструкции мы уже видели на рис. 2.1 и 2.2. Для всех перечисленных базовых конструкций можно отметить одно очень важное общее свойство: каждая из них предполагает ровно одну точку входа и ровно одну точку выхода.

Загадочное слово “суперпозиция” в данном случае означает, что каждый прямоугольник, обозначающий (по правилам блок-схем) некое действие, может быть заменён более подробным (или более формальным) описанием этого действия, то есть, в свою очередь, фрагментом блок-схемы, который тоже построен в виде одной из базовых конструкций. Такая замена называется детализацией; обратную замену корректного фрагмента блок-схемы (то есть фрагмента, имеющего одну точку входа, одну точку выхода и построенного в виде одной из базовых конструкций) на один прямоугольник (“действие”) обычно называют генерализацией. Собственно говоря, сама возможность производить генерализацию возникает в результате соблюдения правила об одной точке входа и одной точке выхода; прямоугольник, обозначающий на блок-схемах отдельно взятое действие, тоже имеет ровно один вход и ровно один выход, что как раз и позволяет заменить любую базовую конструкцию структурного программирования одним прямоугольником, то есть генерализовать её. Это, в свою очередь, позволяет за счёт сокрытия второстепенных деталей делать любую блок-схему всё проще и проще, пока она как единое целое не окажется достаточно простой для понимания с одного взгляда.

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

Легко видеть, что каждая заглушка соответствует прямоугольнику, обозначающему на блок-схеме некое сложное действие, которое в процессе детализации нужно заменить более подробным фрагментом блок-схемы. Между прочим, мы уже пользовались нисходящей пошаговой детализацией при создании программы star_slash.

2.6.2. Исключения из правил: операторы выхода

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

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

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

Если в предыдущем варианте нам пришлось разделить всё тело функции на две ветки оператора if и на протяжении всего тела функции об этом помнить, то здесь мы сначала обрабатываем “специальный случай” (он же в данном случае — базис рекурсии), и если имеет место именно этот случай, то мы устанавливаем возвращаемое значение и немедленно завершаем выполнение. Далее мы благополучно забываем об уже обработанном случае; такая техника позволяет не заключать остальной код (фактически реализующий всё то, ради чего написана функция) в ветку else.

Выгода от использования оператора exit становится более очевидной с ростом количества специальных случаев. Пусть, к примеру, нам нужно написать подпрограмму, решающую квадратное уравнение. Коэффициенты уравнения она получит через параметры; поскольку вычислить (и вернуть) нужно два числа, а не одно, возврат лучше осуществить через параметры-переменные. Поскольку решение квадратного уравнения возможно не для любых коэффициентов, подпрограмму имеет смысл оформить в виде функции, возвращающей логическое значение: true она вернёт, если всё в порядке, ну а если по тем или иным причинам решить уравнение не получается, будет возвращаться значение false.

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

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

На практике встречаются подпрограммы с существенно большим количеством специальных случаев — их может оказаться пять, десять, сколько угодно; при попытке написать такую подпрограмму без exit нам попросту не хватит ширины экрана для структурных отступов. Кроме того, организовывать обработку специальных случаев с помощью конструкции из вложенных if’ов не вполне правильно даже на идейном уровне: общий случай, который очевидно “главнее” всех отдельно рассматриваемых специальных, оказывается обработан где-то в глубине управляющей структуры, что отвлекает от него внимание и противоречит его главенствующей роли.

Досрочно завершить можно и всю программу целиком; это делается оператором halt. В принципе, этот оператор можно применить в любом месте программы, в том числе в любой из подпрограмм, но делать это стоит с известной осторожностью. Например, начинающие программисты очень любят “обрабатывать” любые ошибочные ситуации, выдавая сообщение об ошибке и немедленно завершая программу; чтобы понять, почему так делать не следует, достаточно представить себе редактор текстов, который таким вот радикальным образом будет реагировать на любое неправильное нажатие клавиш.

Версия оператора halt, включённая в Free Pascal, имеет две формы: обычную, когда в программе просто пишется слово halt, к параметрическую — в этом случае после слова halt в скобках указывается целочисленное выражение, то есть пишется что-то вроде halt(1). Параметр оператора halt, если он указан, задаёт для нашей программы код завершения процесса, что позволяет сообщить операционной системе, успешно ли, по нашему мнению, отработала наша программа. Код 0 означает успешное завершение, коды 1, 2 к т. д. операционная система рассматривает как признак ошибки. В качестве кода завершения теоретически можно использовать любое число от 0 до 255 (однобайтовое беззнаковое), но обычно большие числа в этой роли не используют — в большинстве случаев код завершения не превышает 10.

Оператор halt без параметров эквивалентен оператору halt(0), то есть успешному завершению.

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

2.6.3. Безусловные переходы

Оператор безусловного перехода goto позволяет в любой момент передать управление в другую точку программы или, если угодно, продолжить выполнение программы с другого её места. Операторы, на которые будут делаться безусловные переходы, помечаются так называемыми метками, в роли которых могут выступать обычные идентификаторы, а также числа-номера; последнее компилятор поддерживает ради совместимости со старыми диалектами Паскаля. Поскольку программу с метками-идентификаторами читать заведомо проще, чем программу с метками-номерами, в наше время номера в качестве меток обычно не используются.

Метки должны быть перечислены в разделе описаний с помощью ключевого слова label, формирующего раздел описания меток; сами метки перечисляются через запятую, в конце ставится точка с запятой, например:

Обычно описание меток вставляют непосредственно перед словом begin или перед разделом описания переменных. Надо отметить, что Паскаль запрещает “перепрыгивать” из одной подпрограммы в другую, “запрыгивать” в подпрограмму из основной программы и “выпрыгивать” в основную программу из подпрограмм, так что делать метки “глобальными” нет никакого смысла. Метки, используемые внутри подпрограммы, следует описывать в разделе описаний этой подпрограммы, а метки, потребовавшиеся в главной части программы — непосредственно перед её началом.

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

Переход на такую метку делается совсем просто:

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

Первая из двух ситуаций очень простая: выход из многократно вложенных управляющих конструкций, например циклов. С выходом из одного цикла справятся специально предназначенные для этого операторы break и continue, но что делать, если “выпрыгнуть” нужно, скажем, из трёх циклов, вложенных друг в друга? Конечно, обойтись без goto, строго говоря, можно и здесь: в условия циклов вставить проверки какого-нибудь специального флажка, в самом внутреннем цикле этот флажок взвести и сделать break;, и тогда все циклы завершатся. Отметим, что в большинстве случаев флажок придётся проверять не только в условиях циклов, но и некоторые части тел этих циклов обрамлять if’ ами, проверяющими всё тот же флажок. Было бы по меньшей мере странно утверждать, что все эти нагромождения окажутся более ясными, нежели один оператор goto (конечно, при условии, что имя метки выбрано удачно и соответствует ситуации, в которой на неё делается переход).

Вторую ситуацию описать несколько сложнее, поскольку мы пока ещё не рассматривали ни файлы, ни динамическую память. Тем не менее, попробуйте представить себе, что вы пишете подпрограмму, которая в начале своей работы на время забирает себе некий ресурс, который должна потом отдать обратно. Такая схема работы встречается достаточно часто; по-английски высвобождение захваченных ресурсов перед окончанием работы называется cleanup, что может быть приблизительно переведено словом очистка. Необходимость произвести очистку перед выходом из подпрограммы не представляет никаких проблем, если точка выхода у нас одна; проблемы начинаются, если где-нибудь в середине текста подпрограммы возникает потребность досрочного её завершения с помощью exit. Попытка обойтись без goto приведёт к тому, что везде непосредственно перед завершением, т. е. перед exit и перед концом тела подпрограммы придётся продублировать код всех операций, проводящих очистку. Дублирование кода до добра обычно не доводит: если мы теперь изменим начало подпрограммы, добавив или убрав операции по захвату ресурса, велика вероятность того, что из получившихся нескольких одинаковых фрагментов, осуществляющих очистку, мы исправим только некоторые, а про остальные забудем. Поэтому в такой ситуации обычно поступают иначе: перед операциями очистки, находящимися в конце подпрограммы, ставят метку (как правило, её называют quit или cleanup), а вместо exit делают goto на эту метку.

Следует обратить внимание, что в обоих случаях переход делается “вниз” по коду, т. е. вперёд по последовательности выполнения (то есть метка стоит в тексте программы ниже оператора goto) и “наружу” из управляющих конструкций. Если у вас возникло желание сделать goto назад — это означает, что вы создаёте цикл, а для циклов есть специальные операторы; попробуйте использовать while или repeat/until. Если же вам захотелось “впрыгнуть” внутрь управляющей конструкции, то, следовательно, у вас что-то пошло совсем не так, как надо, и нужно срочно понять причины возникновения таких странных желаний; отметим, что Паскаль такого просто не позволит.

2.6.4. О разбиении программы на подпрограммы

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

Начинающие программисты (обычно школьники или студенты младших курсов) часто делают серьёзную стратегическую ошибку: пренебрегая подпрограммами, пытаются реализовать всю задачу в виде одной большой главной части. Когда размер такой программы переваливает за какую-нибудь сотню строк, навигация по коду резко затрудняется; попросту говоря, при работе с такой программой (например, при необходимости что-то в ней исправить) больше времени тратится на поиск нужного фрагмента, нежели на сами исправления. То же самое происходит, если позволить существенно “распухнуть” не главной программе, а любой из подпрограмм.

В идеале каждая обособленная часть программы, будь то подпрограмма или главная программа, должна быть настолько короткой, чтобы одного беглого взгляда на неё было достаточно для понимания её общей структуры. Опыт показывает, что идеальная подпрограмма не должна превышать 25 строк, хотя этот лимит, вообще говоря, не вполне жёсткий. Если в вашей подпрограмме нужно рассмотреть много разных возможных вариантов и действовать в соответствии с таким выбором, вполне допустимо чуть превысить указанный размер: подпрограмма в 50 строк в такой ситуации криминалом не считается, хотя и не приветствуется; с другой стороны, если подпрограмма подбирается к длине в 60, а то и 70 строк, то её необходимо немедленно, не оставляя этого на абстрактное “потом”, разбить на подзадачи. Если же подпрограмма перевалила за сотню строк, вам необходимо переосмыслить своё отношение к оформлению кода, поскольку при правильном подходе такого никогда не произойдёт.

Число 25 возникло здесь не случайно. Традиционный размер экрана алфавитно-цифрового терминала составляет 25x80 или 24x80 (24 или 25 строк по 80 знакомест в строке), к считается, что подпрограмма должна целиком умещаться на такой экран, чтобы для её анализа не приходилось прибегать к скроллингу. Вполне возможно, что лично вы предпочитаете работать с редакторами текстов, использующими графический режим, и на вашем экране умещается гораздо больше, чем 25 строк; это, на самом деле, никак не меняет ситуацию, потому что, во-первых, воспринимать текст существенно длиннее 25 строк тяжело, даже если его удалось поместить на экран; во-вторых, многие программисты при работе в графическом режиме предпочитают крупные шрифты, так что на их экран больше 25 строк всё-таки не влезет.

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

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

Коль скоро речь зашла о параметрах, отметим ещё один момент. Подпрограмму, имеющую не более пяти параметров, использовать легко; подпрограмму с шестью параметрами использовать несколько затруднительно; подпрограммы с семью и более параметрами усложняют, а не облегчают работу с кодом. Это обусловлено особенностями человеческого мозга. Удержать в памяти последовательность из пяти объектов нам достаточно просто, последовательность из шести объектов может удержать в памяти не каждый, ну а если объектов семь или больше, то удержать их в памяти единой картинкой попросту невозможно, приходится соответствующую последовательность зазубривать, а потом тратить время и силы на то, чтобы вспомнить зазубренное. Таким образом, если ваша подпрограмма имеет не больше пяти параметров, то, как правило, вы легко удержите в памяти их семантику (если это не так — скорее всего, подпрограмма неудачно спроектирована), так что сделать вызов такой подпрограммы окажется для вас легко. Если же параметров больше, то написание каждого вызова этой подпрограммы превратится в мучительное и не всегда успешное ковыряние в памяти или, что более вероятно, в тексте программы; такие вещи неизбежно отвлекают программиста от текущей задачи, заставляя вспоминать несущественные детали кода, написанного ранее.

Можно назвать ещё одно эмпирическое правило, помогающее грамотно разбить задачу на подзадачи. Каждая выделенная подзадача должна быть такой, чтобы при работе над вызывающей подпрограммой можно было не помнить детали реализации вызываемой, и наоборот, при работе над вызываемой — никак не учитывать детали реализации вызывающей. Если это правило не выполняется, чтение кода с разбиением на подпрограммы может стать более трудным, чем до такого разбиения — ведь при анализе кода придётся постоянно “прыгать” между телами двух подпрограмм. Чтобы этого избежать, текст вызываемой подпрограммы следует сделать понятным человеку, никогда не видевшему текста вызывающей подпрограммы, и наоборот. Если две взаимодействующие подпрограммы невозможно понять друг без друга, то такое разбиение на подпрограммы бесполезно и даже может оказаться вредным.






Для любых предложений по сайту: [email protected]