ФЕДЕРАЛЬНОЕ АГЕНТСТВО ПО ОБРАЗОВАНИЮ РОССИЙСКОЙ ФЕДЕРАЦИИ НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ ЯДЕРНЫЙ УНИВЕРСИТЕТ «МИФИ»
Д.В. Демидов ОСНОВЫ ПРОГРАММИРОВАНИЯ В ПРИМЕРАХ НА ЯЗЫКЕ PASCAL
Учебное пособие
Москва 2010
УДК 004.43 (075) ББК 32.973-018.1я7 Д30 Демидов Д.В. Основы программирования в примерах на языке Паскаль: Учебное пособие. М.: НИЯУ МИФИ, 2010. – 172 с. Пособие представляет собой переработанный и расширенный текст лекций по курсу «Информатика», который читается автором в НИЯУ МИФИ на кафедре № 22 (специальность «Прикладная математика и информатика»). Пособие содержит как теоретический, так и практический материал по основам программирования, включая описание семантики императивного языка программирования Паскаль, примеры построения алгоритмов и оценки их сложности, способы практической реализации алгоритмов на языке Паскаль, задания для самостоятельного выполнения. Предназначено в качестве дополнительной литературы для студентов первого курса кафедры кибернетики, а также может быть рекомендовано студентам других вузов, изучающим основы программирования. Рецензент проф. М.А. Иванов Рекомендовано редсоветом МИФИ в качестве учебного пособия ISBN 978-5-7262-1303-3
© НИЯУ МИФИ, 2010
Редактор Е.Г. Станкевич Подписано в печать 22.06.2010 Формат 60x84 1/16 Печ. л. 10,75 Уч. изд. л. 10,75 Тираж 100 экз. Изд. № 047-1 Заказ № 207 Национальный исследовательский ядерный университет «МИФИ». Типография НИЯУ МИФИ. 115409, Москва, Каширское ш., 31
Оглавление ПРЕДИСЛОВИЕ ......................................................................................... 4 ГЛАВА 1. ИСТОРИЯ И ОСНОВНЫЕ ПОНЯТИЯ ПРОГРАММИРОВАНИЯ .......................................................................... 5 ГЛАВА 2. «СТРОИТЕЛЬНЫЙ МАТЕРИАЛ» ОПЕРАТОРНОГО ЯЗЫКА ПРОГРАММИРОВАНИЯ ......................................................... 17 ГЛАВА 3. ТИПЫ ДАННЫХ ЯЗЫКА ПАСКАЛЬ ................................. 28 ГЛАВА 4. ПРИСВАИВАНИЕ И ВЕТВЛЕНИЕ ..................................... 37 ГЛАВА 5. СОРТИРОВКА И ПОИСК ..................................................... 48 ГЛАВА 6. ЦИКЛЫ И РЕКУРРЕНТНЫЕ СООТНОШЕНИЯ ............. 61 ГЛАВА 7. ОПЕРАЦИИ НАД МАССИВАМИ И МАТРИЦАМИ......... 73 ГЛАВА 8. СТРУКТУРИРОВАНИЕ ПРОГРАММ ................................ 81 ГЛАВА 9. РЕКУРСИВНЫЕ ПРОЦЕДУРЫ И ФУНКЦИИ ................. 92 ГЛАВА 10. СТРОКИ И МНОЖЕСТВА................................................ 102 ГЛАВА 11. ТЕКСТОВЫЕ ФАЙЛЫ...................................................... 113 ГЛАВА 12. ЗАПИСИ И ТИПИЗИРОВАННЫЕ ФАЙЛЫ .................. 124 ГЛАВА 13. НЕТИПИЗИРОВАННЫЕ ФАЙЛЫ .................................. 134 ГЛАВА 14. ДИНАМИЧЕСКИЕ ПЕРЕМЕННЫЕ, ЛИНЕЙНЫЕ СПИСКИ.................................................................................................. 140 ГЛАВА 15. ДИНАМИЧЕСКИЕ СТРУКТУРЫ ДАННЫХ ................. 154 ГЛАВА 16. КОМАНДНАЯ СТРОКА, СТИЛЬ, ТЕСТИРОВАНИЕ И ОТЛАДКА............................................................................................ 163 СПИСОК ЛИТЕРАТУРЫ...................................................................... 172
3
Предисловие Учебное пособие представляет собой переработанный и расширенный текст лекций по курсу «Информатика», который читается в НИЯУ МИФИ на кафедре кибернетики студентам групп К1-221, К1-222, К1-223, К1-224, К1-681. Целью самого курса является не столько обучение конкретному языку программирования, сколько формирование абстрактного мышления и освоение приемов программирования в целом. Пособие дополняет доступную в библиотеке литературу по программированию, освещая в сжатой и доступной форме важные аспекты программирования, сохраняя при этом структуру курса. Поскольку конспектирование лекций по тематике программирования весьма непросто, пособие, по мнению автора, окажется полезным при подготовке к семестровому контролю и зачету. В пособии дается минимальное описание синтаксиса языка Паскаль, а также описание типов данных и языковых конструкций, присущих таким распространённым диалектам языка, как Turbo Pascal (Free Pascal) и Delphi. Изложение сопровождается материалом по таким вопросам, как: представление данных в памяти ЭВМ; предпосылки появления тех или иных синтаксических конструкций в операторных языках программирования; семантика операторов императивных языков программирования; сложность вычислений и оптимизация, зависимость эффективности алгоритмов от структур данных; стиль программирования. Пособие содержит теоретический и практический материал, включая примеры построения алгоритмов и оценки их сложности, примеры практической реализации алгоритмов на языке Паскаль, задания для самостоятельного выполнения. Многолетняя практика показала, что аудиторные занятия не могут научить программировать. Преподаватель может лишь направить, указать «белые пятна», дать подсказки, проверить навыки. А изучать язык, развивать абстрактное мышление, воспитывать стиль программирования обучаемый должен сам. Искусством программирования можно овладеть только через практику. Итак, практика каждый день.
4
Глава 1. История и основные понятия программирования Информатика Информатика (русскоязычный аналог термина Computer Science) изучает ряд проблем взаимодействия человека с электронно-вычислительной машиной (ЭВМ). Сегодня выделяют несколько направлений информатики: алгоритмы и структуры данных; теория трансляторов и языки программирования; математические основы информатики (системы счисления, дискретная математика, криптография и др.); теория вычислений и сложность вычислений; базы данных, сети, искусственный интеллект. Данный курс посвящён в основном алгоритмам и языкам программирования. Остальные направления подробно освещаются на курсах по дискретной математике, базам данных и интеллектуальным системам и других. Прежде чем перейти непосредственно к языку Паскаль, рассмотрим основные понятия информатики, выясним, что предстоит программировать и как.
Алгоритм, машина Тьюринга, последовательная архитектура ЭВМ Эра информатики берет начало в 30-х гг. XX в., когда стало возможным создание вычислительных машин на основе электронных устройств. До этого существовали лишь механические устройства, впервые построенные в XVII в.: суммирующие часы Вильгельма Шикарда, 1623 г. (кстати, в этом же году родился Блез Паскаль); счётное устройство Блеза Паскаля, 1642 г.; ступенчатый вычислитель Готфрида Лейбница, 1673 г., впервые предложившего двоичную систему счисления. В XIX в. Чарльз Бэббидж изобретает “Аналитическую машину” (1834 г.), в основе которой заложен принцип разделения информации на команды и данные.
5
В 1843 г. 28-летняя графиня Августа Ада Лавлейс пишет научную работу об аналитической машине Бэббиджа, заложившую научные основы программирования на вычислительных машинах. В её работе была приведена программа, предназначенная для решения уравнения Бернулли1. В России в 70-80-х гг. XIX в. появляются механические арифмометры шведско-русского изобретателя Однера В.Т. и русского математика Чебышёва П.Л. В конце XIX в. Холлерит строит счётную машину на перфокартах, которая затем участвует в переписи населения США. В 1936 г. Аланом Тьюрингом была предложена абстрактная вычислительная машина для формализации понятия «алгоритм», которое, пожалуй, является важнейшим понятием императивного программирования. Гипотетическая машина Тьюринга положила начало теории алгоритмов, теории вычислений и программированию. Сейчас достаточно уяснить основную идею этой машины. Подробнее о ней можно узнать из курсов по дискретной математике. Машина Тьюринга имеет управляющее устройство, способное находиться в одном из множества состояний и бесконечную в обе стороны ленту, разделенную на ячейки. В ячейках ленты могут быть записаны символы некоторого конечного алфавита. Управляющее устройство может перемещаться влево и вправо по ленте, считывать и записывать символы в ячейки. Перемещение управляющего устройства основано на правилах перехода, которые представляют алгоритм, реализуемый машиной Тьюринга. Правила перехода применяются последовательно, путём циклического просмотра набора этих правил и применения подходящего на данном шаге правила до тех пор, пока в текущей ячейке ленты не будет встречен специальный символ останова. Именно идея последовательного исполнения правил (команд, инструкций) и легла в основу архитектуры первых ЭВМ 1940-х гг. Здесь следует отметить, что первые ЭВМ не были программируемыми, а набор команд и программ ЭВМ определялся их соста1
В связи с этим фактом графиню Лавлейс считают первым программистом. Некоторые программисты-романтики в день её рождения (10 декабря) отмечают день программиста. Впрочем, другие отсчитывают 28=256-й день в году – 13 сентября (или 12 сентября в високосный год).
6
вом и способом коммутации составляющих их блоков. Первую ЭВМ разработали в начале 1940-х гг. в Университете Пенсильвании Эккерт и Мочли, она называлась ENIAC (Electronic Numerical Integrator And Computer) – электронный численный интегратор и вычислитель. Перепрограммирование такой машины заключалось в перестройке её блоков и устройств и их перекоммутации. В 1945-м был опубликован отчет по архитектуре новой ЭВМ EDVAC, разработанной тем же коллективом, к которому присоединился Джон фон Нейман и др. В новой архитектуре процессорное устройство отделялось от памяти, а основным принципом являлось хранение и данных и программ в одном виде. Один и тот же подход к рассмотрению данных и команд стал настоящим прорывом в области вычислений. Строительство EDVAC, однако, затянулось, и первой ЭВМ, в которой эта архитектура реализована, стала ЭВМ «Марк I», разработанная в 1948 г. в Университете Манчестера (Великобритания). ЭВМ EDVAC разработана годом позже и введена в эксплуатацию ещё через 2 года. Лишь тогда термин «программирование» конкретизировался до термина «программирование ЭВМ». Первая в СССР ЭВМ с хранимой в памяти программой построена под руководством С.А. Лебедева и запущена в эксплуатацию в 1950-м. До сих пор в основе подавляющего большинства современных вычислительных машин, гораздо более сложных, чем EDVAC или «Марк I», всё также лежит последовательная архитектура, предложенная коллективом из Университета Пенсильвании, не совсем справедливо называемая архитектурой фон Неймана. Интересно, что первая в СССР ЭВМ с производительностью 1 млн операций в секунду (БЭСМ-6) построена в 1965-м. Следующим мощным толчком к развитию ЭВМ стало создание процессоров и наборов команд, но это был чисто технологический прорыв в уже проложенном архитектурном русле. Интересно, что любая функция, которая может быть вычислена физическим устройством (ЭВМ), вычисляется и машиной Тьюринга (тезис Чёрча–Тьюринга). Таким образом, все подобные вычислительные машины оказываются эквивалентными гипотетической машине Тьюринга. Все задачи, разрешимые на машине Тьюринга, можно решить на современной ЭВМ, и наоборот. Помимо применения последовательной архитектуры ведутся исследования и в области параллельных вычислений. Представители
7
параллельной архитектуры – нейропроцессоры, квантовые процессоры. Однако пока их создание весьма трудоёмко, а способы решения задач только разрабатываются. Примеры программируемых устройств – компьютеры, КПК, мобильные телефоны, калькуляторы, бытовая техника, цифровая аппаратура, бортовые системы управления. Сегодня можно программировать не только аппаратные устройства, но и программы. Примеры программируемых программ – игровые персонажи, программные агенты.
Алгоритм Итак, что же такое алгоритм? def. Алгоритм – точный набор инструкций, описывающий порядок действий исполнителя для достижения результата. В частности, алгоритм требует для работы некоторые исходные данные и выдает некоторый результат. Алгоритм имеет следующие признаки: 1) определенность (детерминированность) – следующий шаг однозначно определяется текущим состоянием, что гарантирует постоянство результата для одних и тех же входных данных; 2) понятность исполнителю – алгоритм состоит только из команд, входящих в систему команд исполнителя; 3) конечность – способность завершить работу за конечное число шагов при корректных входных данных; 4) массовость (общность) – применимость для разных входных данных. Основная задача начинающих программистов – развитие абстрактного мышления. Нужно учиться создавать алгоритмы, соответствующие перечисленным выше признакам. И если о первых двух признаках позаботится компилятор, то за последние два придётся отвечать самим. Алгоритм является важнейшим понятием императивного программирования. def. Императивное программирование описывает процесс вычисления в виде инструкций, изменяющих состояние программы. Такое название было получено из-за повелительного наклонения в естественном языке, с помощью которого выражаются приказы, команды исполнителю. Главный вопрос императивного программирования – «Как вычислять?»
8
По сути, императивное программирование – это процедура записи алгоритма на языке, понятном ЭВМ. Противоположное по духу направление программирования – декларативное программирование. def. Декларативное программирование описывает результат вычислений и его свойства без предписывания последовательности действий. Ведь на самом деле важен результат, а не способ его достижения. Главный вопрос декларативного программирования – «Что должно получиться?» Ветвями декларативного программирования являются функциональное и логическое программирование. Навыки декларативного программирования обычно приобретаются на старших курсах.
Первое поколение операторных языков В определении алгоритма фигурирует некий исполнитель алгоритма. Если исполнитель – ЭВМ, то допустимые команды – система команд процессора ЭВМ, и здесь уместно назвать такой алгоритм программой или программным кодом. def. Программа – алгоритм, описанный в терминах конкретной системы команд ЭВМ. Команды предназначены для проведения вычислений, работы с ячейками памяти и аппаратными устройствами ЭВМ, управления порядком выполнения команд и многого другого. Но что такое команда для ЭВМ, которая понимает только язык чисел в своей системе счисления? Это специальный числовой код, который, будучи записанным в определённую область памяти в какой-либо момент времени, предлагает ЭВМ выполнить соответствующее действие, а результат (при его наличии) записать в указанное другим числом место. Поскольку данные и программы в ЭВМ хранятся в одном виде, то данные, в том числе знакомые нам символы, также кодируются числами. Для этого систему команд дополняет таблица кодов, где каждому символу соответствует числовое представление. Таким образом, каждая команда программы и все необходимые данные оказываются закодированными в виде последовательности чисел. Получается программа, представляющая собой последовательность кодов. Будем называть ее машинным кодом.
9
def. Исполнение машинного кода – последовательная интерпретация команд применительно к конкретным данным в соответствии с системой команд ЭВМ. Важно понять, что для ЭВМ машинный код – текст на том единственном языке команд, который она понимает. Поскольку первые программы писались именно в кодах, то языки команд по праву считают первым поколением языков программирования.
Языки программирования второго поколения Первые программисты наизусть помнили коды всех команд и символов, как азбуку Морзе. Но с возрастанием числа доступных команд в процессорах и усложнением решаемых задач становилось всё более трудоёмким не только программирование, но и развитие существующих программ. Всё это послужило предпосылками появления языков второго поколения – ассемблеров. Отныне каждой команде приписывался свой символьный мнемонический код, свои имена получили также регистры процессора, стало возможным записывать данные не только в числовой, но и символьной форме. Программа теперь представляла собой удобочитаемый текст, форматированный так, что каждая команда располагалась на отдельной строке: MOV ADD CLI
AX, DX DX, BX
lbl: NOP JMP
lbl
В результате человеку стало гораздо удобнее, а вот ЭВМ текст на языке ассемблера уже не понимала. Для исполнения такой программы теперь требовалась трансляция (перевод) исходного текста программы с языка ассемблера в машинные коды. Трансляцию можно было осуществить двумя способами: с помощью интерпретатора ассемблера; с помощью компилятора ассемблера с последующей интерпретацией. В чём различие способов трансляции? Интерпретатор – исполняемая программа, входными данными для которой является другая программа, записанная на языке ас-
10
семблера. Задача такого интерпретатора состоит в последовательном преобразовании каждой строки программы на языке ассемблера в машинный код и его немедленном исполнении с дальнейшим переходом к следующей строке программы. Таким образом, для исполнения программы таким способом необходимо сначала запустить программу-интерпретатор. Другой подход состоит в однократном преобразовании текста на ассемблере в машинный код (компиляция) с помощью другой программы (компилятора). Этот машинный код далее можно исполнять произвольное число раз, уже не пользуясь посредником (программой-интерпретатором), так как машинный код способна интерпретировать сама ЭВМ. Сегодня применяются оба способа трансляции. С появлением ассемблеров немного изменилась и терминология: текст на ассемблере стал называться исходным кодом программы, а машинный код, полученный в результате компиляции, – исполняемым кодом. С той поры под программой понимают и то и другое.
Языки программирования третьего поколения С помощью простейших команд приходится долго объяснять, а чтобы заставить ЭВМ сделать что-либо более-менее полезное, требовалось написать много строк ассемблерного кода. Средства операционных систем, конечно, сильно облегчали задачу, автоматизируя работу с устройствами и реализуя некоторые типовые операции. Однако, глядя на программу, едва ли можно было сказать, что она делает, а для того чтобы разобраться в чужом коде или понять хотя бы структуру программы, требовалась масса времени. Так возникла необходимость в более развитых языковых средствах. И они появились. Третье поколение языков программирования характеризуется уходом от аппаратного уровня. Теперь программистам уже не нужно было думать о командах процессора, регистрах, ячейках памяти, прерываниях операционной системы – это стало уделом компиляторов. Произошел большой качественный скачок: место команд процессора заняли операторы, место ячеек памяти и регистров – переменные, место прерываний операционной системы – стандартная библиотека процедур и функций, данные стали типизированными, появились структуры данных.
11
Что это дало? Программа так и осталась последовательностью, только уже не команд процессора, а операторов, но добавилось важнейшее свойство – структурированность. Вообще, операторы (так же как и команды процессора) описывают некоторые алгоритмические действия, но только более сложные. Например, операторы ветвления и операторы циклов делают явной структуру программы, допуская вложенность операторов друг в друга, а составной оператор позволяет обособить последовательность операторов в теле программы в рамках процедуры или функции. Арифметические, логические и битовые операции и операции сравнения стало возможным записывать в привычной со школьной скамьи записи со скобками, в отличие от префиксной формы записи в ассемблерах, когда после команды перечислялись её аргументы. Если раньше для вычисления сложной формулы требовалось написать множество команд в нужном порядке, каждая из которых выполняла лишь одно действие, то теперь в одной строке можно было записывать сложные выражения и сразу же присваивать результат соответствующей переменной. Порядок вычислений определял компилятор на основе приоритетов операций и расставленных программистом скобок. Встроенные атомарные типы данных и конструкторы типов данных дали возможность создания сложных статических и динамических структур данных. Процедуры и функции позволили отделять часто используемые фрагменты кода от основной программы. Таким образом, программный код на языке третьего поколения стал в несколько раз компактней и наглядней, а алгоритмы и структуры данных вышли на первый план. Было положено начало структурному программированию. Пример программы на ассемблере, вычисляющей сумму чисел от 1 до 10: MOV MOV
AX, 0 CX, 10
ADD LOOP
AX, CX rep ; уменьшение CX на 1. Выход при CX=0
rep:
В этой программе данные хранятся в двух ячейках (регистрах процессора) – AX и CX. Регистр AX – аккумулятор, а регистр CX – одновременно и слагаемое и счётчик цикла, меняющийся от 10 до
12
0. Повторение сложения реализовано с помощью команды условного перехода LOOP на метку rep. Переход осуществляется до тех пор, пока СХ не станет равен 0, причём каждый переход уменьшает CX на единицу. К этому моменту в аккумуляторе AX будет накоплена вычисляемая сумма. Пример аналогичной программы на Паскале: var sum, i: integer; begin sum := 0; for i := 1 to 10 do sum := sum + i; end.
Как видно из примера, аналогичный алгоритм удаётся записать с помощью операторов более наглядно и практически всегда короче, чем с помощью команд процессора. Позднее в языки вводились новые понятия – объекты, классы, интерфейсы, компоненты, события и обработчики событий. Это дало возможность оперировать классами объектов и отношений между ними. Выделялись методы программирования, ориентированные на события, объекты, компоненты и др. За несколько десятилетий в мире возникло множество языков императивного программирования третьего поколения, сильно отличавшихся друг от друга как синтаксически, так и своими новшествами и особенностями: Фортран, Алгол, Кобол, ПЛ-1, Паскаль, Си, Ада. Однако все они принципиально схожи, так как основываются на концепции «Как вычислять?» Какие проблемы возникли при использовании языков третьего поколения? Во-первых, задача преобразования программы на языке третьего поколения в машинный код перестала быть тривиальной: если в ассемблере каждому мнемоническому коду соответствовал один машинный код команды, то здесь это однозначное соответствие исчезло и появилось множество способов преобразования структуры операторов. Современный компилятор стал гораздо более сложной программой, способной оптимизировать генерируемый машинный код по скорости или расходу памяти. Во-вторых, абстрагирование от аппаратного устройства ЭВМ привело к возникновению новых типов программных ошибок и неоптимальному расходу ресурсов ЭВМ. От ошибок можно избавиться с помощью средств компиляции, отладки и тестирования.
13
Ошибки можно разделить на два класса по времени их возникновения или обнаружения: ошибки проектирования (разработки), выявляемые при компиляции, и ошибки времени выполнения, выявляемые лишь во время работы программы. Как правило, чем позже обнаруживается ошибка, тем сложнее найти её источник и дороже её исправить, поэтому разработчики языков программирования и компиляторов стремятся минимизировать ошибки времени выполнения. Например, язык Си очень гибок, и для получения работоспособной программы, как шутят программисты, требуется 1 раз её скомпилировать и 100 раз запустить. Язык Ada предельно строг, и для получения работоспособной программы требуется 100 раз её скомпилировать и 1 раз запустить.
Зависимость от платформы, байт-код и псевдокод На протяжении эволюции языков третьего поколения их разработчики пытались так снизить зависимость от аппаратной платформы, чтобы исходный код не менялся при смене платформы, а менялся лишь компилятор. К сожалению, в полной мере эту проблему решить не удалось в силу большого различия программноаппаратных архитектур. Для переноса программы на языке третьего поколения, скажем, с IBM PC на Apple, приходится менять исходный код, чтобы учесть особенности архитектуры, и перекомпилировать программу с помощью соответствующего компилятора. Технология Java решает проблему переносимости с помощью виртуальной машины, имеющей свою систему команд. def. Байт-код – машинный код, состоящий из команд виртуальной машины java. Под каждую аппаратную платформу разрабатывается программа-интерпретатор байт-кода, которая называется виртуальной javaмашиной (jvm). Она учитывает особенности каждой конкретной программно-аппаратной архитектуры. Компилятору Java остаётся преобразовать исходный код на языке Java в этот байт-код. Таким образом, Java-программу достаточно один раз скомпилировать в байт-код, и она будет работать на любой платформе с javaмашиной. Чтобы не зависеть от конкретного языка при представлении алгоритмов широкой общественности (пусть даже такого независимого от платформы) и в то же время исключить различное толко-
14
вание инструкций (шагов алгоритма) в периодической литературе по информатике часто используется псевдокод. def. Псевдокод – полуформальное описание алгоритма в терминах ограниченного естественного языка с элементами математики и теории множеств. Например, Начало Установить sum = 0 В цикле от i = 1 до 10 выполнять Установить sum = sum + i Конец цикла Конец
Сейчас для исследования таких характеристик алгоритмов, как временная сложность и требовательность к ресурсам (памяти) используются системы команд или языки абстрактных машин. Например, Дональд Кнут в своей книге «Искусство программирования» применяет систему команд абстрактной машины MIX, близкой по своей архитектуре к настоящим процессорам (с точки зрения системы команд). Исследователи в области лямбдаисчисления, функционального программирования пользуются категориальной абстрактной машиной (КАМ). Также применяются абстрактная машина Тьюринга и нормальные алгорифмы Маркова. Известны их программные реализации на ЭВМ – своего рода виртуальные машины-интерпретаторы.
Языки четвёртого поколения С усложнением программных систем появилась потребность в специализации и разделении труда. Универсальные языки становились всё менее удобными для решения узких задач. Начало меняться мышление: программисты стали больше думать о самой задаче, а не о том, как приспособить задачу для её решения на каком-либо языке программирования. Языкам четвёртого поколения свойственен высокий уровень абстракции, они не универсальны, так как нацелены на определённую нишу. Например, задача проведения вычислений (то, ради чего создавались ЭВМ) уступила место задаче организации доступа к хранилищам данных. Появились структурированный язык запросов SQL, встроенные языки разработки приложений с базами данных
15
от компаний SAP, Oracle, Software AG, IBM, Lotus. В области автоматизированного проектирования (CAD) можно отметить встроенные языки визуальных инженерных сред, таких как AutoCAD, MathCAD и т.п. В области искусственного интеллекта – языки в инструментальных средствах разработки интеллектуальных систем. Термин «программа» снова изменил своё значение – чаще всего программа на языке четвёртого поколения уже не является самостоятельной, а исполняется под управлением своей программной среды (интерпретируется). Такая программа совсем не обязательно описывает какой-либо алгоритм, т.е. не диктует последовательность действий, это берёт на себя среда выполнения. Грань между императивным и декларативным подходами здесь истончается. В целях увеличения скорости выполнения программа на языке четвёртого поколения может компилироваться в некоторый промежуточный код наподобие байт-кода java и храниться как в текстовом, так и скомпилированном виде.
16
Глава 2. «Строительный материал» операторного языка программирования В предыдущей главе были рассмотрены основные вехи развития операторных языков программирования: машинный код – ассемблер – операторный подход – встроенные языки и специализация. Следует напомнить, что под программой понимается последовательность операторов. Задача читателя состоит в том, чтобы научиться терпеливо и точно закладывать в компьютер такую последовательность действий, которая приведёт к желаемому результату. Можно сказать, что компьютер – это идеальный исполнитель, буквально понимающий команды, быстро их выполняющий, не задающий дополнительных вопросов и не испытывающий эмоций.
Язык Паскаль В 1971 г. доктор Никлаус Вирт из Института информатики Швейцарской высшей политехнической школы создал язык Паскаль для обучения студентов программированию. Язык назван в честь французского математика и философа Блеза Паскаля – создателя счетно-решающей машины. С тех пор прошло несколько десятилетий, а язык применяется до сих пор. За прошедшее время язык развивался не только самим Виртом, но и коммерческими компаниями. Наибольший вклад в распространение языка внесла компании Borland, которая долгое время развивала свой компилятор Паскаля, среду программирования Turbo Pascal, работавшую под DOS, среду Borland Pascal под Windows. В 1990-х наступила эра компонентного программирования, появились интегрированные среды разработки на Object Pascal – Delphi и Kylix, c недавнего времени язык именуется Delphi. Эволюция языка привела к появлению множества его диалектов, новых языков, компиляторов, сред программирования. Так, при непосредственном участии Н. Вирта из Паскаля вышли языки Модула, Модула-2, Оберон, среда программирования бортовых систем XDS. Сегодня развивается и Open Source версия языка – Free Pascal, существуют интерпретаторы языка Паскаль. Интересно, что в ERP-системе Microsoft Business Solutions Navision синтаксис встроенного языка четвертого поколения С/AL практически идентичен синтаксису Паскаля.
17
Пришло время взглянуть на внутреннее устройство программы, ввести основные понятия. Рассмотрим пример программы, которая определяет, являются ли два числа взаимно простыми, т.е. не имеют общих делителей, кроме единицы: program coprimes; var M,N: integer; // Определить, являются ли числа M и N взаимно простыми function notcoprime(M,N: integer): boolean; var K, i: integer; Res: Boolean; begin Res := false; if N > M then K := M else K := N; for i := 2 to K do Res := Res or (N mod i = 0) and (M mod i = 0); notcoprime := Res; end; begin // ввод данных write('Please enter a natural number: N='); readln(N); write('Please enter a natural number: M='); readln(M); // вызов функции и if notcoprime(M,N) writeln(M, ' and else writeln(M, ' and readln; end.
выдача результата then ', N, ' are not coprime.') ', N, ' are coprime.');
Атомы и молекулы языков программирования Для операторного языка важнейшим строительным материалом являются, конечно же, операторы. Операторы – приказы для ЭВМ в стиле «делай то-то и то-то», выполняющие в языке программирования роль сказуемых, которые записываются с помощью так называемых ключевых слов. В приведенном примере это оператор цикла for, условный оператор if then else, составной оператор begin end. В Паскале все
18
операторы разделяются между собой символом «;» (точка с запятой). Над чем может производить действия ЭВМ? Над данными в ячейках памяти. Ячейка памяти или совокупность ячеек представляют своего рода контейнер для хранения данных, который с точки зрения программы называется переменной. Содержимое контейнера тогда будет называться значением переменной. Для удобства данные типизируют. Типом данных называют поименованное множество значений, задаваемое различными способами. При объявлении переменной указывается её тип, т.е. ограничивается область значений переменной. Как правило, в языке определено несколько встроенных типов данных, например целые и вещественные числа, строки, а также имеются механизмы для определения сложных типов данных на основе простых, например, массивов, структур, классов. Тип данных также определяет и множество допустимых действий над значениями этого типа. В связи с этим роль типов данных настолько велика, что далее она будет рассмотрена более подробно. Для обращения к переменной в теле программе ей назначается уникальное имя – идентификатор. Идентификаторы даются также типам данных, подпрограммам (процедурам и функциям), модулям программы, константам. В нашем примере идентификаторами переменных являются M, N, K, i, а также результат функции notcoprime. В примере использованы встроенные типы данных языка Паскаль – integer и boolean. Важную роль в языке играют такие конструкции, как выражения. Они строятся из констант и переменных, связанных различными функциями и операциями. Например, (Pi * sqr(radius) / 2). Операции (арифметические, логические, сравнения и др.) и функции предназначены для выполнения каких-либо действий над аргументами. Они, как и операторы, играют роль своего рода клея в программе. В примере для вычисления результата notcoprime используется булево выражение, составленное из операций mod, or, =. Совокупность связанных операторов можно выделить в отдельный фрагмент программы – подпрограмму (процедуру или функцию). Функция отличается от процедуры только тем, что возвращает значение, это даёт возможность вставлять вызовы функций в выражения. Связанные по какому-либо смысловому признаку про-
19
цедуры, функции, типы данных имеет смысл выносить в отдельные модули. Тогда основная программа будет применять эти модули. Сами модули также могут использовать другие модули, в том числе модули стандартной библиотеки. В примере реализована одна функция notcoprime, принимающая два аргумента и возвращающая истину, если аргументы не являются взаимно простыми.
Ввод и вывод Представьте программу, в которую все исходные данные уже заложены. Если их требуется изменить, то сначала следует исправить исходный код, а затем перекомпилировать программу. Представьте, что эта программа ни с кем не делится результатами своей работы. Такая программа неуправляема и бесполезна. Но цель программиста состоит в разработке полезных программ или, по крайней мере, таких программ, которыми можно пользоваться. Здесь уместно ввести понятие пользователя или оператора, т.е. человека, взаимодействующего с программой. Итак, программист должен позаботиться о пользователях программы, определить способы их взаимодействия с программой. Взаимодействие может заключаться в передаче данных и (или) управления. Простейший способ взаимодействия обеспечивается средствами ввода-вывода, встроенными практически в любой операторный язык программирования. Подсистема ввода данных обеспечивает работу с клавиатурой, подсистема вывода выдает информацию на дисплей. Вообще говоря, в основе системы ввода-вывода лежит механизм работы с файлами, который будет рассмотрен в следующих главах. Взаимодействие с другими программами и устройствами, такими как мышь, сканер, принтер, осуществляется посредством специальных программных интерфейсов. Что происходит при обращении к подсистеме ввода с клавиатуры? Работа программы приостанавливается до момента возврата управления пользователем. Подсистема ввода начинает сканировать сигналы от клавиатуры и запоминать коды нажатых клавиш. По окончании ввода полученная последовательность передаётся в программу. Если осуществлялся ввод значения переменной, то происходит приведение этой последовательности к типу перемен-
20
ной, например к числу с плавающей запятой. Если преобразование невозможно, возникает ошибка времени выполнения. Система вывода передаёт на экран дисплея последовательность символов. При выводе значения числовой переменной происходит обратное преобразование числа в строку. В примере для ввода данных используется процедура readln, а для вывода текста и значений переменных – процедуры write и writeln стандартной библиотеки. В чём же заключается работа программиста с точки зрения пользователя? Операторы и операции позволяют выполнять некоторые действия над данными. Обращение к данным происходит, так или иначе, через переменные. Таким образом, задача программиста состоит в том, чтобы обеспечить сбор данных от пользователя, преобразовать их и выдать пользователю результат на основе конечных значений переменных.
Понятие блока и замечания по стилю Для удобства восприятия текста программы человеком принято структурировать программу – разделять на независимые блоки и подчёркивать вложенность блоков друг в друга с помощью отступов от начала строки. Блок представляет собой последовательность инструкций, логически связанных между собой и выполняющих в совокупности относительно независимый участок работы. Принято выделять блоки с помощью составного оператора begin end. В приведённом примере код структурирован и снабжён комментариями. Таким образом, подчёркнута структура программы, что также облегчает её понимание. Обратите внимание на то, как названы типы и переменные, как подобраны ключевые слова. Из названий можно сделать некоторые выводы о назначении обозначаемой сущности, а ведь это сильно облегчает понимание программы. Возьмём за правило выбирать осмысленные идентификаторы для вводимых типов, переменных, функций. В противном случае разобраться в программе с такими идентификаторами, как asdf, vv12, type2 и fff4, будет невозможно. В сообществах программистов существуют негласные стилистические правила именования. Сравните: program mypr; var M,N: integer;
21
function f(M,N: integer): boolean; var K, bb: integer; a: Boolean; begin a := false; if N > M then K := M else K := N; for bb := 2 to K do a := a or (N mod bb = 0) and (M mod bb = 0); f := a; end; begin readln(N); readln(M); if f(M,N) then writeln(M, ' and ', N, ' are not coprime.') else writeln(M, ' and ', N, ' are coprime.'); end.
Возьмём за правило структурировать программы.
Синтаксис Основные строительные элементы программы для типового операторного языка рассмотрены. Пришло время взглянуть на сам язык с точки зрения его лексики, грамматики, правил орфографии и пунктуации, которые будем называть синтаксисом. def. Алфавит – множество допустимых символов языка программирования. Подробно алфавит описан в обширной литературе по языку Паскаль, здесь же приведены лишь основные сведения. Алфавит языка Паскаль включает прописные и строчные латинские буквы, арабские цифры и специальные символы. Из букв, цифр и знака подчеркивания строятся идентификаторы. Спецсимволами являются ключевые слова, знаки операций, знаки пунктуации, разделители. Идентификаторы не должны совпадать со спецсимволами. В примере использованы следующие ключевые слова: для операторов – begin, end, if, then, else, for, to, do; встроенные логические и арифметические операции – or, mod, and; другие – program, var, function. В примере использованы следующие знаки пунктуации: ; отделяет операторы, объявления; : отделяет переменную от ее типа; , отделяет элементы списка друг от друга; = логическая операция равенства;
22
()
круглые скобки, обрамляющие списки параметров или выражения; := оператор присваивания; > операция сравнения «больше»; // однострочный комментарий; ' апостроф (обрамляет строку); . конец программы, а также отделение целой и дробной части числа, отделение записи и поля записи. Разделителями являются пробел, управляющие символы, комментарии.
Общая структура программы на языке Паскаль Программа coprimes включала следующие разделы: заголовок программы (program); раздел объявления переменных (var); раздел процедур и функций (procedure/function); тело программы (begin … end.). Также в программе могут присутствовать: раздел объявления констант (const); раздел объявления типов данных (type); раздел объявления используемых модулей (uses); раздел объявления меток (label). Кроме заголовка и тела программы разделы могут повторяться. Тело программы является обязательным разделом. Таким образом, минимальная программа на Паскале выглядит следующим образом: begin end.
А программа, печатающая на дисплее приветствие, выглядит так: begin writeln("Hello world!"); end.
В языке Паскаль принят следующий подход: сначала необходимо сделать объявления переменных, типов и только затем исполь-
23
зовать. В языке C++ переменные можно объявлять по ходу программы, но также до их использования.
Встроенные, определяемые и типизированные константы Константы именуются для удобства. Например, чтобы не писать каждый раз число Пи до 7-го знака, можно определить его один раз в виде константы с нужной точностью и обращаться к ней по имени. Удобно объявлять в виде констант предельные значения, например границы массивов. Этот приём минимизирует число правок в исходном тексте программы при необходимости изменить какоелибо предельное значение. Константы определяются с помощью ключевого слова const. Если при определении константы указывается её тип, то она становится типизированной и может играть роль переменной, отличие от обычных переменных только в том, что она инициализирована, т.е. ей присвоено начальное значение: const // константы pi и e – определены в модуле Math pi = 3.14159; max = 16; // объявление типизированной константы (переменной) k: integer = 89; var a,b: array[1..max] of real; i: integer; begin for i:=1 to max do begin a[i] := pi*i; b[i] := a[i]*pi; end; end.
Роль типизации в операторных языках Далее рассмотрим способы представления данных, их типы и переменные. Как уже говорилось, тип данных – поименованное множество значений, допускающее определённые действия над значениями. Помимо множества значений тип определяет: множество допустимых операций; множество допустимых преобразований в другие типы; внутреннюю структуру хранения данных.
24
Какие проблемы решают с помощью типизации? Типы данных имеют огромное значение при проектировании программ, играют большую роль при компиляции и во время исполнения программы. Проектирование. Как показал опыт программирования, разные типы данных по-разному эффективны для разных задач: одни типы данных хороши для хранения (компактны), другие – для поиска (организованы), третьи – для проведения вычислений и т.п. Таким образом, типы данных во многом определяют способы обработки данных и общее быстродействие программы, а значит, конструировать типы данных в отрыве от задачи недальновидно. Поэтому при проектировании типы данных разрабатываются с учётом алгоритмов их последующей обработки. Компиляция. Человеку свойственно ошибаться. Типы данных помогают компилятору минимизировать ошибки в программе до её исполнения. При компиляции осуществляется сверка типов переменных и присваиваемых им значений. Если присваивается выражение, то тип выражения определяется на основе типов его аргументов и использованных операций. Кроме того, осуществляется проверка допустимости аргументов операций, например, строки нельзя умножать, а массивы сравнивать. Исполнение. В объектно-ориентированном программировании во время выполнения программы используется механизм динамического определения типа объекта для вызова полиморфных методов класса. Здесь следует отметить, что введение типов в язык программирования оборачивается и увеличением сложности построения программ, а также снижением гибкости. Языки сценариев, как правило, являются нетипизированными, что делает их лёгкими для освоения и использования, но всё же менее производительными.
Введение в типы данных Какие типы данных используются в программировании? Типы данных можно условно разделить на несколько групп: простые, структурированные, процедурный тип и указатели. К простым относятся числовые типы (целые, вещественные), логический, перечисляемый, строковый (в зависимости от языка программирования строка может являться простым типом, как в языке Паскаль, а может быть разновидностью массивов, как, например, в Си).
25
К структурированным типам данных относятся массивы, записи, классы, файлы, множества. Процедурный тип определяет число и тип аргументов процедуры (функции); значениями данного типа являются соответственно процедуры или функции. Этот тип играет большую роль в событийно-ориентированном программировании. Механизм указателей позволяет оперировать адресами участков памяти, в которых размещаются значения переменных, как значениями особого типа. Это важнейший механизм для построения динамических структур данных в частности в объектноориентированном программировании. Как задаются множества значений типа? С точки зрения математики множество значений типа можно задать двумя способами: перечислив все возможные значения или указав способ получения всех значений. С точки зрения операторного языка программирования множество значений типа данных определяется в основном внутренним представлением значений в памяти ЭВМ и механизмами контроля типов. Множество значений простого типа определяется способом внутреннего представления. Так, для целых чисел с шагом 1 максимальное число определяется как 28*N, где N – число байт, отводимое для представления числа в ЭВМ с двоичной системой счисления. Если требуется хранить знак числа, то для этого отводится один бит, что в два раза уменьшает диапазон допустимых чисел. Если требуется хранить число с плавающей запятой, то часть памяти отводится под мантиссу, а часть – под порядок числа. Таким образом, числа с плавающей запятой имеют разную точность представления. Множество значений строкового типа определяется алфавитом языка и максимальной длиной строки, зависящей от внутреннего представления. Множество значений структурированного типа есть декартово произведение множеств значений типов элементов, составляющих структурированный тип. Как определяются новые типы данных? Они определяются с помощью встроенных механизмов определения типов и встроенных (стандартных) типов данных. Встроенные механизмы определения типов позволяют определять новые структурированные типы данных. Для каждой группы структурированных типов имеется свой механизм – своего рода метатип, который нужно конкретизи-
26
ровать для построения нового типа. Например, для определения массива необходимо сначала определить тип элементов массива, а для определения записи необходимо определить структуру записи – набор типизированных полей записи. Целые и вещественные встроенные типы, как правило, зависят от длины машинного слова в битах (разрядности ЭВМ), например 8, 16, 32, 64. Машинное слово целиком помещается в регистры процессора, а значит, обрабатывается быстро и просто. Полезным свойством типизированных переменных является то, что с помощью оператора присваивания на основе информации о типе можно создавать копии значений переменной, поскольку известен размер памяти, занимаемый значениями. Как следствие, значения переменных структурированных типов (массивы или записи) можно копировать без поэлементного присваивания. При работе с указателями следует иметь в виду, что в случае присваивания создаётся лишь копия адреса области памяти, а не собственно области памяти. В Java механизм указателей отсутствует, однако возможность создавать динамические структуры данных имеется: в момент присваивания создаётся копия области памяти, в которой размещается динамическая структура. В Object Pascal работа с динамическими массивами возможна без применения указателей.
27
Глава 3. Типы данных языка Паскаль Рассмотрим типы данных языка: 1) простые типы данных; 2) указатели; 3) структурированные типы данных; 4) процедурный тип. Некоторые типы уже встроены в язык и не требуют объявления, их называют стандартными или встроенными типам данных. Все остальные типы необходимо объявлять с помощью специальных конструкций. К встроенным типам относятся: простые целые и вещественные типы; логический тип Boolean; символьный тип Char и тип-строка String; текстовый файл Text; нетипизированный указатель pointer; тип-строка с завершающим нулевым символом PChar.
Числовые типы Как уже говорилось, целые и вещественные типы отличаются количеством байтов, отводимых под представление числа, а также способностью представлять отрицательные числа. В языке Паскаль определены следующие числовые типы: Byte 1 байт 0..(28–1), беззнаковые целые Word 2 байта 0…(216–1), беззнаковые целые Shortint 1 байт -27..(27–1), знаковые целые Integer 2 байта -215..(215–1), знаковые целые Longint 4 байта -231..(231–1), знаковые целые Real 6 байт 11–12 значащих разрядов Single 4 байта 7–8 значащих разрядов Double 8 байт 15–16 значащих разрядов Extended 10 байт 19–20 значащих разрядов Comp 8 байт 19–20 значащих разрядов, целые Переменные вещественных типов могут принимать как положительные, так и отрицательные значения. Значения задаются либо явно с помощью констант, либо неявно через выражения. Примеры объявления и задания переменных:
28
type Numeric = integer; var i: integer; m,n: real; number: Numeric; begin i := 3; I := $F3; // шестнадцатиричный формат m := -5.78; m := 8e-5; // формат со степенью: 8*10-5 n := m + 3.13*2 - i; number := i; end.
Все числа представляются в двоичной системе счисления. Для представления знаковых чисел старший бит используется для указания знака (0 – у положительных, 1 – у отрицательных).
Логический тип Переменные логического типа Boolean могут принимать только два значения False (ложь) и True (истина). Выполняется постулат False < True. Во внутреннем представлении ЭВМ ложь представляется нулём, а истина – единицей. Есть и другие логические типы, в которых истинным считается любое значение, отличное от нуля: var flag: boolean; a, b: integer; begin a := 3; b := 4; flag := false; flag := a < b; flag := flag and (a=b); end.
Рассмотрим несколько примеров, в которых участвуют переменные логического типа: if a = true then … if a = false then …
if a then … if not a then …
if i < j then a := true else a := false;
a := i < j;
29
if i < j then a := false else a := true;
a := not (i < j); a := i >= j;
Эквивалентные формы записи в правом столбце более предпочтительны. Рассмотрим записи if i < j then a := true;
и a := i < j;
Можно ли считать их эквивалентными? Нет, так как присваивание в первом случае выполняется не всегда, а только когда i < j.
Символы и строки Почему в ASCII-таблице (рис. 1) всего 256 символов? Встроенный символьный тип Char объединяет символы кодировки ASCII. По своей мощности символьный тип равен типу Byte, т.е. имеет всего 256 различных значений. Все символы имеют свой собственный код в таблице ASCII от 0 до 255. Таким образом, для однобайтовой кодировки, примером которой является кодировка ASCII, возможно 256 различных символов. Как видно из таблицы, символы английского алфавита идут в алфавитном порядке. При сравнении символов реально сравниваются их коды в таблице, поэтому символ 'a' будет меньше всех остальных символов алфавита. Заглавные буквы представляются другими символами и соответственно имеют другие коды. Помимо букв в ASCII-таблице закодированы цифры, знаки пунктуации, управляющие символы (перевод строки, табуляция и др.), символы псевдографики. Символьные константы записываются в одинарных кавычках. Узнать код символа можно с помощью функции ord(), а получить символ по коду можно с помощью функции chr() или поставив знак # перед кодом.
30
Рис. 1. ASCII-таблица
Примеры использования типа char и функций ord и chr: var c: char; a, b: byte; begin c := 'Z'; a := ord(c); // a = 122 b := 32; c := chr(b); // с = ' ' – пробел с := #32; // тоже самое write(chr(10)+chr(13)); end.
31
Следует отметить, что в языке Си используется один и тот же тип char для представления чисел от 0 до 255 и символов. Значениями встроенного строкового типа string, по сути, являются последовательностями значений символьного типа, т.е. строка в Паскале – 0 и более символов, идущих один за другим. Внутреннее представление строк – последовательность байт, причём первый байт отводится для хранения реальной длины строки. Уже упоминалось, что максимальное значение, представимое в ячейке памяти размером в 1 байт, равно 255. Поэтому максимальная длина строки ограничивается 255 символами. Программист может ещё больше ограничить длину строки, указав максимальное число символов при объявлении переменной строкового типа. Строковые константы записываются в одинарных кавычках, как и символьные константы. Для помещения в строку одинарной кавычки (апострофа) его следует записать дважды. Примеры объявления и задания переменных строкового типа: var s: string; c: char; fio: string[60]; leng: byte; begin s := 'Don''t do it'; // строка с апострофом fio := 'Niklaus Wirth'; writeln(s + ' ' + fio); c := s[2]; // 'o' leng := ord(s[0]); // определение длины строки leng := length(s); // определение длины строки // Строка с непечатаемыми символами перевода строки // и возврата каретки s := 'string 1'#10#13'string 2'; end.
Для доступа к конкретному символу строки нужно после имени переменной в квадратных скобках указать индекс символа. Нумерация символов строки начинается с единицы, а первый байт с индексом 0 хранит длину строки. Предпочтительней определять длину строки с помощью специальной функции length(s: string), так как при этом программа не будет зависеть от внутреннего представления строк в Паскале. В частности, в языке Си, строки представляются по-иному, а символы в строке индексируются с нуля.
32
Структурированные типы данных Для чего вводятся структурированные типы данных? Любая программа оперирует данными, но данные вовсе не обязаны быть такими же простыми, как числа или символы. В реальности это самые разнообразные структуры, которые программисты пытаются разложить на простейшие элементы и построить программу для их обработки. Представьте, что нужно представить и обработать результаты сотен измерений температуры. Каждое измерение представимо в виде вещественного числа, но не объявлять же для этого сотни разных переменных? Для этого есть массивы. Массивы Массив представляет собой последовательность элементов одного типа. Для объявления массива необходимо указать число и тип элементов массива. Для обращения к элементу массива необходимо указать имя переменной-массива и в квадратных скобках индекс элемента. Например, var m, n: array[1..100] of real; x: array[1..100] of real; begin for i:=1 to 100 do m[i] := random; n := m; x := m; end.
// допустимо // недопустимо, при компиляции будет ошибка
Здесь число элементов массива указывается с помощью диапазона, причём минимальное число будет соответствовать минимальному индексу элемента, а максимальное – максимальному индексу элемента. Следует отметить, что при таком объявлении массивов не работает механизм контроля типов, так как типы переменных считаются различными, несмотря на то, что их структура идентична. Поэтому хорошим тоном считается объявление типа-массива: type arr = array[1..100] of real; var m, n, x: arr;
33
При таком подходе переменные можно будет присваивать, передавать в виде параметров в функции и процедуры. Массивы могут иметь и несколько размерностей. В этом случае необходимо через запятую указать диапазоны индексов для каждой размерности. В частности, для представления матриц 100х200 можно использовать следующее объявление: type Matrix = array[1..100, 1..200] of real; var m: Matrix; begin // инициализируем элемент 23 строки в 48 столбце m[23,48] := -4.89; end.
С точки зрения структур данных здесь объявляется двумерная матрица вещественных чисел, у которой 100 строк и 200 столбцов. Для обращения к элементу этой матрицы следует записать что-то вроде m[i, j]. Рассмотрим далее следующий пример: type Arr = array [1..200] of real; Matrix = array[1..100] of Arr; var m: Matrix; a: Arr; i,j: integer; begin // в а копируется 23-я строка матрицы a := m[23]; // 48-й элемент в массиве, но не в матрице! a[48] := -4.89; // 48-й элемент 23-его массива матрицы m[23][48] := -4.89; end.
Казалось бы, массив массивов можно считать матрицей, но это не так. Структурно элементом Matrix здесь является массив, а не вещественное число. Обратите внимание на синтаксические различия в записи при доступе к элементам m.
34
Записи Представьте, что в программе требуется описать некоторый объект реального мира, например запись в телефонной книжке, и далее работать с ним как с единым целым. Это возможно только с помощью совокупности переменных. В Паскале для этого введён тип-запись, в Си – тип-структура. Для объявления типа-записи следует использовать ключевое слово record и далее описать составляющие запись переменные, которые называются полями записи. Например, type phone_rec = record of fio: string[60]; phone: string[20]; e_mail: string[50]; end; var pr1,pr2: phone_rec; begin pr1.fio := 'Ivanon Ivan'; pr1.phone := '89169110203'; pr1.e_mail := '
[email protected]'; pr2 := pr1; writeln('fio: ' + pr1.fio); writeln('phone: ' + pr1.phone); writeln('e-mail: ' + pr1.e_mail); end.
Доступ к полям записи осуществляется через символ '.' после имени переменной типа-записи. Значением переменной типазаписи является совокупность значений полей записи. Телефонная книга на 200 номеров может быть определена с помощью массива записей: type phone_book = array [1..200] of phone_rec; var pb: phone_book; begin … pb[i].fio := … … end.
35
Файлы Понятно, что хранить телефонную книгу в памяти программы неудобно, так как при завершении работы программы все данные будут потеряны. Чтобы избежать утраты данных, необходимо записать их во внешнюю память (жесткий диск). Для этого используются файлы. В языке Паскаль предусмотрены средства для работы с файлами разного типа. Телефонная книга в нашем примере представляет собой последовательность однотипных записей, поэтому удобно объявить типизированный файл. В языке Паскаль существуют и другие типы данных – последовательности, множества, классы. Первые два используются сравнительно редко, поэтому оставлены на самостоятельное изучение. Рассмотрение классов и объектно-ориентированного подхода в программировании выходит за рамки данного пособия.
36
Глава 4. Присваивание и ветвление Оператор присваивания Оператор присваивания – один из базовых в императивном программировании. Происходит он из команды процессора по копированию содержимого одной ячейки памяти в другую (mov в ассемблере). Вот несколько применений оператора присваивания: инициализация значений переменных; запоминание (в некоторой области памяти) промежуточных результатов вычислений для последующего использования; создание копии значения (копии содержимого в области памяти). Оператор действует над двумя аргументами: первый аргумент – некоторая переменная, второй – вычислимое выражение. В языке Паскаль оператор обозначается «:=» (двоеточие равно). Обычно используется инфиксная запись оператора, т.е. сначала записывается переменная, затем оператор, а потом – присваиваемое выражение. Например, beta := 180/n; height := (0.5 + sin(beta)) * tg(alpha);
При компиляции проверяется соответствие типов выражения и переменной. Если типы не совместимы, то возникает ошибка преобразования типов. Например, нельзя число с плавающей запятой присвоить целочисленной переменной. Операционная семантика2 оператора присваивания: 1) во время выполнения программы вычисляется присваиваемое выражение по правилам вычисления выражений. Если при вычислении выражения возникает ошибка, то выполнение прекращается; 2) если требуется приведение типов, то оно выполняется, т.е. полученное значение преобразуется в формат типа переменной;
2
Операционная семантика описывает то как следует интерпретировать фрагмент программы в виде последовательности вычислительных шагов.
37
3) если объём области памяти, отведённой для значения переменной, не равен объему, необходимому для хранения нового значения, то память, выделенная под предыдущее значение переменной, возвращается программному окружению, и выделяется область памяти для нового значения переменной; 4) полученное значение помещается в область памяти, выделенную для переменной. Пояснения: На первом шаге ошибки могут возникать по различным причинам: при вычислении арифметических операций (деление на 0, переполнение типов), преобразовании аргументов функций к нужным типам (ошибка преобразования типов), обращении к несуществующим элементам массивов и т.п. На втором шаге может потребоваться преобразовать, например, число 500 к типу byte, с помощью которого можно представить числа от 0 до 256, тогда возникает ошибка переполнения типа. Если же переменная имеет тип real, то число 500 будет успешно преобразовано в формат числа с плавающей запятой 5.0e+2. Третий шаг выполняется при присваивании строк или динамических массивов, так как выделяемая область памяти зависит от их текущей длины. Для остальных типов объём памяти, необходимый для хранения значения данного типа, вычисляется еще на этапе компиляции. Применение оператора связано с побочным эффектом, возникающем на третьем и четвёртом шаге: предыдущее значение переменной безвозвратно теряется. Этот эффект негативно проявляется в случаях, когда память под значение переменной выделяется динамически в самой программе. Если выделенную память не вернуть программному окружению до присваивания, то после присваивания она так и будет считаться занятой и будет потеряна для дальнейшего использования. Это распространённая ошибка программистов приводит к так называемой утечке памяти. В Java реализован механизм автоматической сборки «мусора», который возвращает неиспользуемые более области памяти программному окружению (менеджеру памяти). Неявное присваивание Присваивание может происходить неявно, т.е. без применения оператора присваивания.
38
Рассмотрим пример: var a,b: integer; function Sum(arg1,arg2: integer): integer; begin Sum := arg1 + arg2; end; begin a := 5; b := 3; writeln(sum(a,b)); end;
В этой программе определена функция суммирования двух чисел, в которой выражение arg1 + arg2 присваивается возвращаемому значению функции. В начале работы программы происходит инициализация значений переменных a и b. Далее на экран печатается значение, возвращаемое функцией sum. При этом функции передаются два аргумента: a и b. В момент вызова функции sum вычисляются аргументы и осуществляется их неявное присваивание локальным переменным функции, т.е. значение переменной a присваивается переменной arg1, а значение переменной b присваивается переменной arg2.
Оператор ветвления if Ветвление в операторных языках программирования – точка принятия решения, после которой выполнение программы может пойти по одной из альтернативных ветвей. Операторы ветвления позволяют наглядно в тексте программы отделить одну ветвь от другой, т.е. подчеркнуть структуру участка программы с альтернативными ветвями. При этом программа по-прежнему остаётся последовательностью инструкций, так как выполняться будет лишь одна ветвь из всех возможных. Выбираться та или иная ветвь будет в соответствии с принятым решением. Для чего используется ветвление? Рассмотрим пример вычисления корней квадратного уравнения ax2+bx+c=0. Как известно, решение существует только в том случае, если дискриминант уравнения неотрицателен, в противном случае под корнем возникнет число меньше 0. В программе это не-
39
допустимо, поскольку приведёт к ошибочному завершению работы программы. Уместно использовать ветвление. На псевдокоде алгоритм для вычисления корней уравнения может выглядеть так: установить d = b2-4ac если d >= 0 то установить x1 = (-b + √d)/2a установить x2 = (-b - √d)/2a вывести значения x1 и x2 на экран иначе сообщить, что корней нет
Конструкция «если – то – иначе» используется для того, чтобы разделить два возможных случая и выполнить различный набор действий. В языке Паскаль имеется аналогичный оператор ветвления, записываемый с помощью ключевых слов if then else. Данный оператор позволяет записать две альтернативных ветви, причём ветвь else является необязательной. В условии выбора должно стоять выражение, имеющее логический тип (Boolean), т.е. значением выражения должна быть либо истина, либо ложь. Операционная семантика оператора ветвления if: 1) вычислить условное выражение оператора; 2) если получена истина, то выполнить оператор в основной ветви (после then). Если получена ложь и предусмотрена альтернативная ветвь (после else), то выполнить оператор в альтернативной ветви. Блок-схема алгоритма приведена на рис. 2. В нашем случае основная ветвь содержит в себе несколько инструкций, поэтому её следует поместить в операторные скобки begin … end (так называемый составной оператор): var a,b,c,d,x1,x2: real; begin readln(a,b,c); d := b*b-4*a*c; if d >= 0 then begin x1 := (-b + sqrt(d))/(2*a); x2 := (-b – sqrt(d))/(2*a); writeln(x1, x2); end else
40
writeln('no solution'); end.
d = b2-4ac
Да
Нет D >= 0 ?
x1 = (-b + √d)/2a
x2 = (-b - √d)/2a
Вывести x1 и x2
Вывести «Корней нет»
Рис. 2. Блок-схема алгоритма вычисления корней квадратного уравнения
Оператор ветвления case Ещё один оператор ветвления case … of используется в тех случаях, когда существует несколько альтернативных ветвей. Например, стоит задача получить название месяца по его порядковому номеру в году. Функция, которая это делает, может выглядеть следующим образом: function GetMonthName(MonthNumber: integer): string; begin case MonthNumber of 1: GetMonthName := 'Gennaio'; 2: GetMonthName := 'Febbraio'; 3: GetMonthName := 'Marzo';
41
4: GetMonthName := 'Aprile'; 5: GetMonthName := 'Maggio'; 6: GetMonthName := 'Giugno'; 7: GetMonthName := 'Luglo'; 8: GetMonthName := 'Agosto'; 9: GetMonthName := 'Settembre'; 10: GetMonthName := 'Ottobre'; 11: GetMonthName := 'Novembre'; 12: GetMonthName := 'Dicembre'; else GetMonthName := ''; end; end;
Оператор case принимает один аргумент, который может принимать значения порядковых типов (целых типов, типовперечислений, логического или символьного типа). Для каждого варианта значения может быть указана своя ветвь. Также есть возможность описать ветвь else. Конечно, эту задачу можно решить и с помощью оператора if, но тогда понадобится 12 вложенных операторов if вместо одного case. Кроме того, скорость работы оператора case будет гораздо выше: function GetMonthName(MonthNumber: integer): string; begin if MonthNumber = 1 then GetMonthName := 'Gennaio'; else if MonthNumber = 2 then GetMonthName := 'Febbraio'; else if MonthNumber = 3 then … else GetMonthName := ''; end; end;
Значения в операторе case можно комбинировать. Например, функция определения сезона по номеру месяца может выглядеть следующим образом: function GetSeason(MonthNumber: integer): string; begin case MonthNumber of 1,2,12: GetSeason:= 'L''inverno';
42
3..5: GetSeason:= 'La primavera'; 6..8: GetSeason:= 'L''estate'; 9..11: GetSeason:= 'L''autunno'; else GetMonthName := ''; end; end;
Здесь указываются несколько вариантов либо с помощью перечисления через запятую, либо с помощью диапазона значений (интервала). Допускается указывать несколько интервалов через запятую. NB! Одинарная кавычка внутри строки удваивается. Операционная семантика оператора ветвления case: 1) найти значение аргумента оператора среди вариантов; 2) если найден вариант, включающий искомое значение, то выполнить оператор в соответствующей ветви. Если вариант не найден и предусмотрена альтернативная ветвь (после else), то выполнить оператор в альтернативной ветви. Ветвление с помощью оператора case графически изображено на рис. 3.
… Аргумент-переменная Подмножество значений 1
Ветвь 1
…
Подмножество значений N
Иначе
Ветвь N
Ветвь N+1
…
… Рис. 3. Блок-схема исполнения оператора case
43
Замечания: значения в альтернативах не должны повторяться или пересекаться между собой – это гарантирует единственность выбора альтернативы; при описании альтернативы можно использовать только константные значения и их комбинации; если нужно указать несколько операторов в одной ветви, то необходимо использовать составной оператор begin … end после двоеточия: case
of : begin … end; : … end;
Главное – научиться выбирать подходящий оператор. В силу особенности реализации case-оператор характеризуется логарифмическим временем поиска, а цепочка равносильных if-операторов – более медленным линейным. Возьмём за правило использовать оператор if так, чтобы в основную ветвь помещался наиболее часто исполняемый код, поскольку в случае истинности условного выражения он сразу выполнится, а в случае ложности условного выражения необходим дополнительный переход на альтернативную ветвь.
Выражения и операции Выражение определяет способ вычисления какого-либо значения и записывается согласно синтаксическим правилам языка программирования. В нотации РБНФ3 выражение можно определить следующим образом: 3
Нотация РБНФ (расширенная Бэкус-Наурова форма) была предложена Н. Виртом для описания синтаксиса формальных языков, к которым относится и Паскаль. Описание состоит из набора правил, в каждом из которых определяется некая языковая конструкция (в левой части правила) через комбинацию других конструкций (в правой части).
44
выражение = переменная | константа | вызов_функции | (выражение) | унарная_операция выражение | выражение бинарная_операция выражение вызов_функции = имя_функции "(" [ выражение {"," выражение}] ")"
Здесь знак «|» означает выбор, квадратными скобками обрамляют необязательные конструкции, фигурными – повторение конструкции 0 и более раз, в кавычки помещают символы языка. Из определения следует, что простейшими выражениями являются переменные и константы. В Паскале к множеству переменных относятся любые переменные, объявленные в секциях var, типизированные инициализированные константы, переменные, объявленные в секции объявления параметров процедур и функций, а также части переменных структурированного типа, в частности, элементы массивов, поля записей и объектов. Примеры простейших выражений: 5 8.92 pi arr[i] a_phone_rec.fio
Далее, результат вызова функции также является выражением. Функции могут принадлежать стандартной библиотеке либо быть написаны программистом. Параметры функции, если они есть, заключаются в круглые скобки и перечисляются через запятую. Сами параметры также являются выражениями. Любое выражение, поставленное в скобки, также является выражением. Перед выражением может стоять унарная операция (например, «-» или not). Бинарные операции записываются в инфиксной форме, т.е. знак операции стоит между операндами-выражениями. Примеры: (a or b) and (not a or not b) and true (arr[i] + arr[j])*sin(alpha)
Выражения вычисляются в определённом порядке: прежде остальных вычисляются выражения, находящиеся внутри скобок; перед вычислением значения операции или перед вызовом функции вычисляются её аргументы; порядок вычисления операций определяется их относительным приоритетом. Операции с одним приори-
45
тетом вычисляются слева направо, однако в рамках оптимизации компилятор может изменить этот порядок. Аргументы операций должны быть либо одинакового типа, либо совместимых типов. Операции делят на несколько групп: арифметические (+, -, *, /, div, mod); логические (and, or, not, xor); битовые (shl, shr, and, or, not, xor); сравнения (=, <>, <, >, >=, <=); строковая операция конкатенации (+); операция взятия адреса значения переменной @. Тип всего выражения можно определить по используемым операциям и типу аргументов операций. Компилятор следит за тем, чтобы операция была допустима для типов аргументов. Например, аргументы операций div и mod могут быть только целочисленных типов, причём результат неизбежно имеет целочисленный тип. Операции сравнения допустимы для многих типов аргументов, но компилятор отследит, чтобы типы были одинаковы или совместимы. Результат операций сравнения неизбежно имеет логический тип, независимо от типов аргументов.
Задания для самостоятельного выполнения 1. Применение сложных выражений внутри if, применение case. Написать программу, определяющую существование треугольника по трём сторонам: var side1, side2, side3: integer; exist: Boolean; begin writeln('Enter 3 sides:'); readln(side1); readln(side2); readln(side3); exist := (side1 + side2 > side3) and (side2 + side3 > side1) and (side1 + side3 > side2); if exist then writeln('Triangle exists') else writeln('Triangle does not exist'); end.
2. Написать программу, производящую над двумя вводимыми операндами указанную операцию ( +, -, *, /). Обратите внимание на потенциальное деление на ноль:
46
var num1, num2: integer; op: char; begin writeln('Enter 2 numbers:'); readln(num1); readln(num2); writeln('Еnter operation: '); read(op); case op of ‘+’: writeln(num1 + num2); ‘-’: writeln(num1 - num2); ‘*’: writeln(num1 * num2); ‘/’: if num2 = 0 then writeln ('Division by zero!') else writeln(num1/num2); else writeln('Unknown operation!'); end; end.
3. Для произвольной точки (x, y) вычислить выражение U в зависимости от принадлежности области D:
1 x2 y2 U 1 x2 1 y2
if x, y D; if x, y D,
где D – заштрихованная область.
Основная задача состоит в конструировании логического выражения, истинного для всех точек заштрихованной области. Чтобы не делать выражение слишком сложным, можно использовать ветвление, т.е. рассматривать отдельные случаи. Один из вариантов программы выглядит следующим образом: var x,y,u: real; begin writeln('Enter 2 numbers:'); readln(x); readln(y); if (x*x + y*y < 1)and ((y> x)and(x>0) or (y< x)and(x<0)) or (x*x + y*y > 1)and ((y>-x)and(x<0) or (y<-x)and(x>0)) then u := sqrt(1+x*x*y*y); else u := sqr(1+x)*sqr(1+y); end.
47
Глава 5. Сортировка и поиск Сортировка и поиск – важные виды деятельности человека, которым он обучается с младенчества и развивающие его способности к анализу и классификации. Более того, это его стремление к упорядочиванию хаоса, организации и осознанной подстройке мира под себя является неотъемлемой частью сущности человека. Сортировка и поиск – сложные процессы, в которых задействовано множество объектов. Цель сортировки – получение множества объектов, упорядоченного по некоторому критерию; цель поиска – выделение объекта, удовлетворяющего некоторому критерию, из множества объектов. Попытаемся разложить решение этих задач на простые действия и построить алгоритмы для решения этих задач на ЭВМ. Как известно, способов решить задачу может быть несколько – они могут различаться по своей эффективности, но все должны приводить к одинаковому результату. Будем рассматривать различные методы сортировки, но прежде придётся ввести следующие допущения: критерий сравнения существует и известен (доступность); объекты допускают сравнение по этому критерию (применимость); результат сравнения двух объектов однозначен (различимость объектов). Применение критерия можно реализовать с помощью операций сравнения над двумя объектами, а принятие решений после сравнения – с помощью оператора ветвления. Следует отметить, что и сортировка и поиск в общем случае подразумевают многократное принятие решений, так как объектов много. В этом смысле сортировка является более сложной задачей, чем принятие решений. Реальная жизнь осложняется отсутствием, неизвестностью или невозможностью применения критерия сравнения, а также неполнотой множества альтернатив, поэтому нам проще отсортировать колоду карт, чем принять важное решение. Рассмотрим возможную схему сортировки по возрастанию множества кубиков с числами, записанную на псевдокоде: 1. Найти кубик с минимальным числом 2. Поместить найденный кубик в начало ряда 3. Если ещё остались кубики,
48
то Найти кубик с минимальным числом среди оставшихся, Поместить его в ряд крайним справа иначе завершить работу 4. Повторить шаг 3.
Здесь подразумевается, что исполнитель умеет искать кубик с минимальным числом. Однако задача поиска ещё не решалась, поэтому рассмотрим возможную схему поиска, записанную на псевдокоде: 1. Взять первый попавшийся кубик 2. Если ещё остались кубики, то взять кубик среди оставшихся кубиков, сравнить числа на взятых кубиках, кубик с большим значением отложить в сторону иначе завершить работу 3. Повторить шаг 2.
Обе схемы обладают свойством цикличности, т.е. описывают процессы, которые могут повторяться в зависимости от выполнения условия повторения. В программировании повторение носит название «итерация». Если бы программа писалась на языке ассемблера, то переход к началу цикла представлял бы собой инструкцию безусловного или условного перехода jmp, je, jg и др. – от англ. jump (прыжок). Однако в языках третьего поколения имеются операторы цикла, которые объединяют в себе проверку условия повторения, тело цикла (что повторять) и переход на новый виток цикла. При этом структура программы становится прозрачной и простой. Таким образом, операторы циклы вполне применимы для решения задач сортировки и поиска.
Операторы цикла Вводятся три оператора для разных видов циклов: с предусловием; с постусловием; с параметром. Первые два вида циклов отличаются от третьего тем, что тело цикла выполняется в зависимости от некоторого условия. В цикле с
49
параметром тело цикла выполняется столько раз, сколько указано в параметре цикла. Оператор цикла while Оператор предназначен для описания циклов с предусловием, записывается с помощью ключевых слов while и do и читается так: «Пока истинно <условие> выполнять <инструкции>». Если требуется указать несколько инструкций в теле цикла, то следует использовать составной оператор begin…end: while loop_condition do begin … end;
Операционная семантика оператора while: 1) вычисление значения предусловия; 2) если полученное значение истинно, то выполнить оператор в теле цикла, иначе завершить выполнение цикла (перейти к оператору, следующему за оператором цикла); 3) перейти на шаг 1. Оператор цикла repeat Оператор предназначен для описания циклов с постусловием, записывается с помощью ключевых слов repeat и until и читается так: «Выполнять <инструкции> до тех пор, пока <условие> не станет истинным». repeat … until exit_condition;
Операционная семантика оператора цикла repeat: 1) выполнить операторы в теле цикла; 2) вычислить значение постусловия; 3) если полученное значение ложно (!), то перейти на шаг 1, иначе завершить выполнение цикла (перейти к оператору, следующему за оператором цикла). Нужно отметить, что условие в цикле repeat имеет обратный смысл, по сравнению с условием цикла while: для цикла repeat сле-
50
дует указывать условие завершения, а для цикла while – условие выполнения. Оператор цикла for Оператор предназначен для описания циклов с параметром, записывается с помощью ключевых слов for, to/downto, do и читается так: «Изменяя <параметр-переменную> от <начального значения> до <конечного значения>, выполнять <инструкции>». Если требуется указать несколько инструкций в теле цикла, то следует использовать составной оператор begin…end. Параметр цикла всегда меняется на единицу на каждом следующем витке цикла, а с помощью ключевых слов to и downto можно указать направление изменения. Если требуется, чтобы параметр цикла возрастал с каждым витком, то следует использовать ключевое слово to, при этом начальное значение должно быть не больше конечного значения параметра: for i:=1 to 7 do begin … end;
Чтобы параметр цикла убывал с каждым витком, следует использовать ключевое слово downto, при этом начальное значение должно быть не меньше конечного значения параметра: for i:=10 downto 1 do begin … end;
Операционная семантика оператора цикла for: 1) присвоить параметру цикла начальное значение; 2) если текущее значение параметра цикла не превышает конечное значение (для цикла to), то выполнить оператор в теле цикла, иначе завершить выполнение цикла; 3) вычислить новое значение параметра цикла; 4) перейти на шаг 2. Замечания: если начальное значение параметра цикла равно конечному, то выполнится один виток цикла, а если начальное значение больше конечного (для to), то ни одного; не следует менять значение параметра цикла явно;
51
значение параметра цикла после завершения цикла считается неопределённым.
Операторы управления циклами: continue, break, exit Оператор continue досрочно завершает очередной виток цикла и переходит к новому витку. Оператор break досрочно прерывает выполнение цикла и передаёт управление оператору, следующему за оператором цикла. Оператор exit досрочно прерывает выполнение процедуры или программы. Все три оператора не имеют параметров. Правила применения операторов цикла Если требуется проверять выполнимость некоторого условия до витка цикла, то имеет смысл использовать оператор цикла с предусловием. Если первый виток цикла выполняется в любом случае, а далее следует проверять некоторое условие, то имеет смысл использовать оператор цикла с постусловием. Если заранее известно количество витков цикла, то разумно использовать оператор цикла с параметром. Если известно максимальное количество витков цикла и в то же время цикл следует прервать, когда условие повторения перестанет выполняться, то можно использовать как циклы с условием, так и цикл с параметром в сочетании с оператором break. Проиллюстрируем последнее правило на двух примерах: // первый вариант i := 1; while Condition and (i <= Max) do begin … i := i+1; end; // второй вариант for i:=1 to Max do begin … if not Condition then break; … end;
52
Алгоритмы сортировки и поиска Вернёмся к задаче сортировки и поиска. Чтобы построенные схемы можно было реализовать на ЭВМ, необходимо выбрать типы данных и переписать алгоритм в терминах операторного языка. Для представления сортируемого множества удобно использовать массив чисел. Недостатком такого представления является лишь то, что массивы в Паскале имеют заранее определённый размер, что, впрочем, является недостатком структуры данных, а не алгоритма. Начнём с более простой задачи поиска минимального значения в массиве. Заметьте, что нас будет интересовать не само минимальное значение, а его местонахождение, т.е. индекс элемента массива. По индексу элемента в массиве всегда можно выяснить его значение. Для хранения индекса текущего минимального значения нам понадобится дополнительная переменная minIndex. Блок-схема алгоритма поиска приведена на рис. 4. По завершении алгоритма индекс элемента с минимальным значением будет храниться в переменной minIndex. Программу, реализующую этот алгоритм, можно написать как с помощью цикла while, так и с помощью цикла for: const Max = 200; type TCubes = array [1..Max] of integer; var cubes: TCubes; i, minIndex: integer; begin // инициализация массива … // поиск минимального элемента minIndex := 1; for i:=2 to Max do begin if cubes[i] < cubes[minIndex] then minIndex := i; end; // вывод на экран минимального значения writeln(cubes[minIndex]); end.
53
В примере пока пропущен блок, отвечающий за инициализацию массива. Без этого блока элементы массива будут иметь неопределённые значения, которые находились в памяти до момента её выделения под массив. Позже рассмотрим способы задания начальных значений. Установить minIndex = индекс первого элемента массива
Установить i = индекс второго элемента массива Индекс i < индекса последнего элемента массива Нет Да
Элемент с индексом i < элемента с индексом minIndex?
Да
Нет
Установить minIndex = i
Увеличить i на 1
Рис. 4. Блок-схема алгоритма поиска
54
Сортировка Будем считать, что исходные данные также хранятся в массиве. Результат сортировки поместим в дополнительный массив той же длины. Для простоты допустим, что числа в массиве не повторяются. Так как убирать элементы из массива нельзя, то при повторных просмотрах будем игнорировать элементы, уже вставленные в результирующий массив. Их определить просто: они все меньше или равны элементу, вставленному в результирующий массив последним. Поэтому запомним индекс последнего вставленного в результирующий массив элемента в переменной CountIndex. Одновременно её значение будет равно числу уже вставленных элементов. Построим блок-схему алгоритма сортировки (рис. 5). По завершении алгоритма индекс CountIndex равен индексу последнего элемента исходного массива. Программа может выглядеть следующим образом: const Max = 200; type TCubes = array [1..Max] of integer; var cubes, sorted: TCubes; i, minIndex, CountIndex: integer; begin // инициализация массива … // поиск первого минимального элемента minIndex := 1; for i:=2 to Max do begin if cubes[i] < cubes[minIndex] then minIndex := i; end; // вставка первого элемента sorted[1] := cubes[minIndex]; for CountIndex := 2 to Max do begin // поиск минимального элемента большего, // чем sorted[CountIndex-1] minIndex := 1; for i:=2 to Max do begin if (cubes[i] > sorted[CountIndex-1]) and (cubes[i] < cubes[minIndex]) then minIndex := i; end; // вставка sorted[CountIndex] := cubes[minIndex];
55
end; // вывод результата на экран … end.
Найти индекс минимального элемента исходного массива
Поместить элемент в начало результирующего массива
Установить CountIndex = 2 СountIndex < индекса последнего элемента массива Да
Нет
Найти индекс минимального элемента исходного массива, который больше элемента, помещённого результирующий массив последним
Поместить элемент в результирующий массив по индексу CountIndex
Увеличить CountIndex на 1
Рис. 5. Блок-схема алгоритма сортировки
56
Данная программа не способна сортировать массивы с повторяющимися элементами, однако её можно модифицировать или реализовать иной способ сортировки.
Оценка сложности алгоритмов и оптимизация При использовании дополнительного массива для сортировки расход памяти был увеличен в два раза. Обычно к этому прибегают для получения выигрыша в скорости. Так, в методах сортировки без дополнительного массива (в том же объёме памяти), требуется постоянно менять местами элементы массива. Два ресурса – память и время – являются основными при оценке алгоритмов. При сегодняшнем уровне развития вычислительной техники стоимость памяти упала настолько, что ресурс памяти перестал быть дефицитным и оптимизация по объему памяти отошла на второй план. Если раньше нехватка памяти приводила к большим изменениям архитектуры программ, то сейчас это влияние минимизировано. Главный невосполнимый ресурс – время – вышел на первый план. Несмотря на то, что быстродействие техники выросло на порядки, это не повод писать медленные программы и транжирить память. Следует помнить, что выросли размер и сложность программ, а компьютеры теперь решают гораздо более сложные задачи. При сравнении алгоритмов традиционно оценивают в некоторых условных единицах их временную сложность и емкостную сложность, т.е. затраты по времени и требуемой памяти. Поскольку алгоритмы обладают свойством массовости, т.е. применимы к различным наборам входных данных, то затраты по времени и памяти могут различаться для разных данных. Поэтому принято вычислять три оценки: минимальную, максимальную и среднюю. Оценка требуемой памяти складывается из объема памяти, необходимого для размещения значений всех переменных в течение всего времени выполнения программы. Время выполнения можно оценить только относительно исполнителя, т.е. конкретной ЭВМ, что мало говорит о качестве самого алгоритма. В связи с этим оценивают не само время, а количество команд (операций, шагов, инструкций), выполняемых во время работы алгоритма (программы), с учетом относительного времени выполнения каждой команды. Конечно, базисный набор команд также зависит от исполнителя, но
57
большинство современных ЭВМ имеет общие архитектурные корни, что снижает значимость этого различия. Оценим сложность алгоритма поиска минимального элемента в массиве: minIndex := 1; for i:=2 to Max do begin if cubes[i] < cubes[minIndex] then minIndex := i; end;
Емкостная сложность вычисляется просто: требуется n = Max ячеек для хранения элементов массива, а также ячейки для переменных minIndex, I и константы Max. Если все они типа Integer, под который отводятся два байта, то всего получается (n + 3)*2 = 2*n+6 байт. Оценим временную сложность алгоритма. Очевидно, в любом случае выполняются следующие инструкции: одно начальное присваивание, n–1 присваиваний значения счетчику цикла i и столько же сравнений в условии оператора if. Если копать глубже, то для витка цикла требуется также проверка условия выполнения цикла i<=n, переход к следующему витку и т.п, однако, как будет показано далее, по большому счёту всё это не имеет большого значения для общей сложности. Присваивание внутри ветвления выполняется в зависимости от истинности условия. Можно предположить, что в среднем при вероятности 50/50 будет выполнено (n–1)/2 присваиваний. Обращение к элементу массива по индексу, вообще говоря, требует некоторого времени для вычисления адреса ячейки памяти, но для простоты будем считать доступ по индексу мгновенным, тем более, что счетчик цикла for неявно как раз и соответствует адресу ячейки памяти с элементом массива. Тогда всего получается 1+(n–1)+(n–1)+(n–1)/2 = 2.5*n–1.5 команды. Если предположить, что присваивание и сравнение выполняются за один квант времени, то в среднем потребуется ave = 2.5*n–1.5 кванта времени. Рассмотрим некоторые экстремальные случаи. Если массив отсортирован по возрастанию, то минимальный элемент является первым элементом массива. В этом случае присваивание внутри ветвления вообще не выполняется, т.е. суммарное количество ко-
58
манд равно min = 1+(n–1)+(n–1) = 2*n–1. Это нижняя оценка сложности алгоритма – минимально возможное время выполнения. Если массив отсортирован по убыванию, то минимальный элемент будет последним в массиве, а присваивание внутри ветвления будет выполняться на каждом витке цикла. Т.е. суммарное количество команд равно max = 1+(n–1)+(n–1)+(n–1)=3*n–2. Это верхняя оценка сложности алгоритма. Видно, что все полученные оценки линейно зависят от длины исходного массива, несмотря на различные коэффициенты пропорциональности. Длина массива в этом случае является наиболее важной характеристикой задачи, её размерностью или величиной, обусловливающей сложность задачи. Полученная зависимость означает, что при увеличении размерности задачи (увеличении массива) время поиска будет возрастать линейно, поэтому говорят о линейной сложности алгоритма, что записывается как O(n). Именно определение зависимости сложности решения от размерности задачи является целью оценки сложности алгоритма. Коэффициенты пропорциональности имеют значение лишь при сравнении алгоритмов одного класса сложности. В построенном алгоритме каждый элемент массива просматривается один раз, что является ценным свойством. Впрочем, для алгоритма поиска это неудивительно. Оценим сложность алгоритма сортировки. Емкостная сложность складывается из двух массивов длины n, трёх переменных и константы, что даёт (2*n + 4)*2 = 4*n + 8 байт. Для оценки временной сложности этого алгоритма удобно использовать предыдущие оценки для алгоритма поиска, поскольку он часто здесь используется. В любом случае выполняется алгоритм поиска, далее присваивание, далее n–1 раз в цикле выполняется алгоритм поиска и присваивание. Таким образом, всего получаем n раз для алгоритма поиска + n присваиваний: ave = n * (2.5*n–1.5) + n = 2.5 * n2 – 0.5 * n min = n * (2*n–1) + n = 2 * n2 max = n * (3*n–2) + n = 3 * n2 – n Замечание: часто за среднюю оценку можно взять среднее арифметическое минимальной и максимальной оценки. Методы оценки сложности алгоритмов описаны в замечательной книге Дональда Кнута «Искусство программирования».
59
Полученная зависимость означает, что при увеличении размерности задачи время сортировки будет возрастать квадратично, поэтому говорят о квадратичной сложности алгоритма, что записывается как O(n2). Почему так получилось? Из-за вложенности циклов каждый элемент исходного массива теперь просматривается не один, а n раз (по числу витков цикла верхнего уровня), т.е. каждый уровень вложенности циклов даёт увеличение сложности на порядок. Полученный алгоритм сортировки является одним из наиболее медленных. Существуют алгоритмы сортировки меньшей сложности, например O(n*log(n)). Если сравнить графики этих функций, то будет видно, что парабола растет гораздо быстрее.
60
Глава 6. Циклы и рекуррентные соотношения Сортировка в постоянном объеме памяти4 Задача: реализовать алгоритм сортировки в том же объеме памяти. Ограничение на объем памяти означает, что использовать дополнительный массив запрещается. Алгоритм строится следующим образом: 1) найти минимальный элемент массива. Первый и минимальный элемент поменять местами. Запомнить количество элементов в отсортированной последовательности (на первом шаге это один элемент); 2) начиная с конца растущей отсортированной последовательности, выполнить действие 1. Повторять до тех пор, пока отсортированная последовательность не достигнет длины n–1. Оставшийся элемент будет максимальным. Прежде чем приступать к реализации алгоритма рассмотрим задачу обмена значениями двух переменных a и b. Очевидно, фрагмент кода a := b; b := a;
не даст должного результата, так как после первого присваивания значение a будет потеряно. Можно воспользоваться дополнительной переменной buffer для сохранения a: buffer := a; a := b; b := buffer;
Однако гораздо интереснее решить эту задачу без дополнительной переменной: a := a + b; b := a - b; a := a - b;
4
Данный подраздел – продолжение предыдущей главы. Перенесён в главу 6 из соображений сбалансированности глав по времени изложения.
61
Такое решение чревато переполнением типа при вычислении суммы или разности, т.е. не является универсальным. Здесь можно вспомнить, что числа представляются в двоичной форме, над которой определены безопасные битовые операции. Применяя операцию «исключающее или»: a := a xor b; b := a xor b; a := a xor b;
Проверка. Пусть a = 510 = 01012, b = 310 = 00112. Тогда a = 0101 xor 0011 = 0110 b = 0110 xor 0011 = 0101 = 5 a = 0110 xor 0101 = 0011 = 3
Вернемся к алгоритму сортировки. Потребуются два вложенных цикла, причем число витков второго цикла будет постоянно сокращаться с ростом отсортированной последовательности. Поскольку последовательность растет слева, имеет смысл двигать нижнюю границу счетчика цикла. Тогда const Max = 200; type TCubes = array [1..Max] of integer; var cubes: TCubes; i, min, sorted: integer; begin // инициализация массива … for sorted:=1 to Max-1 do begin // поиск минимального элемента, начиная с sorted min := sorted; for i := sorted+1 to Max do begin if cubes[i] < cubes[min] then min := i; end; // обмен cubes[sorted] := cubes[sorted] xor cubes[min]; cubes[min] := cubes[sorted] xor cubes[min]; cubes[sorted] := cubes[sorted] xor cubes[min]; end; // вывод результата на экран … end.
62
По сравнению с алгоритмом сортировки выборкой, построенным в гл. 5, емкостная сложность алгоритма уменьшилась в два раза. Временная сложность алгоритма в целом осталась квадратичной, однако всё же снизилась в два раза, так как число витков второго цикла постоянно сокращается. Кроме того, у данного алгоритма появилось одно достоинство: он может сортировать массивы с повторяющимися элементами. Из недостатков можно отметить то обстоятельство, что если минимальный элемент на очередном витке главного цикла располагается в начале, то будет выполняться лишний обмен элемента самим с собой. Впрочем, этого легко избежать с помощью оператора ветвления. Примеры инициализации массива Когда алгоритмическая часть завершена, пришло время вспомнить о пользователе. Чтобы пользователь смог оценить работу программы, он должен видеть исходный массив и результирующий массив. Заполнить исходный массив можно несколькими способами: посредством ввода с клавиатуры (если массив не очень большой) или же случайными числами. Рассмотрим оба варианта инициализации массива и вывод массива на экран с помощью оператора цикла: begin // инициализация массива с клавиатуры for i:=1 to Max do begin write('element ', i, ' = '); readln(cubes[i]); end; // инициализация массива случайными числами randomize; // инициализация датчика случайных чисел for i:=1 to Max do begin cubes[i] := random(100); end; // вывод на экран элементов массива через пробел for i:=1 to Max do begin write(cubes[i], ' '); end; end.
63
Пример зацикливания программы Чтобы зациклить программу, достаточно поместить тело программы в оператор цикла repeat until. Таким образом, программа выполнится как минимум один раз, а далее всё будет зависеть от желания пользователя. Например: var s: string; begin repeat … // тело программы // запрос на повтор writeln('run once again (yes/no)? '); readln(s); until s ='nо'; end.
Пример вложенных циклов Вывести на экран: 1 13 135 1357 13579 Для решения этой задачи посмотрим на внешний вид результата – треугольная матрица со стороной n=5. Матрица – квадрат, а значит, предполагаемая сложность алгоритма – квадратичная, а это в свою очередь может говорить о наличии двух вложенных циклов в алгоритме. Действительно, один цикл может отвечать за перебор строк, а второй – за перебор столбцов, причем элементы выше главной диагонали печататься не должны. Далее можно заметить, что значение элемента можно однозначно вычислить по его положению в матрице. Пусть i[1..5] – номер строки, а j[1..5] – номер столбца, тогда aij = 2* j – 1. В принципе алгоритм готов. var i,j,n: integer; begin n:=5; for i:=1 to n do begin for j:=1 to n do begin if j<=i then write(2*j–1); end; writeln;
64
end; end.
После того как наивный алгоритм завершен, можно взяться за его оптимизацию. Условие j<=i, по сути, ограничивает число витков второго цикла, так как никаких действий вне if не выполняется. Получаем var i,j,n: integer; begin n:=5; for i:=1 to n do begin for j:=1 to i do write(2*j–1); writeln; end; end.
Общее число витков второго цикла теперь уменьшилось c n2 до 1+2+…+n = (n+1)*n/2. Сложность, тем не менее, осталась квадратичной, но по сравнению со сложностью прежней версии заметно улучшилась. Понимание программ Что напечатает следующая программа? const n=30; m=10; var i: integer; begin for i:=2 to m do if (n mod i =0) and (m mod i =0) then writeln(i,` `); end.
Для чисел от 2 до m=10 программа будет проверять, является ли это число делителем n и m (нулевой остаток). Т.е. программа напечатает общие делители n и m. Поскольку используется writeln, числа будут выведены в столбик: 2 5 10
65
Рекуррентные соотношения и их реализация в виде циклических программ Рассмотрим задачу вычисления экспоненты ex. На заре развития ЭВМ тригонометрические функции приходилось реализовывать программно. Когда появились языки третьего поколения, то вместе с компиляторами начали поставляться стандартные библиотеки, включающие набор математических функций. С появлением математических сопроцессоров большинство функций были реализованы на аппаратном уровне. Сейчас любой процессор умеет вычислять эти и другие функции, но сначала было разложение в ряд Тейлора. Такие функции, как exp, ln, sin, tg и др., аппроксимируются многочленом, который можно вычислить лишь с некоторой точностью в силу его бесконечности и ограниченности машинного представления чисел. Однако этой точности оказывается достаточно для прикладных вычислений. Итак,
ex 1
x x2 xn xn ... ... . 1! 2! n! n 1 n!
Можно вычислить сумму последовательности, ограничив число n. Причем чем больше предел суммирования, тем точнее результат. Возведение в степень и вычисление факториала можно реализовать с помощью цикла; сложение членов последовательности также удобно реализовать в виде цикла. Рассмотрим один из способов реализации: var i,j, n, fact, pow: integer; sum, x: real; begin readln(x); sum := 1; n := 100; for i := 1 to n do begin // вычисление степени x и факториала pow := 1; fact := 1; for j := 1 to i do begin pow := pow * x; fact := fact * j; end; // добавление к сумме очередного члена ряда
66
sum := sum + pow/fact; end; writeln('exp(x)=',sum); end.
Оценим сложность этого алгоритма. Основной характеристикой задачи здесь будет число суммируемых членов последовательности n, так как именно от него зависит количество вычислений. Видно, что число витков внутреннего цикла зависит от значения счётчика внешнего цикла: на первом витке внешнего цикла внутренний цикл выполнится один раз, на втором – два раза и т.д. По методу Гаусса в общей сложности будет выполнено (n+1)*n/2 витков внутреннего цикла. Таким образом, суммарное число выполняемых команд равно 2+n*6+(n+1)*n/2*5 = 2.5n2+8.5n+2. Квадратичная сложность обычно говорит о неэффективной реализации. Можно заметить, что каждый последующий член последовательности можно выразить через предыдущий следующим образом:
a n a n1
x . n
Это соотношение называется рекуррентным (от лат. recurro – возвращаться). Оно позволяет вычислить очередной член последовательности гораздо быстрее. Например, рекуррентное соотношение для арифметических прогрессий выглядит так: an = an-1+b, для геометрических прогрессий: an = an-1*q, для факториала: an = an-1*n, для степени: an = an-1*x. При этом всегда необходимо задавать первый член последовательности (её базис). Таким образом, в программе на каждом витке основного цикла очередной член последовательности будет вычисляться на основе предыдущего: var i,j, n: integer; sum, x, member: real; begin readln(x); member := 1; sum := member; n := 100; for i := 1 to n do begin member := member * x / i; sum := sum + member;
67
end; writeln('exp(x)=',sum); end.
Сложность этого алгоритма линейная, так как от вложенного цикла удалось избавиться: 3+n*(3+2) = 5*n+3 = O(n). Это яркий пример упрощения вложенных циклов. Оптимизация Зачем оптимизировать программу, когда современные компьютеры обладают огромными аппаратными мощностями? На самом деле разница в сложности огромна: в оптимизированном варианте для расчета суммы тысячи членов ряда требуется выполнить порядка тысячи инструкций, а в наивном алгоритме – уже порядка миллиона (коэффициентами и меньшими степенями можно пренебречь). А теперь представьте, что разработанная функция вызывается миллионы раз в каком-либо цикле внутри приложения, например компьютерной игры. Вспомните работу в Word: пока человек печатает, программа автоматически пересчитывает число страниц, применяет нужные параметры форматирования, выравнивает текущую строку по ширине страницы, периодически выполняет автосохранение и проверку орфографии, подчеркивая незнакомые слова. Одним словом, всегда стремитесь оптимизировать свой алгоритм. Как сказал А.Д. Мишин, «делайте хорошо, плохо само получится». Возьмём это за правило. С другой стороны, Д. Кнут предостерегает от преждевременной оптимизации. Дело в том, что если постоянно думать об уменьшении расхода памяти или времени, то можно настолько ухудшить структуру программы, что даже незначительные ее модификации будут приводить к возникновению ошибок. Вместо этого лучше оптимизировать уже готовую программу. С точностью до epsilon Хотя алгоритм и стал эффективней, всё же число n было задано совершенно произвольным образом. Для задач, где точность вычислений играет решающую роль, такой подход неприемлем. Разложение sin(x) представляет собой бесконечную сумму убывающего ряда, где каждый последующий член ряда вносит всё меньший вклад в общую сумму. В теории пределов говорят о вычислении
68
сумм с точностью до некоторого малого , при этом суммирование прекращается при выполнении условия |an-an-1| < либо, как вариант, более простого условия |an| < . В нашей программе имеет смысл заменить цикл for на цикл с постусловием repeat until: var i,j: integer; sum, x, member, epsilon: real; begin readln(x); member := 1; sum := member; epsilon := 0.0001; i := 1; repeat member := member * x / i; sum := sum + member; i := i+1; until member < epsilon; writeln('exp(x)=',sum); end.
Эта программа посчитает сумму с точностью до 4-го знака после запятой. Замечание: программист должен заранее позаботиться о том, чтобы условие выхода из цикла когда-нибудь выполнилось, иначе программа зациклится. Использованное условие применимо лишь при стремлении остаточного члена к нулю, а для возрастающих рядов такой подход вообще не уместен. В качестве самостоятельного упражнения напишите программу, рассчитывающую sin(x) по формуле
sin( x) x
x3 x5 x 2 m 1 ... (1) m 1 ... 3! 5! (2m 1)!
Идея для решения: из формулы для an найти рекуррентное соотношение для вычисления an через an-1, т.е. зависимость an = f(an-1). В программе для вычисления экспоненты достаточно будет изменить всего пару строк.
69
Более сложные рекуррентные соотношения В рассмотренных соотношениях последующий член зависел только от предыдущего. Для реализации такой зависимости достаточно было использовать один цикл. Однако рекуррентное соотношение не всегда очевидно, а тем более не всегда настолько простое. Задача о кроликах и числа Фибоначчи Задача формулируется следующим образом. Как-то хозяин купил пару кроликов. В первый месяц они росли, а во второй месяц принесли еще пару кроликов. Через месяц начала плодоносить выросшая пара кроликов, и первая пара также принесла потомство. Каждый месяц каждая взрослая пара кроликов приносила еще одну пару кроликов. Сколько пар кроликов будет у хозяина через n месяцев? Леонардо из Пизы, сын Боначчи, обнаружил, что численность пар кроликов в любом месяце может быть рассчитана с помощью соотношений fib(1) = 1 fib(2) = 1 fib(n) = fib(n–1) + fib(n–2) Так был открыт ряд чисел (ряд Фибоначчи), каждое из которых представляет собой численность пар кроликов в i-м месяце: 1 1 2 3 5 8 13 21… При построении программы простейшим решением было бы записывать ряд чисел Фибоначчи в массив, вычисляя в цикле очередное число на основе двух предыдущих элементов массива. Однако если число N выбирается пользователем программы произвольно, то никакого массива может не хватить. Собственно весь массив и не нужен, ведь для расчета очередного числа Фибоначчи достаточно помнить лишь два предыдущих числа. Тогда программу можно построить следующим образом: var i,n, f1,f2,f3: integer; begin readln(n); f1 := 1; f2 := 1; f3 := 1; // расчёт
70
for i:=3 to n do begin f3 := f1 + f2; f1 := f2; f2 := f3; end; writeln('rabbits = ', f3); end.
Этому алгоритму, очевидно, свойственна линейная временная сложность и константная емкостная сложность, так как удалось обойтись без массива! Вообще, для быстрой оценки сложности простого алгоритма, когда число витков циклов зависит от одной и той же величины, достаточно взглянуть на общую структуру программы: каждый вложенный цикл на порядок увеличивает сложность. Многочлен Чебышёва Рекуррентное соотношение для многочленов Чебышёва выглядит следующим образом: T0(x) = 1; T1(x) = x; Tn(x) = 2xTn-1(x) – Tn-2(x). Согласно указанным соотношениям T2(x) = –1 + 2x2; T3(x) = 2x*(–1 + 2x2) – x = –3x + 4x3; T4(x) = 2x*(–3x + 4x3) – (–1 + 2x2) = 1 – 8x2 +8x4; … Задача состоит в нахождении коэффициентов k-го многочлена Чебышёва Tk(x). Идеи для решения. Коэффициенты многочлена можно сохранять в массиве, где индекс элемента массива соответствует степени х, начиная с нулевой степени. Первые пять массивов имеют вид: для k = 0 – [1, 0, …] для k = 1 – [0, 1, 0, …] для k = 2 – [–1, 0, 2, 0, …] для k = 3 – [0, –3, 0, 4, 0, …] для k = 4 – [1, 0, –8, 0, 8, 0,…] … Задача сводится к вычислению элементов массива для очередного многочлена Tn на основе двух массивов Tn-1 и Tn-2, хранящих
71
коэффициенты предыдущих многочленов. Интерпретируя рекуррентное соотношение для элементов массива, можно построить следующую процедуру вычисления коэффициентов: 1) умножение многочлена на константу равносильно умножению всех элементов массива на эту константу; 2) умножение многочлена на переменную х равносильно сдвигу каждого элемента вправо на 1 разряд; 3) разность многочленов равносильна разности соответствующих элементов в массивах коэффициентов. Таким образом, Tn[i] = Tn-1[i-1]*2 – Tn-2[i] для всех i, кроме 0, а Tn[0] = –Tn-2[0]. Потребуются два цикла: в главном будет нарастать счетчик k, а во вложенном – рассчитываться по выведенной формуле коэффициенты многочлена Tk.
72
Глава 7. Операции над массивами и матрицами Сдвиг элементов массива Дан массив чисел. Необходимо сдвинуть все элементы массива вправо на одну позицию. При этом крайний правый элемент теряется, а крайний левый становится нулевым. В случае циклического сдвига крайний правый элемент переходит на освободившееся место в начале массива. Для случая сдвига вправо разумно организовать проход по массиву с конца. Алгоритм тривиален: var a: array [1..n] of integer; i: integer; begin … for i:=n downto 2 do a[i] := a[i-1]; a[1] := 0; … end.
Для циклического сдвига необходимо сначала запомнить последний элемент: var a: array [1..n] of integer; buf,i: integer; begin … buf := a[n]; for i:=n downto 2 do a[i] := a[i-1]; a[1] := buf; … end.
Для сдвига влево меняется направление прохода по массиву и граничные условия.
73
Циклический сдвиг элементов массива на k позиций Циклический сдвиг на k позиций можно организовать на основе сдвига на одну позицию, применив построенную процедуру k раз. Однако данное решение не эффективно, так как элемент массива занимает своё место лишь после k переходов, а каждый переход это несколько операций. В k раз эффективней было бы сразу переносить элемент на k позиций. Чтобы не усложнять алгоритм лучше воспользоваться дополнительным массивом, если это не запрещается условием задачи. Необходимо построить взаимно-однозначное соответствие между элементами массивов, т.е. получить формулу для расчета индекса newi элемента в новом массиве по его индексу i в исходном массиве. Для обычного сдвига newi = i + k, но для циклического сдвига необходимо предусмотреть случай, когда newi становится больше N. Модифицируем формулу следующим образом: newi = (i + k) mod N, где i, newi [1..N]. Действительно, если i + k = N + 3, то (i + k) mod N + 1 = 4. Но если i + k = N, то должно получиться N, а у нас получается 0. Тогда модифицируем формулу следующим образом: newi = (i + k – 1) mod N + 1. var a,b: array [1..n] of integer; i,k: integer; begin … for i:=1 to n do b[(i+k-1) mod n + 1] := a[i]; … end.
Вообще, если бы индекс массива i [lo..hi], то формула выглядела бы так: newi = (i + k – lo) mod (hi – lo + 1) + lo.
Реверсирование массива Рассмотрим произвольный массив. Необходимо построить другой массив, в котором элементы идут в обратном порядке по сравнению с порядком элементов в исходном массиве. Если можно использовать дополнительный массив, то формула для расчёта индекса такова: newi = n – i + 1. В противном случае можно построить следующую процедуру: сначала обменять места-
74
ми первый и последний элементы массива, далее второй и предпоследний и т.д. Однако если построить цикл от 1 до n, то элементы поменяются местами дважды и получится исходный массив, поэтому обмен следует вести только до середины массива, которая определяется как n div 2. Для нечетных n – это индекс элемента, предшествующего центральному элементу, который останется на своём месте: var a: array [1..n] of integer; i,buf: integer; begin … for i:=1 to n div 2 do begin buf := a[i]; a[i] := a[n-i+1]; a[n-i+1] := buf; end; … end.
Слияние массивов Даны два массива чисел длины N, упорядоченных по возрастанию. Следует получить массив длины 2*N, также упорядоченный по возрастанию и состоящий из элементов исходных массивов. Для решения этой задачи явно требуются операторы цикла. Введем три счетчика i, j, k, где i, j [1..N] – текущие позиции в исходных массивах, а k [1..2*N] – текущая позиция в новом массиве. Для определения того, из какого массива копировать очередной элемент, на каждом витке цикла необходимо сравнивать текущие элементы исходных массивов и копировать наименьший. После этого необходимо увеличивать счетчик того массива, из которого скопирован элемент. Так как должны быть скопированы все элементы исходных массивов, то по окончании работы программы счетчики должны принять максимальные значения. Тогда конечное состояние работы программы можно записать как (i=n) and (j=n). Ясно, что в этом случае k = 2*n. Отрицание этого выражения можно считать условием выполнения цикла: not ((i=n) and (j=n)) not (i=n) or not (j=n) (i<=n) or (j<=n)
75
При таком условии окончания работы цикл for вряд ли будет оптимальным выбором, поэтому имеет смысл использовать оператор цикла с условием. Дабы избежать случай, когда программа обращается к несуществующему элементу массива с индексом n+1, используем цикл с предусловием while. Здесь есть подводный камень: массивы не могут закончиться одновременно. Один из массивов всегда закончится раньше другого и тогда программа все же обратится к несуществующему элементу. Тогда необходимо модифицировать условие выполнения цикла на (i<=n) and (j<=n). В этом случае цикл прекратится, как только один из счетчиков достигнет n. Тогда останется просто скопировать оставшиеся элементы другого массива: var a,b: array [1..n] of integer; c: array [1..2*n] of integer; i,j,k: integer; begin … i:=1; j:=1; k:=1; while (i<=n) and (j<=n) do begin if a[i] < b[j] then begin c[k] := a[i]; i := i+1; end else if a[i] > b[j] then begin c[k] := b[j]; j := j+1; end else begin c[k] := a[i]; k := k+1; c[k] := a[i]; // второй раз тоже самое i := i+1; j := j+1; end; k := k+1; end; // дописываем остаток, если есть if i>n then while (j<=n) do begin c[k] := b[j]; j := j+1;
76
k := k+1; end else while (i<=n) do begin c[k] := a[i]; i := i+1; k := k+1; end; … end.
В общем случае программа копирует один или более элементов из одного массива, затем из другого, после опять из первого и т.д., сохраняя порядок чисел, пока оба массива не закончатся.
Работа с матрицами Матрица – массив, обладающий не менее чем двумя измерениями. Количество измерений оказывает сильнейшее влияние на сложность алгоритмов обработки матриц – добавление измерения увеличивает сложность на порядок. С точки зрения машинного представления матрица не является чем-то новым, это область памяти, доступ к элементам которой осуществляется теми же средствами, что и для одномерных массивов. Проверка свойств матрицы Задача: определить, является ли матрица треугольной. Достаточно проверить, что все элементы ниже главной диагонали равны 0. Если хотя бы один элемент ненулевой, то проверку остальных элементов можно не делать: var matrix: array[1..n,1..n] of integer; i,j: integer; triangle: boolean; begin … triangle := true; // начинаем сразу со второй строки for i:=2 to n do begin // с первого столбца до диагонали for j:=1 to i-1 do begin if matrix[i,j] <> 0 then begin triangle := false; break; end;
77
end; // break прерывает только тот цикл, в котором находится if triangle then break; end; … end.
Генерация матрицы заданного вида 1. Дано целое число n>1 и действительное число х. Написать процедуру, заполняющую матрицу вида 1 x x2 xn-1 xn
x 0 0 0 xn-1
x2 0 0 0 xn-2
. . . . .
. . . . .
. . . . .
xn-1 0 0 0 x
xn xn-1 xn-2 x 1
Элементы, расположенные по периметру образуют степенной ряд, при этом налицо симметрия данных элементов. Это даёт возможность заполнить их в одном цикле, используя рекуррентное соотношение: var matrix: array[0..n,0..n] of integer; i,j: integer; x,p: real; begin readln(x); p := 1; // заполнение элементов по периметру матрицы for i:=0 to n do begin matrix[i,0] := p; // первый столбец matrix[0,i] := p; // первая строка matrix[n-i,n] := p; // последний столбец matrix[n,n-i] := p; // последняя строка p := p*x; end; // остальное заполняется нулями for i:=1 to n-1 do for j:=1 to n-1 do matrix[i,j]:=0; … end.
78
2. Используя целочисленную квадратную матрицу А, построить матрицу В того же типа, где bij определяется следующим образом. Через аij проведем в А диагонали, параллельные главной и побочной диагоналям матрицы; bij равен максимальному элементу в области матрицы А, ограниченной снизу проведенными диагоналями. Пусть матрицы объявлены следующим образом: var A,B: array [0..9, 0..9] of integer;
Если провести аналогию между матрицей и системой координат, то элемент с индексом [0, 0] будет центром координат, ось абсцисс соответствует столбцам, ось ординат – строкам (направлена вниз). Тогда диагонали в матрице для каждого элемента brs можно описать уравнениями прямых на плоскости: i – r = j – s; i – r = – j + s. Здесь r, s – координаты очередного элемента матрицы B. Каждый элемент brs выбирается из соответствующей области матрицы А как brs = max{aij}, где i[0..n–1], j[0..n–1], i – r <= j – s, i – r <= –j+s. При поиске максимального элемента для очередного brs имеет смысл инициализировать переменную max элементом, стоящим на пересечении соответствующих диагоналей матрицы A, т.е. ars. Понятно, что в область входят только элементы, находящиеся на строках от 0 до r, причем в строке r это единственный элемент ars. Тогда границами цикла по строкам будут 0 и r–1, так как элемент ars учтён при инициализации. Границами цикла по столбцам в общем случае будут 0 и n, а ограничения будут проверяться явно: var A,B: array[0..n,0..n] of integer; i,j,r,s,max: integer; begin … // циклы для всех элементов матрицы B, счетчики r и s for r:=0 to n do for s:=0 to n do begin max := A[r,s]; // выбор максимального из соответствующей области в А for i:=0 to r-1 do for j:=0 to n do if (i <= j-s+r) and (i <= -j+s+r)
79
and (A[i,j] > max) then max := A[i,j]; B[r,s] := max; end; … end.
Этому алгоритму свойственна сложность O(n4), так как выполняются четыре вложенных цикла. Однако для матриц алгоритм не так уж плох – он не хуже, чем алгоритмы квадратичной сложности для одномерных массивов. 3. Умножение матриц. Пусть имеется матрица A[M, K] и матрица B[K, N]. Получить матрицу C[M, N], где K
cij aik bkj , i [1..M ], j [1..N ] . k 1
Идея для решения: для заполнения матрицы C потребуется два вложенных цикла по i для строк и по j для столбцов, а чтобы вычислить очередной элемент, нужен ещё один вложенный цикл по k. Не страшно, если программа не сразу заработает. Помните: «Программа – зеркало глупости программиста».
80
Глава 8. Структурирование программ Программы сложной структуры До сих пор рассматривались программы, решающие одну простую задачу. Для того чтобы программа была полезной, она должна решать гораздо более сложные (на порядки) задачи. Приём, который используется человеком для решения сложных задач, состоит в разбиении задачи на более мелкие подзадачи, решении задач по отдельности и построении решения основной задачи на базе решений подзадач. Использование данного приёма в программировании приводит к построению программ сложной структуры, где каждый блок соответствует решению одной из подзадач и логически может быть отделён от других. Усложнение структуры программ сопровождается возникновением целого класса ошибок, обусловленных взаимосвязью блоков программы. Эту взаимосвязь часто называют «сцеплением программного кода». Порочность сцепления проявляется тогда, когда изменения в одном блоке программы приводят к неожиданным изменениям поведения других блоков программы. Поэтому, в целях снижения вероятности возникновения ошибок, стремятся снизить сцепление. Для отделения подпрограмм от основной программы в операторные языки программирования вводятся специальные синтаксические конструкции. В языке Паскаль это процедуры и функции. По существу, процедуры и функции представляют собой поименованные фрагменты программного кода, которые могут вызываться в теле основной программы по имени. Отличие процедур от функций в том, что функции возвращают значение, а значит, могут входить в состав выражений. В языке Паскаль для определения процедур используется ключевое слово procedure, а для определения функций – function. Простое выделение блока программы и синтаксическое его оформление в виде процедуры поможет избежать чистого дублирования кода в программе. Например: var a: array[1..10] of integer; i: integer; // определение процедуры с именем Print procedure Print;
81
begin for i:=1 to 10 do write(a[i], ' '); writeln; end; begin … // заполнение массива a i := 4; Print; // вызов процедуры по имени // здесь i уже не равно 4 … // например, сортировка массива a Print; end.
Однако сильное сцепление остаётся, поскольку процедура и основная программа работают с одними и теми же переменными. В данном случае процедура изменяет значение переменной i, что может сильно повлиять на работу программы после вызова Print. Получается, что качество программы не улучшилось. Побочного эффекта можно избежать, если объявить необходимые переменные внутри процедуры. Это можно сделать следующим образом: procedure Print; var i: integer; begin for i:=1 to 10 do write(a[i], ' '); writeln; end;
Теперь в процедуре объявлена своя переменная i. Хотя она имеет то же имя, что и переменная в основной программе, конфликта имён не возникает, и переменная i перекрывает переменную в основной программе.
Глобальные и локальные переменные. Область видимости переменных Глобальные переменные – переменные, объявленные в основной программе. Время их жизни равно времени выполнения всей программы. Глобальные переменные доступны в любом месте программы, в том числе в теле процедур и функций, определённых в программе. Локальные переменные – переменные, объявленные внутри процедуры или функции. Память под значения локальных пере-
82
менных выделяется в момент вызова процедуры и возвращается системе в момент завершения процедуры. Таким образом, время жизни локальных переменных равно времени выполнения процедуры, а значит, значения локальных переменных между двумя вызовами процедуры не сохраняются. Локальные переменные доступны только внутри процедуры, в которой они объявлены, в основной программе локальные переменные недоступны. Если локальная переменная имеет имя, совпадающее с именем глобальной переменной, то внутри процедуры будет использоваться именно локальная переменная. Значит, локальная переменная как бы перекрывает одноименную глобальную переменную на время выполнения процедуры.
Формальные и фактические параметры Построенная процедура Print до сих пор не слишком полезна, так как умеет выводить на печать лишь массив а, да еще и только длины 10. Чтобы обобщить процедуру на все подобные массивы, необходимо объявить новый тип данных и параметризовать процедуру: const n = 10; type tarray = array[1..n] of integer; var a,b: tarray; procedure Print(mas: tarray); var i: integer; begin for i:=1 to n do write(mas[i], ' '); writeln; end; begin … // заполнение массива a Print(a); … // заполнение массива b Print(b); end.
Здесь в заголовке процедуры после имени определён список передаваемых параметров – параметр mas типа tarray. Следует отметить, что в заголовке процедуры нельзя определять новый тип, но можно использовать любые встроенные типы или типы, определенные ранее в программе.
83
Теперь процедура Print умеет выводить на экран любой массив типа tarray и использует глобальную константу n, что вполне допустимо. Что же происходит при вызове процедуры? Параметр mas превращается в дополнительную локальную переменную, инициализируемую значением той переменной, которая передается в качестве параметра, т.е. происходит неявное присваивание или копирование значения. Передаваемые переменные называют фактическими параметрами, а параметры в определении процедуры – формальными. Способы передачи параметров Аналогично процедуре Print для вывода массива можно написать процедуру Init для ввода массива: const n = 10; type tarray = array[1..n] of integer; var a,b: tarray; procedure Init(mas: tarray); var i: integer; begin randomize; for i:=1 to n do mas[i] := random(100)-50; end; begin Init(a); Init(b); end.
Однако после работы процедуры Init переменные a и b, переданные как фактические параметры, останутся неизменными. Дело в том, что при таком способе передачи параметров процедура ничего не знает о переменных, переданных в качестве фактических параметров. В связи с этим процедура не может их изменить, и после завершения работы значения формальных параметров теряются. В данном случае говорят о передаче параметров по значению. Для того чтобы изменить значение глобальной переменной в процедуре, необходимо передать переменную в качестве параметра по ссылке. Указать способ передачи по ссылке можно с помощью ключевого слова var перед объявлением параметра:
84
procedure Init(var mas: tarray); var i: integer; begin randomize; for i:=1 to n do mas[i] := random(100)-50; end;
Тогда при вызове процедуры не будет создаваться дополнительной переменной, а параметр mas станет синонимом переменной, передаваемой в качестве параметра. Существует второй способ передачи параметра по ссылке, при котором значение передаваемой переменной менять нельзя. Для этого перед объявлением параметра следует указать модификатор const: procedure Print(const mas: tarray); var i: integer; begin for i:=1 to n do write(mas[i], ' '); writeln; end;
Здесь параметр mas также становится синонимом передаваемой по ссылке переменной, но менять её значение уже нельзя. Данный способ позволяет снизить накладные расходы на вызов процедуры: новая переменная не создаётся, и копирование не происходит. Вообще, по значению в качестве параметров можно передавать любые выражения, а по ссылке – только переменные. Попытки передать по ссылке выражение, не являющееся переменной, приведёт к возникновению ошибки компиляции. В общем случае в заголовке процедуры может находиться список определений параметров, перечисляемых через точку с запятой. При этом для каждого из них задаются имя, тип и способ передачи. Хорошим стилем принято считать помещение объявлений параметров, передаваемых по значению, до объявлений параметров, передаваемых по ссылке. Например: var res: integer; procedure Sum(a: integer; b: integer; var c: integer); begin c:=a+b;
85
end; begin Sum(3,5,res); res := res * 8; end.
Для краткости параметры одного типа можно перечислить через запятую в одном объявлении: procedure Sum(a,b: integer; var c: integer);
Однако параметры, передаваемые по ссылке, должны быть объявлены по отдельности. Если процедура имеет один параметр, передаваемый по ссылке, то имеет смысл оформить её в виде функции, возвращающей значение данного параметра. При этом параметр исчезает, а для функции в конце заголовка указывается тип возвращаемого значения через двоеточие. Чтобы вернуть значение из функции, нужно имени функции присвоить вычислимое выражение: function Sum(a, b: integer): integer; begin Sum := a+b; end;
Определение подпрограммы в виде функции позволяет сократить текст основной программы, так как функция может непосредственно входить в выражение в том месте, где требуется результат её вычислений: begin res := Sum(3,5) * 8; end.
Следует предостеречь от использования имени функции в качестве локальной переменной. Хорошим тоном считается однократное использование имени функции при возврате результата. Если функция не имеет параметров, то вхождение имени функции в какое-либо выражение внутри тела функции приведёт к её вызову, а вовсе не к подстановке значения, присвоенного функции ранее. Например:
86
function Calculate: integer; var i: integer; begin Calculate := 67; // далее вместо подстановки 67 функция вызовет себя i := Calculate * 7; Calculate := i; end;
Гораздо лучше ввести дополнительную локальную переменную и в конце вычислений присвоить функции её значение: function Calculate: integer; var i,j: integer; begin j := 67; i := j * 7; Calculate := i; end;
Вложенные подпрограммы Процедура (функция) может быть определена внутри другой процедуры (функции). В этом случае вложенная функция должна размещаться между заголовком и телом другой процедуры (функции): procedure proc1(a: integer); procedure proc2(a,b: integer); var … begin // здесь видны глобальные переменные, // параметры родительской функции, // собственные параметры и локальные переменные … end; var … begin // здесь видны глобальные переменные, // собственные параметры и локальные переменные … end;
87
Следует отметить, что параметры и локальные переменные вложенной процедуры будут перекрывать одноименные параметры родительской функции и глобальные переменные.
Модульная структура программ Модули – более крупные структурные единицы программы. Они имеют имя и могут содержать совокупность процедур и функций, а также ряд дополнительных типов данных, переменных, констант. В языке Паскаль модули определяются в отдельных файлах и начинаются с объявления module <имя>. К основной программе модули подключаются с помощью ключевого слова uses <список имён модулей>. В модули имеет смысл объединять логически связанные группы процедур и функций. Программист может создавать свои модули (например, для работы с мышкой, работы со специальными структурами данных и т.п.), а также использовать модули стандартной библиотеки языка. Модули могут использовать другие модули. Некоторые стандартные модули Любая программа на языке Паскаль, созданная в среде Turbo Pascal, может использовать по умолчанию средства модуля System. Его не требуется указывать в объявлении uses. Модуль содержит базовые средства ввода-вывода, функции работы со строками, динамической памятью, числами с плавающей запятой, а также встроенные константы, системные переменные и др. Модуль Сrt используют для организации работы с экраном в текстовом режиме, с клавиатурой, курсором и встроенным динамиком. Модуль Graph предназначен для работы с экраном в графическом режиме. Структура модуля 1) объявление модуля (unit); 2) объявление используемых модулей (uses); 3) интерфейсная секция модуля (interface); 4) секция реализации модуля (implementation); 5) секция инициализации (begin … end). Пример модуля, предоставляющего математические функции: unit MathUtils; interface
88
function Sum(a,b: real):real; function Mult(a,b: real):real; implementation function Sum(a,b: real):real; begin Sum := a+b; end; function Mult(a,b: real):real; begin Mult:= a*b; end;
Использование модулей Перед компиляцией основной программы должны быть скомпилированы все используемые модули. Скомпилированный модуль имеет расширение *.tpu. Пример использования модуля: program math; uses MathUtils;
// подключение модуля MathUtils
begin writeln(Mult(5,Sum(3,8)); // вызов функций модуля end.
При запуске основной программы система осуществляет поиск и подключение скомпилированных модулей к программе. Поиск модулей и динамических библиотек выполняется в заранее определенных местах дискового пространства: системный каталог среды, системный каталог операционной системы, текущий каталог, каталоги для поиска, указанные в среде (для Turbo Pascal – с помощью меню Options/Directories), каталоги, указанные в переменной $PATH операционной системы. С появлением объектно-ориентированного подхода к построению программ стало возможным обособление данных и функций обработки данных в отдельные контейнеры – объекты. Различают классы объектов и их экземпляры, где класс определяется как тип данных, а объект как переменная этого типа. В Паскале класс объектов должен быть описан в рамках одного модуля.
89
Задания по процедурам и функциям 1. Написать процедуру, возвращающую разность средних арифметических значений двух вещественных массивов из десяти элементов. Вариант решения: const n = 10; type tarray = array[1..n] of real; function diff(const a, b: tarray); var i: integer; sum, rez: real; begin sum := 0; for i:=1 to n do sum := sum + a[i]; rez := sum/n; sum := 0; for i:=1 to n do sum := sum + b[i]; rez := rez – sum/n; diff := rez; end;
После оптимизации получаем решение, в два раза более эффективное: function diff(const a, b: tarray); var i: integer; sum: real; begin sum := 0; for i:=1 to n do sum := sum + a[i] – b[i]; diff := sum/n; end;
2. Что напечатает следующая программа? var a, b, c, d, e: integer; procedure X(a, b, c: integer; var d: integer);
90
var e: integer; begin c := a + b; d := c; e := c; writeln('Подпрограмма:'); writeln('c = ', c, ' d = ', d, ' e = ', e); end; begin a := 3; b := 5; x(a, b, c, d); writeln('Главная программа:'); writeln('c = ', c, ' d = ', d, ' e = ', e); end.
Результаты работы программы: Подпрограмма: c = 8 d = 8 e = 8 Главная программа: c = 0 d = 8 e = 0
Значение переменной «с» в главной программе не изменилось, поскольку переменная передавалась по значению, а значение переменной е не изменилось, потому что в подпрограмме была описана локальная переменная с тем же именем. Здесь следует отметить, что глобальные переменные автоматически инициализируются 0, однако это поведение может отличаться в зависимости от компилятора языка. Поэтому не стоит рассчитывать на автоматическую инициализацию, а выполнять её самостоятельно.
91
Глава 9. Рекурсивные процедуры и функции Рекурсивные подпрограммы, условие выхода из рекурсии Как было рассмотрено в предыдущих главах, процедура (функция) представляет собой обособленный поименованный участок программы, который может вызываться из основной программы, а также из другой процедуры (функции). Однако ничто не мешает процедуре (функции) вызывать саму себя. Подпрограмма, в которой содержится обращение к самой себе, называется рекурсивной, при этом имеет место прямая рекурсия. Ранее была построена функция Calculate, которая из-за использования её имени в качестве локальной переменной случайно вызывала саму себя. При этом она никогда бы не завершила своей работы из-за бесконечной рекурсии, так как у нее не было возможности не выполнять рекурсивный вызов. Рассмотрим пример другой рекурсивной функции: function r(n: integer): integer; begin if n <= 1 then r := 1 else r := n * r(n - 1); end;
Данная программа вычисляет значение факториала, при этом используется рекуррентное соотношение n! = n*(n–1)!, где 1! = 1. Здесь вычисления завершатся, поскольку после серии рекурсивных вызовов при n = 1 выполнится основная ветвь условного оператора. Такое условие называют условием выхода из рекурсии, а ветвь, не содержащая рекурсивных вызовов, именуется терминальной. Если одна подпрограмма вызывает другую, а та в свою очередь первую, то имеет место косвенная рекурсия: procedure FА(…); begin ... FB(…); ... end; procedure FВ(…);
92
begin ... FA(…); ... end;
Однако по правилам языка Pascal нельзя использовать объект раньше, чем он был описан. Таким образом, определение подпрограммы FA некорректно, поскольку имеет ссылку на неопределенный объект FB(). В этих случаях необходимо объявить (но не определять!) процедуру FB до процедуры FA. В языке Паскаль это выполняется с помощью директивы forward после заголовка подпрограммы. Например: procedure FВ(a: integer); forward; procedure FА(a: integer); begin ... FB(…); ... end; procedure FВ(a: integer); begin ... FA(…); ... end;
Косвенная рекурсия часто используется при обработке динамических структур данных. Правила построения рекурсивных подпрограмм: 1) рекурсивная подпрограмма должна иметь хотя бы одну терминальную ветвь, переход на которую должен осуществляться в зависимости от некоторого условия (условия выхода); 2) проверка условия выхода должна предшествовать рекурсивным вызовам, в противном случае условие никогда не будет проверено.
93
Итерация и рекурсия. Сравнение подходов Рассмотрим пример рекурсивной функции, вычисляющей значение n-го члена ряда Фибоначчи: function fib(n: integer): integer; begin if n <= 2 then fib := 1 else fib := fib(n-1) + fib(n-2); end;
Функция fib содержит два рекурсивных вызова, а в общем случае рекурсивных вызовов в теле подпрограммы может быть сколько угодно. Как видно из программного кода, эта рекурсивная функция, как и функция вычисления факториала, практически копирует рекуррентные соотношения, определяющие n-й член ряда. Таким образом, рекурсия оказывается более компактным способом реализации рекуррентных соотношений, нежели итерация. Существуют задачи, в частности обработка динамических структур данных, для которых рекурсия – наиболее естественный способ решения. Но насколько эффективен этот способ? Итеративный алгоритм вычисления чисел Фибоначчи имел линейную временную сложность и константную емкостную сложность. Чтобы оценить временную сложность рекурсивного алгоритма необходимо рассчитать общее число операций, выполняемых в результате всех рекурсивных вызовов. Вызов подпрограммы будем считать одной операцией. Для n=1 или n=2 выполняются одна проверка условия выхода и возврат значения (две операции), а для остальных случаев после проверки условия выполняются два вычитания, два рекурсивных вызова, сложение и присваивание (семь операций). К этому числу ещё необходимо добавить число операций, выполняемых в результате двух рекурсивных вызовов. Тогда: для n = 3 имеем ∑(3) = 7 + ∑(2) + ∑(1) = 7+2+2 = 11 операций; для n = 4 имеем ∑(4) = 7 + ∑(3) + ∑(2) = 7+11+2 = 20 операций; для n = 5 имеем ∑(5) = 7 + ∑(4) + ∑(3) = 7+20+10 = 37 операции; … Обобщая, получаем ∑(n) = 7 + ∑(n–1) + ∑(n–2), где ∑(2) = 2, ∑(1) = 2. Итак, получено рекуррентное соотношение, напоминающее соотношение для n-го члена ряда Фибоначчи, что для выбранного
94
способа реализации вполне ожидаемо. Таким образом, сложность рекурсивного алгоритма пропорциональна fib(n). Для получения аналитического вида функций fib(n) и ∑(n) необходимо вспомнить удивительное свойство чисел Фибоначчи:
fib(n) 1 5 1,618... fib(n 1) 2 Тогда для больших n
fib(n) 1,618 1,618 n n
Таким образом, функция fib(n) оказывается прямо пропорциональна 1,618n. То же самое справедливо и для ∑(n). Рассмотрим графики функций fib(n), ∑(n) и 1,618n, представленные на рис. 6: 500 450 400 350 n
300
fib(n) 250
∑(n) n^2
200
1,618^n
150 100 50 0 1
2
3
4
5
6
7
8
9 10 11 12 13 14 15
Рис. 6. График функции fib(n) в сравнении с другими
Видно, что графики fib(n) и sigma(n) повторяют форму графика 1,618n, а сами функции связаны соотношениями fib(n) = 0,4475*1,618n; ∑(n) = 4,028*1,618n.
95
Таким образом, сложность рекурсивного алгоритма оценивается как O(1,618n). Можно заметить, что функция fib(n) возрастает быстрее n2 и в точке n = 12 перегоняет её. Столь низкая производительность рекурсивной функции fib(n) объясняется тем, что вычисления многократно повторяются. Визуально рекурсивные вызовы можно представить в виде дерева, где число повторных вычислений растёт при углублении рекурсии (рис. 7). fib(n)
fib(n-1)
fib(n-2)
fib(n-2)
fib(n-3)
fib(n-3)
fib(n-3)
fib(n-4)
fib(n-4)
Рис. 7. Дерево рекурсивных вызовов fib(n)
Интуитивно понятно, что с точки зрения расхода памяти рекурсивный алгоритм также не на высоте. Чтобы понять, что происходит при рекурсивных вызовах необходимо обратиться к реализации самого механизма вызова подпрограмм в операторных языках программирования.
Реализация рекурсии в операторных языках. Стек вызовов подпрограмм При вызове подпрограммы системное окружение программы выполняет ряд действий:
96
1) запоминается место возврата управления в вызывающей программе (подпрограмме). Поскольку все инструкции программы хранятся в оперативной памяти, то каждая исполняемая команда имеет свой адрес. Таким образом, запоминается адрес команды, следующей за вызовом подпрограммы. Кроме того, могут запоминаться состояния системных переменных, в частности регистров процессора, для гарантии их сохранности после работы подпрограммы; 2) вычисляются значения фактических параметров, выполняется контроль типов; 3) для всех локальных переменных подпрограммы и параметров, передаваемых по значению, выделяется память. Кроме того, могут быть выделены дополнительные системные ресурсы. Вычисленные значения параметров передаются подпрограмме; 4) управление передается первому оператору подпрограммы. После завершения работы подпрограммы выделенные ресурсы возвращаются системе, восстанавливаются состояния переменных, управление передается обратно. Таким образом, использование подпрограмм связано с определенными накладными расходами. Для хранения адресов возврата и состояний окружения (значений параметров и переменных) используется стек. Стеком называется упорядоченный набор некоторого переменного числа объектов, работающий по правилу: «Последним пришел, первым вышел» (от англ. LIFO – Last In, First Out). Схему работы стека можно проиллюстрировать на следующем примере: представим, что машина заехала в узкий тупик, а за ней еще несколько машин. Выезжать они будут в обратном порядке, и последней выедет первая машина. Такая схема работы позволяет организовать вложенные и рекурсивные вызовы подпрограмм: при вызове подпрограмм в стек добавляется информация, а после завершения работы она извлекается в обратном порядке. Рассмотрим работу стека и порядок вычислений fib(n) для n = 4: fib(4) в стек проверка n<=2 выделение памяти для промежуточных переменных @1,@2,@3 fib(3) в стек
97
проверка n<=2 выделение памяти для промежуточных переменных @4,@5,@6 fib(2) в стек проверка n<=2 возврат 1 @41 (извлечение из стека) fib(1) в стек проверка n<=2 возврат 1 @51 из стека @6 = @4 + @5 = 2 возврат @6 @12 из стека fib(2) в стек проверка n<=2 возврат 1 @21 из стека @3 = @1+@2 = 3 возврат @3 3 из стека
Видно, что расчёта числа Фибоначчи требуется сначала рекурсивно погрузиться на максимальную глубину, а лишь затем вычислить сумму: fib(4) fib(4-1) + fib(4-2) fib(3) + fib(2) (fib(3-1) + fib(3-2)) + fib(2) (fib(2) + fib(1)) + fib(2) (1 + fib(1)) + fib(2) (1 + 1) + fib(2) 2 + fib(2) 2 + 1 3
При этом память тратится не только на обеспечение рекурсивных вызовов в стеке, а еще и на хранение многочисленных вспомогательных локальных переменных. Для рекурсивных подпрограмм накладные расходы многократно увеличиваются с ростом числа рекурсивных вызовов. При большой глубине рекурсии существует реальная опасность переполнения стека. Если же в рекурсивной процедуре по каким-либо причинам условие выхода всегда ложно или терминальная ветвь отсутствует, то переполнение стека неизбежно.
98
Вообще, отладка рекурсивных процедур гораздо сложней, так как в один и тот же момент времени могут выполняться несколько экземпляров рекурсивной функции. Все они имеют одно и то же имя, поэтому для локализации места возникновения ошибки требуется анализировать значения передаваемых параметров.
Хвостовая рекурсия и аккумуляторы, оптимизация рекурсивных программ Глядя на дерево рекурсивных вызовов для функции fib(n), возникает желание избежать повторных вычислений. Рассмотрим следующий вариант реализации функции: function fib(n: integer): integer; function f(n, prev, pprev: integer): integer; begin if n <= 2 then f := prev else f := f(n-1, prev+pprev, prev); end; begin f(n, 1,1); end;
Дерево рекурсивных вызовов для fib(4) выглядит так: fib(4) f(4, 1, 1) f(4-1, 1+1, 1) f(3, 2, 1) f(3-1, 2+1, 2) f(2, 3, 2) 3
В отличие от первого варианта дерево не разрастается в ширину, память стека расходуется только на обеспечение рекурсивных вызовов, вычисления проводятся в постоянном объеме памяти и не дублируются. Так происходит потому, что вспомогательная функция в качестве параметров рекурсивного вызова передаёт уже вычисленную очередную пару чисел Фибоначчи, причём рекурсивный вызов выполняется последним действием. Рекурсия такого вида называется хвостовой. Сложность вычислений fib(n) при хвостовой рекурсии линейна, а сам алгоритм может быть легко преобразован в итеративный с помощью циклов.
99
Правила построения рекурсивных подпрограмм с аккумулятором: 1) вводится вспомогательная функция с дополнительным параметром-аккумулятором, в котором будет накапливаться результат вычислений. В общем случае аккумуляторов может быть несколько; 2) главная функция вызывает вспомогательную функцию с начальными значениями аккумуляторов; 3) при выполнении условия выхода из рекурсии вспомогательная функция возвращает значение аккумулятора; 4) рекурсивный вызов вспомогательной функции выполняется с таким значением аккумулятора, которое возвращалось бы главной функцией. Следует отметить, что далеко не для всех рекурсивных подпрограмм можно построить вариант с хвостовой рекурсией. Это процесс творческий. Задание для самостоятельного выполнения: написать функцию вычисления факториала f(n)=n*f(n-1) с помощью хвостовой рекурсии. Возможный вариант решения: function fact(n: integer): integer; function f(n, acc: integer): integer; begin if n <= 1 then f := acc else f := f(n-1, n*acc); end; begin f(n,1); end;
Проверка работы хвостовой рекурсии для 4!: fact(4) f(4, 1) f(4-1, 4*1) f(3, 4) f(3-1, 3*4) f(2, 12) f(2-1, 2*12) f(1, 24) 24
100
Аналогично функции fib(n) факториал вычисляется в постоянном объеме памяти, и в последней рекурсивной ветви результат уже вычислен. Иллюстрация работы обычной рекурсии для 4!: fact(4) 4*fact(4-1) 4*fact(3) 4*(3*fact(3-1)) 4*(3*fact(2)) 4*(3*(2*fact(2-1))) 4*(3*(2*fact(1))) 4*(3*(2*1)) 4*(3*2) 4*6 24
Здесь память расходуется на хранение растущего выражения. Пик расхода памяти приходится на самую глубокую рекурсивную ветвь, только после этого начинают выполняться арифметические действия.
101
Глава 10. Строки и множества Символы и строки в языке Паскаль Строка в языке Паскаль представляет собой цепочку символов длиной от 0 до 255, причем программисту предоставляется возможность самому определять максимальную длину строки, но не более 255. Известно, что любые символы кодируются числами в соответствии с ASCII-таблицей и для работы со строками можно было бы обойтись массивами. Однако в силу того, что область применения строк сильно отличается от области применения массивов, в Паскаль были введены два новых типа: char для символов и string для цепочек символов. Для решения простых задач 255 символов в строке оказывается достаточно, но в сложных задачах такое ограничение на длину только снижает общность алгоритма. В Паскале имеется другой тип данных PChar, представляющий собой цепочку символов, которая замыкается нулевым символом (с ASCII-кодом 0). Длина такой строки ограничивается максимальным объемом памяти, который система может адресовать. В 32-разрядных системах адрес занимает 4 байта (32 бита), т.е. мощность пространства адресов равна 2^32 = 4 Гб. В реальности операционная система не может отвести программе всё адресное пространство, так как ей самой требуется определенный ресурс. В языке Си такие строки носят название ASCII-z, т.е. строки символов ASCII, завершающиеся 0 (zero). Для определения длины такой строки достаточно вычислить порядковый номер нулевого символа относительно первого символа строки. В современных версиях языка Паскаль поддерживается кодировка символов Unicode, в которой под каждый символ отводится по два байта. Данное представление позволяет закодировать уже 2^16 = 65536 различных символов, что оказывается достаточным для кодировки символов всех языков планеты. Для удобства работы с различными представлениями строк программисту предоставляются процедуры преобразования строк одного типа в другой. Примеры объявления строковых переменных: var s: string; const n = 15;
102
var s1: string[n]; type string4 = string[4]; // строка, занимающая 5 байт var s2: string4;
Если строковой переменной присваивается строка, длина которой превышает допустимую длину строковой переменной, то строка усекается справа до нужной длины. Синтаксически строки и символы в Паскале записываются в апострофах. Если требуется поместить в строку сам символ апострофа, то его необходимо записать дважды. Строковые переменные перед использованием также необходимо инициализировать. Если начальное значение неизвестно, то хорошим вариантом является инициализация пустой строкой. Для задания пустой строки следует поставить два апострофа: s := 'Hello world'; // обычная строка s := ''; // пустая строка s := 'Don''t do it'; // строка с одним апострофом внутри
К символам строки можно обращаться как к элементам массива по индексу, причём символы в строке нумеруются с 1. Взятый по индексу символ можно трактовать и как значение типа char, и как значение типа string. Изменение символа в строке по его индексу допустимо, однако не приветствуется: ch := s[3]; s2 := s[4]; s[3] := 'm';
Поскольку символы, по сути, являются кодами в ASCII-таблице, то в Паскале имеется возможность задания символа по его коду, а также определен ряд функций для работы с кодами символов: ch := #100; // 100 – код буквы 'd' ch := chr(100); // получение символа по коду code := ord(ch); // получение кода по символу
Таким образом, chr(ord(ch)) = ch и ord(chr(code)) = code. Строки можно выводить на экран и вводить с клавиатуры с помощью стандартных процедур writeln() и readln(), символы – с помощью write() и read() соответственно.
103
Строковые операции Важной операцией для работы со строками является конкатенация (склеивание) строк. Её результатом является строка, образованная в результате стыковки конца первой строки с началом второй строки. Обозначается операция знаком + и записывается в инфиксной форме, т.е. между аргументами. Например: message := 'Length = ' + IntToStr(Length(s)) + ' symbols';
Для символов и строк определены операции отношения. При сравнении символов реально сравниваются их ASCII-коды, поэтому, например, 'W' < 'w', '$' < '?', 'я' <'Ё'. Символы цифр от '0' до '9' упорядочены и имеют коды от 48 до 57 соответственно. При сравнении строк они рассматриваются посимвольно слева направо. Для строк определен лексикографический порядок, выражаемый в следующих правилах: 1) пустая строка меньше любой другой; 2) две строки равны тогда и только тогда, когда они имеют одинаковую длину и все символы с равными индексами в строках совпадают. Если одна из строк полностью совпадает с началом другой, то по первому правилу она считается меньшей; 3) если строки не равны, то сравниваются первые различные символы от начала строк. Меньшей считается та строка, у которой символ имеет меньший ASCII-код. Примеры: 'abc' = 'abc', 'abc' > 'ab', 'abc' < 'abc ', 'abc' < 'xyz', 'a' < 'abc', '1200' < '45', 'Anny' < 'anny',
правило правило правило правило правило правило правило
2 1 1 3 3 3 3
('с' > '') ('' < ' ') ('a' < 'x') ('' < 'bc') ('1' < '4') ('A' < 'a')
Подпрограммы для работы со строками В стандартной библиотеке реализован ряд полезных подпрограмм для работы со строками. Рассмотрим некоторые из них: procedure Insert(Source: string; var S: string; Index: Integer) – вставка строки внутрь другой строки. Процедура имеет три аргу-
104
мента: Source – вставляемая строка, S – исходная строка, Index – позиция в исходной строке, в которую следует вставить другую строку. Меняется строка S: S := 'Hello world'; Insert(' my ', S, 6)
S = 'Hello my world'
procedure Delete(var S: string; Index, Count: Integer) – удаление части строки. Процедура имеет три аргумента: S – исходная строка, Index – позиция в исходной строке, начиная с которой следует удалять символы, Count – максимальное число удаляемых символов. Меняется строка S: S := 'Hello world'; Delete(S, 4, 5) Delete(S, 10, 5)
S = 'Helrld' S = 'Hello wor'
function Copy(S: string; Index, Count: Integer): string – копирование подстроки. Функция принимает три аргумента: S – строка, Index – индекс начального символа, с которого начинается копирование, Count – количество копируемых символов. Возвращается участок строки S от индекса Index длиной не более Count: Copy('Hello world', 4, 5) = 'lo wo' Copy('Hello', 4, 5) = 'lo'
function Pos(Substr: string; S: string): Integer – поиск подстроки в строке. Функция принимает два аргумента: Substr – подстрока для поиска, S – строка, в которой следует искать подстроку. Возвращается первая слева позиция найденной подстроки. Если подстрока не найдена, то возвращается 0: Pos('llo', 'Hello world') = 3 Pos('low', 'Hello world') = 0
procedure Str(X [: Width [: Decimals ]]; var S) – преобразование числа в строковое представление. Число X может быть как целого, так и вещественного типа. Форматирующие модификаторы Width и Decimals указывают общую длину строки и число цифр после запятой, аналогично writeln. В S возвращается полученная строка: Str(36.6:7:2, S)
S = '
105
36.60'
procedure Val(S; var V; var Code: Integer) – преобразование строки в числовое представление. Процедура имеет три параметра: S – символьное представление числа, V – числовая переменная, в которую записывается результат преобразования, Code – числовая переменная для возврата ошибки. В случае успешного преобразования Code равна нулю, в случае ошибки в Code помещается индекс символа, который привел к возникновению ошибки: Val('36.6', n, code) n = 36.6, code = 0 Val('780*2', n, code) code = 4
Процедуру Val удобно использовать для безопасного чтения числа с экрана. Например: begin … // запрашиваем пользователя до тех пор, // пока не будет введено число repeat writeln('Enter a number: '); readln(str); // безопасное чтение в строку Val(str, number, code); // безопасное преобразование if code > 0 then writeln('Wrong input'); until code = 0; … end.
Можно оформить этот фрагмент кода в виде функции.
Примеры подпрограмм редактирования строк 1. Реализовать процедуру insert: procedure Insert(sub: string; var s: string; ind: integer); var i: integer; res: string; begin res := ''; for i:=1 to ind -1 do res := res + s[i]; res := res + sub; for i := ind to length(s) do res := res + s[i]; s := res;
106
end;
2. Удалить лишние пробелы между словами в строке. Рассмотрим следующее решение: procedure Filter(var S: string); var i: integer; begin for i:=1 to length(s)-1 do begin if (s[i] = ' ') and (s[i+1] = ' ') then Delete(s,i,1); end; end;
В ходе работы со строкой уменьшается её длина, а граничные значения счётчика цикла for вычисляются один раз ещё до первого витка. Значит, функция length(s) выполнится один раз, и цикл for не узнает об изменении длины строки внутри цикла. В данном случае произойдет ошибочное обращение к несуществующему элементу строки. В общем случае, если строка укорачивается, то цикл будет выполняться лишнее число раз, а если строка удлиняется, то цикл завершится преждевременно. Вместо цикла for следует использовать while, так как условие цикла while вычисляется перед каждым витком: procedure Filter(var S: string); var i: integer; begin i:=1; while i
Если убрать else, то из трех пробелов будет удаляться лишь второй.
Множества и типы множеств В Паскале множество – совокупность элементов. Типмножество – тип данных, определяющий множества на конечном наборе элементов. Элементы множества считаются неупорядочен-
107
ными и могут принадлежать к любому порядковому типу, размер которого не превышает 1 байт. Максимальное количество элементов во множестве – 256, поскольку все они должны быть попарно различны. Потенциально понятие множества можно реализовать на массивах, однако в Паскале для определения множеств введены специальные синтаксические конструкции и ряд операций для работы с множествами. Множества-переменные описываются следующим образом: var <имя множества>: set of <тип элементов множества>;
Например: var // множество символов (256 элементов) CharSet: set of char; // множество букв латинского алфавита LettersSet: set of 'a'..'z','A'..'Z'; // множество цифр (максимум 10 элементов) DigitSet: set of 0..9; // множество значений истинности (максимум 2 элемента) BoolSet: set of boolean;
Тип-множество определяется по общим правилам. Например, type TCharSet = set of char;
Для задания множества необходимо перечислить элементы, входящие в это множество. Элементы записываются в квадратных скобках: [] – пустое множество; [e1, e2, e5, en] – непустое множество. Кроме прямого перечисления элементов множества можно конструировать с помощью бинарных теоретико-множественных операций пересечения, объединения и разности множеств. Теоретико-множественные операции в языке Паскаль S1 * S2 Пересечение множеств S1 и S2. S1 + S2 Объединение множеств S1 и S2. S1 – S2 Разность множеств S1 и S2. E in S Проверка принадлежности элемента E множеству S. Результат – значение истинности.
108
S1=S2
Проверка на равенство множеств S1 и S2. Результат – значение истинности. S1<S2 Проверка на включение множества S1 в S2. Результат – значение истинности. S1>S2 Проверка на включение множества S2 в S1. Результат – значение истинности. Задания на множества и строки 1. Выразить множества D, F на диаграмме (рис. 8) через множества А, B, C: A
D
B
F
C Рис. 8. Диаграмма пересечения множеств
Ответ: D := A*B-C; F := A*B*C;
Чему равно значение выражений C > D, F < C*B, D+F = A*B? Ответ: False, True, True. 2. Вывести на экран содержимое множества. для вывода на экран содержимого множества необходимо вывести по отдельности каждый элемент множества: var s: set of char; k: byte; begin … for k := 0 to 255 do if chr(k) in s then write(chr(k), ' '); end.
109
3. Оставить в строке только первое вхождение каждого символа, взаимный порядок оставленных символов сохранить. Использовать множества: var s: set of char; input, result: string; i: byte; begin s := []; result := ''; for i := 1 to length(input) do if not (input[i] in s) then begin result := result + input[i]; s := s+[input[i]]; // добавление элемента к множеству end; end.
4. Дана строка символов, содержащая путь к файлу. Выделить имя диска, расширение файла и имя файла, если они есть. При отсутствии соответствующих данных возвращаются пустые строки: procedure SplitPath(Path: string; var disk, name, ext: string); var i: integer; begin disk := copy(Path, 1, pos(':\',Path)); i:=length(Path); while (i>0) and (Path[i] <> '.') do i := i-1; ext := copy(Path, i+1, length(Path)-i); while (i>0) and (Path[i] <> '\') do i := i-1; name := copy(Path, i+1, length(Path)-i-length(ext)-1); end;
5. Реализовать функцию преобразования строки в число с плавающей запятой: function Evaluate(s: string): real; var i: integer; int, frac: real; comma: boolean; begin int := 0; frac := 0;
110
comma := false; // признак дробной части i:=1; while i
6. Реализовать алгоритм поиска подстроки в строке: function Pos(sub, S: string): integer; var i,j: integer; ok, found: boolean; begin Pos := 0; i:=1; while (i<=length(s)) do begin while s[i] <> sub[1] do i:=i+1; // ищем первый символ // сравниваем остаток ok := true; j:=1; while (i+j-1<=length(s)) and (j<=length(sub)) do begin ok := ok and (s[i+j-1] = sub[j]); j := j+1; end; // если все символы совпали, то подстрока найдена found := ok and (j>length(sub)); if found then break; i := i+1; end; if found then Pos := i; end;
Данный алгоритм далёк от оптимального – информация о символах в подстроке и результаты предыдущих сравнений никак не
111
используются после увеличения i. Подобные алгоритмы называют алгоритмами «грубой силы и невежества» (ГСН, brute force algorithm). Существуют гораздо более эффективные алгоритмы, например алгоритм Кнута–Морриса–Пратта или алгоритм Бойера– Мура.
112
Глава 11. Текстовые файлы Понятие файла Файл, записанный на какой-либо электронный носитель, представляет собой обособленный поименованный набор данных произвольной длины. В таком представлении файл статичен. Основное назначение таких файлов состоит в хранении информации любого рода от неструктурированных данных до исполняемых программ и библиотек. Для этого, как правило, применяются энергонезависимые носители, такие как гибкие и жесткие магнитные диски, ленты, оптические диски, flash-накопители и т.п. Для указания местоположения файла на носителе используют либо абсолютный путь, либо путь относительно текущего каталога. Форма записи пути к файлу различается в зависимости от операционной системы. Для MS Windows путь имеет вид [<диск>:\]{<каталог>\}<имя файла>[.<расширение>]. Здесь <>{}[] – метасимволы, [] – необязательная конструкция, {} – конструкция, повторяющаяся 0 и более раз. Например: coprime.pas – файл в текущем каталоге bin\coprime.exe – относительный путь к файлу D:\winnt\paint.exe – абсолютный путь к файлу Но прежде чем файл окажется на каком-либо носителе, необходимо выполнить ряд действий, в результате которых данные, располагавшиеся в оперативной памяти, станут отдельной сущностью: создать и открыть файл, записать данные, закрыть файл. Вообще говоря, понятие файла является абстракцией, лежащей в основе системы ввода-вывода. Ввод – процесс передачи данных в оперативную память извне (клавиатура, носители данных, порты ввода-вывода), а вывод – процесс передачи данных из оперативной памяти во внешнюю среду (дисплей, носители данных, порты ввода-вывода). Каждый канал передачи данных между устройством и оперативной памятью имеет свое уникальное имя, а для осуществления операций ввода-вывода используется тот же механизм работы с файлами. Например, устройствам соответствуют такие имена файлов, как COM1, COM2, PRN, CON, NUL. В языке Паскаль для работы с файлами существуют различные средства, учитывающие специализацию файлов. Читаемость чело-
113
веком является тем критерием, по которому файлы делят на два класса: текстовые файлы; бинарные файлы. Текстовый файл представляет собой последовательность строк, разделенную маркерами конца строки (символами возврата каретки #13 и перевода строки #10). При редактировании текстового файла эти символы вставляются в файл при нажатии клавиши Enter. Сами по себе они никак не отображаются на экране, а служат для визуального разделения строк. Каждая строка в свою очередь является последовательностью привычных печатных символов: буквенноцифровые символы, знаки пунктуации, математические символы, специальные символы. Примеры текстовых файлов: txt-файлы, файлы с исходными текстами программ (*.pas, *.c, *.hpp и др.), пакетные исполняемые файлы (*.bat), интернет-страницы, скрипты, размеченные файлы (*.asp, *.vb, *.txt, *.htm, *.xml, *.xslt, *.xsd) и любые другие файлы, читаемые человеком в простейших текстовых редакторах. Бинарный файл представляет собой произвольную последовательность байт. При открытии бинарного файла в текстовом редакторе содержимое каждого байта будет отображаться в виде соответствующего ему символа в ASCII-таблице. Бинарные файлы в свою очередь делятся на два подкласса: типизированные файлы; нетипизированные файлы. Типизированный файл представляет собой последовательность однотипных структур данных, известных программисту. Эти структуры данных еще называют компонентами. Типизированный файл может содержать 0 и более компонент. Нетипизированный файл – произвольная последовательность байт. Таким образом, нетипизированным файлом можно считать любой файл, который нельзя рассматривать как текстовый или типизированный. Если не представляется возможным выяснить, можно ли файл рассматривать как текстовый или типизированный или же это вообще не важно, то файл также следует считать нетипизированным. Эти классы определяют лишь способ интерпретации внутренней структуры файлов программистом, поэтому один и тот же файл
114
может быть отнесен в разных случаях к разным классам. В зависимости от интерпретации будут уместны те или иные механизмы работы с файлами, реализованные в языке Паскаль. С точки зрения машинного представления любой файл – это всегда последовательность байт.
Буферизация операций ввода-вывода Жесткие диски, внешние устройства, подключенные через порты, работают гораздо медленнее, чем оперативная память и процессор. В связи с этим операции обмена данными с их участием выполняются гораздо медленнее. Для ускорения операций вводавывода используют кэш-память, для минимизации числа операций укрупняют объем данных, передаваемых за одну операцию вводавывода. Для временного хранения данных до их востребования в программе используется буфер – специальная область оперативной памяти. При этом для каждого файла, используемого в программе, выделяется свой буфер. При записи в файл информация записывается в буфер до тех пор, пока он не заполнится. Передача данных из буфера внешнему устройству происходит либо по заполнению буфера, либо после специальной команды сброса буфера. При чтении из файла данные считываются в буфер, причем за один раз считывается столько данных, сколько поместится в буфере. Далее по мере необходимости данные передаются программе, а в буфер периодически подкачиваются новые данные. В Паскале по умолчанию буфер для работы с файлами имеет размер 128 байт. Однако существует возможность изменить размер буфера до начала работы с файлом.
Механизм работы с файлами в языке Паскаль Работа с файлами в языке Паскаль производится посредством файловых переменных, объявляемых в секциях var. Файловая переменная исполняет роль связующего звена между физическим файлом в файловой системе компьютера и его представлением в программе. Во избежание путаницы представление физического файла в программе называют логическим файлом. Общую схему работы с файлом можно представить следующим образом: 1) объявление файловой переменной; 2) связывание файловой переменной с физическим файлом;
115
3) открытие файла; 4) выполнение операций ввода-вывода; 5) закрытие файла. Для работы с текстовыми файлами следует объявлять файловые переменные типа TextFile. Например: var f,g: Text; // в Delphi тип TextFile
Операции с любыми файлами в Паскале выполняются с помощью стандартных процедур и функций. При этом для текстовых и бинарных файлов различаются только режимы открытия файла и процедуры чтения-записи. Общие процедуры и функции работы с файлами Рассмотрим программу, распечатывающую на экране содержимое текстового файла: var f: Text; path, s: string; begin write('Enter file path: '); readln(path); assign(f, path); // связывание файла с переменной reset(f); // открытие файла на чтение // пока не достигнут конец файла while not EOF(f) do begin readln(f, s); // чтение из файла writeln(s); end; close(f); // закрытие файла end.
В этой программе используется ряд стандартных подпрограмм работы с файлами: procedure Assign(var f; filename: string) – связывает файловую переменную f с физическим файлом, путь к которому указан в строке filename. При этом существование файла не требуется. procedure Reset(var f [: File; RecSize: Word ]) – открывает существующий физический файл, на чтение-запись. Если файл не существует, то возникает ошибка ввода-вывода. Если файл был открыт ранее, то он закрывается и открывается заново. Текстовые файлы открываются только на чтение. Параметр RecSize передает-
116
ся только для нетипизированных файлов и указывает размер блока для операций ввода-вывода. procedure Rewrite(var f [: File; RecSize: Word ]) – открывает на запись новый файл, связанный с переменной f. Если файл существовал ранее, то он стирается, и создается новый файл. Если f была связана с пустым путем Assign(F,''), то после вызова Rewrite f будет связана со стандартным файлом вывода. Текстовые файлы открываются только на запись. Параметр RecSize передается только для нетипизированных файлов и указывает размер блока для операций ввода-вывода. function EOF(var f): boolean – возвращает истину, если при чтении достигнут конец файла, и ложь – в противном случае. Сокращение EOF происходит от англ. End Of File (конец файла). procedure Сlose(var f) – закрывает открытый файл, связанный с переменной f. Если файл был открыт для записи, то перед закрытием в файл дописывается содержимое буфера записи. procedure Erase(var f) – уничтожает физический файл, связанный с переменной f. До вызова Erase файл должен быть закрыт. procedure Rename(var f; newname : string) – переименовывает физический файл, связанный с переменной f.
Организация доступа к данным в файле. Курсор В программе при работе с файлами удобно считывать информацию порциями. В результате операции чтения информация должна быть записана в какую-либо переменную нужного размера для последующей обработки (аналогично чтению с клавиатуры). Ясно, что при последовательном чтении должна быть считана следующая порция информация. Это обеспечивается запоминанием текущей позиции курсора. Курсор – логическая сущность, однако его можно сравнить со считывающей головкой магнитного диска, которая также меняет свою позицию в ходе операций ввода-вывода. Существуют два режима доступа к содержимому файла: последовательный; прямой. Схема последовательного доступа такова: 1) при открытии файла курсор позиционируется на первом символе файла; 2) в ходе операций чтения последовательно считываются участки файла, пока не будет достигнут конец файла.
117
При этом курсор каждый раз позиционируется на символе, следующим за считанным участком. Таким образом, при последовательном доступе нет возможности считать произвольный участок файла. Если нужно считать участок в середине файла, то сначала необходимо считать всё содержимое файла с его начала до этого участка. Если требуется вернуться назад и повторно считать некоторый участок, то для этого следует заново открыть файл и вновь выполнить последовательное чтение. Схема прямого доступа: 1) при открытии файла курсор позиционируется на первом символе файла; 2) курсор перемещается на нужный участок файла; 3) считывается нужный участок. Таким образом, перемещая курсор в любом направлении, можно считывать информацию в любом порядке. Массивы являются примером структуры данных с прямым доступом (по индексу). Для текстовых файлов реализован только последовательный доступ.
Чтение и запись текстовых файлов Для чтения из файла используются процедуры: procedure Read( [ var F: Text; ] V1 [, V2,...,Vn ] ) – считывает из файла данные в переданные переменные в порядке их следования. Курсор позиционируется на символ, следующий за последним считанным символом. Считываемые переменные могут иметь различные типы, но типы должны быть простыми, например string, char, integer, real, за исключением boolean. Не допустимы сложные типы (массивы, записи, файлы). При чтении осуществляется приведение очередной считанной цепочки символов к типу фактического параметра. procedure Readln([ var F: Text; ] V1 [, V2, ...,Vn ]) – аналогично Read, но после чтения курсор позиционируется в начало следующей строки. Для записи в файл используются процедуры: procedure Write( [var F: Text; ] P1 [ , P2,..., Pn] ) – записывает в файл содержимое фактических параметров. При этом осуществляется преобразование типов к строковому. Фактические параметры также могут иметь различные простые типы, включая boolean. Сложные типы данных недопустимы.
118
procedure Writeln([ var F: Text; ] P1 [, P2, ...,Pn ]) – аналогично Write, но после записи в файл помещается маркер конца строки (#13#10). Значения сложных типов можно вводить и выводить в текстовый файл только поэлементно. Например: var f: Text; a: array[1..200] of integer; i: integer; begin write('Enter file path: '); readln(path); assign(f, path); rewrite(f); // открытие файла на запись for i:=1 to 200 do writeln(f, a[i]); // запись элементов в столбик close(f); // закрытие файла end.
Видно, что для операций чтения-записи применяются одноименные процедуры Read и Write, используемые для ввода значений с клавиатуры и вывода на экран. Как уже говорилось, для работы с этими устройствами используется тот же файловый механизм. Существуют две системные переменные: INPUT – файловая переменная, связанная со стандартным вводом операционной системы (клавиатурой), и OUTPUT – файловая переменная, связанная со стандартным выводом операционной системы (экраном). Для программы эти файлы всегда открыты (INPUT – на чтение, OUTPUT – на запись), и в явном виде их нельзя открывать и закрывать. Так, Read(x) равносильно Read(INPUT, x), а Write(x) равносильно Write(OUTPUT, x). Другие стандартные подпрограммы для работы только с текстовыми файлами. procedure Append(var f: Text) – открывает на запись физический файл, связанный с переменной f. Запись производится в конец файла, т.е. данные добавляются, не замещая ранее записанных данных. Процедура работает только с текстовыми файлами. procedure Flush(var f: Text) – сброс содержимого буфера записи в файл, открытый процедурой rewrite или append. Процедура
119
используется для досрочной записи в файл независимо от уровня заполнения буфера. procedure SetTextBuf(var F: Text; var Buf [ ; Size: Integer] ) – замещение буфера, выделяемого по умолчанию, буфером Buf размером Size. function SeekEOF(var f: Text): boolean – сдвигает курсор относительно текущей позиции до следующего символа, отличного от пробела. Если при этом достигнут конец файла, то возвращается истина, иначе возвращается ложь. Функция используется для передвижения по файлу и пропуска пробелов. function SeekEOLN(var f: Text): boolean – сдвигает курсор относительно текущей позиции до следующего символа, отличного от пробела, в этой же строке. Если при этом достигнут конец строки или файла, то возвращается истина, иначе возвращается ложь. Сокращение EOLN происходит от англ. End Of LiNe (конец строки).
Обработка ошибок ввода-вывода Ошибки ввода-вывода вызывают аварийную остановку программы. Для того чтобы программа была более универсальной и продолжала работу в любых случаях, существует возможность обработки ошибок ввода-вывода внутри программы. Этим поведением управляет директива компилятора $I. Вообще, директивы компилятора размещаются в тексте программы внутри комментария {}. Для включения режима автоматической обработки ошибок следует записать {$I+}, для отключения – {$I-}. Область действия директивы простирается с момента её появления в программе до конца программы или до отменяющей её директивы. По умолчанию опция включена. Для ручного анализа ошибок ввода-вывода необходимо отключить опцию $I и использовать стандартную системную функцию IOResult: function IOResult: integer – возвращает результат последней операции ввода-вывода. При успешном завершении операции ввода-вывода возвращается 0, а в случае ошибки – ненулевой код ошибки. Если при отключенной опции $I происходит ошибка, то все последующие операции ввода-вывода игнорируются до первого обращения к функции IOResult. После вызова функции IOResult
120
ошибка считается обработанной, после чего можно продолжать операции ввода-вывода. Основным уязвимым местом в программе при работе с файлами является момент открытия. В частности, ошибка может возникнуть при открытии несуществующего файла. Рассмотрим программу: var f: Text; begin write('Enter file path: '); readln(path); assign(f, path); {$I-} reset(f); {$I+} if IOResult <> 0 then writeln('Error while opening file '+path); exit; end; // штатная работа с файлом … end.
В данной программе при ошибке открытия файла будет выдано сообщение об ошибке и программа завершится. Задача слияния словарей В двух текстовых файлах записаны слова – по одному в строке. При этом слова упорядочены по алфавиту. Необходимо получить третий файл, в котором содержатся слова из первого и второго файла без повторений, сохранив алфавитный порядок слов. Данная задача схожа с задачей слияния массивов. Однако имеются некоторые отличия. Во-первых, количество слов в файлах заранее неизвестно, файлы вообще могут быть пусты. Во-вторых, чтение элементов массивов и их сравнение могут осуществляться в одном выражении, а при работе с файлами требуется сначала считать строки, а лишь затем сравнивать. Поэтому удобнее организовать один сложный цикл, который будет выполняться до тех пор, пока хотя бы один файл не считан до конца: var f, g, h: Text; s1, s2: string;
121
fstRead, sndRead: boolean; begin assign(f, '…'); assign(g, '…'); assign(h, '…'); reset(f); reset(g); rewrite(h);
// первый входной открываем на чтение // второй входной открываем на чтение // выходной открываем на запись
fstRead := false; // строка из первого файла не считана sndRead := false; // строка из второго файла не считана // основной цикл слияния двух файлов while not EOF(f) or not EOF(g) do begin // безопасное считывание if not fstRead and not EOF(f) then begin readln(f, s1); fstRead := true; end; if not sndRead and not EOF(g) then begin readln(g, s2); sndRead := true; end; // слияние if not fstRead then begin // первый файл кончился, а второй еще нет writeln(h, s2); sndRead := false; end else if not sndRead then begin // второй файл кончился, а первый еще нет writeln(h, s1); fstRead := false; end else begin // оба файла еще не кончились if s1 < s2 then begin writeln(h, s1); fstRead := false; end else if s1>s2 then begin writeln(h, s2); sndRead := false; end else begin // при s1=s2 строка записывается только один раз
122
writeln(h, s1); fstRead := false; sndRead := false; end; end; end; close(f); close(g); close(h); end.
Основная идея этого решения заключается в разделении процессов чтения строк из файлов и слияния. Для этого вводятся две дополнительные булевы переменные – fstRead и sndRead, каждая из которых указывает на то, что из соответствующего файла была считана строка. При слиянии эти переменные играют важнейшую роль.
123
Глава 12. Записи и типизированные файлы Записи и массивы записей Представьте, что в программе требуется описать некоторый объект реального мира, например запись в телефонной книжке. Это возможно только с помощью совокупности переменных. Для работы с этой совокупностью, как с единым целым, в языке Паскаль вводится тип-запись, в Си – тип-структура. Для объявления типазаписи следует использовать ключевое слово record и далее описать составляющие запись переменные, которые называются полями записи. Далее можно объявлять переменные нового сложного типа. Например: type phone_rec = record fio: string[60]; phone: string[20]; e_mail: string[50]; end; var pr1, pr2: phone_rec;
Здесь в типе phone_rec объявлено три поля fio, phone, e_mail строковых типов. Для обращения к конкретному полю записи необходимо после имени переменной-записи через '.' указать имя поля: begin … pr1.fio := 'Ivanov Ivan'; pr1.phone := '89169110203'; pr1.e_mail := '[email protected]'; … writeln('fio: ' + pr1.fio); writeln('phone: ' + pr1.phone); writeln('e-mail: ' + pr1.e_mail); … end;
Кроме того, для обращения к полю записи может использоваться оператор with <имя записи> do. Тогда при обращении к полю имя записи можно не указывать:
124
begin … with pr1 do begin writeln('fio: ' + fio); writeln('phone: ' + phone); writeln('e-mail: ' + e_mail); end; … end;
При этом текст программы немного сокращается, однако программисту следует позаботиться об исключении конфликтов имен переменных. Для записей определена операция присваивания, при этом копируются все значения полей записи. Однако для записей не определены операции сравнения. Для сравнения записей необходимо попарно сравнить значения одноименных полей. function Equal(pr1, pr2: phone_rec): boolean; begin Equal := (pr2.fio = pr1.fio) and (pr2.phone = pr1.phone) and (pr2.e_mail = pr1.e_mail); end.
Пример: описать телефонную книжку на 200 номеров. Это можно сделать с помощью массива однотипных записей. В следующей программе на экран выводится содержимое телефонной книги, хранящейся в массиве: type phone_book = array [1..200] of phone_rec; var pb: phone_book; pr: phone_rec; i: integer; begin … for i:=1 to 200 do begin pr := pb[i]; writeln('№', i); writeln('Name: ' + pr.fio); writeln('Phone: ' + pr.phone); writeln('E-mail: ' + pr.e_mail); writeln;
125
end; end.
Вообще, записи и массивы можно комбинировать для создания сколь угодно сложных структур данных, вложенных друг в друга. Например: type passport_rec = record series: string[4]; number: string[6]; when: DateTime; whom: string[100]; end; person = record fio: string; birthdate: DateTime; passport: passport_rec; end; var p: person; begin p.fio := 'Smith'; p.passport.series := '4700'; … with p.passport do writeln(series, ' ', number); end.
Записи с вариантной частью В примере с телефонной книгой одна запись содержала три поля. Однако часто в разных случаях уместен разный набор полей. Так, формат библиографической записи различается в зависимости от типа издания: для книги следует указывать автора, название, год издания, издательство; для журнала – название, год и месяц издания, номер, издательство; для газеты – название, дату выхода (день, месяц, год), номер, издательство. Конечно, можно определить универсальную структуру данных, содержащую все возможные поля, и заполнять поля по мере надобности. Например:
126
type biblio = record bibtype: char; author: string[40]; name: string[40]; year: integer; month: byte; day: byte; number: integer; publisher: string[40]; end;
Но такая структура неэкономна с точки зрения расхода памяти. На одну запись расходуется 1+40+40+4+1+1+4+40 = 131 байт. При этом общими для всех трех типов изданий являются название, год, издательство, а остальная информация может различаться или вовсе отсутствовать. Для создания более компактных структур данных в языке Паскаль реализована возможность определения записей с вариантной частью. В таких записях после постоянной части, которая может и отсутствовать, указывается вариантная часть с помощью ключевого слова case. Общий вид записи с вариантной частью: type <имя типа> = record <поле>: <тип>; … <поле>: <тип>; case <тег>: <перечислимый тип> of <список констант>: ( <поле>: <тип>; … <поле>: <тип>; ); … <список констант>: ( <поле>: <тип>; … <поле>: <тип>; ); end;
Финальное слово end закрывает и запись, и вариантную часть.
127
Каждый список констант соответствует одному варианту, состоящему из одного и более полей. Содержимое списков констант для компилятора не важно, однако списки не должны пересекаться между собой. Для полей вариантов выделяется один и тот же участок памяти, т.е. варианты являются его различными представлениями и как бы наложены друг на друга. Таким образом, общий размер записи равен сумме размера постоянной части и размера самого ёмкого варианта. Тег в вариантной части, вообще говоря, необязателен, однако его перечислимый тип следует указывать всегда. Если же тег указан, то он считается дополнительным полем постоянной части и может использоваться для анализа того, какой вариант из вариантной части уместен. В вариантной части (так же как и в постоянной) не должно быть одноименных полей. Это ограничение гарантирует однозначное распознавание варианта и последующее вычисление относительной позиции поля в памяти. С помощью вариантной части запись biblio можно сделать более компактной: type biblio = record name: string[40]; year: integer; publisher: string[40]; case bibtype: char of 'b': (author: string[40]); 'j': ( jmonth: byte; jnumber: integer ); 'n': ( nmonth: byte; day: byte; nnumber: integer ); end;
В данном случае размер записи равен (40+4+40+1) + (40) = 125. Схема распределения памяти для структуры с вариантной частью показана на рис. 9.
128
name (40)
year publisher bibtype (4) (40) (1)
author (40) jmonth (1) nmonth (1)
jnumber (4) day (1)
number (4)
Рис. 9. Схема распределения памяти для структуры с вариантной частью
Обращение к полям вариантной записи ничем не отличается от обращения к полям обычной записи. Однако программист должен сам определять, с каким из вариантов вариантной части работать в данный момент. Например, с помощью тега вариантной части: procedure Print(bib: biblio); begin with bib do begin writeln(name); writeln(year); writeln(publisher) case bibtype of 'b': writeln(author); 'j': writeln(jmonth, ' ', jnumber); 'n': writeln(nmonth, ' ', day, ' ', nnumber); end; end; end;
Поскольку все варианты накладываются на один и тот же участок в памяти, то можно, например, записать в этот участок фамилию автора книги author, а затем обратиться к номеру журнала jnumber. При этом байты со 2 по 5, занимаемые author, будут расценены как число типа integer.
Типизированные файлы Понятно, что для долгосрочного хранения совокупности записей, например телефонной книги, оперативная память непригодна, так как при завершении работы программы все данные будут потеряны. Для долгосрочного пользования телефонной книгой следует использовать механизм работы с файлами. Телефонная книга представляет собой совокупность записей типа phone_rec, т.е. последовательность однотипных компонентов, поэтому разумно использовать типизированные файлы.
129
Для работы с типизированным файлом следует объявить файловую переменную, указав тип компонентов файла с помощью ключевых слов file of: var pb: file of phone_rec;
Или же ввести новый файловый тип phone_book, а затем объявить файловую переменную: type phone_book = file of phone_rec; var pb: phone_book;
В отличие от массива, на число компонентов файла не накладывается ограничений. Компоненты типизированного файла всегда нумеруются с 0. Для открытия типизированных файлов используют знакомые процедуры rewrite и reset. Процедура rewrite открывает типизированный файл в режиме перезаписи, а процедура reset – в режиме чтения и записи. Следует напомнить, что текстовые файлы открываются процедурой reset только на чтение. Для выполнения операций чтения и записи используются процедуры Read и Write. Однако, в отличие от текстовых файлов, процедуры работают с целыми компонентами файла: Read считывает компонент целиком, а Write записывает компонент целиком. В следующей программе на экран выводится содержимое телефонной книги, хранящейся в файле 'c:\dev\phones.bin' (предполагается, что файл уже существует): var pb: phone_book; pr: phone_rec; i: integer; begin Assign(pb, 'c:\dev\phones.bin'); Reset(pb); while not EOF(pb) do begin Read(pb, pr); // чтение одного компонента writeln('Name: ' + pr.fio); writeln('Phone: ' + pr.phone); writeln('E-mail: ' + pr.e_mail);
130
end; Close(pb); end.
Другие процедуры и функции для работы с типизированными файлами: procedure Seek(var F; N: Longint) – позиционирует курсор на компоненте с номером N. Процедура служит для организации прямого доступа к компонентам файла. function FilePos(var F): Longint – возвращает номер компонента, перед которым в данный момент стоит курсор. Если курсор указывает на конец файла, содержащего N компонентов, то возвращается число N, так как последний компонент имеет номер N–1. function FileSize(var F): Integer – возвращает число компонентов файла. procedure Truncate(F) – укорачивает файл с конца до текущего положения курсора, так что вызов EOF(F) после Truncate вернет истину. Функции EOLN, SeekEOF, SeekEOLN не применимы к типизированным файлам.
Организация простой базы данных на одном типизированном файле Для организации простейшей базы данных телефонных записей требуется реализация вспомогательных сервисов: функции поиска записи (select) по фамилии, добавления новой записи (insert), изменение записи (update) и удаление записи (delete). Поиск записи в телефонной книге по фамилии Функцию поиска можно реализовать аналогично поиску в массиве. Найдя запись с нужной фамилией, функция возвращает номер записи в файле и саму запись. Номер записи может понадобиться в дальнейшей работе, например для её обновления или удаления из файла: function Select( f: phone_book; fio: string; var N: longint; var rec: phone_rec): boolean; var
131
found: Boolean; r: phone_rec; begin found := false; reset(f); while not eof(f) and not found do begin read(f, r); if r.fio = fio then begin N := FilePos(f); rec := r; found := true; end; end; Select := found; end;
Добавление записи в телефонную книгу Для простоты новые записи будут добавляться в конец файла: procedure Insert(f: phone_book; p: phone_rec); begin reset(f); seek(f, FileSize(f)); // ставим курсор на конец файла write(f,p); close(f); end;
Обновление записи в телефонной книге под номером N Обновление записи тривиально: курсор позиционируется на нужный компонент и осуществляется запись поверх него: procedure Update( f: phone_book; N: integer; rec: phone_rec); begin reset(f); seek(f, N); write(f, rec); // перезапись компоненты под номером N close(f); end;
Удаление из телефонной книги записи под номером N К сожалению, в стандартной библиотеке отсутствуют процедуры удаления записей из произвольного места файла. Эту процедуру можно было бы реализовать следующим способом:
132
1) создать новый типизированный файл g, задав ему некоторое имя; 2) скопировать в него записи файла f, начиная с 0 до N–1; 3) скопировать в него записи файла f, начиная с N+1 до FileSize(f); 4) удалить прежний файл f; 5) переименовать новый файл g, дав ему имя прежнего файла; 6) связать f с g. Однако такой способ громоздок и неэффективен. Гораздо лучше обойтись одним файлом, воспользовавшись процедурой Truncate. Процедура удаляет записи из файла, начиная с текущей позиции курсора до конца файла. Тогда удаление записи из произвольного места файла можно выполнить так: 1) считать последнюю запись; 2) записать её на место N (она гарантированно имеет тот же размер); 3) поместить курсор на последнюю запись; 4) укоротить файл. procedure Delete(f: phone_book; N: integer); var rec: phone_rec; begin reset(f); seek(f, FileSize(f)-1); read(f, rec); // читаем последнюю запись seek(f, N); write(f, rec); // записываем на позицию N seek(f, FileSize(f)-1); truncate(f); // укорачивание файла close(f); end;
В основной программе необходимо связать файловую переменную с нужным файлом и предоставить средства для работы пользователя с одной записью. Работу с файлом возьмут на себя сервисные функции.
133
Глава 13. Нетипизированные файлы Для работы с бинарными файлами общего вида в Паскале имеется встроенный тип данных – нетипизированный файл. Файловые переменные для работы с такими файлами описываются следующим образом: var f: file;
Для нетипизированных файлов возможна организация как последовательного, так и прямого доступа. Для программы нетипизированный файл представляет собой последовательность байт. При этом структура файла неизвестна, неважна или не соответствует типизированному файлу. Например, утилиты операционной системы для поиска файлов по содержимому или для архивации в силу своей универсальности рассматривают все файлы как нетипизированные. В операциях ввода-вывода также используется буферизация, причем размер буфера ввода-вывода равен по умолчанию 128 байтам. Иной размер буфера можно задать при открытии файла с помощью второго параметра процедур reset и rewrite: reset(f[,size]); rewrite(f[,size]);
Размер буфера size должен находиться в пределах от 1 байта до 64 Кб.
Чтение и запись нетипизированных файлов Чтение и запись выполняются блоками, объем которых равен объему буфера. Операции блокового чтения и записи выполняются с помощью процедур: procedure BlockRead(var F: File; var Buf; Count: Integer [; var AmtTransferred: Integer]) – считывает в переменную Buf количество блоков Count 1. В необязательном параметре AmtTransferred возвращается количество реально прочитанных блоков. procedure BlockWrite(var f: File; var Buf; Count: Integer [; var AmtTransferred: Integer]) – выполняет запись в файл Сount блоков из области памяти Buf. В параметре AmtTransferred возвращается число успешно записанных блоков.
134
В процедурах BlockRead и BlockWrite параметр Buf должен являться переменной, однако тип её не указан. Это означает, что в качестве параметра можно передать переменную любого типа. Процедуры и функции Seek, FilePos, FileSize за размер одной компоненты берут размер блока (буфера), указанный при открытии нетипизированного файла. Задание 1. Написать программу чтения вещественных чисел из текстового файла и записи их в нетипизированный файл блоками по N чисел. Для определения размера в байтах вещественного числа использовать функцию SizeOf: function SizeOf(X): Integer – возвращает размер в байтах области памяти, занимаемый значением переменной X произвольного типа. Решение может выглядеть так: const N = 8; var buf: array[1..N] of real; f: text; g: file; i, k: integer; begin assign(f, '…'); reset(f); assign(g, '…'); rewrite(g, sizeof(real) * 4);
// входной файл // выходной файл
i := 0; while not eof(f) do begin i := i+1; read(f, buf[i]); if i = N then begin blockwrite(g, buf, 1); i := 0; end; end; // дополним нулями последнюю N-ку, если нужно if i > 0 then begin for k := i + 1 to N do buf[k] := 0; blockwrite(g, buf, 1); end; close(f); close(g);
135
end.
Задание 2. Написать программу шифрования файла путем наложения ключа с помощью операции xor. Программа должна запрашивать имена файлов с экрана. Пусть имеется файл размером N байт и ключ – строка длиной М. Задача шифрования состоит в разбиении файла на последовательные участки длиной M и наложения ключа на каждый участок. Наложение ключа осуществляется с помощью битовой операции исключающего или для каждой пары соответствующих символов из очередного участка файла и ключа. Расшифровка файла осуществляется с помощью повторного наложения того же ключа: const Size = 8; var f, g: file; Path: string; Key: string[Size]; buf: array [1..Size] of char; Transferred: longint; begin write('Введите ключ длины ', Size, ': '); readln(Key); write('Введите имя входного файла: '); readln(path); Assign(f, Path); {$I-} reset(f, 1); {$I+} if IOResult <>0 then begin writeln('Файл ', path,' не найден'); exit; end; write('Введите имя выходного файла: '); readln(path); Assign(g, path); Rewrite(g, 1); // шифрование while not EOF(f) do begin BlockRead(f, buf, Size, Transferred);
136
// наложение ключа на очередной участок for k := 1 to Transferred do buf[k] := buf[k] xor key[k]; BlockWrite(g, buf, Transferred); end; Close(f); Close(g); end.
Если длина файла кратна длине ключа, то переменная Transferred всегда будет равна Size, иначе на последнем витке цикла переменная Transferred будет меньше Size. Задание 3. Написать программу расчета энтропии файла. Энтропия рассчитывается по формуле H pi log( pi ) ,
i
где pi
Ni – доля вхождений (вероятность появления) симво Ni i
ла i в файле, а Ni – число вхождений символа i в файле. Поскольку в компьютерах используется двоичная система счисления и общее количество ASCII-символов равно 256, то основание логарифма имеет смысл выбрать равным двум, а пределы суммирования – от 0 до 255. Основание логарифма одновременно даёт название единицам измерения энтропии (2 – биты, 10 – диты, e – наты). Для решения задачи понадобится массив длиной 256, в котором будет рассчитываться число вхождений соответствующего символа. Программа будет состоять из двух частей: 1) сканирование файла и подсчет числа вхождений символов. Чтобы узнать общий размер файла в байтах, необходимо будет открыть его на чтение, указав размер буфера равным 1 байту; 2) расчет энтропии на основе полученного массива по формуле. В стандартной библиотеке языка Паскаль имеется функция ln(x), возвращающая натуральный логарифм. Для получения двоичного логарифма необходимо воспользоваться соотношением loga(b) = ln(b)/ln(a). Вариант реализации программы выглядит следующим образом:
137
const size = 1024; type // статистика вхождений symbols = array[0..255] var f: file; Path: string; total: longint; stats: symbols; b: byte; buf: array [1..size] of Transferred: longint; k: integer; H: real; begin write('Enter file path: readln(Path);
символов of longint;
char;
');
Assign(F, Path); {$I-} Reset(F, 1); // буфер в 1 байт, чтобы узнать размер файла в байтах {$I+} if IOResult <>0 then begin Writeln('File not found'); exit; end; Total := FileSize(F); for b:=0 to 255 do stats[b] := 0; // чтение файла и подсчет вхождений символов while not EOF(F) do begin BlockRead(F, buf, size, Transferred); for k := 1 to Transferred do stats[Ord(buf[k])] := stats[Ord(buf[k])] + 1; end; Close(F); // расчет энтропии H := 0; for b := 0 to 255 do begin if stats[b] > 0 then H := H - (stats[b] * ln(stats[b]/Total)); end; // разделим на общий знаменатель слагаемых
138
H := H / Total / ln(2); // вывод результата и статистики вхождений Writeln('H = ', H: 10: 10, ' bit'); for b := 0 to 255 do writeln('N(', b:3, ')= ', stats[b]); end.
Условие stats[b] > 0 необходимо для того, чтобы исключить выполнение функции ln(0) = - , которое приведёт к переполнению вещественного типа. Для сжатых файлов, к которым относятся архивные файлы (*.zip, *.rar), мультимедиа файлы (*.mp3, *.avi, *.jpg), энтропия близка к 8. Это не случайно. Число 8 – максимальная энтропия для 256 символов, кодируемых восемью битами. Эта величина достижима только в том случае, если символы, как бы хаотично, встречаются с одинаковой частотой:
pi
255 1 1 1 1 H log 2 ( 8 ) log 2 ( 8 ) 8 . 256 2 2 i 0 256
Энтропия и есть мера беспорядка. В теории информации максимальная энтропия соответствует наиболее эффективной кодировке, где для передачи сообщения требуется минимальное количество символов. В случае с архивными файлами каждый символ несет в себе максимальную информацию, что приводит к значительно меньшему объему архивного файла по сравнению с оригиналом.
139
Глава 14. Динамические переменные, линейные списки Динамические переменные и их особенности При запуске программы для нее выделяется определенная область в оперативной памяти. Выделенный ресурс памяти делится на несколько областей (сегментов): сегмент кода; сегмент данных и стека; куча (динамическая память). В сегменте кода размещается собственно последовательность инструкций, составляющих программу. В сегменте данных и стека размещаются все константы и переменные, которые рассматривались до сих пор. Стек обслуживает вызовы процедур и функций. Эти области памяти можно назвать статическими, так как их объем определяется во время компиляции и на протяжении работы программы не меняется. При компиляции идентификатору переменной или константы ставится в соответствие определенная ячейка памяти в сегменте данных, которая имеет свой уникальный адрес. Фактически идентификатор становится синонимом этого адреса. Поэтому при обращении к переменной по её идентификатору программа будет работать с участком памяти, расположенным по заранее известному адресу. Куча (динамическая память) предназначена для размещения и обработки данных, структура или объем которых могут меняться в ходе выполнения программы. Из-за этого свойства такие данные называют динамическими. Так как на этапе компиляции объем памяти, необходимый для хранения динамических данных, неизвестен, то память должна выделяться уже во время выполнения программы. Причем заранее неизвестно, какой участок памяти будет выделен. Каждый раз при запросе динамической памяти блок нужного размера выбирается среди незанятых на данный момент участков. В связи с этим динамическая структура данных может занимать одновременно несколько участков памяти, разбросанных относительно друг друга. Понятно, что при такой схеме работы с памятью статические переменные для работы с динамическими данными применять
140
нельзя. Для этого в языке Паскаль используются динамические переменные, создающиеся в момент выделения памяти для хранения их значений. Именно в этот момент работы программы становятся известными адрес и размер динамической переменной. При возврате памяти системе динамическая переменная уничтожается. Таким образом, в отличие от статических переменных, динамические не имеют заранее определенных адресов, а значит, и нет смысла использовать синонимы адресов (идентификаторы). Для работы с динамическими переменными требуется иной механизм доступа. В языке Паскаль для этого используются указатели – переменные специального типа.
Адреса, указатели и переменные ссылочного типа Указателями называют переменные, значениями которых являются адреса ячеек памяти. По сути, адрес ячейки памяти представляет собой число, для представления которого в 32-разрядных системах отводится четыре байта (32 бита), что в сумме даёт 232 = 4 Гб адресного пространства. Для удобства всю память можно логически поделить на равные сегменты, причем старшие два байта адреса будут представлять собой номер сегмента памяти, а младшие два байта – смещение ячейки памяти относительно начала сегмента. Таким образом, общее число сегментов равно 216 при объеме одного сегмента также 216 байт (64 Кб). Адрес ячейки памяти, выделенной под значение статической переменной, можно узнать по её идентификатору с помощью функции addr(v) или аналогичной унарной операции взятия адреса, обозначаемой символом '@', который записывается слева от идентификатора переменной. Помня о том, что адрес представляется 4х байтным числом, можно вывести его на экран, предварительно преобразовав его к какому-либо числовому типу. Например: var i: integer; begin // адрес статической переменной известен до инициализации writeln(integer(@i)); i := 9; writeln(integer(addr(i))); end.
141
В языке Паскаль реализована возможность использования указателей двух видов: типизированных и нетипизированных. Это деление аналогично делению файлов на типизированные и нетипизированные. Типизированные указатели предназначены для работы с данными, для которых известен их тип. Соответственно, если тип данных неизвестен или неважен, то используются нетипизированные указатели. Тип указателя указывается при его объявлении. Для типизированного указателя следует записать символ '^', а за ним название типа данных, для работы с которым вводится указатель. Нетипизированные указатели имеют тип pointer. Указатели объявляются в секции переменных. Например: var pbyte: ^byte; // указатель на значение типа byte prec: ^phone_rec; // указатель на запись типа phone_rec p: pointer; // нетипизированный указатель
Здесь только объявлены три статические переменные-указатели, которые пока ни на что не ссылаются. И, конечно, никаких динамических переменных здесь также не создается. В программе указатели в основном используются для работы с динамическими переменными и лишь изредка для ссылок на статические переменные. Но, вообще говоря, в указатель можно записать произвольный адрес, по которому может располагаться фрагмент данных, участок стека или вовсе какая-либо исполняемая инструкция. Однако вольное обращение с указателями может привести к ошибкам доступа, который контролируется как самой программой, так и её средой выполнения (операционной системой или виртуальной машиной), а также к различным сбоям в работе. Примеры ссылок на статические переменные: var i: integer; p: pointer; pint: ^integer; begin pint := @i; p := addr(i); end.
// ссылка на переменную i // здесь равносильно p := pint;
142
Для обращения к данным, на которые ссылается указатель, предназначена унарная операция разыменования указателей ^, обозначаемая символом '^', который в этом случае записывается справа от идентификатора указателя. Операция разыменования определена только для типизированных указателей. Например: var i: integer; pint: ^integer; begin i := 9; pint := @i; // Присваиваем указателю адрес переменной i // В ячейку по адресу, хранимому pint, присваивается 5, // что в данном случае равносильно инструкции i := 5; pint^ := 5; // Печатается содержимое ячейки по адресу, // хранимому pint, что равносильно writeln(i); writeln(pint^); i := 2 + pint^; end.
// Равносильно i := 2 + i;
Таким образом, после выполнения инструкции pint := @i; разыменованный указатель pint^ становится синонимом переменной i до следующего изменения значения pint. По сути, разыменованный указатель представляет собой обычную переменную, только безымянную. В зависимости от адреса (местонахождения в памяти) это будет либо статическая, либо динамическая переменная. Так как значением указателя является адрес, то в случае присваивания значения одного указателя другому копируется именно адрес, а копирования самих данных по этому адресу не происходит. В результате присваивания оба указателя будут ссылаться на одни и те же данные в памяти. По этой же причине при сравнении указателей сравнивается не содержимое ячеек памяти, на которые они ссылаются, а их адреса. Поэтому, если указатели указывают на две ячейки, содержащие одинаковые значения, результатом проверки указателей на равенство будет ложь (рис. 10).
143
P1
≠
P2
37
=
37
P1
=
P2
37
Рис. 10. Разница в сравнении указателей и значений по указателям
Совместимость указателей разных типов При работе с указателями осуществляется контроль типов. При этом: 1) типизированному указателю можно присваивать значение указателя того же типа, а также значения нетипизированного указателя, при этом происходит неявное преобразование типов; 2) нетипизированному указателю можно присваивать любые значения-адреса, в том числе значения типизированных указателей, при этом информация о типе теряется; 3) любому указателю можно присваивать ссылки на переменные, возвращаемые операцией взятия адреса @ и функцией addr; 4) любому указателю можно присваивать константу NIL, означающую, что указатель не ссылается на какую-либо конкретную ячейку памяти. Константа используется для инициализации значений указателей. Если требуется обратиться к данным, на которые указывается нетипизированный указатель, то допускается явное приведение типов, однако ответственность за результат приведения типов и последующее использование операции разыменования ложится на программиста. Что напечатает следующая программа? type pword = ^word; var p: pword; w: word; pint: ^integer;
144
begin // печать адреса переменной pint writeln(integer(@pint)); // ссылка на себя (значение = свой адрес) pint := @pint; // снова печать собственного адреса writeln(pint^); // true writeln(p = @(p^)); // true, приведение типов + разыменование writeln((pword(@w))^ = w); end.
Если тип значения, хранимого в ячейке памяти, отличается от типа значений типизированного указателя, который на него указывает, то осуществляется автоматическое преобразование типов. При этом возможны аномалии преобразования: const a: array[1..10] of char ='any string'; var p: ^word; begin p := @a; // преобразование первых двух символов в 2-байтовое число writeln(p^); end.
В результате будет напечатано число 28257, равное в 16-ричном представлении числу $6E61, где $61 (младший байт) – ASCII-код буквы ‘a’, $6E (старший байт) – ASCII-код буквы ‘n’.
Работа с динамической памятью Указатель может ссылаться как на участок статической памяти, отведенный под значение заранее объявленной (статической) переменной, так и участок динамической памяти (динамическую переменную). Однако для работы с динамической памятью необходимо сначала выделить участок нужного размера. Поскольку при этом новая переменная явно не объявляется, то доступ к этому участку памяти возможен только с помощью указателя. По завершении работы с динамической переменной выделенная для нее память должна быть возвращена системе. В противном случае система будет считать её занятой до конца работы программы. Для выделения и возврата динамической памяти предназначены следующие процедуры стандартной библиотеки:
145
procedure New(var P: Pointer) – выделяет участок памяти для новой динамической переменной, на которую будет ссылаться указатель P. Размер участка соответствует типу данных, указанному при объявлении P. В результате работы процедуры значением указателя P станет адрес первого байта выделенного участка. Если выделить требуемый объем памяти невозможно, то программа аварийно завершается. procedure Dispose(var P: Pointer) – удаляет динамическую переменную, на которую указывает P (возвращает выделенную под неё память системе), созданную с помощью процедуры New. procedure GetMem(var P: Pointer; Size: Integer) – выделяет участок памяти для новой динамической переменной, на которую будет ссылаться указатель P. Размер участка в байтах равен Size. В результате работы процедуры значением указателя P станет адрес первого байта этого участка. Если выделить требуемый объем памяти невозможно, то программа аварийно завершается. Указатель P может быть любого типа. procedure FreeMem(var P: Pointer[; Size: Integer]) – удаляет динамическую переменную, на которую указывает P, созданную с помощью процедуры GetMem. procedure Initialize(var V [ ; Count: Integer ] ) – инициализация участка памяти V длины Count нулевыми значениями. Вызов процедуры уместен в том случае, если память выделялась не с помощью процедуры New. Пример работы с динамическими переменными: type pword = ^word; var p1: pword; pr: ^phone_rec; begin new(p1); p1^ := 2; inc(p1^); dispose(p1); // выделение памяти под запись и заполнение её полей new(pr); pr^.fio := 'Ivanov'; pr^.phone := '123-56-78'; pr^.email := '[email protected]';
146
// доступ к полям записи по указателю with pr^ do writeln(fio, phone, email); dispose(pr); end.
Таким образом, работа с динамическими переменными посредством разыменованных указателей отличается только необходимостью явного выделения и уничтожения памяти. Если во время работы программы наступает момент, когда на выделенный участок динамической памяти не ссылается ни один указатель, то участок становится мусором – его адрес программе больше неизвестен. Такой эффект называется утечкой памяти. В следующем фрагменте указатель ptr изменяет свое значение на NIL и адрес ячейки с числом 53 безвозвратно теряется. Так могут быть потеряны целые структуры данных, расположенные в динамической памяти: var ptr: ^Integer; begin … New(ptr); ptr^ := 53; … ptr := NIL; // с этого момента выделенная память потеряна … end;
Кроме того, попытка вызова Dispose для указателя, который принял значение NIL, приведет к ошибке.
Применение динамической памяти для работы с телефонной книгой Вспомним задачу о телефонной книге. Одним из ее свойств является динамичность – изменчивость количества записей в ней. Изза этого свойства использование массива записей для представления телефонной книги оказывалось неадекватным, так как массив имеет строго определенную длину. В связи с этим выбрано файловое представление, а для работы с телефонной книгой были реализованы функции поиска нужной записи, добавления, изменения и удаления записей.
147
Однако постоянные обращения к файлу приводят к низкому быстродействию программы. Удобнее и быстрее считать телефонную книгу из файла в динамическую память, произвести необходимые действия и сохранить все изменения обратно в файл. Потенциально динамические переменные могли стать хранилищем записей телефонной книги, однако явное объявление указателей в секции var для всех используемых динамических переменных равносильно объявлению статических переменных. Но число записей всё равно заранее неизвестно. Выходит, что такая схема работы не дает никакой выгоды при работе с динамической структурой. Кроме того, работа с динамическими переменными сложнее, так как требует явного выделения и возврата динамической памяти, приводит к дополнительному расходу статической памяти для переменныхуказателей, а также требует применения операции разыменования. Но ничто не мешает размещать в динамической памяти сами указатели, тогда для хранения адресов будут также применяться динамические переменные. Для этого необходимо выделять память не только для хранения полезных данных (записи телефонной книги), но и хранения указателей на сами записи. Реализация базовых функций в этом случае возможна только тогда, когда вся память, выделенная для хранения данных и указателей, каким-либо образом связана. Простейший вариант – линейная связность, когда записи образуют последовательность (список). Введём необходимые структуры данных: type phone_rec = record fio: string[60]; phone: string[20]; e_mail: string[50]; end;
Для размещения этой записи в динамической памяти потребуется переменная-указатель типа ^phone_rec. Но эту структуру необходимо расширить ещё одним полем для хранения указателя на следующую запись, если таковая понадобится. Это поле также должно иметь тип ^phone_rec. Тогда новый тип будет иметь вид type phone_rec = record fio: string[60];
148
phone: string[20]; e_mail: string[50]; next: ^phone_rec; end;
Такой тип данных не является допустимым с точки зрения синтаксиса языка Паскаль, так как одно из полей записи ссылается на тип, еще не обработанный компилятором (рекурсивная ссылка). Для разрешения этого конфликта разрешается объявить новый типуказатель на запись ещё до объявления самого типа-записи, а далее использовать этот тип-указатель внутри объявления типа-записи: type pphone_rec = ^phone_rec; phone_rec = record fio: string[60]; phone: string[20]; e_mail: string[50]; next: pphone_rec; end;
С помощью этого типа данных можно построить список записей, связывая одну с другой через поле next. Последняя запись не должна ни на что ссылаться, поэтому поле next должно иметь значение пустого указателя NIL. На рис. 11 показана схема организации списка записей в памяти. Статическая переменнаяуказатель Head на первую запись типа phone_rec
Статическая память Динамическая память
fio
Азов Иван
fio
Боев Антон
phone
911-02-03
phone
290-05-14
e_mail
[email protected]
e_mail
[email protected]
next
$ff034517
next
$df0da302
Рис. 11. Схема организации динамического списка в памяти
149
…
NIL
Таким образом, заранее объявляется лишь одна переменнаяуказатель на головной элемент списка, а сами записи и связи между ними создаются уже в динамической памяти. При этом первая запись ссылается на вторую, вторая – на третью и так далее до NIL. Константа NIL (нулевой адрес) должна завершать любой список, чтобы можно было различить конец списка. Подпрограммы для работы со списками Добавление записи в начало списка можно осуществить следующим образом: 1) выделить память для новой записи. При этом адрес выделенного участка станет значением какой-либо переменной-указателя; 2) заполнить поля записи с помощью операции разыменования; 3) полю next новой записи присвоить адрес первой записи в существующем списке. Этот адрес хранится в статической переменной-указателе Head; 4) адрес новой записи поместить в значение переменной Head. Наглядно этот процесс проиллюстрирован на рис. 12. p
Head
…
p
Head
… Рис. 12. Схема процесса добавления записи в начало списка
150
Таким образом, новая запись станет первой в списке. Применяя эту операцию многократно, можно получить список любой длины. Очевидно, начальным значением Head должен быть NIL. Пример подпрограммы для добавления записи выглядит следующим образом: procedure Add2List(var Head: pphone_rec; fio, phone, email: string); var p: pphone_rec; begin New(p); p^.fio := fio; p^.phone := phone; p^.email := email; p^.next := Head; Head := p; end;
Нужно отметить, что по завершении работы процедуры память, выделенная под переменную p, будет возвращена системе, однако выделенная динамическая память останется, причем адрес выделенного участка будет помещен в переменную Head. Очень важно, чтобы переменная Head всегда указывала на первую запись в списке. Зная адрес первой записи, можно перейти к любой записи списка с помощью поля next. Например, процедура вывода содержимого телефонной книги на экран может выглядеть так: procedure PrintList(Head: pphone_rec); var p: pphone_rec; begin p := Head; while p <> NIL do begin writeln(p^.fio, p^.phone, p^.email); p := p^.next; // переход к следующей записи end; end;
Аналогичным образом можно посчитать число записей в списке. По завершении работы со списком выделенную под него динамическую память необходимо вернуть системе. Здесь следует остерегаться потери указателя на начало списка. Например, вызов Dis-
151
pose(Head) приведет к возврату памяти, выделенной для первой записи, и, одновременно, к потере ссылки на следующую запись, а значит, и на всю оставшуюся часть списка. Следовательно, до удаления очередной записи необходимо запоминать ссылку на остаток списка. Процедура удаления списка может выглядеть следующим образом: procedure DisposeList(var Head: pphone_rec); var p: pphone_rec; begin p := Head; while p <> NIL do begin // запоминаем указатель на следующую запись Head := Head^.next; // возвращаем память для первой записи Dispose(p); // переходим к остатку списка p := Head; end; end;
Задание. Реализовать процедуру поиска записи в списке по фамилии: function Search(Head: pphone_rec; fio: string): pphone_rec; var p: pphone_rec; begin Search := NIL; p := Head; while p <> NIL do begin if p^.fio = fio then begin Search := p; break; // прекращение поиска, когда запись найдена end; p := p^.next; end; end;
При сохранении в типизированный файл записей телефонной книги содержимое полей next теряет актуальность, так как адреса динамическим переменным всегда назначаются в момент исполнения программы. Чтобы не записывать в файл лишнюю информацию, можно отделить полезные данные от служебных с помощью дополнительного типа данных:
152
type // полезная часть, записываемая в типизированный файл phone_rec = record fio: string[60]; phone: string[20]; e_mail: string[50]; end; // служебная часть для работы с динамическими списками pNode = ^Node; Node = record pr: phone_rec; next: pNode; end;
Тогда полезную часть можно сохранять и восстанавливать из файла, а служебную – использовать только при работе программы. Если пойти еще дальше, то служебную часть для работы со списком можно сделать полностью независимой от типа данных, хранящихся в этом списке. Универсальный тип данных для работы со списком будет выглядеть следующим образом: type pNode = ^Node; Node = record data: pointer; next: pNode; end;
Тогда нетипизированному указателю data можно присваивать адреса динамических переменных любого типа данных, в частности типа phone_rec. При этом программист должен сам позаботиться о том, чтобы обращение к данным, на которые ссылается поле data, происходило корректно. Следует отметить: динамическая память под полезные данные должна выделяться в этом случае независимо от памяти для списковой структуры.
153
Глава 15. Динамические структуры данных Организация стеков и очередей на линейных списках Рассмотрим следующую структуру данных: type pNode = ^Node; Node = record data: pointer; next: pNode; end;
С помощью этого типа-записи можно строить линейные списки, доступ к которым может быть организован по-разному. Например, для работы со стеком необходимо реализовать следующие сервисные подпрограммы: procedure Push(var Stack: pNode; data: pointer) – добавление элемента в стек; function Pop(var Stack: pNode): pointer – извлечение элемента из стека (поскольку стек работает по принципу LIFO «Последним пришел, первым вышел», то функция Pop должна извлекать последний добавленный элемент); function Length(Stack: pNode): boolean – текущая длина стека; procedure Clear(var Stack: pNode) – инициализация стека (если в стеке находились какие-либо данные, то они должны удаляться). Пусть процедура Push добавляет элемент в начало списка. Тогда // добавление элемента в стек procedure Push(var Stack: pNode; data: pointer); var n: pNode; begin New(n); n^.data := data; n^.next := Stack; Stack := n; end; // извлечение последнего добавленного элемента function Pop(var Stack: pNode): pointer; var n: pNode; begin if Stack <> nil then begin
154
n := Stack; Result := n.data; Stack := Stack.next; Dispose(n); end else Result := nil; end; // определение длины стека или очереди function Length(Stack: pNode): Boolean; var p: pNode; i: integer; begin i := 0; p := Stack; while p<>NIL do begin i := i+1; p := p^.next; end; Result := i; end; // удаление всех элементов из стека procedure Clear(var Stack: pNode); begin while Stack <> NIL do begin data := Pop(Stack); dispose(data); end; end;
Схожей со стеком структурой данных является очередь – упорядоченный набор некоторого переменного числа объектов, обработка которых ведется по правилу: «Первым пришел, первым вышел» (от англ. FIFO – First In, First Out), т.е. в порядке поступления данных. Например, часто используются очереди событий, очереди сообщений. Для организации очереди в динамической памяти требуется, чтобы процедура PushQ и функция PopQ работали с противоположными элементами списка. Например, если процедура PushQ добавляет элемент в начало списка, то извлекать следует последний элемент списка, и наоборот. Пусть процедура PushQ добавляет элемент в конец списка. Тогда функция PopQ для очереди будет
155
идентична функции Pop для стека. Реализация функции PushQ может выглядеть так: procedure PushQ(var Queue: pNode; data: pointer); var n, p: pNode; begin New(n); n^.data := data; n^.next := NIL; if Queue = NIL then Queue := n else begin p := Queue; while p^.next <> nil do p := p.next; p^.next := n; end; end;
Для очереди имеет смысл хранить два указателя: на голову и хвост. Тогда для добавления элемента в очередь не нужно будет проходить по всем элементам до её конца, а сразу узнавать, «кто последний». Процедура PushQ, очевидно, станет на степень более эффективной.
Двунаправленные списки, бинарные деревья Рассмотрим следующую структуру данных: type pNode = ^Node; Node = record data: pointer; left: pNode; right: pNode; end;
Два поля типа pNode дают возможность создания двусвязных динамических структур. Интерес представляет двунаправленный список, каждый элемент которого указывает как на следующий, так и предыдущий элемент списка. Двунаправленный список удобен для реализации очереди с двумя «хвостами» – дека (от англ. DEQ – double-ended queue). Наличие двусторонних связей обеспечивает высокую скорость доступа к элементам дека, а также свободное перемещение по деку в обе стороны. Для дека удобно использовать
156
также два указателя на первый и последний элементы, хотя порядок элементов в данном случае становится относительным. Пример дека приведен на рис. 13. Head
Data 1
#
Data 2
Data 3
… Tail
Data N
#
Рис. 13. Схема организации дека в памяти
Помимо двунаправленных линейных списков эта же структура данных дает возможность построения бинарных деревьев. Первый элемент структуры – корень дерева (Root), при этом каждый элемент может иметь до двух дочерних элементов. Пример бинарного дерева представлен на рис. 14, знак # – синоним NIL. Root Data 1 Data 3 #
Data 2
Data 4 #
#
Data 5 #
#
Data 6
Рис. 14. Схема организации дерева в памяти
157
#
#
Значение древовидных структур данных поистине трудно переоценить: они лежат в основе иерархических систем (файловых систем, систем разграничения доступа) и находят применение в трансляторах, базах данных, поисковых машинах, системах искусственного интеллекта.
Сетевые структуры: графы Вообще говоря, число связей может быть произвольным и задаваться динамически, что дает возможность описывать сложные сетевые структуры данных. Такие структуры применяются при решении топологических задач, задач оптимизации, незаменимы для программной реализации искусственных нейронных сетей, графовых моделей, автоматов, сетей Петри, семантических сетей. Сами связи между узлами сети могут нести важную дополнительную информацию, например расстояние между двумя узлами или тип связи. В этом случае описание связи в типе данных нагружается дополнительным полем для хранения полезной информации. На рис. 15 представлен ориентированный граф. 1 2
3 4
5
Рис. 15. Пример ориентированного графа
Для его представления может быть использована динамическая структура данных, в которой вершины хранятся в главном списке, а к каждому узлу списка дополнительно привязан список дуг, исходящих из данной вершины. Визуально динамическую структуру данных можно представить так, как это выглядит на рис. 16.
158
#
1
2
#
3
#
4
#
5
#
#
Рис. 16. Схема организации графа в памяти
Типы данных, необходимые для представления приведенной структуры в динамической памяти, могут быть такими: type pArcElem = ^ArcElem; pNodeElem = ^NodeElem; ArcElem = record Arc: pNodeElem; Data: pointer; Next: pArcElem; end; NodeElem = record Data: pointer; Arcs: pArcElem; Next: pNodeElem; end;
Запись NodeElem способна хранить адрес полезной структуры данных, указатели на список исходящих связей типа ArcElem и следующий в списке узел типа NodeElem. В свою очередь запись
159
ArcElem способна хранить указатель на узел типа NodeElem, адрес полезных данных о связи, а также указатель на следующую в списке запись типа ArcElem. Для удаления подобной структуры из динамической памяти нужно сначала удалить все списки связей, привязанные к узлам, а затем и сами узлы. Введенные типы данных дают возможность описания сложных сетевых структур, где узлы связаны неограниченным числом связей, к которым также привязана некоторая информация.
Сортировка с использованием линейных списков Сортировку с использованием линейного списка можно построить следующим образом: каждый элемент исходного массива должен вставляться в список таким образом, чтобы соблюдалась требуемая упорядоченность. Тогда задача сведётся к реализации процедуры вставки очередного элемента в нужное место списка. Процедура сортировки должна принимать один параметр, в который будет передаваться сортируемый массив. Элементы массива по очереди будут вставляться в линейных список с сохранением упорядоченности. После формирования списка упорядоченные элементы следует переписать в исходный массив. Скелет программы сортировки может выглядеть следующим образом: const n = 100; type tarray = array [1..n] of integer; pNode = ^Node; Node = record elem: integer; next: pNode; end; // процедура вставки с сохранение порядка procedure OrderedInsert(var List: pNode; elem: integer); begin … end; // основная процедура сортировки открытого массива procedure Sort(var a: array of integer); var
160
i: integer; List,p: pNode; begin List := NIL; for i:=Low(a) to High(a) do begin OrderedInsert(List, a[i]); end; // переписываем упорядоченный список обратно в массив p := List; for i:=Low(a) to High(a) do begin a[i] := p^.elem; p := p^.next; end; DisposeList(List); end; begin … Sort(a); … end.
Для того чтобы процедура сортировки работала для массивов любой длины, её параметр должен быть открытым массивом. Для открытых массивов не указывается диапазон значений индекса массива, однако он может быть определен с помощью функций Low(TypeOrArray) и High(TypeOrArray). Функция Low возвращает минимальное значение индекса массива, а функция High – максимальное. Функции работают как для переменных-массивов, так и типов-массивов. Когда в процедуру сортировки передается массив а, то неявно в процедуру передаётся информация о его типе, в том числе о диапазоне значений индекса. Задание. Реализовать процедуру OrderedInsert для вставки элемента массива в упорядоченный список. Прямой подход должен предусматривать анализ следующих случаев вставки: первого элемента, в начало, в конец, в середину упорядоченного списка. Использование указателя на указатель позволяет реализовать процедуру OrderedInsert лаконично и изящно, хотя и сложнее для понимания:
161
// вставка с помощью указателя на указатель procedure OrderedInsert2(var List: pNode; elem: integer); var n: pNode; pp: ^pNode; begin New(n); n^.elem := elem; n^.next := NIL; // вставляем первый элемент if List = NIL then List := n else begin pp := @List; while (pp^ <> NIL) and (elem > pp^^.elem) do pp := @(pp^^.next); // если не последний, то приставим хвост if (pp^ <> NIL) then n^.next := pp^; // переставляем найденный указатель на новый элемент pp^ := n; end; end;
Благодаря тому, что добавлять элемент можно в любое место списка без необходимости двигать остальные элементы на соседние места, сортировка с помощью списка оказывается более эффективной по сравнению с алгоритмически аналогичной сортировкой на основе только лишь массива.
162
Глава 16. Командная строка, стиль, тестирование и отладка Передача аргументов в программу из командной строки Часто удобно указывать входные данные для программы с помощью командной строки. По этому принципу работают многие утилиты операционных систем. Аргументы, передаваемые в командной строке, имеют тот же смысл, что и параметры процедур и функций. Всё, что в командной строке следует за именем программы, образует список аргументов. Разделителями аргументов являются символы пробела и табуляции. Например, утилита format (исполняемый файл format.com) операционной системы DOS требует указания диска в качестве первого параметра. Вызов утилиты может выглядеть следующим образом: format c: /q
При этом программа анализирует все параметры и запускает быстрое форматирование нужного диска. Если в командной строке указать format /?
то на экран будет выдана справка об использовании утилиты с описанием всех возможных параметров. В стандартную библиотеку языка Паскаль включены две функции, позволяющие программе анализировать аргументы, указанные в командной строке: function ParamCount: word – возвращает количество аргументов, переданных в программу при вызове из командной строки; function ParamStr(k: word): string – возвращает k-й аргумент, переданный в программу из командной строки. Программа, выводящая на экран список аргументов, переданных из командной строки, может выглядеть следующим образом: program PrintArgs; var i,k,n: word; begin
163
n := ParamCount; writeln('Всего аргументов в командной строке: ', n); for i := 1 to n do writeln('№', i, ': ', ParamStr(i)); end.
Пусть исполняемый файл программы имеет имя PrintArgs.exe. Если вызвать эту программу из командной строки следующим образом: printargs arg1 /? -275.15 "c:\program files\office"
то на экране будет распечатано Всего аргументов в командной строке: 4 №1: arg1 №2: /? №3: -275.15 №4: c:\program files\office
Следует отметить, что последовательность символов, заключенная в двойные кавычки, считается одним аргументом, который передается в программу как одно целое, но уже без кавычек. Это сделано, чтобы не разрывать аргумент на части из-за того, что он содержит пробелы внутри себя. Обычно пробелы содержатся в именах файлов и каталогов. Чтобы новый пользователь смог самостоятельно разобраться с аргументами программы, принято включать в программу справочный режим, вызываемый с помощью аргумента «/?». При этом программа должна выводить на экран описание правил работы с ней.
Тестирование и отладка Цель тестирования – нахождение ошибок. Формально ошибкой можно считать несоответствие поведения программы предъявляемым требованиям. Цель отладки – нахождение причин возникновения ошибки. Практика программирования показывает, что проще предупредить ошибку или исправить её на ранних этапах разработки программы, чем искать и исправлять её на поздних этапах, когда программа станет большой и сложной. Один из возможных подходов к разработке программ, следующих этому принципу, состоит в том,
164
чтобы всегда иметь работающую версию, постепенно усложняя программу. Восходящий и нисходящий подходы к построению программ Очевидно, заставить работать сложную программу тяжелее, чем простую. Однако часто задачу можно либо упростить, либо разбить на более простые подзадачи. В первом случае имеет смысл реализовать программу, корректно решающую упрощенный вариант задачи, в последствии усложняя её. Во втором случае программа составляется из вспомогательных программ, каждая из которых решает свою подзадачу. Восходящий подход заключается в разработке отдельных блоков программы с их последующей интеграцией, а нисходящий – в разработке скелетной программы с последующей разработкой отдельных частей. Вместо отсутствующих частей используются так называемые заглушки – пустые подпрограммы, которые позволяют постепенно наращивать функциональность, сохраняя свойство работоспособности программы в целом. При любом подходе перед написанием программы нужно продумать её общую схему, подобрать алгоритмы и необходимые структуры данных, оценить эффективность будущих алгоритмов и трудоемкость их реализации. Оптимизацию следует оставить на последний этап разработки, так как до этого момента могут меняться алгоритмы, структуры данных и общая структура программы. Но никакой подход к разработке не гарантирует отсутствие ошибок в программе. Рассмотрим некоторые приёмы тестирования и отладки программного кода. Если вспомнить понятие алгоритма, то смысл тестирования программы, реализующей алгоритм, сводится к доказательству свойств алгоритма: определенность, конечность, понятность исполнителю, массовость. При многообразии входных данных и дефиците времени доказать эти свойства прямым перебором вариантов входных данных не представляется возможным. Поэтому тестирование носит во многом эвристический характер, т.е. основывается на идеях, позволяющих найти значительную часть ошибок за приемлемое время. При простейшем сценарии тестирования следует проверить: 1) работу программы для случайных входных данных;
165
2) работу программы для экстремальных значений входных данных (например, границы допустимых диапазонов, нулевые значения); 3) работу программы в исключительных ситуациях (внешнее окружение программы также может послужить источником сбоев); 4) «защиту от дурака» (данные могут иметь неверный формат, отсутствовать; пользователь должен иметь право на ошибку). Для этого тестировщику необходимо ставить себя на место программиста, пользователя и даже хакера. Когда ошибка найдена, можно приступать к отладке программы. Анализ причин возникновения ошибки начинается с локализации ошибки. Следует выяснить, в каком месте программы ошибка возникает. Как только найден оператор, приводящий к ошибке, можно приступать к анализу источника (причины) ошибки. Важное свойство ошибки – её воспроизводимость, т.е. повторяемость в одинаковых условиях (наиболее сложные для исправления ошибки проявляются спонтанно), а важный принцип отладки – последовательное исправление ошибок. В противном случае изменений в программе будет сделано так много, что станут неясны причины исчезновения конкретных ошибок. А это означает, что причины их появления останутся не выясненными и ошибки могут проявиться вновь. Общий сценарий отладки может выглядеть следующим образом: 1) локализация ошибки; 2) выработка предположения о причине ошибки; 3) исправление программы в соответствии с выдвинутым предположением; 4) попытка воспроизведения ошибки на тех же входных данных; 5) при повторном проявлении ошибки перейти к шагу 2. После исправления ошибки имеет смысл обобщить её и защитить программу от целого класса аналогичных ошибок для разных входных данных. Перечислим подходы к локализации ошибки и анализу причин её возникновения:
166
1. Комментирование подозрительных участков кода и использование заглушек. Основная идея подхода заключается в получении программы, в которой ошибка не проявляется, хотя при этом часть полезных действий может не выполняться. Далее подозрительные участки кода постепенно вводятся в программу до тех пор, пока ошибка не начнет проявляться вновь. Последний введенный к этому моменту участок с большой долей вероятности окажется местом возникновения ошибки. На начальных этапах создания программы часто используются заглушки. Если программа теряет работоспособность после замены очередной заглушки на рабочую подпрограмму, то она с большой долей вероятности является источником ошибки. 2. Пошаговое выполнение программы (трассировка). Трассировка осуществляется посредством специальной программы (отладчика), встраиваемой в среду разработки. Отладчик позволяет исполнять программу построчно. В связи с этим разумно размещать в строке не более одного оператора. Тогда при отладке место возникновения ошибки может быть локализовано с большей точностью. При компиляции такое размещение операторов также оказывается удобным – в случае ошибки компилятор указывает номер строки, при обработке которой возникла ошибка компиляции. Работа отладчика управляется командами меню (Run в среде Turbo Pascal) или горячими клавишами: Run to cursor – выполнить операторы, расположенные до текущей позиции курсора в редакторе исходного кода, и приостановить выполнение программы; Trace Into – войти внутрь подпрограммы, вызов которой выполняется в текущей строке. Дальнейшая трассировка будет осуществляться для строк подпрограммы. После завершения работы подпрограммы трассировка продолжится со строки, следующей за вызовом подпрограммы в основной программе; Step Over – перейти к следующей строке, исполнив код в текущей строке, и приостановить выполнение программы. При этом если в строке вызываются подпрограммы, то они выполняются за один шаг трассировки; Run – запуск программы. Если она находится в режиме трассировки, то ее выполнение продолжится с текущего шага трассировки. Выполнение программы после команды Run уже не может быть приостановлено;
167
Program Reset – выход из режима трассировки и остановка программы. Трассировка может быть продолжена любой из команд Run to cursor, Trace into и Step over. Если на каком-либо шаге трассировки возникает ошибка, то выполнение программы прекращается и на экран выводится сообщение об ошибке. При этом становится ясно, в какой строке ошибка возникла. 3. Анализ значений переменных. Переменные играют важнейшую роль при отладке программ, так как текущие значения переменных позволяют делать вывод о состоянии вычислений. Подход часто применяется уже после того, как выявлено место возникновения ошибки (оператор или совокупность операторов) и необходимо выявить те переменные, которые в этот момент приняли недопустимые значения. Выяснить значения переменных можно двумя способами: 1) отладочной печатью значений переменных на экран или в файл; 2) отслеживанием значений переменных при трассировке. Первый способ наиболее прост, так как сводится к добавлению вызовов процедур write (writeln) в нужных местах программы. Если анализируется переменная, изменяемая внутри цикла (циклов), то имеет смысл выводить ее значение на экран не на каждом витке, а при определенном условии. Второй способ не требует изменения исходного кода. Программа запускается на трассировку и приостанавливается в нужных местах. Во время очередной остановки с помощью диалога DebugEvaluate/modify можно выяснить значение любой переменной, попадающей в текущую область видимости. Для этого в поле Expression необходимо ввести имя переменной (элемента массива или записи) или выражение и нажать кнопку Evaluate. Значение будет вычислено и помещено в поле Result. При необходимости, значение переменной можно изменить, чтобы программа продолжила работу с другим значением. Для этого необходимо ввести новое значение в поле New value и нажать кнопку Modify. Особенно сложен анализ рекурсивных подпрограмм, так как отладчик не показывает текущую глубину рекурсии. Однако с помощью комбинирования подходов можно справиться и с этой задачей.
168
Стиль Соглашения по идентификаторам Рекомендуется подбирать идентификаторы из слов английского языка с учетом читаемости и максимальной смысловой нагрузки. Не рекомендуется использовать транслитерацию с русского языка. Примеры неудачных идентификаторов: var UU: Boolean; Kk: string; Konec: Boolean; Shirina, Vysota: Real; procedure Zvuk(Chast, Dlit: Word); function EstFile(Im: String): Boolean;
Примеры удачных идентификаторов: const Eps = 0.0001; var Sum : Integer; Message: String; Done: Boolean; Width, Height: Real; procedure Beep(Hertz, MSec: Word); function ExistFile(FName: String): Boolean;
Часто названия идентификаторов берут из математики. При этом используют следующие ассоциативные связи: alpha, beta, gamma – углы; i, j, k – счетчики элементов множеств, перечисление; x, y, z, p, q, r, s, t – неизвестные; p – вероятность; f, g, h – функции; a, b, c, d – коэффициенты или массивы коэффициентов; n, m – верхние границы диапазонов, размерности массивов, число элементов; eps – точность; delta – дельта, разность; dx, dy – приращение;
169
pi, e – известные константы. Написание идентификаторов Зарезервированные слова языка Turbo Pascal рекомендуется записывать строчными буквами. В любых идентификаторах каждое слово, входящее в идентификатор, рекомендуется записывать с прописной буквы, остальные – строчные. Например: var NextX, LastX: Real; BeepOnError: Boolean;
Не рекомендуется разделять слова в идентификаторе символами подчеркивания. Следует писать с прописной буквы идентификаторы, состоящие из одной буквы, если они не счетчики циклов. Типы-записи и типы-классы начинать с буквы T (сокращение от type). Типы указателей начинать с буквы P (от pointer). Например: type TPhoneRec pPhoneRec = ^tPhoneRec;
Соглашения по самодокументируемости программ Комментарии в теле программы следует писать на русском языке и, по существу, так, чтобы другой программист на языке Паскаль, мог понять логику программы и продолжить развитие программы. Рекомендуется комментарии к программе писать внутри символов { и }, а (* и *) использовать при отладке программы как «заглушки» участков программного кода. Для каждой пользовательской процедуры или функции должна быть описана в виде комментария спецификация, содержащая неочевидную информацию о назначении процедуры или функции, семантике параметров и возвращаемого значения (для функций). Если подпрограмма реализует какой-либо известный вычислительный метод, рекомендуется либо поместить комментарий с кратким описанием метода, либо дать ссылку на автора или источник, где описан метод.
170
Программный файл или модуль (unit) должен начинаться со спецификации в виде комментария, содержащего информацию об авторе программы, дате написания файла, версии компилятора (если требуется), назначении программы. В начале программного файла рекомендуется поместить комментарий с указаниями по запуску программы и работе с ней (указаниями по использованию модуля другими программистами). Соглашения по читаемости программ Длина строк программы не должна превышать ширины экрана в текстовом режиме (80 символов). Структура программы должна отражаться благодаря отступам для вложенных операторов в 2–3 пробела. Рекомендуется: операнды бинарных операций (+, := и т.п.) отделять от знака операции пробелом; при перечислении идентификаторов после запятой ставить один пробел (как в литературном языке); после символа-спецификатора типа «:» (двоеточие) ставить один пробел; буквы в 16-ричных числах записывать прописными (например, $1АFD); директивы компилятора записывать прописными буквами (например, {$I+}). Как правило, все примеры в справочной системе среды программирования оформлены в хорошем стиле. Следование определённому стилю – признак зрелости программиста.
171
Список литературы Основная 1. Зуев Е.А. Программирование на языке TURBO PASCAL 6.0, 7.0. М.: Радио и связь, Веста, 1993. 2. Джонс Ж., Харроу К. Решение задач в системе ТурбоПаскаль: Пер. с англ. М.: Финансы и статистика, 1991. 3. Пильщиков В.Н. Сборник упражнений по языку Паскаль. М.: Наука, 1989. 4.* Фаронов В.В. Турбо-Паскаль. В 3-х кн. М.: Изд.-во МВТУ, 1992, 1993. 5. Гусева А.И. Учимся информатике: задачи и методы решения. М.: Диалог-МИФИ, 2004. * Книга находится в читальном зале библиотеки НИЯУ МИФИ Дополнительная Епанешников А.М., Епанешников В.А. Программирование в среде Turbo Pascal 7.0. М.: Диалог-МИФИ, 1998. – 288 с. 2. Вирт Н. Алгоритмы и структуры данных. М.: Мир, 1989. – 360 с. 3. Кнут Д. Искусство программирования для ЭВМ. М.: Мир, 1976. 1.
172