Logo GenDocs.ru

Поиск по сайту:  


Загрузка...

Бен-Ари М. Языки программирования. Практический сравнительный анализ - файл Языки программирования. Практический сравнительный анализ.doc


Бен-Ари М. Языки программирования. Практический сравнительный анализ
скачать (2753 kb.)

Доступные файлы (1):

Языки программирования. Практический сравнительный анализ.doc2621kb.24.11.2009 13:46скачать

содержание
Загрузка...

Языки программирования. Практический сравнительный анализ.doc

  1   2   3   4   5   6   7   8   9
Реклама MarketGid:
Загрузка...
знание без границ
М. Бен-Ари Языки программирования. Практический сравнительный анализ.

Предисловие

Значение языков программирования


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

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

это тот «материал», из которого создается программное обеспечение, то, что мы видим на наших экранах большую часть дня. Я верю, что язык программирования — один из наиболее, а не наименее важных факторов, которые влияют на окончательное качество программной системы. К сожалению, слишком у многих программистов нет достаточных языковых навыков. Они страстно любят свой «родной» язык программирования и не способны ни проанализировать и сравнить конструкции языка, ни оценить преимущества и недостатки современных языков и языковых понятий. Слишком часто можно услышать утверждения, демонстрирующие концептуальную путаницу: «Язык L1мощнее (или эффективнее) языка L2».

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

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

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

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

^ Цель книги

Цель этой книги — научить читателя разбираться в языках программирова­ния, анализируя и сопоставляя языковые конструкции, и помочь ему уяснить:

• Какие альтернативы доступны разработчику языка?

• Как реализуются языковые конструкции?

• Как их следует использовать?

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

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

^ Выбор материала
Автору книги по языкам программирования неизбежно приходится обижать, по крайней мере 3975 из 4000, если не больше, изобретателей различных язы­ков! Я сознательно решил (даже если это обидит 3994 человека) сосредоточить внимание на очень небольшом наборе языков, поскольку уверен, что на их примере смогу объяснить большинство языковых понятий. Другие языки об­суждаются только при демонстрации таких понятий, которые отсутствуют в языках, выбранных для основного рассмотрения.

Значительная часть книги посвящена «заурядным» процедурным (импе­ративным, imperative) языкам; из этого класса выбраны два. Языки с низким уровнем абстракции представляет С, который обошел Fortran, прежде доми­нирующий в этой категории. Для представления более высокого уровня аб­стракции мы выбрали язык Ada с гораздо более четкими определениями, чем в широко известном языке Pascal.

Этот выбор оправдывает также то, что оба языка имеют расширения (C++ и Ada 95), которые можно использовать для изучения языковой поддержки объектно-ориентированного метода программирования, доминирующего в настоящее время.

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

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

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

^ О чем эта книга
Часть 1 является описательной. Она содержит определения и обзор языков и сред программирования. Во второй части подробно объясняются основные конструкции языков программирования: типы, операторы и подпрограммы. В части 3 рассматриваются более сложные понятия программирования, та­кие, как действительные числа, статический полиморфизм, обработка оши­бок и параллелизм. В части 4 обсуждается программирование больших систем с акцентом на языковой поддержке объектно-ориентированного программи­рования. Заключительная часть 5 посвящена основным концепциям функци­онального и логического программирования.
^ Рекомендации по обучению
Необходимое условие для изучения этой книги — по крайней мере один год программирования на каком-либо языке типа Pascal или С. В любом случае, студент должен уметь читать С-программы. Также будет полезно знакомство со структурой и набором команд какого-либо компьютера.

На основе изложенного материала можно составить несколько курсов лек­ций. Части 1 и 2 вместе с разделами части 4 по модулям и объектно-ориенти­рованному программированию могут послужить основой односеместрового курса лекций для второкурсников. Для продвинутых студентов можно уско­рить изложение первой половины, с тем чтобы сосредоточиться на более трудном материале в частях 3 и 4. Углубленный курс, несомненно, должен включить часть 5, дополненную в большом объеме материалом по некоторо­му непроцедурному языку, выбранному преподавателем. Разделы, отмечен­ные звездочкой, ориентированы на продвинутых студентов.

Для большинства языков можно бесплатно получить компиляторы, как описано в приложении А. Студенты также должны быть обучены тому, как просмотреть команды ассемблера, генерируемые компиляторами.

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

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

Лично я предпочитаю более высокие уровни абстракции низким. Это — убеждение, а не предубеждение. Нам — разработчикам программного обес­печения — принадлежат печальные рекорды в вопросах разработки надеж­ных программных систем, и я полагаю, что решение отчасти лежит в пере­ходе к языкам программирования более высоких уровней абстракции. Обоб­щая высказывание Дейкстры, можно утверждать: если у вас есть программа в 100 000 строк, в которой вы запутались, то следует переписать ее в виде про­граммы в 10 000 строк на языке программирования более высокого уровня.

Первый опыт я получил в начале 1970-х годов как член большой группы программистов, работающих над системой финансовых транзакций. Мы ус­тановили новую интерактивную систему, хотя знали, что она содержала ошибку, которую мы не могли найти. Спустя несколько недель ошибка была, наконец, обнаружена: оказалось, что изъяны в используемом языке програм­мирования привели к тому, что тривиальная опечатка превратилась в несоот­ветствие типов. Пару лет спустя, когда я впервые увидел Pascal, меня «зацепи­ло». Мое убеждение в важности проблемы усиливалось всякий раз, когда я по­могал ученому, потратившему впустую недели на отыскание ошибки в про­грамме, причем в такой, которую, будь она на языке Pascal, нельзя было бы даже успешно скомпилировать. Конечно, несоответствие типов — не единст­венный источник ошибок программирования, но оно настолько часто встре­чается и так опасно, хотя и легко обнаруживается, что я считаю жесткий кон­троль соответствия типов столь же необходимым, как и ремень безопасности в автомобиле: использование его причиняет неудобство, но оно весьма незна­чительно по сравнению с возможным ущербом, а ведь даже самые лучшие во­дители могут попасть в аварию.

Я не хочу быть вовлеченным в языковые «войны», утверждая, что один язык лучше другого для какой-либо определенной машины или прикладной программы. Я попытался проанализировать конструкции языка по возмож­ности объективно в надежде внести вклад в повышение уровня научных дис­куссий относительно языков программирования.
Благодарности
Я хотел бы поблагодарить Кевлина А.П. Хеннея (Kevlin A.P Неппеу) и Дэ­вида В. Баррона (David W. Barron) за ценные замечания по всей рукописи, так же как Гарри Майрсона (Harry Mairson), Тамара Бенея (Tamar Benaya) и Бруриа Хабермена (Bruria Haberman), которые прочитали отдельные ча­сти. Я обязан Амирему Ехудаи (Amiram Yehudai), моему гуру в объектно-ориентированном программировании: он руководил мной во время много­численных обсуждений и тщательно проверял соответствующие главы. Эдмон Шенберг (Edmond Schonberg), Роберт Девар (Robert Dewar) вместе со своей группой в NYU быстро отвечали на мои вопросы по GNAT, позволив мне обучиться и написать о языке Ada 95 еще до того, как стал доступен полный компилятор. Ян Джойнер (lan Joyner) любезно предоставил свой неопубликованный анализ языка C++, который был чрезвычайно полезен. Подобно моим предыдущим книгам, эта, вероятно, не была бы написана без LATEX Лесли Лампорта (Leslie Lamport)!

Мне посчастливилось работать с высоко профессиональной, квалифици­рованной издательской группой Джона Уайли (John Wiley), и я хотел бы по­благодарить всех ее членов и особенно моего редактора Гейнора Редвеса-Мат-тона (Gaynor Redvers-Mutton).

^ М. Бен-Ари


Реховот, Израиль
1 Введение

в языки

программирования

Глава 1

Что такое

языки программирования
1.1. Некорректный вопрос
Первый вопрос, который обычно задает человек, впервые сталкивающийся с новым языком программирования:

Что этот язык может «делать»?

Неявно мы сравниваем новый язык с другими. Ответ очень прост: все язы­ки могут «делать» одно и то же — производить вычисления! В разделе 1.8 объ­яснена правомерность такого ответа. Однако, если все они могут выполнять одно и то же — вычисления — то, несомненно, причины существования сотен языков программирования должны быть в чем-то другом.

Позвольте начать с нескольких определений:
Программа — это последовательность символов, определяющая вычисление.
^ Язык программирования — это набор правил, определяющих, какие по­следовательности символов составляют программу и какое вычисление описывает программа.
Вас может удивить, что в определении не упоминается слово «компьютер»! Программы и языки могут быть определены как сугубо формальные матема­тические объекты. Однако люди больше интересуются программами, чем другими математическими объектами типа групп, именно потому, что про­грамму — последовательность символов — можно использовать для управле­ния работой компьютера. Хотя мы настоятельно рекомендуем изучение тео­рии программирования, здесь ограничимся, в основном, изучением того, как программы выполняются на компьютере.

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

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

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

Поскольку компьютеры — двоичные машины, распознающие только нули и единицы, то хранить программы в компьютере технически просто, но прак­тически неудобно: каждая команда должна быть записана в виде двоичных цифр (битов), которые можно представить механически или электрически. Одним из первых программных средств был символический ассемблер. Ассемб­лер берет программу, написанную на языке ассемблера (каждая команда пред­ставлена в нем в символьном виде), и транслирует символы в двоичное пред­ставление, пригодное для выполнения на компьютере. Например, команду
load R3,54
означающую «загрузить в регистр 3 данные из ячейки памяти 54», намного легче прочитать, чем эквивалентную последовательность битов. Трудно пове­рить, но термин «автоматическое программирование» первоначально отно­сился к ассемблерам, так как они автоматически выбирали правильную по­следовательность битов для каждого символа. Известные языки программи­рования, такие как С и Pascal, сложнее ассемблерных языков, потому что они «автоматически» выбирают адреса и регистры и даже «автоматически» выби­рают последовательности команд для организации циклов и вычисления арифметических выражений.

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

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

Из концепции абстракции вытекает общее правило:
^ Чем выше уровень абстракции, тем больше деталей исчезает.
Если вы пишете программу на С, то теряете возможность задать распреде­ление регистров, которая есть в языке ассемблера; если вы пишете на языке Prolog, то теряете имеющуюся в С возможность определить произвольные связанные структуры с помощью указателей. Существует естественное проти­воречие между стремлением к краткому, ясному и надежному выражению вы­числения на высокоабстрактном уровне и стремлением к гибкости подробно­го описания вычисления. Абстракция никогда не может быть такой же точной или оптимальной, как описание низкого уровня.

В этом учебнике вы изучите языки трех уровней абстракции. Опуская ас­семблер, мы начнем с «обычных» языков программирования, таких как Fortran, С, Pascal и Pascal-подобные конструкции языка Ada. Затем в части 4 мы обсудим языки типа Ada и С ++, которые позволяют программисту созда­вать абстракции более высокого уровня из операторов обычных языков. В за­ключение мы опишем языки функционального и логического программиро­вания, работающие на еще более высоком уровне абстракций.

^ 1.2. Процедурные языки
Fortran
Первым языком программирования, который значительно превзошел уро­вень языка ассемблера, стал Fortran. Он был разработан в 1950-х годах груп­пой специалистов фирмы IBM во главе с Джоном Бекусом и предназначался для абстрактного описания научных вычислений. Fortran встретил сильное противодействие по тем же причинам, что и все последующие предложения абстракций более высокого уровня, а именно из-за того, что большинство программистов полагало, что сгенерированный компилятором программный код не может быть лучше написанного вручную на языке ассемблера.

Подобно большинству первых языков программирования, Fortran имел серьезные недостатки в деталях самого языка, и, что важнее, в нем отсутство­вала поддержка современных концепций структурирования модулей и дан­ных. Сам Бекус, оглядываясь назад, говорил:
Мы просто придумывали язык по мере его осмысления. Мы расценива­ли проектирование языка не как трудную задачу, а просто как прелюдию к реальной проблеме: проектированию компилятора, который мог бы генерировать эффективные программы.
Однако преимущества абстракции быстро покорили большинство про­граммистов: разработка программ стала более быстрой и надежной, а их машинная зависимость уменьшилась из-за абстрагирования от регистров и машинных команд. Поскольку самыми первыми на компьютерах рассчитыва­лись научные задачи, Fortran стал стандартным языком в науке и технике, и только теперь на смену ему приходят другие языки. Fortran был неоднократно модернизирован (1966,1977,1990) с тем, чтобы адаптировать его к требовани­ям современных программных разработок.

^ Cobol и PL/1
Язык Cobol был разработан в 1950-х для обработки коммерческих данных. Он создавался комитетом, состоящим из представителей Министерства Обороны США, производителей компьютеров и коммерческих организаций типа стра­ховых компаний. Предполагалось, что Cobol — это только временное решение, необходимое, пока не создан лучший проект; однако язык быстро стал самым распространенным в своей области (как Fortran в науке), причем по той же самой причине: он обеспечивал естественные средства выражения вычисле­ний, типичных для своей области. При обработке коммерческих данных не­обходимо делать относительно простые вычисления для большого числа сложных записей данных, а по возможностям структурирования данных Cobol намного превосходит алгоритмические языки типа Fortran или С.

IBM позже создала язык PL/1, универсальный, обладающий всеми свой­ствами языков Fortran, Cobol и Algol. PL/1 заменил Fortran и Cobol на мно­гих компьютерах IBM, но этот язык очень широкого диапазона никогда не поддерживался вне IBM, особенно на мини- и микроЭВМ, которые все больше и больше используются в организациях, занимающихся обработкой данных.


^ Algol и его потомки
Из ранних языков программирования Algol больше других повлиял на созда­ние языков. Разработанный международной группой первоначально для об­щих и научных приложений, он никогда не достигал такой популярности, как Fortran, поскольку не имел той поддержки, которую Fortran получил от боль­шинства производителей компьютеров. Описание первой версии языка Algol было опубликовано в 1958 г.; пересмотренная версия, Algol 60, широко ис­пользовалась в компьютерных научных исследованиях и была реализована на многих машинах, особенно в Европе. Третья версия языка, Algol 68, пользова­лась влиянием среди теоретиков по языкам, хотя никогда широко не приме­нялась.

От языка Algol произошли два важных языка: Jovial, который использовался Военно-воздушными силами США для систем реального времени, и Simula, один из первых языков моделирования. Но, возможно, наиболее известным его потомком является Pascal, разработанный в конце 1960-х Никлаусом Виртом. Целью разработки было создание языка, который можно было бы использовать для демонстрации идей объявления типов и контроля их соответствия. В после­дующих главах мы докажем, что эти концепции относятся к наиболее важным, когда-либо предлагавшимся в проектировании языков.

Как язык практического программирования Pascal имеет одно большое преимущество и один большой недостаток. Первоначальный компилятор языка Pascal был написан на самом языке Pascal и, таким образом, мог быть легко перенесен на любой компьютер. Язык распространялся быстро, особен­но на создаваемых в то время мини- и микроЭВМ. К сожалению, как язык, Pascal слишком мал. Стандартный Pascal вообще не имеет никаких средств для деления программы на модули, хранящиеся в отдельных файлах, и поэто­му не может использоваться для программ объемом больше нескольких тысяч строк. Компиляторы Pascal, используемые на практике, поддерживают деком­позицию на модули, но никаких стандартных методов для этого не существу­ет, так что большие программы непереносимы. Вирт сразу понял, что модули являются необходимой частью любого практического языка, и разработал язык Modula. Modula (теперь в версии 3, поддерживающей объектно-ориенти­рованное программирование) — популярная альтернатива нестандартным «диалектам» языка Pascal.
С
Язык С был разработан в начале 1970-х Деннисом Ричи, сотрудником Bell Laboratories, как язык реализации операционной системы UNIX. Операцион­ные системы традиционно писали на ассемблере, поскольку языки высокого уровня считались неэффективными. Язык С абстрагируется от деталей программирования, присущих ассемблерам, предлагая структурированные управ­ляющие операторы и структуры данных (массивы и записи) и сохраняя при этом всю гибкость ассемблерного низкоуровневого программирования (указа­тели и операции на уровне битов).

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

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


Язык С был стандартизирован в 1989 г. Американским Национальным Ин­ститутом Стандартов (ANSI); практически тот же самый стандарт был принят Международной Организацией по Стандартизации (ISO) годом позже. В этой книге делаются ссылки на ANSI С, а не на более ранние версии языка.
C++
В 1980-х годах Бьярн Строуструп, также из Bell Laboratories, использовал С как базис языка C++, добавив поддержку объектно-ориентированного програм­мирования, аналогичную той, которую предоставлял язык Simula. Кроме то­го, в C++ исправлены многие ошибки языка С, и ему следует отдавать пред­почтение даже в небольших программах, где объектно-ориентированные свойства, возможно, и не нужны. C++ — наиболее подходящий язык для об­новления систем, написанных на С.

Обратите внимание, что C++ — развивающийся язык, и в вашем справоч­ном руководстве или компиляторе, возможно, отсутствуют последние изме­нения. Обсуждаемый в этой книге язык соответствует книге Annotated C++ Reference Мanual Эллиса и Строуструпа (издание 1994 г.), которая является ос­новой рассматриваемого в настоящее время стандарта.


Ada
В 1977 г. Министерство Обороны Соединенных Штатов решило провести унификацию языка программирования, в основном, чтобы сэкономить на обучении и стоимости поддержки операционных сред разработки программ для различных военных систем. После оценки существующих языков было принято решение провести конкурс на разработку нового языка, положив в основу хороший существующий язык, такой как Pascal. В конце концов был выбран язык, который назвали Ada, и стандарт был принят в 1983 г. Язык Ada уникален в ряде аспектов:
• Большинство языков (Fortran, С, Pascal) создавались едиными ко­мандами разработчиков и были стандартизованы уже после их широкого распространения. Для сохранения совместимости все случайные прома­хи исходной команды включались в стандарт. Ada же перед стандартиза­цией подверглась интенсивной проверке и критическому разбору.
• Многие языки первоначально были реализованы на единственном ком­пьютере, и на них сильно повлияли особенности этого компьютера. Язык Ada был разработан для написания переносимых программ.
• Ada расширяет область применения языков программирования, обеспечи­вая обработку ошибок и параллельное программирование, что традицион­но считалось (нестандартными) функциями операционных систем.
Несмотря на техническое совершенство и преимущества ранней стандар­тизации, язык Ada не достиг большой популярности вне военных и других крупномасштабных проектов (типа коммерческой авиации и железнодорож­ных перевозок). Язык Ada получил репутацию трудного. Это связано с тем, что он поддерживает многие аспекты программирования (параллелизм, обра­ботку исключительных ситуаций, переносимые числовые данные), которые другие языки (подобные С и Pascal) оставляют операционной системе. На са­мом деле его нужно просто больше изучать. К тому же первоначально были недоступны хорошие и недорогие среды разработки для сферы образования. Теперь, когда есть бесплатные компиляторы (см. приложение А) и хорошие учебники, Ada все чаще и чаще встречается в учебных курсах даже как «пер­вый» язык.


Ada 95
Ровно через двенадцать лет после принятия в 1983 г. первого стандарта языка Ada был издан новый стандарт. В новой версии, названной Ada 95, исправле­ны некоторые ошибки первоначальной версии, но главное — это добавление поддержки настоящего объектно-ориентированного программирования, включая наследование, которого не было в Ada 83, так как его считали неэф­фективным. Кроме того, Ada 95 содержит приложения, в которых описываются стандартные (но необязательные) расширения для систем реального вре­мени, распределенных систем, информационных систем, защищенных сис­тем, а также «числовое» (numerics) приложение.

В этой книге название «Ada» будет использоваться в тех случаях, когда об­суждение не касается особенностей одной из версий: Ada 83 или Ada 95. Заме­тим, что в литературе Ada 95 упоминалась как Ada 9X, так как во время разра­ботки точная дата стандартизации не была известна

.

^ 1.3. Языки, ориентированные на данные
На заре программирования было создано и реализовано несколько языков, значительно повлиявших на дальнейшие разработки. У них была одна общая черта: каждый язык имел предпочтительную структуру данных и обширный набор команд для нее. Эти языки позволяли создавать сложные программы, которые трудно было бы написать на языках типа Fortran, просто манипули­рующих компьютерными словами. В следующих подразделах мы рассмотрим некоторые из этих языков.
Lisp
Основная структура данных в языке Lisp — связанный список. Первоначаль-но Lisp был разработан для исследований в теории вычислений, и многие ра­боты по искусственному интеллекту были выполнены на языке Lisp. Язык был настолько важен, что компьютеры разрабатывались и создавались так, чтобы оптимизировать выполнение Lisp-программ. Одна из проблем языка состояла в обилии различных «диалектов», возникавших по мере того, как язык реализовывался на различных машинах. Позже был разработан стандар­тный язык Lisp, чтобы программы можно было переносить с одного компью­тера на другой. В настоящее время популярен «диалект» языка Lisp — CLOS, поддерживающий объектно-ориентированное программирование.

Три основные команды языка Lisp — это car(L) и cdr(L), которые извлека­ют, соответственно, начало и конец списка L, и cons(E, L), которая создает но­вый список из элемента Е и существующего списка L. Используя эти коман­ды, можно определить функции обработки списков, содержащих нечисловые данные; такие функции было бы довольно трудно запрограммировать на языке Fortran.

Мы не будем больше обсуждать язык Lisp, потому что многие его основополагающие идеи были перенесены в современные функциональные языки программирования типа ML, который мы обсудим в гл. 16.
APL
Язык APL является развитием математического формализма, который ис­пользуется для описания вычислений. Основные структуры данных в нем — векторы и матрицы, и операции выполняются над такими структурами непо­средственно, без циклов. Программы на языке APL очень короткие по срав­нению с аналогичными программами на традиционных языках. Применение APL осложняло то, что в язык перешел большой набор математических сим­волов из первоначального формализма. Это требовало специальных термина­лов и затрудняло экспериментирование с APL без дорогостоящих аппаратных средств. Современные графические интерфейсы пользователя, применяю­щие программные шрифты, решили эту проблему, которая замедляла приня­тие APL.
Предположим, что задана векторная переменная:
V= 1 5 10 15 20 25
Операторы языка APL могут работать непосредственно с V без записи цик­лов с индексами:

+ /V =76 Свертка сложением(суммирует элементы)

фV = 25 20 15 10 5 1 Обращает вектор
2 3 pV = 1 5 10 Переопределяет размерность

V 15 20 25 как матрицу 2x3

Векторные и матричные сложения и умножения также можно выполнить непосредственно над такими переменными.
^ Snobol, Icon
Первые языки имели дело практически только с числами. Для работы в таких областях, как обработка естественных языков, идеально подходит Snobol (и его преемник Icon), поскольку их базовой структурой данных является стро­ка. Основная операция в языке Snobol сравнивает образец со строкой, и по­бочным результатом совпадения может быть разложение строки на подстро­ки. В языке Icon основная операция — вычисление выражения, причем выра­жения включают сложные операции со строками.

В языке Icon есть важная встроенная функция find(s1, s2), которая ищет вхождения строки s1 в строку s2. В отличие от подобных функций языка С find генерирует список всех позиций в s2, в которых встречается s1:


line := 0 # Инициализировать счетчик строк while s := read() { # Читать до конца файла

every col := find("the", s) do # Генерировать позиции столбца write (line, " ",col) # Write(line,col) для "the"

line := line+ 1

}
Эта программа записывает номера строк и столбцов всех вхождений стро­ки "the" в файл. Если команда find не находит ни одного вхождения, то она «терпит неудачу» (fail), и вычисление выражения завершается. Ключевое сло­во every вызывает повторение вычисления функции до тех пор, пока оно за­вершается успешно.

Выражения Icon содержат не только строки, которые представляют собой последовательности символов; они также определены над наборами символов csets. Таким образом
vowels := 'aeiou'

присваивает переменной vowel (гласные) значение, представляющее собой набор указанных символов. Это можно использовать в функциях типа upto(vowels,s), генерирующих последовательность позиций гласных в s, и many(vowels,s), возвращающих самую длинную начальную последователь­ность гласных в s.

Более сложная функция bal подобна upto за исключением того, что она ге­нерирует последовательности позиций, которые сбалансированы по «ско­бочным» символам:
bal(' +-*/','([', ')]',*)
Это выражение могло использоваться в компиляторе, чтобы генериро­вать сбалансированные арифметические подстроки. Если в качестве строки s задать

"х + (у [u/v] - 1 )*z", вышеупомянутое выражение сгенерирует индек­сы, соответствующие подстрокам:
x
x+(y[u/v]-1
Первая подстрока сбалансирована, так как она заканчивается «+» и не содержит никаких скобок; вторая подстрока сбалансирована, поскольку она завершается символом «*» и имеет квадратные скобки, правильно вложенные внутри круглых скобок.

Так как вычисление выражения может быть неуспешным (fail), исполь­зуется откат (backtracking), чтобы продолжить поиск от предыдущих генерирующих функций. Следующая программа печатает вхождения глас­ных, за исключением тех, которые начинаются в столбце 1 .
line := 0 # Инициализировать счетчик строк while s := read() { # Читать до конца файла every col := (upto (vowels, line) > 1 ) do

# Генерировать позиции столбца write (line, " ",col) # write(line,col) для гласных

line := line + 1

}

Функция поиска генерирует индекс, который затем проверяется на «>». Если проверка неуспешна (не говорите: «если результат ложный»), программа возвращает управление генерирующей функции upto, чтобы получить новый индекс.

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


SETL
Основная структура данных в SETL — множество. Так как множество — наи­более общая математическая структура, с помощью которой определяются все другие математические структуры, то SETL может использоваться для со­здания программ высокой степени общности и абстрактности и поэтому очень коротких. Такие программы имеют сходство с логическими программа­ми (гл. 17), в которых математические описания могут быть непосредственно исполняемыми. В теории множеств используется нотация: {х \р(х)}, обозна­чающая множество всех х таких, что логическое выражение р(х) является ис­тиной. Например, множество простых чисел в этой нотации может быть запи­сано как
{ п \ -,3 т [(2<т<п1) л (nmodm = 0)]}
Эта формула читается так: множество натуральных чисел п таких, что не cуществует натурального т от 2 до п — 1 , на которое п делится без остатка.

Чтобы напечатать все простые числа в диапазоне от 2 до 100, достаточно «протранслировать» это определение в однострочную программу на языке SETL:
print ({n in {2.. 100} | not exists m in {2.. n — 1} | (n mod m) = 0});

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

Языки, ориентированные на данные, сейчас несколько менее популярны, чем раньше, отчасти потому, что объектно-ориентированные методы позво­ляют внедрить операции, ориентированные на данные, в обычные языки ти­па C++ и Ada, а также из-за конкуренции более новых языковых концепций, таких как функциональное и логическое программирование. Тем не менее эти языки интересны с технической точки зрения и вполне подходят для ре­шения задач, для которых они были разработаны. Студентам рекомендуется приложить усилие и изучить один или несколько таких языков, потому что это расширит их представление о том, как может быть структурирован язык программирования.
^ 1.4. Объектно-ориентированные языки
Объектно-ориентированное программирование (ООП) — это метод структу­рирования программ путем идентификации объектов реального мира или других объектов и написания модулей, каждый из которых содержит все дан­ные и исполняемые команды, необходимые для представления одного класса объектов. Внутри такого модуля существует отчетливое различие между абст­рактными свойствами класса, которые экспортируются для использования другими объектами, и реализацией, которая скрыта таким образом, что может быть изменена без влияния на остальную часть системы.

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

Исследовательский центр Xerox Palo Alto Research Center популяризировал ООП с помощью языка Smalltalk. Такие же исследования вели к системам окон, так популярным сегодня, но важное преимущество Smalltalk заключает­ся в том, что это не только язык, но и полная среда программирования. В тех­ническом плане Smalltalk был достижением как язык, в котором классы и объ­екты являются единственными конструкциями структурирования, так что нет необходимости встраивать эти понятия в «обычный» язык.

Технический аспект этих первых объектно-ориентированных языков по­мешал более широкому распространению ООП: отведение памяти, диспетче­ризация операций и контроль соответствия типов осуществлялись динамиче­ски (во время выполнения), а не статически (во время компиляции). Не вда­ваясь в детали (см. соответствующий материал в гл. 8 и 14), отметим, что в ито­ге программы на этих языках связаны с неприемлемыми для многих систем накладными расходами по времени и памяти. Кроме того, статический кон­троль соответствия типов (см. гл. 4) теперь считается необходимым для раз­работки надежного программного обеспечения. По этим причинам в языке Ada 83 реализована только частичная поддержка ООП.

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


Однако нет необходимости «прививать» поддержку ООП в существующие языки, чтобы получить эти преимущества. Язык Eiffel подобен Smalltalk в том, что единственным методом структурирования является метод классов и объ­ектов, а также подобен C++ и Ada 95 в том, что проверка типов статическая, а реализация объектов может быть как статической, так и динамической, если нужно. Простота языка Eiffel по сравнению с гибридами, которым «привита» полная поддержка ООП, делает его превосходным выбором в качестве первого языка программирования.

Мы обсудим языковую поддержку ООП более подробно, сначала в C++, а затем в Ada 95. Кроме того, краткое описание Eiffel покажет, как выглядит «чистый» язык ООП.
^ 1.5. Непроцедурные языки
Все языки, которые мы обсудили, имеют одну общую черту: базовый оператор в них — это оператор присваивания, который заставляет компьютер переме­стить данные из одного места в другое. В действительности это относительно низкий уровень абстракции по сравнению с уровнем проблем, которые мы хотим решать с помощью компьютера. Более новые языки скорее предназна­чены для того, чтобы описывать проблему и перекладывать на компьютер выяснение, как ее решить, чем для подробного определения, как перемещать данные.

Современные программные пакеты (software packages), как правило, представляют собой языки действительно высокого уровня абстракции. Генератор I Приложений позволяет вам описать последовательность экранов и структур базы данных и по этим описаниям автоматически генерирует команды, реализующие ваше приложение. Точно также электронные таблицы, настольные издательские системы, пакеты моделирования и другие системы имеют обширные средства абстрактного программирования. Недостаток программного обеспечения этого типа в том, что оно обычно ограничивается приложениями, которые можно легко запрограммировать. Их можно назвать параметризованными программами в том смысле, что, получая описания как параметры, пакет конфигурирует себя для выполнения нужной вам программы.

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

Мы обсудим два формализма непроцедурных языков: 1) функциональное программирование (гл. 16), которое основано на математическом понятии чистой функции, такой как sin и log, которые не изменяют своего окружения в отличие от так называемых функций обычного языка типа С, которые могут иметь побочные эффекты; 2) логическое программирование (гл. 17), в кото­ром программы выражены как формулы математической логики и «компиля­тор», чтобы решить задачу, пытается вывести логические следствия из этих формул.

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

Функциональные и логические языки программирования настоятельно рекомендуются как первые из изучаемых, для того чтобы студенты с самого начала учились работать на более высоких уровнях абстракции, чем при про­граммировании на таких языках, как Pascal или С.
1.6. Стандартизация
Следует подчеркнуть значение стандартизации. Если для языка существует стандарт, и если компиляторы его поддерживают, то программы можно пере­носить с одного компьютера на другой. Когда вы пишете пакет программ, ко­торый должен выполняться на разных компьютерах, вы должны строго при­держиваться стандарта. Иначе задача сопровождения чрезвычайно усложнит­ся, потому что придется следить за десятками или сотнями машинно-зависи­мых вопросов.

Стандарты существуют (или находятся в стадии подготовки) для большин­ства языков, обсуждаемых в этой книге. К сожалению, обычно стандарт пред­лагается спустя годы после широкого распространения языка и должен сохра­нять машинно-зависимые странности ранних реализаций. Язык Ada — иск­лючение в том смысле, что стандарты (1983 и 1995) создавались и оценивались одновременно с проектом языка и первоначальной реализацией. Более того, стандарт ориентирован на то, чтобы компиляторы можно было сравнивать по производительности и стоимости, а не только на соответствие стандарту. Компиляторы зачастую могут предупреждать вас, если вы использовали не­стандартную конструкцию. Если необходимо использовать такие конструк­ции, их следует сконцентрировать в нескольких хорошо документированных модулях.


^ 1.7. Архитектура компьютера

Поскольку мы рассматриваем языки программирования с точки зрения их практического использования, мы включаем короткий раздел по архитектуре компьютеров, чтобы согласовать минимальный набор терминов. Компьютер состоит из центрального процессора (ЦП) и памяти (рис. 1.1). Устройства вво­да-вывода могут рассматриваться как частный случай памяти.




рис. 1 . 1 . Архитектура компьютера.


Все компоненты компьютера обычно подсоединяются к общей шине. Физически шина — это набор разъемов, соединенных параллельно; логически шина — это спецификация сигналов, которые дают возможность компонен­там обмениваться данными. Как показано на рисунке, современные компьютеры могут иметь дополнительные прямые соединения между компонентами для повышения производительности (путем специализации интерфейса и расширения узких мест). С точки зрения программного обеспечения единственное различие состоит в скорости, с которой данные могут передаваться Между компонентами.

В ЦП находится набор регистров (специальных ячеек памяти), в которых выполняется вычисление. ЦП может выполнить любую хранящуюся в памя­ти команду; в ЦП есть указатель команды, который указывает на расположение очередной команды, которая будет выполняться. Команды разделены на Следующие классы.
• Доступ к памяти. Загрузить (load) содержимое слова памяти в регистр и сохранить (store) содержимое регистра в слове памяти.
• Арифметические команды типа сложить (add) и вычесть (sub). Эти дей­ствия выполняются над содержимым двух регистров (или иногда над со­держимым регистра и содержимым слова памяти). Результат остается в регистре. Например, команда
add m,N R1,N
складывает содержимое слова памяти N с содержимым регистра R1 и ос­тавляет результат в регистре.
• Сравнить и перейти. ЦП может сравнивать два значения, такие как со­держимое двух регистров; в зависимости от результата (равно, больше, и т.д.) указатель команды изменяется, переходя к другой команде. На­пример:
jump_eq R1.L1



L1: ...
заставляет ЦП продолжать вычисление с команды с меткой L1, если со-держимое R1 — ноль; в противном случае вычисление продолжается со следующей команды.
Во многих компьютерах, называемых ^ Компьютерами с Сокращенной Системой команд команд (RISC— Reduced Instruction Set Computers), имеются только такие элементарные команды.Обосновывается это тем, что ЦП, который должен выполнять всего несколько простых команд, может быть очень быст­рым. В других компьютерах, известных как CISC (Complex Instruction Set Computers), определен Сложный Набор команд, позволяющий упростить как программирование на языке ассемблера, так и конструкцию компилятора. Обсуждение этих двух подходов выходит за рамки этой книги; у них достаточ­но много общего, так что выбор не будет иметь для нас существенного значе­ния.

Память — это набор ячеек, в которых можно хранить данные. Каждая ячейка памяти, называемая словом памяти, имеет адрес, а каждое слово состо­ит из фиксированного числа битов, обычно из 16, 32 или 64 битов. Возможно, что Компьютер умеет загружать и сохранять 8-битовые байты или двойные слова из 64 битов.

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

нотацию С:
load R3, # 54 Загрузить значение 54 в R3 load

R2, &N Загрузить адрес N в R2

Следующий способ — это абсолютная адресация, в которой обычно ис­пользуется символический адрес переменной:
load R3,54 Загрузить содержимое адреса 54

load R4, N Загрузить содержимое переменной N
Современные компьютеры широко используют индексные регистры. Ин­дексные регистры не обязательно обособлены от регистров, используемых для вычислений; важно, что содержимое индексного регистра может ис­пользоваться для вычисления адреса операнда команды. Например:

load R3,54(R2) Загрузить содержимое addr(R2) + 54

load R4, (R1) Загрузить содержимое addr(R1) + О
где первая команда означает «загрузить в регистр R3 содержимое слова памя­ти, чей адрес получен, добавлением 54 к содержимому (индексного) регистра R2»; вторая команда — это частный случай, когда содержимое регистра R1 ис­пользуется просто как адрес слова памяти, содержимое которого загружается в R4. Индексные регистры необходимы для эффективной реализации циклов и массивов.
^ Кэш и виртуальная память
Одна из самых трудных проблем, стоящих перед архитекторами компьюте­ров, — это приведение в соответствие производительности ЦП и пропускной способности памяти. Быстродействие ЦП настолько велико по сравнению со временем доступа к памяти, что память не успевает поставлять данные, что­бы обеспечить непрерывную работу процессора. Для этого есть две причины: 1) в компьютере всего несколько процессоров (обычно один), и в них можно использовать самую быструю, наиболее дорогую технологию, но объем памя­ти постоянно наращивается и технология должна быть менее дорогая; 2) ско­рости настолько высоки, что ограничивающим фактором является быстрота, с которой электрический сигнал распространяется по проводам между ЦП и памятью.

Решением проблемы является использование иерархии блоков памяти, как показано на рис. 1.2. Идея состоит в том, чтобы хранить неограниченное количество команд программы и данных в относительно медленной (и недо­рогой) памяти и загружать порции необходимых команд и данных в меньший объем быстрой (и дорогой) памяти. Если в качестве медленной памяти ис пользуется диск, а в качестве быстрой памяти — обычная оперативная память с произвольным



доступом (RAM — Random Access Memory), то концепция называется виртуальной памятью или страничной памятью. Если медленной памятью является RAM, а быстрой — RAM, реализованная по более быстрой технологии, то концепция называется кэш-памятью.

Обсуждение этих концепций выходит за рамки этой книги, но програм­мист должен понимать потенциальное воздействие кэша или виртуальной па­мяти на программу, даже если функционирование этих блоков памяти обеспечивается компьютерными аппаратными средствами или операцион­ной системой и полностью невидимо для программиста. Команды и данные передаются между медленной и быстрой памятью блоками, а не отдельными словами. Это означает, что исполнение последовательно расположенных команд без переходов, так же как и обработка, последовательно располо­женных данных (например, просмотр элементов массива), должны быть на­много эффективнее, чем исполнение групп команд с переходами и обраще­ния к памяти в случайном порядке, что требует интенсивного обмена блоками информации между различными иерархическими уровнями памяти. Если вы пытаетесь улучшать эффективность программы, то следует противиться иску­шению писать куски на языках низшего уровня или ассемблере; вместо этого попытайтесь реорганизовать вычисление, приняв во внимание влияние кэша и виртуальной памяти. Перестановка операторов языка высокого уровня не воздействует на переносимость программы, хотя, конечно, улучшение эф­фективности может теряться при перенесении ее на компьютер с иной архи­тектурой.
1.8. Вычислимость
В 1930-х годах, еще до того, как были изобретены компьютеры, логики иссле­довали абстрактные концепции вычисления. Алан Тьюринг и .Алонзо Черч независимо предложили чрезвычайно простые модели вычисления (назван­ные соответственно машинами Тьюринга и Лямбда-исчислением) и затем вы­двинули следующее утверждение (известное как Тезис Черча —Тьюринга):
Любое исполнимое вычисление может быть выполнено на любой из этих моделей.

Машины Тьюринга чрезвычайно просты; если воспользоваться синтак­сисом языка С, то объявления данных будут выглядеть так:
char tape[...];

int current = 0;


где лента (tape) предполагается бесконечной. Программа состоит из любого числа операторов вида:
L17: if (tape[currentj == 'g') {

tape[current++] = 'j'i

goto L43;

}
Оператор машины Тьюринга выполняется за четыре следующих шага.
• Считать и проверить символ в текущей ячейке ленты.
• Заменить символ на другой символ (необязательно).
• Увеличить или уменьшить указатель текущей ячейки.
• Перейти к другому оператору.
Согласно Тезису Черча — Тьюринга, любое вычисление, которое действи­тельно можно описать, может быть запрограммировано на этой примитивной машине. Интуитивная очевидность Тезиса опирается на два утверждения:
• Исследователи предложили множество моделей вычислений, и было до­казано, что все они эквивалентны машинам Тьюринга.
• Никому пока не удалось описать вычисление, которое не может быть ре­ализовано машиной Тьюринга.

Так как машину Тьюринга можно легко смоделировать на любом языке программирования, можно сказать, что все языки программирования «дела­ют» одно и то же, т. е. в некотором смысле эквивалентны.
1.9. Упражнения
1. Опишите, как реализовать компилятор для языка на том же самом язы­ке («самораскрутка»).
2. Придумайте синтаксис для APL-подобного языка для матричных вы­числений, используя обычные символы.
3. Составьте список полезных команд над строками и сравните ваш список со встроенными командами языков Snobol и Icon.
4. Составьте список полезных команд над множествами и сравните ваш список со встроенными командами языка SETL.
5. Смоделируйте (универсальную) машину Тьюринга на нескольких язы­ках программирования.

Глава 2

Элементы

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

Самая распространенная формальная нотация синтаксиса — это расширен­ная форма Бекуса — Наура (РБНФ). В РБНФ мы начинаем с объекта самого верхнего уровня, с программы, и применяем правила декомпозиции объектов, пока не достигнем уровня отдельного символа. Например, в языке С синтак­сис условного оператора (if-оператора) задается правилом:

if-onepamop :: = if (выражение) оператор [else оператор]

Имена, выделенные курсивом, представляют синтаксические категории, а имена и символы, выделенные полужирным шрифтом, представляют факти­ческие символы, которые должны появиться в программе. Каждое правило содержит символ «:: =», означающий «представляет собой». Прочие символы используются для краткости записи:
[ ] Не обязательный {} Ноль или более повторений | Или

Таким образом, else-оператор в if-операторе не является обязательным. Использование фигурных скобок можно продемонстрировать на (упрощен­ном) правиле для объявления списка переменных:

Объявление-переменной ::= спецификатор-типа идентификатор {, идентификатор};

Это читается так: объявление переменной представляет собой специфика­тор типа, за которым следует идентификатор (имя переменной) и необяза­тельная последовательность идентификаторов, предваряемых запятыми, в конце ставится точка с запятой.

Правила синтаксиса легче изучить, если они заданы в виде диаграмм (рис. 2.1). Круги или овалы обозначают фактические символы, а прямоугольники — синтаксические категории, которые имеют собственные диаграммы.




.


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

Хотя многие программисты страстно привязаны к синтаксису опреде­ленного языка, этот аспект языка, пожалуй, наименее важен. Любой разумный синтаксис легко изучить; кроме того, синтаксические ошибки об­наруживаются компилятором и редко вызывают проблемы с работающей программой. Мы ограничимся тем, что отметим несколько возможных син­таксических ловушек, которые могут вызвать ошибки во время выполнения программы:
Будьте внимательны с ограничениями на длину идентификаторов. Ес­ли значимы только первые 10 символов, то current_winner и current _width будут представлять один и тот же идентификатор.

Многие языки не чувствительны к регистру, то есть СЧЕТ и счет пред-ставляют одно и то же имя. Язык С чувствителен к регистру, поэтому эти имена представляют два разных идентификатора. При разработке чувст­вительных к регистру языков полезно задать четкие соглашения по ис­пользованию каждого регистра, чтобы случайные опечатки не приводи­ли к ошибкам. Например, по одному из соглашений языка С в програм­ме все записывается на нижнем регистре за исключением определенных имен констант, которые задаются на верхнем регистре.

Существуют две формы комментариев: комментарии в языках Fortran, Ada и C++ начинаются с символа (С, - -, и //, соответственно) и распро­страняются до конца строки, в то время как а языках С и Pascal коммента­рии имеют как начальный, так и конечный символы: /* ... */ в.С и (* ... *) иди {...} в Pascal. Вторая форма удобна для «закомментаривания» неис-пользуемого кода (который, взможнo, был вставлен для тестированя), но при этом существует опасность пропустить конечный символ, в результате чего будет пропущена последовательность операторов:

с
/* Комментарий следовало бы закончить здесь

а = b + с; Оператор будет пропущен

/*...*/ Здесь конец комментария

Остерегайтесь похожих, но разных символов. Если вы когда-либо изуча­ли математику, то вас должно удивить, что знакомый символ «=» ис­пользуется в языках С и Fortran как оператор присваивания, в то время как новые символы «==» в С и «.eq.» в Fortran используются в качестве операции сравнения на равенство. Стремление написать:


с
if(a=b)...

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

В качестве исторического прецедента напомним известную проблему с синтаксисом языка Fortran. Большинство языков требует, чтобы слова в программе отделялись одним или несколькими пробелами (или другими пробельными символами типа табуляции), однако в языке Fortran пробель­ные символы игнорируются. Рассмотрим следующий оператор, который определяет «цикл до метки 10 при изменении индекса i от 1 до 100»:



Fortan



do 10 i = 1,100


Если запятая случайно заменена точкой, то этот оператор становится на самом деле.оператором присваивания, присваивая 1.100 переменной, имя которой образуется соединением всех символов перед знаком «=»:


Fortan



do10i = l.TOO
Говорят, эта ошибка заставила ракету взорваться до запуска в космос!


2.2. Семантика


Семантика — это смысл высказывания (программы) в языке (программи­рования).


Если синтаксис языков понять очень легко, то понять семантику намного труднее. Рассмотрим для примера два предложения на английском языке:


The pig is in the pen. (Свинья в загоне.)

The ink is in the pen. (Чернила в ручке.)


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

Формализованная нотация семантики языков программирования выходит за рамки этой книги. Мы только кратко изложим основную идею. В любой точ­ке выполнения программы мы можем описать ее состояние, определяемое: (1) указателем на следующую команду, которая будет выполнена, и (2) содержимым памяти программы. Семантика команды задается описанием изменения состо­яния, вызванного выполнением команды. Например, выполнение:
а:=25
заменит состояние s на новое состояние s', которое отличается от s только тем, что ячейка памяти а теперь содержит 25.

Что касается управляющих операторов, то для описания вычисления используется математическая логика. Предположим, что мы уже знаем смыслы двух операторов S1 и S2 в произвольном состоянии s. Обозначим это с по­мощью формул р (S1, s) и р (S2, s) соответственно. Тогда смысл if-оператора:
if С then S1 elseS2
задается формулой:
(C(s)=>p(S1,s))&((-C(s)=>p(S2,s))
Если вычисление С в состоянии s дает результат истина, то смысл if-опера­тора такой же, как смысл S1; в противном случае вычисление С дает результат не истина и смысл if-оператора такой же, как у S2.

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


Проверяется условие после if; если результат — истина, выполняется сле­дующий после then оператор, иначе выполняется оператор, следующий за else.


Формализация семантики языков программирования дает дополнитель­ное

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

Конечно, «доказанную» правильность программы следует понимать лишь относительно утверждений на входе и выходе, поэтому не имеет смысла доказывать, что программа вычисляет квадратный корень, если вам нужна программа для вычисления кубического корня! Тем не менее верификация про­граммы применялась как мощный метод проверки для систем, от которых требуется высокая надежность. Важнее то, что изучение верификации поможeт вам писать правильные программы, потому что вы научитесь мыслить, исходя из требований правильности программы. Мы также рекомендуем изучить и использовать язык программирования Eiffel, в который включена под­держка утверждений (см. раздел 11.5).


2.3. Данные


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

Программы на языке ассемблера можно рассматривать как описания действий, которые должны быть выполнены над физическими сущностями, такими как ячейки памяти и регистры. Ранние языки программирования продолжили эту традицию идентифицировать сущности языка, подобные переменным, как слова памяти, несмотря на то, что этим переменным припи­сывались математические атрибуты типа целое. В главах 4 и 9 мы объясним, почему int и float — это не математияеское, а скорее, физическое представле-

ние памяти.

Теперь мы определим центральную концепцию программирования:

Тип — это множество значений и множество операций над этими значениями.

Правильный смысл int в языке С таков: int — это тип, состоящий из конеч­ного множества значений (количестве примерно 65QOQ или 4 миллиардов, в зависимости от компьютера) и набора операций (обозначенньгх, +, < =, и т.д.) над этими значениями. В таких современных языках программирования, как Ada и C++, есть возможность создавать новые типы. Таким образом, мы боль­ше не ограничены горсткой типов, предопределенных разработчиком языка; вместо этого мы можем создавать собственньхе типы, которые более точно.со-ответствуют решаемой задаче.

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

В заключение этого раздела мы определим следующие термины, которые будут использоваться при обсуждении данных:
Значение. Простейшее неопределяемое понятие.
Литерал. Конкретное значение, заданное в программе «буквально», в виде последовательности символов, например 154, 45.6, FALSE, 'x', "Hello world".
Представление. Значение, представленное внутри компьютера конкретной строкой битов. Например, символьное значение, обозначенное 'х', мо­жет представляться строкой из восьми битов 01111000.
^ Переменная. Имя, присвоенное ячейке памяти или ячейкам, которые могут содержать представление значения конкретного типа. Значение может изменяться во время выполнения программы.
Константа. Константа является именем, присвоенным ячейке памяти или ячейкам, которые могут содержать представление значения конкретного типа. Значение не может быть изменено во время выполнения программы.
Объект. Объект — это переменная или константа.


Обратите внимание, что для переменной должен быть определен конкрет­ный тип по той простой причине, что компилятор должен знать, сколько па­мяти для нее нужно выделить! Константа — это просто переменная, которая не может изменяться. Пока мы не дошли до обсуждения объектно-ориенти­рованного программирования, мы будем использовать знакомый термин «пе­ременная» в более общем смысле, для обозначения и константы, и перемен­ной вместо точного термина «объект».

^ 2.4. Оператор присваивания
Удивительно, что в обычных языках программирования есть только один опера­тор, который фактически что-то делает, — оператор присваивания. Все другие операторы, такие как условные операторы и вызовы процедур, существуют только для того, чтобы управлять последовательностью выполнения операто­ров присваивания. К сожалению, трудно формально определить смысл опера­тора присваивания (в отличие от описания того, что происходит при его вы­полнении); фактически, вы никогда не встречали ничего подобного при изуче­нии математики в средней школе и колледже. То, что вы изучали, — были урав­нения:
ах2 + bх + с = О
Вы преобразовывали уравнения, вы решали их и выполняли соответствую­щие вычисления. Но вы никогда их не изменяли: если х представляло число в одной части уравнения, то оно представляло то же самое число и в другой ча­сти уравнения.

Скромный оператор присваивания на самом деле является очень сложным и решает три разные задачи:
1. Вычисление значения выражения в правой части оператора.
2. Вычисление выражения в левой части оператора; выражение должно определять адрес ячейки памяти.
3. Копирование значения, вычисленного на шаге 1, в ячейки памяти, начи­ная с адреса, полученного на шаге 2.
Таким образом, оператор присваивания
a(i + 1) = b + c;
несмотря на внешнее сходство с уравнением, определяет сложное вычисление.


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

Строгий контроль соответствия типов может исключить скрытые ошибки, которые обычно вызываются опечатками или недоразумениями. Это особенно важно в больших программных проектах, разрабатываемых группами програм­мистов; из-за трудностей общения, смены персонала, и т.п. очень сложно объ­единять такое программное обеспечение без постоянной проверки, которой является строгий контроль соответствия типов. Фактически, строгий конт­роль соответствия типов пытается превратить ошибки, возникающие во вре­мя выполнения программы, в ошибки, выявляемые при компиляции. Ошиб­ки, проявляющиеся только во время выполнения, часто чрезвычайно трудно найти, они опасны для пользователей и дорого обходятся разработчику про­граммного обеспечения в смысле отсрочки сдачи программы и испорченной репутации. Цена ошибки компиляции незначительна: вам, вероятно, даже не требуется сообщать своему начальнику, что во время компиляции произошла ошибка.
2.6. Управляющие операторы
Операторы присваивания обычно выполняются в той последовательности, в какой они записаны. Управляющие операторы используются для измене­ния порядка выполнения. Программы на языке ассемблера допускают про­извольные переходы по любым адресам. Язык программирования по анало­гии может включать оператор goto, который осуществляет переход по мет­ке на произвольный оператор. Программы, использующие произвольные переходы, трудно читать, а следовательно, изменять и поддерживать.
^ Структурное программирование — это название, данное стилю программи­рования, который допускает использование только тех управляющих опера­торов, которые обеспечивают хорошо структурированные программы, легкие для чтения и понимания. Есть два класса хорошо структурированных управ­ляющих операторов.

• Операторы выбора, которые выбирают одну из двух или нескольких альтернативных последовательностей выполнения: условные операто­ры (if) и переключатели (case или switch).

• Операторы цикла, в которых повторяется выполнение последовательно­сти операторов: операторы for и while.
Хорошее понимание циклов особенно важно по двум причинам: 1) боль­шая часть времени при выполнении будет (очевидно) потрачена на циклы, и 2) многие ошибки связаны с неправильным кодированием начала или конца цикла.


2.7. Подпрограммы
Подпрограмма — это программный сегмент, состоящий из объявлений дан­ных и исполняемых операторов, которые можно неоднократно вызывать (call) из различных частей программы. Подпрограммы называются процедура­ми (procedure), функциями (function), подпрограммами (subroutine) или метода­ми (method). Первоначально они использовались только для того, чтобы раз­решить многократное использование сегмента программы. Современная точ­ка зрения состоит в том, что подпрограммы являются важным элементомструктуры программы и что каждый сегмент программы, который реализует не­которую конкретную задачу, следует оформить как отдельную подпрограмму.

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


2.8. Модули


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

Возможно, вам говорили, что отдельная подпрограмма не должна превы­шать 40 или 50 строк, потому что программисту трудно читать и понимать большие сегменты кода. Согласно тому же критерию, должно быть понятно взаимодействие 40 или 50 подпрограмм. Отсюда следует очевидный вывод: любую программу, в которой больше 1600 — 2500 строк, трудно понять! Так как в полезных программах могут быть десятки тысяч строк и нередки систе­мы из сотен тысяч строк, то очевидно, что необходимы дополнительные структуры для создания больших систем.

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

Есть две потенциальные трудности применения модулей на практике.
• Необходима мощная среда, разработки программ, чтобы отслеживать «истории», модулей и проверять интерфейсы. ;
• Разбиение на модули поощряет использование большого числа неболь­ших подпрограмм с соответствующим увеличением времени выполне­ния из-за накладных расходов на вызовы подпрограмм.
Однако это больше не является проблемой: ресурсов среднего персональ­ного компьютера более чем достаточно для поддержки среды языков C++ или Ada, а современная архитектура вычислительной системы и методы компиля­ции минимизируют издержки обращений.

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

Несмотря на тот факт, что невозможно научить здравому смыслу в проек­тировании программ, есть некоторые принципы, которые можно изучить. Одним из основных методов декомпозиции программы является объектно-ориентированное программирование (ООП), опирающееся на концепцию типа, рассмотренную выше. Согласно ООП, модуль следует создавать для любого реального или абстрактного «объекта», который может представляться набо­ром данных и операций над этими данными. В главах 14 и 15 детально обсуж­дается языковая поддержка ООП.
2.9. Упражнения
1. Переведите синтаксис (отдельные фрагменты) языков С или Ada из нор­мальной формы Бекуса — Наура в синтаксические диаграммы.
2. Напишите программу на языке Pascal или С, которая компилируется и выполняется, но вычисляет неправильный результат из-за незакрытого комментария.
3. Даже если бы язык Ada использовал стиль комментариев, как в языках С и Pascal, то ошибки, вызванные незакрытыми комментариями, были бы менее частыми, Почему?
4. В большинстве языков ключевые слова, подобные begin и while, зарезер­вированы и не могут использоваться как идентификаторы. В других языках типа Fortran и PL/1 нет зарезервированных ключевых слов. Ка­ковы преимущества и недостатки зарезервированных слов?

^ Глава 3

Среды программирования
Язык — это набор правил для написания программ, которые являются все­го лишь последовательностями символов. В этой главе делается обзор ком­понентов среды программирования — набора инструментов, используемых для преобразования символов в выполнимые вычисления.
Редактор - это, инструментальное средство для создания и изменения ис­ходных файлов, которые являются символьными файлами, содержащими написанную на языке программирования программу.
Компилятор транслирует символы из исходного файла в объектной модуль, который содержит команды в машинном коде для конкретного комью-тера.
Библиотекарь поддерживает совокупности объектных файлов, называемые библиотеками.
^ Компоновщик, или редактор связей, собирает объектные файлы отдельных компонентов программы и разрешает внешние ссылки от одного компо­нента к другому, формируя исполняемый файл.
Загрузчик копирует исполняемый файл с диска в память и инициализирует компьютер перед выполнением программы.
Отладчик — это инструментальное средство, которое дает возможность программисту управлять выполнением программы на уровне отдельных операторов для диагностики ошибок.
Профилировщик измеряет, сколько времени затрачивается на каждый ком­понент программы. Программист, может затем улучшить эффективность критических компонентов, ответственных за большую часть времени вы­полнения.
^ Средства тестирования автоматизируют процесс тестирования программ, создавая и выполняя тесты и анализируя результаты тестирования.
Средства конфигурирования автоматизируют создание программ и просле­живают изменения до уровня исходных файлов.
Интерпретатор непосредственно выполняет исходный код программы в от­личие от компилятора, переводящего исходный файл в объектный.
Среду программирования можно составить из отдельных инструменталь­ных средств; кроме того, многие поставщики продают интегрированные среды программирования, которые представляют собой системы, содержащие боль­шую часть или все перечисленные выше инструментальные средства. Пре­имущество интегрированной среды заключается в чрезвычайной простоте интерфейса пользователя: каждый инструмент инициируется нажатием един­ственной клавиши или выбором из меню вместо набора на клавиатуре имен файлов и параметров.

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

В руководстве по языку могут быть указаны рекомендации для формата ис­ходного кода: введение отступов, разбивка строк, использование верхне­го/нижнего регистров. Эти правила не влияют на правильность программы, но ради будущих читателей вашей программы такие соглашения следует .соблю­дать. Если вам не удалось выполнить соглашения при написании программы, то вы можете воспользоваться инструментальным средством, называемым красивая печать (pretty-printer), которое переформатирует исходный код к ре­комендуемому формату. Поскольку эта программа может непреднамеренно внести ошибки, лучше соблюдать соглашения с самого начала.

3.2. Компилятор
Язык программирования без компилятора (или интерпретатора) может пред­ставлять большой теоретический интерес, но выполнить на компьютере про­грамму, написанную на этом языке, невозможно. Связь между языками и компиляторами настолько тесная, что различие между ними расплывается, и часто можно услышать такую бессмыслицу, как:
Язык L1 эффективнее языка L2.
Правильно же то, что компилятор С1 может сгенерировать более эффек­тивный код, чем компилятор С2, или что легче эффективно откомпилировать конструкции L1, чем соответствующие конструкции L2. Одна из целей этой книги — показать соотношение между конструкциями языка и получающим­ся после компиляции машинным кодом.

Структура компилятора показана на рис. 3.1. Входная часть компилятора



«понимает» программу, анализируя синтаксис и семантику согласно прави­лам языка. Синтаксический анализатор отвечает за преобразование последова­тельности символов в абстрактные синтаксические объекты, называемые лек­семами. Например, символ «=» в языке С преобразуется в оператор присваива­ния, если за ним не следует другой «=»; в противном случае оба соседних сим­вола «=» (т.е. «==») преобразуются в операцию проверки равенства. Анализа­тор семантики отвечает за придание смысла этим абстрактным объектам. На­пример, в следующей программе семантический анализатор выделит глобаль­ный адрес для первого i и вычислит смещение параметра — для второго i:

с
static int i;

void proc(inti) {... }
Результат работы входной части компилятора — абстрактное представ­ление программы, которое называется промежуточным представлением. По нему можно восстановить исходный текст программы, за исключением имен идентификаторов и физического формата строк, пробелов, коммента­риев и т.д.

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

^ Выходная часть компилятора берет промежуточное представление про­граммы и генерирует машинный код для конкретного компьютера. Таким об­разом, входная часть является языковозависимой, в то время как выходная — машиннозависимой. Поставщик компиляторов может получить семейство ком­пиляторов некоторого языка L для ряда самых разных компьютеров Cl, C2,..., написав несколько выходных частей, использующих промежуточное представление общей входной части. Точно так же поставщик компьютеров может создать высококачественную выходную часть для компьютера С и затем под­держивать большое число языков LI, L2,..., написав входные части, которые компилируют исходный текст каждого языка в общее промежуточное представление. В этом случае фактически не имеет смысла спрашивать, какой язык на компьютере эффективнее.

С генератором объектного кода связан оптимизатор, который пытается улучшать код, чтобы сделать его более эффективным. Возможны несколько способов оптимизации:
• Оптимизация промежуточного представления, например нахождение общего подвыражения:
a = f1 (x + y) + f2(x + y);
Вместо того чтобы вычислять выражение х + у дважды, его можно вы­числить один раз и сохранить во временной переменной или регистре. Подобная оптимизация не зависит от конкретного компьютера и может быть сделана до генерации кода. Это означает, что даже компоненты выходной части могут быть общими в компиляторах разных компьюте­ров.
• Машинно-ориентированная оптимизация. Такая оптимизация, как со­хранение промежуточных результатов в регистрах, а не в памяти, явно должна выполняться при генерации объектного кода, потому что число и тип регистров в разных компьютерах различны.
^ Локальная оптимизация обычно выполняется для сгенерированных ко­манд, хотя иногда ее можно проводить для промежуточного представле­ния. В этой методике делается попытка заменять короткие последователь­ности команд одной, более эффективной командой. Например, в языке С выражение n++ может быть скомпилировано в следующую последователь­ность:
load R1,n

add R1,#1

store R1,n
но локальный оптимизатор для конкретного компьютера мог бы заме­нить эти три команды одной, которая увеличивает на единицу непосред­ственно слово в памяти:
incr n


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


с


transmit_register = 0x70; /* Ждать 1 секунду */ transmit_register = 0x70;
Оптимизатор предположит, что второе присваивание лишнее и удалит его из сгенерированного объектного кода.
3.3. Библиотекарь
Можно хранить объектные модули либо в отдельных файлах, либо в одном файле, называемом библиотекой. Библиотеки могут поставляться с компи­лятором, либо приобретаться отдельно, либо составляться программис­том.

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

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

3.4. Компоновщик
Вполне возможно написать программу длиной в несколько тысяч строк в виде отдельного файла или модуля. Однако для больших программных сис­тем, особенно разрабатываемых группами программистов, требуется, чтобы программное обеспечение было разложено на модули (гл. 13). Если обраще­ние делается к процедуре, находящейся вне текущего модуля, компилятор никак не может узнать адрес этой процедуры. Вместо этого адреса в объек­тном модуле записывается внешняя ссылка. Если язык разрешает разным модулям обращаться к глобальным переменным, то внешние ссылки долж­ны быть созданы для каждого такого обращения. Когда все модули отком­пилированы, компоновщик разрешает эти ссылки, разыскивая описания процедур и переменных, которые экспортированы из модуля для нелокаль­ного использования.

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

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

3.5. Загрузчик

Как подразумевает название, загрузчик загружает программу в память и инициализирует ее выполнение. На старых компьютерах загрузчик был не-тривиален, так как должен был решать проблему перемещаемости программ. Такая команда, как load 140, содержала абсолютный адрес памяти, и его приходилось настраивать в зависимости от конкретных адресов, в которые загружалась программа. В современных компьютерах адреса команд и данных задаются относительно значений в регистрах. Для каждой области памяти с программой или данными выделяется регистр, указывающий на начало этой области, поэтому все, что должен сделать загрузчик теперь, — это скопировать программу в память и инициализировать несколько регистров. Команда load 140 теперь означает «загрузить значение, находя­щееся по адресу, полученному прибавлением 140 к содержимому регистра, который указывает на область данных».

3.6. Отладчик
Отладчики поддерживают три функции.
Трассировка. Пошаговое выполнение программы, позволяющее програм­мисту точно отслеживать команды в порядке их выполнения.
^ Контрольные точки. Средство, предназначенное для того, чтобы заставить программу выполняться до конкретной строки в программе. Специ­альный вид контрольной точки — точка наблюдения — вызывает вы­полнение программы, пока не произойдет обращение к определенной ячейке памяти.
^ Проверка/изменение данных. Возможность посмотреть и изменить значе­ние любой переменной в любой точке вычисления.
Символьные отладчики работают с символами исходного кода (именами переменных и процедур), а не с абсолютными машинными адресами. Сим­вольный отладчик требует взаимодействия компилятора и компоновщика для того, чтобы создать таблицы, связывающие символы и их адреса.

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

Некоторые проблемы трудно решить даже с помощью отладчика. Напри­мер, динамические структуры данных (списки и деревья) нельзя исследовать в целом; вместо этого нужно вручную проходить по каждой связи. Есть более серьезные проблемы типа затирания памяти (см. раздел 5.3), которые вызваны ошибками, находящимися далеко от того места, где они проявились. В этих ситуациях мало проку от отладчиков, нацеленных на выявление таких симптомов, как «деление на ноль в процедуре p1».

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

3.7. Профилировщик

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

• Не существует лучшего способа улучшить эффективность. В общем слу­чае выбор более эффективного алгоритма даст лучший результат, чем по­пытка перепрограммировать существующий алгоритм (для примера см. раздел 6.5).
• Можно выявить причину неэффективности.
Чрезвычайно сложно обнаружить причину неэффективности без помощи измерительных средств. Дело в том, что временные интервалы, которые мы инстинктивно воспринимаем (секунды), и временные интервалы работы компьютера (микро- или наносекунды) отличаются на порядки. Функция, которая нам кажется сложной, возможно, оказывает несущественное влияние на общее время выполнения программы.

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

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

^ 3.8. Средства тестирования
Тестирование большой системы может занять столько же времени, сколько и программирование вместе с отладкой. Для автоматизации отдельных аспек­тов тестирования были разработаны программные инструментальные средст­ва. Одно из них — анализатор покрытия (coverage analyzer), который отслежи­вает, какие команды были протестированы. Однако такой инструмент не по­могает создавать и выполнять тесты.

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

^ 3.9. Средства конфигурирования
Инструментальные средства конфигурирования используются для автомати­зации управленческих задач, связанных с программным обеспечением. Инс­трумент сборки (make) создает исполняемый файл из исходных текстов, вы­зывая компилятор, компоновщик, и т.д. При проектировании большой сис­темы может оказаться трудно в точности отследить, какие файлы должны быть перекомпилированы, в каком порядке и с какими параметрами, и лег­ко, найдя и исправив ошибку, тут же вызвать другую, использовав устарев­ший объектный модуль. Инструмент сборки программы гарантирует, что но­вый исполняемый файл создан корректно с минимальным количеством перекомпиляций.

Инструментальные средства управления исходными текстами (source control) или управления изменениями (revision control) используются для от­слеживания и регистрации всех изменений модулей исходного текста. Это важно, потому что при проектировании больших систем часто необ­ходимо отменить изменение, которое вызвало непредвиденные пробле­мы, либо проследить изменения для конкретной версии или сделанные конкретным программистом. Кроме того, разным заказчикам могут по­ставляться различные версии программы, а без программных средств при­шлось бы устранять общую ошибку во всех версиях. Инструментальные средства управления изменениями упрощают эти задачи, поскольку сохра­няют изменения (так называемые дельты) относительно первоначальной версии и позволяют на их основе легко восстановить любую предыдущую версию.

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

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



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

Первоначально Pascal-компилятор был написан для получения ма­шинного кода конкретной машины (CDC 6400). Немного позже Никлаус Вирт создал компилятор, который вырабатывал код, названный Р-кодом, для абстрактной стековой машины. Написав интерпретатор для Р-кода или компилируя Р-код в машинный код конкретной машины, можно создать интерпретатор или компилятор для языка Pascal, затратив относительно небольшие усилия. Компилятор для Р-кода был решающим фактором в превращении языка Pascal в широко распространенный язык, каким он яв­ляется сегодня.

Язык логического программирования Prolog (см. гл. J7) рассматривался вначале как язык, пригодный только для интерпретации. Дэвид Уоррен (David Warren) создал первый настоящий компилятор для языка Prolog, опи­сав абстрактную машину (абстрактная машина Уоррена, или WAM), которая управляла основными структурами данных, необходимыми для выполнения программы на языке. Как компиляцию Prolog в WAM-программы, так и ком­пиляцию WAM-программы в машинный код проделать не слишком трудно; достижение Уоррена состояло в том, что он сумел между двух уровней опреде­лить правильный промежуточный уровень — уровень WAM. Многие исследо­вания по компиляции языков логического программирования опирались на WAM.

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

3.11. Упражнения
1. Изучите документацию используемого вами компилятора и перечисли­те оптимизации, которые он выполняет. Напишите программы и про­верьте получающийся в результате объектный код на предмет оптими­зации.
2. В какой информации от компилятора и от компоновщика нуждается от­ладчик?
3. Запустите профилировщик и изучите, как он работает.
4. Как можно написать собственный простой инструментарий для тести­рования? В чем заключается влияние автоматизированного тестирова­ния на проектирование программы?
5. AdaS — написанный на языке Pascal интерпретатор для подмножества Ada. Он работает, компилируя исходный код в Р-код и затем выполняя Р-код. Изучите AdaS-программу (см. приложение А) и опишите Р-ма-шину.
2 Основные понятия
Глава 4

Элементарные типы данных

^ 4.1. Целочисленные типы
Слово «целое» (integer) в математике обозначает неограниченную, упорядо­ченную последовательность чисел:
...,-3, -2,-1,0,1,2,3,...
В программировании этот термин используется для обозначения совсем другого — особого типа данных. Вспомним, что тип данных — это множество значений и набор операций над этими значениями. Давайте начнем с опреде­ления множества значений типа Integer (целое).

Для слова памяти мы можем определить множество значений, просто ин­терпретируя биты слова как двоичные значения. Например, если слово из 8 битов содержит последовательность 10100011, то она интерпретируется как:

(1 х 27) + (1 х 25) + (1 х 21) + (1 х 2°) = 128 + 32 + 2 + 1 = 163

Диапазон возможных значений — 0.. 255 или в общем случае 0.. 2В - 1 для слова из В битов. Тип данных с этим набором значений называется unsigned integer (целое без знака), а переменная этого типа может быть объявлена в язы­ке С как:
unsigned intv;
Обратите внимание, что число битов в значении этого типа может быть разным для разных компьютеров.
Сегодня чаще всего встречается размер слова в 32 бита, и целое (без знака) находится в диапазоне 0.. 232 - 1 к 4 х 109. Таким образом, набор математиче­ских целых чисел неограничен, в то время как целочисленные типы имеют ко­нечный диапазон значений.

Поскольку тип unsigned integer не может представлять отрицательные числа, он часто используется для представления значений, считываемых внешними устройствами.

Например, при опросе температурного датчика поступает 10 битов информации; эти целые без знака в диапазоне 0.. 1023 нужно будет затем пре­образовать в обычные (положительные и отрицательные) числа. Целые чис­ла без знака также используются для представления символов (см. ниже). Их не следует использовать для обычных вычислений, потому что большинство компьютерных команд работает с целыми числами со знаком, и компилятор, возможно, будет генерировать дополнительные команды для операций с целыми без знака.

Диапазон значений переменной может быть таким, что значения не поместятся в одном слове или займут только часть слова. Чтобы указать раз­ные целочисленные типы, можно добавить спецификаторы длины:
unsigned int v1 ; /* обычное целое */ [с]

unsigned short int v2; /* короткое целое */

unsigned long int v3; /* длинное целое */

В языке Ada наряду с обычным типом Integer встроены дополнительные типы, например Long_integer (длинное целое). Фактическая интерпретация спецификаторов длины, таких как long и short, различается для различных компиляторов; некоторые компиляторы могут даже давать одинаковую ин­терпретацию двум или нескольким спецификаторам.

В математике для представления чисел со знаком используется специаль­ный символ «-», за которым следует обычная запись абсолютного значения числа. Компьютеру с таким представлением работать неудобно. Поэтому боль­шинство компьютеров представляет целые числа со знаком в записи, называ­ющейся дополнением до двух *. Положительное число представляется старшим нулевым битом и следующим за ним обычным двоичным представлением зна­чения. Из этого вытекает, что самое большое положительное целое число, ко­торое может быть представлено словом из w битов, не превышает 2W-1 - 1.

Для того чтобы получить представление числа -п по двоичному представ­лению В = b1b2...bwчисла n:
• берут логическое дополнение В, т. е. заменяют в каждом b ноль на едини­цу, а единицу на ноль,
• прибавляют единицу.

Например, представление -1, -2 и -127 в виде 8-разрядных слов получается так:


У отрицательных значений в старшем бите всегда будет единица.

Дополнение до двух удобно тем, что при выполнении над такими пред­ставлениями операций обычной двоичной целочисленной арифметики полу­чается правильное представление результата:
(-!)-! = -2

1111 1111-00000001 = 1111 1110

Отметим, что строку битов 1000 0000 нельзя получить ни из какого поло­жительного значения. Она представляет значение -128, тогда как соответству­ющее положительное значение 128 нельзя представить как 8-разрядное число. Необходимо учитывать эту асимметрию в диапазоне типов integer, особенно при работе с типами short.

Альтернативное представление чисел со знаками — дополнение до единицы, в котором представление значения -n является просто дополнением п. В этом случае набор значений симметричен, но зато есть два представления для нуля: 0000 0000 называется положительным нулем, а 1111 1111 называется отрица­тельным нулем.

Если в объявлении переменной синтаксически не указано, что она без знака (например, unsigned), то по умолчанию она считается целой со знаком:

I

nt i; /* Целое со знаком в языке С */

I: Integer; -- Целое со знаком в языке Ada

^ Целочисленные операции
К целочисленным операциям относятся четыре основных действия: сложе­ние, вычитание, умножение и деление. Их можно использовать для составле­ния выражений:
а + b/с - 25* (d - е)
К целочисленным операциям применимы обычные математические правила старшинства операций; для изменения порядка вычислений можно исполь­зовать круглые скобки.

Результат операции над целыми числами со знаком не должен выходить за диапазон допустимых значений, иначе произойдет переполнение, как рассмотрено ниже. Для целых чисел без знака используется циклическая ариф­метика. Если short int хранится в 16-разрядном слове, то:


с



unsigned short int i; /* Диапазон i= 0...65535*/

i = 65535; /* Наибольшее допустимое значение*/

i = i + 1; /*Циклическая арифметика, i = 0 */

Разработчики Ada 83 сделали ошибку, не включив в язык целые без знака. Ada 95 обобщает концепцию целых чисел без знака до модульных типов, кото­рые являются целочисленными типами с циклической арифметикой по про­извольному модулю. Обычный байт без знака можно объявить как:


Ada



type Unsigned_Byte is mod 256;
тогда как модуль, не равный двум, можно использовать для хеш-таблиц или случайных чисел:

Ada
Ada type Randomjnteger is mod 41;

Обратите внимание, что модульные типы в языке Ada переносимы, так как частью определения является только циклический диапазон, а не размер пред­ставления, как в языке С.
Деление
В математике в результате деления двух целых чисел а/b получаются два зна­чения: частное q и остаток r, такие что:
а = q * b + r
Так как результатом арифметического выражения в программах является единственное

значение, то для получения частного используют оператор «/», а для получения остатка применяют другой оператор (в языке С это «%», а в Ada — rem). Выражение 54/10 дает значение 5, и мы говорим, что результат операции был усечен (truncated). В языке Pascal для целочисленного деления используется специальная операция div.

При рассмотрении отрицательных чисел определение целочисленного де­ления не столь тривиально. Чему равно выражение -54/10: -5 или -6? Другими словами, до какого значения делается усечение: до меньшего («более отрица­тельного») или до ближайшего к нулю? Один вариант — это усечение в сторону нуля, поскольку, чтобы удовлетворить соотношение для целочис­ленного деления, достаточно просто сменить знак остатка:
-54 = -5*10 + (-4)
Однако существует и другая математическая операция, взятие по модулю (modulo), которая соответствует округлению отрицательных частных до мень­шего («более отрицательного») значения:
-54 = -6* 10+ 6

-54 mod 10 = 6


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

Значение операций «/» и «%» в языке С зависит от реализации, поэтому программы, использующие эти целочисленные операции, могут оказаться не­переносимыми. В Ada операция «/» всегда усекает в сторону нуля. Операция rem возвращает остаток, соответствующий усечению в сторону нуля, в то вре­мя как операция mod возвращает остаток, соответствующий усечению в сто­рону минус бесконечности.


Переполнение
Говорят, что операция приводит к переполнению, если она дает результат, ко­торый выходит за диапазон допустимых значений. Следующие рассуждения для ясности даются в терминах 8-разрядных целых чисел.

Предположим, что переменная i типа signed integer имеет значение 127 и что мы увеличиваем i на 1. Компьютер просто прибавит единицу к целочис­ленному представлению 127:
0111 1111+00000001 = 10000000
и получит -128. Это неправильный результат, и ошибка вызвана переполне­нием. Переполнение может приводить к странным ошибкам:

C



for (i = 0; i < j*k; i++)….

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

Предположите теперь, что переменные a, b и с имеют значения 90, 60 и 80, соответственно. Выражение (а - b + с) вычисляется как 110, потому что (а - b) дает 30, и затем при сложении получается 110. Однако оптимизатор для вычис­ления выражения может выбрать другой порядок, (а + с - b), давая непра­вильный ответ, потому что сложение (а + с) дает значение 170, что вызывает переполнение. Если вам в средней школе говорили, что сложение является коммутативным и ассоциативным, то речь шла о математике, а не о програм­мировании!

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

Реализация
Целочисленные значения хранятся непосредственно в словах памяти. Неко­торые компьютеры имеют команды для вычислений с частями слов или даже отдельными байтами. Компиляторы для этих компьютеров обычно помещают short int в часть слова, в то время как компиляторы для компьютеров, которые распознают только полные слова, реализуют целочисленные типы int и short int одинаково. Тип long int обычно распределяется в два слова, чтобы получить больший диапазон значений.

Сложение и вычитание компилируются непосредственно в соответствую­щие команды. Умножение также превращается в одну команду, но выпол­няется значительно дольше, чем сложение и вычитание. Умножение двух слов, хранящихся в регистрах R1 и R2, дает результат длиной в два слова и тре­бует для хранения двух регистров. Если регистр, содержащий старшее значе­ние, — не ноль, то произошло переполнение.

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

Арифметические операции выполняются для типа long int более чем вдвое дольше, нежели операции для int. Причина в том, что нужны дополнительные команды для «распространения» переноса, который может возникать, из слова младших разрядов в слово старших.


^ 4.2. Типы перечисления
Языки программирования типа Fortran и С описывают данные в терминах компьютера. Данные реального мира должны быть явно отображены на типы данных, которые существуют на компьютере, в большинстве случаев на один из целочисленных типов. Например, если вы пишете программу для управле­ния нагревателем, вы могли бы использовать переменную dial для хранения текущей позиции регулятора. Предположим, что реальная шкала имеет четы­ре позиции: off (выключено), low (слабо), medium (средне), high (сильно). Как бы вы объявили переменную и обозначили позиции? Поскольку компьютер не имеет команд, которые работают со словами памяти, имеющими только четы­ре значения, для объявления переменной вы выберете тип integer, а для обо­значения позиций четыре конкретных целых числа (скажем 1, 2, 3, 4):


C



int dial; /* Текущая позиция шкалы */

if (dial < 4) dial++; /* Увеличить уровень нагрева*/
Очевидно, что при использовании целых чисел программу становится трудно читать и поддерживать. Чтобы понять код программы, вам придется написать обширную документацию и постоянно в нее заглядывать. Чтобыулучшить программу, можно прежде всего задокументировать подразумевае­мые значения внутри самой программы:
#define Off 1


C
#define Low 2

#define Medium 3

#define High 4
int dial;

if(dial<High)dial++;

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

C
dial=-1; /* Нет такого значения*/

dial = High + 1; /* Нераспознаваемое переполнение*/

dial = dial * 3; /* Бессмысленная операция*/
Другими словами, представление шкалы с четырьмя позициями в виде цело­го числа позволяет программисту присваивать значения, которые выходят за допустимый диапазон, и выполнять команды, бессмысленные для реального объекта. Даже если программист не создаст преднамеренно ни одну из этих проблем, опыт показывает, что они часто появляются в результате отсутствия I взаимопонимания между членами группы разработчиков программы, опеча- ток и других ошибок, типичных при создании сложных систем.

Решение состоит в том, чтобы разрешить разработчику программы созда- вать новые типы, точно соответствующие тем объектам реального мира, кото­рые нужно моделировать. Рассматриваемая здесь короткая упорядоченная последовательность значений настолько часто встречается, что современные языки программирования поддерживают создание типов, называемых типа­ми — перечислениями (enumiration types)*. В языке Ada вышеупомянутый при­мер выглядел бы так:
Ada type Heat is (Off, Low, Medium, High);

Dial: Heat;


Ada
Dial := Low;

if Dial < High then Dial := Heat'Succ(DialJ;

Dial:=-1; —Ошибка

Dial := Heat'Succ(High); -- Ошибка

Dial := Dial * 3; - Ошибка


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


C



typedef enum {Off, Low, Medium, High} Heat;
Однако переменные, объявленные с типом Heat, — все еще целые, и ни од­на из вышеупомянутых команд не считается ошибкой (хотя компилятор мо­жет выдавать предупреждение):
Heat dial;

C
dial = -1; /*He является ошибкой!*/

dial = High + 1; /* Не является ошибкой! */

dial = dial * 3; /* Не является ошибкой! */


Другими словами, конструкция enum* — всего лишь средство документи­рования, более удобное, чем длинные строки define, но она не создает но­вый тип.

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


C++
dial = (Heat) (dial + 1);

Обратите внимание на неявное преобразование dial в целочисленный тип, вслед за которым происходит явное преобразование результата обратно в Heat. Операции «++» и «--» над целочисленными типами в C++ можно пере­грузить (см. раздел 10.2), поэтому они могут быть использованы для определе­ния операций над типами перечисления, которые синтаксически совпадают с операциями над целочисленными типами.

В языке Ada определение типа приводит к созданию нового типа Heat. Зна­чения этого типа не являются целыми числами. Любая попытка выйти за диапа­зон допустимых значений или применить целочисленные операции будет от­мечена как ошибка. Если вы случайно нажмете не на ту клавишу и введете Higj вместо High, ошибка будет обнаружена, потому что тип содержит именно те четыре значения, которые были объявлены. Если бы вы использовали один из типов integer, 5 было бы допустимым целым, как и 4.

Перечисляемые типы аналогичны целочисленным: вы можете объявлять переменные и параметры этих типов. Однако набор операций, которые могутвыполняться над значениями этого типа, ограничен. В него входят присваи­вание (:=), равенство (=) и неравенство (/=). Поскольку набор значений в объявлении интерпретируется как упорядоченная последовательность, для него определены операции отношений (<,>,>=,<=).

В языке Ada для заданного Т перечисляемого типа и значения V типа Т оп­ределены следующие функции, называемые атрибутами:
• T'First возвращает первое значение Т.

• Т'Last возвращает последнее значение Т.
• T'Succ(V) возвращает следующий элемент V.
• T'Pred(V) возвращает предыдущий элемент V.
• T'Pos(V) возвращает позицию V в списке значений Т.
• T'Val(l) возвращает значение I-й позиции в Т.

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

for I in Heat'First.. Heat'Last - 1 loop


Ada
A(l):=A(Heat'Succ(l));

end loop;
He каждый разработчик языка «верует» в перечисляемые типы. В языке Eiffel их нет по следующим причинам:
• Желательно было сделать язык как можно меньшего объема.
• Можно получить тот же уровень надежности, используя контрольные утверждения (раздел 11.5).
• Перечисляемые типы часто используются с вариантными записями (раз­дел 10.4); при правильном применении наследования (раздел 14.3) по­требность в перечисляемых типах уменьшается.

Везде, где только можно, следует предпочитать типы перечисления обыч­ным целым со списками заданных констант; их вклад в надежность програм­мы невозможно переоценить. Программисты, работающие на С, не имеют преимуществ контроля соответствия типов, как в Ada и C++, и им все же следует использовать enum, чтобы улучшить читаемость программы.


Реализация
Я расскажу вам по секрету, что значения перечисляемого типа представляют­ся в компьютере в виде последовательности целых чисел, начинающейся с ну­ля. Контроль соответствия типов в языке Ada делается только во время ком­пиляции, а такие операции как «<» представляют собой обычные целочис­ленные операции.

Можно потребовать, чтобы компилятор использовал нестандартное пред­ставление перечисляемых типов. В языке С это задается непосредственно в определении типа:

C
typedef enum {Off = 1, Low = 2, Medium = 4, High = 8} Heat;
тогда как в Ada используется спецификация представления: __


Ada


type Heat is (Off, Low, Medium, High);

for Heat use (Off = >1, Low = >2, Medium = >4, High = >8);


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

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

Различие в способе определения символов в языках Ada и С аналогично различию в способе определения перечисляемых типов. В Ada есть встроен­ный перечисляемый тип: __


Ada



type Character is (..., 'А', 'В',...);
и все обычные операции над перечисляемыми типами (присваивание, отно­шения, следующий элемент, предыдущий элемент и т.д.) применимы к сим­волам. В Ada 83 для типа Character допускались 128 значений, определенных в американском стандарте ASCII, в то время как в Ada 95 принято представление этого типа байтом без знака, так что доступно 256 значений, требуемых международными стандартами.


В языке С тип char — это всего лишь ограниченный целочисленный тип, и допустимы все следующие операторы, поскольку char и int по сути одно и то же:
char с;

int i;

с='А' + 10; /* Преобразует char в int и обратно */


C
i = 'А'; /* Преобразует char в int */

с = i; /* Преобразует int в char */
В языке C++ тип char отличается от целочисленного, но поскольку допустимы преобразования в целочисленный и обратно, то перечисленные операторы оста­ются допустимыми.

Для неалфавитных языков могут быть определены 16-разрядные символы. Они называются wcharj в С и C++, и Wide_Character в Ada 95.

Единственное, что отличает символы от обычных перечислений или це­лых, — специальный синтаксис ('А') для набора значений и, что более важно, спе­циальный синтаксис для массивов символов, называемых строками (раздел 5.5).


4.4. Булев тип
Boolean — встроенный перечисляемый тип в языке Ada:
type Boolean is (False, True);
Тип Boolean имеет очень большое значение, потому что:
• операции отношения (=, >, и т.д.) — это функции, которые возвращают значение булева типа;
• условный оператор проверяет выражение булева типа;
• операции булевой алгебры (and, or, not, xor) определены для булева типа.
В языке С нет самостоятельного булева типа; вместо этого используются целые числа в следующей интерпретации:
• Операции отношения возвращают 1, если отношение выполняется, и 0 в противном случае.
• Условный оператор выполняет переход по ветке false (ложь), если вы­числение целочисленного выражения дает ноль, и переход по ветке true (истина) в противном случае.
В языке С существует несколько методов введения булевых типов. Одна из возможностей состоит в определении типа, в котором будет разрешено объяв­ление функций с результатом булева типа:
typedef enum {false, true} bool;


C
bool data_valid (int a, float b);

if (data-valid (x, y)). . .
но это применяется, конечно, только для документирования и удобочитаемо­сти, потому что такие операторы, как:


C
bool b;

b = b + 56; /* Сложить 56 с «true» ?? */
все еще считаются приемлемыми и могут приводить к скрытым ошибкам.

В языке C++ тип bool является встроенным целочисленным типом (не ти­пом перечисления) с неявными взаимными преобразованиями между ненуле­выми значениями и литералом true, а также между нулевыми значениями и false. Программа на С с bool, определенным так, как показано выше, может быть скомпилирована на C++ простым удалением typedef.

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

C
if (а + b== 2)... /* Этот вариант понятнее, чем */

if (a + b-2)... /* ...такойвариант.*/

if (а + b ! = О)... /* Этот вариант понятнее, чем */

if (! (а + b))... /*... такой вариант. */
Наконец, отметим, что в языке С применяется так называемое укороченное (short-circuit) вычисление выражений булевой алгебры. Это мы обсудим в раз­деле 6.2.


4.5. Подтипы
В предыдущих разделах мы обсудили целочисленные типы, позволяющие вы­полнять вычисления в большом диапазоне значений, которые можно пред­ставить в слове памяти, и перечисляемые типы, которые работают с меньши­ми диапазонами, но не позволяют выполнять арифметические вычисления. Однако во многих случаях нам хотелось бы делать вычисления в небольших диапазонах целых чисел. Например, должен существовать какой-нибудь спо­соб, позволяющий обнаруживать такие ошибки как:
Temperature: Integer;

Temperature := -280; -- Ниже абсолютного нуля!

Compass-Heading: Integer;

Compass-Heading := 365; - Диапазон компаса 0..359 градусов!
Предположим, что мы попытаемся определить новый класс типов:

type Temperatures is Integer range -273 .. 10000; - - He Ada!

type Headings is Integer range 0 .. 359; -- He Ada!
Это решает проблему проверки ошибок, вызванных значениями, выходящи­ми за диапазон типа, но остается вопрос: являются эти два типа разными или нет? Если это один и тот же тип, то
Temperature * Compass_Heading
является допустимым арифметическим выражением на типе целое; если нет, то должно использоваться преобразование типов.

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

Подтип (subtype) — это ограничение на существующий тип. Дискретные типы (целочисленные и перечисляемые) могут иметь ограничение диапазона.

subtype Temperatures is Integer range -273 .. 10000;

Temperature: Temperatures;
subtype Headings is Integer range 0 .. 359;

Compass_Heading: Headings;
Тип значения подтипа S тот же, что и тип исходного базового типа Т; здесь ба­зовый как у Temperatures, так и у Headings — тип Integer. Тип определяется во время компиляции. Значение подтипа имеет то же самое представление, что и значение базового типа, и допустимо везде, где требуется значение базового типа:
Temperature * Compass_Heading

это допустимое выражение, но операторы:
Temperature := -280;

Compass-Heading := 365;
приводят к ошибке, потому что значения выходят за диапазоны подтипов. На­рушения диапазона подтипа выявляются во время выполнения.

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

subtype Upper-Case is Character range 'A'.. 'Z';

U: Upper-Case;

C: Character;

U := 'a'; -- Ошибка, выход за диапазон

С := U; -- Всегда правильно

U := С; -- Может привести к ошибке
Подтипы важны для определения массивов, как это будет рассмотрено в раз­деле 5.4. Кроме того, именованный подтип можно использовать для упроще­ния многих операторов:
if С in Upper-Case then ... - Проверка диапазона

for C1 in Upper-Case loop ... — Границы цикла


^ 4.6. Производные типы
Вторая интерпретация отношения между двумя аналогичными типами состо­ит в том, что они представляют разные типы, которые не могут использовать­ся вместе. В языке Ada такие типы называются производными (derived) типами и обозначаются в определении словом new:
type Derived_Dharacter is new Character;

C: Character;

D: Derived_Character;

С := D: -- Ошибка, типы разные
Когда один тип получен из другого типа, называемого родительским (parent) типом, он наследует копию набора значений и копию набора операций, но ти­пы остаются разными. Однако всегда допустимо явное преобразование между типами, полученными друга из друга:
D := Derived_Character(C); -- Преобразование типов

С := Character(D); -- Преобразование типов

Можно даже задать другое представление для производного типа; преобразо­вание типов будет тогда преобразованием между двумя представлениями (см. раздел 5.8).

Производный тип может включать ограничение на диапазон значений родительского типа:


type Upper_Case is new Character range 'A'.. 'Z';

U: Upper_Case;

C: Character;

С := Character(U); -- Всегда правильно

U := Upper_Case(C); -- Может привести к ошибке
Производные типы в языке Ada 83 реализуют слабую версию наследования (weak version of inheritance), которая является центральным понятием объект­но-ориентированных языков (см. гл. 14). Пересмотренный язык Ada 95 реали­зует истинное наследование (true inheritance), расширяя понятие производ­ных типов; мы еще вернемся к их изучению.


^ Целочисленные типы
Предположим, что мы определили следующий тип:
type Altitudes is new Integer range 0 .. 60000;
Это определение работает правильно, когда мы программируем моделирова­ние полета на 32-разрядной рабочей станции. Что случается, когда мы пере­дадим программу на 16-разрядный контроллер, входящий в состав бортовой электроники нашего самолета? Шестнадцать битов могут представлять целые числа со знаком только до значения 32767. Таким образом, использование производного типа было бы ошибкой (так же, как подтипа или непосред­ственно Integer) и нарушило бы программную переносимость, которая явля­ется основной целью языка Ada.

Чтобы решать эту проблему, можно задать производный целый тип без яв­ного указания базового родительского типа:
type Altitudes is range 0 .. 60000;
Компилятор должен выбрать представление, которое соответствует требуемо­му диапазону — integer на 32-разрядном компьютере и Long_integer на 16-раз­рядном компьютере. Это уникальное свойство позволяет легко писать на языке Ada переносимые программы для компьютеров с различными длинами слова.

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

A: Altitude;

А := I; -- Ошибка, разные типы

А := Altitude(l); -- Правильно, преобразование типов
Таким образом, существует неизбежный конфликт:
• Подтипы потенциально ненадежны из-за возможности писать смешан­ные выражения и из-за проблем с переносимостью.
• Производные типы безопасны и переносимы, но могут сделать програм­му трудной для чтения из-за многочисленных преобразований типов.

4.7. Выражения
Выражение может быть очень простым, состоящим только из литерала (24, V, True) или переменной, но может быть и сложной комбинацией, включающей операции (в том числе вызовы системных или пользовательских функций). В результате вычисления выражения получается значение.

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

Значение литерала — это то, что он обозначает; например, значение 24 — целое число, представляемое строкой битов 0001 1000. Значение переменной V — содержимое ячейки памяти, которую она обозначает. Обратите внимание на возможную путаницу в операторе:
V1 :=V2;
V2 — выражение, значение которого является содержимым некоторой ячейки памяти. V1 — адрес ячейки памяти, в которую будет помещено значение V2.

Более сложные выражения содержат функцию с набором параметров или операцию с операндами. Различие, в основном, в синтаксисе: функция с па­раметрами пишется в префиксной нотации sin (x), тогда как операция с опе­рандами пишется в инфиксной нотации а + b. Поскольку операнды сами мо­гут быть выражениями, можно создавать выражения какой угодно сложности:
a + sin(b)*((c-d)/(e+34))
В префиксной нотации порядок вычисления точно определен за исключени­ем порядка вычисления параметров отдельной функции:
max (sin (cos (x)), cos (sin (y)))
Можно написать программы, результат которых зависит от порядка вычисле­ния параметров функции (см. раздел 7.3), но такой зависимости от порядка вычисления следует избегать любой ценой, потому что она является источни­ком скрытых ошибок при переносе программы и даже при ее изменении.

Инфиксной нотации присущи свои проблемы, а именно проблемы стар­шинства и ассоциативности. Почти все языки программирования придержива­ются математического стандарта назначения мультипликативным операциям («*», «/») более высокого старшинства, чем операциям аддитивным («+», «-»), старшинство других операций определяется языком. Крайности реализованы в таких языках, как АР L, в котором старшинство вообще не определено (даже для арифметических операций), и С, где определено 15 уровней старшинства! Час­тично трудность изучения языка программирования связана с необходимостью привыкнуть к стилю, который следует из правил старшинства.

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


pascal



if а > b and b > с then ...
является ошибочным, потому что это выражение интерпретируется

Pascal
if а > (b and b) > с then . . .
и синтаксис оказывается неверен.

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


C
inti=6, j = 7, k = 3;

i = i * j / k; /* результат равен 1 2 или 1 4? */
В целом, бинарные операции группируются слева направо, так что рас­смотренный пример компилируется как:

C
I=(i*j)/k

в то время как унарные операции группируются справа налево: !++i в языке С вычисляется, как ! (++i).

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

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


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

Выражения вычисляются изнутри наружу; например, а * (b + с) вычисля­ется так:
load R1,b

load R2, с

add R1 , R2 Сложить b и с, результат занести в R1

load R2, а

mult R1.R2 Умножить а на b + с, результат занести в R1
Можно написать выражение в форме, которая делает порядок вычисления явным:

явным:

bс + а
Читаем слева направо: имя операнда означает загрузку операнда, а знак операции означает применение операции к двум самым последним операн­дам и замену всех трех (двух операндов и операции) результатом. В этом случае складываются b и с; затем результат умножается на а.

Эта форма называется польской инверсной записью (reverse polish notationRPN) и может использоваться компилятором. Выражение переводится в RPN, и затем компилятор вырабатывает команды для каждого операнда и опе­рации, читая RPN слева направо..

Для более сложного выражения, скажем:
(а + b) * (с + d) * (е + f)
понадобилось бы большее количество регистров для хранения промежуточ­ных результатов: а + b, с + d и т. д. При увеличении сложности регистров не хватит, и компилятору придется выделить неименованные временные пере менные для сохранения промежуточных результатов. Что касается эффектив ности, то до определенной точки увеличение сложности выражения дает луч­ший результат, чем использование последовательности операторов присваи­вания, так как позволяет избежать ненужного сохранения промежуточных ре­зультатов в памяти. Однако такое улучшение быстро сходит на нет из-за необ­ходимости заводить временные переменные, и в некоторой точке компиля­тор, возможно, вообще не сможет обработать сложное выражение.

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

Другой вид оптимизации — свертка констант. В выражении:

2.0* 3.14159* Radius
компилятор сделает умножение один раз во время компиляции и сохранит результат. Нет смысла снижать читаемость программы, производя свертку констант вручную, хотя при этом можно дать имя вычисленному значению:

C
PI: constants 3.1 41 59;

Two_PI: constant := 2.0 * PI;

Circumference: Float := Two_PI * Radius;


^ 4.8. Операторы присваивания
Смысл оператора присваивания:
переменная := выражение;
состоит в том, что значение выражения должно быть помещено по адресу па­мяти, обозначенному как переменная. Обратите внимание, что левая часть оператора также может быть выражением, если это выражение можно вычис­лить как адрес:

Ada
a(i*(j+1)):=a(i*j);
Выражение, которое может появиться в левой части оператора присваивания, называется l-значением; константа, конечно, не является 1-значением. Все вы­ражения дают значение и поэтому могут появиться в правой части оператора присваивания; они называются r-значениями. В языке обычно не определяет­ся порядок вычисления выражений слева и справа от знака присваивания. Ес­ли порядок влияет на результат, программа не будет переносимой.


В языке С само присваивание определено как выражение. Значение конструкции
переменная = выражение;
такое же, как значение выражения в правой части. Таким образом,


C



int v1 , v2, v3;

v1 = v2 = v3 = e;
означает присвоить (значение) е переменной v3, затем присвоить результат переменной v2, затем присвоить результат переменной v1 и игнорировать ко­нечный результат.

В Ada присваивание является оператором, а не выражением, и многократ­ные присваивания не допускаются. Многократное объявление
V1.V2.V3: Integer :=Е;
рассматривается как сокращенная запись для

Ada
V1 : Integer :=E;

V2: Integer := Е;

V3: Integer := Е;
а не как многократное присваивание.
Хотя стиль программирования языка С использует тот факт, что присваи­вание является выражением, этого, вероятно, следует избегать как источник скрытых ошибок программирования. Весьма распространенный класс оши­бок вызван тем, что присваивание («=») путают с операцией равенства («==»). В следующем операторе:

C
If (i=j)...
программист, возможно, хотел просто сравнить i и j, не обратив внимания, что значение i изменяется оператором присваивания. Некоторые С-компиляторы расценивают это как столь плохой стиль программирования, что выдают пре­дупреждающее сообщение.

Полезным свойством языка С является комбинация операции и присваи­вания:


C



v+=e; /* Это краткая запись для... */

v = v + е; /* такого оператора. */
Операции с присваиванием особенно важны в случае сложной переменной, включающей индексацию массива и т.д. Комбинированная операция не толь­ко экономит время набора на клавиатуре, но и позволяет избежать ошибки, если v написано не одинаково с обеих сторон от знака «=». И все же комбинированные присваивания — всего лишь стилистический прием, так как оптимизирующий компилятор может удалить второе вычисление адреса v.
Можно предотвратить присваивание значения объекту, объявляя его как константу.
const int N = 8; /* Константа в языке С */

N: constant Integer := 8; — Константа в языке Ada
Очевидно, константе должно быть присвоено начальное значение.

Есть различие между константой и статическим значением (static value), которое известно на этапе компиляции:

procedure P(C: Character) is

С1 : constant Character := С;


Ada
С2: constant Character :='х';

Begin



case C is

when C1 => -- Ошибка, не статическое значение

when C2 => -- Правильно, статическое значение



end case;



end P;
Локальная переменная С1 — это постоянный объект, в том смысле что значе­ние не может быть изменено внутри процедуры, даже если ее значение будет разным при каждом вызове процедуры. С другой стороны, варианты выбора в case должны быть известны во время компиляции. В отличие от языка С язык C++ рассматривает константы как статические:

C++
const int N = 8;

int a[N]; //Правильно в C++, но не в С


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

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


4.9. Упражнения
1. Прочитайте документацию вашего компилятора и выпишите, какая точность используется для разных целочисленных типов.
2. Запишите 200 + 55 = 255 и 100-150 = -50 в дополнительном коде.
3. Пусть а принимает все значения в диапазонах 50 .. 56 и -56 .. -50, и пусть b равно 7 или -7. Каковы возможные частные q и остатки г при делении а на b? Используйте оба определения остатка (обозначенные rem и mod в Ada) и отобразите результаты в графической форме. Подсказка: если используется rem, r будет иметь знак а; если используется mod, r будет иметь тот же знак, что и b.

4. Что происходит, когда вы выполняете следующую С-программу на ком­пьютере, который сохраняет значения short int в 8 битах, а значения int в 16 битах?

short int i: [с]

int j = 280;

for (i = 0; i <j; i++) printf("Hello world");
5. Как бы вы реализовали атрибут T'Succ (V) языка Ada, если используется нестандартное представление перечисляемого типа?
6. Что будет печатать следующая программа? Почему?

C
int i=2;

int j = 5;

if (i&j)printf("Hello world");

if (i.&&j) printf("Goodbye world");
7. Каково значение i после выполнения следующих операторов?

C
int | = 0;'

int a[2] = { 10,11};

i=a[i++];
8. Языки С и C++ не имеют операции возведения в степень; почему?
9. Покажите, как могут использоваться модульные типы в Ada 95 и типы целого без знака в С для представления множеств. Насколько переноси­мым является ваше решение? Сравните с типом множества (set) в языке Pascal.

^ Глава 5
Составные типы данных


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

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

Поскольку массивы являются разновидностью записей, мы начнем обсуж­дение с записей (в языке С они называются структурами).
5.1. Записи
Значение типа запись (record) состоит из набора значений других типов, назы­ваемых компонентами (componentsAda), членами (membersС) или полями (fields —Pascal). При объявлении типа каждое поле получает имя и тип. Следу­ющее объявление в языке С описывает структуру с четырьмя компонентами: одним — типа строка, другим — заданным пользователем перечислением и двумя компонентами целого типа:
typedef enum {Black, Blue, Green, Red, White} Colors;

C
typedef struct {
char model[20];

Colors color;

int speed;

int fuel;

} Car_Data;
Аналогичное объявление в языке Ada таково:
type Colors is (Black, Blue, Green, Red, White);

Ada
type Car_Data is

record

Model: String(1..20);

Color: Colors:

Speed: Integer;

Fuel: Integer;

end record;

После того как определен тип записи, могут быть объявлены объекты (пере­менные и константы) этого типа. Между записями одного и того же типа до­пустимо присваивание:

C
Car_Data c1,c2;

с1 =с2;
а в Ada (но не в С) также можно проверить равенство значений этого типа:
С1, С2, СЗ: Car_Data;


Ada
if C1=C2then

С1 =СЗ;

end if;
Поскольку тип — это набор значений, можно было бы подумать, что всегда можно обозначить* значение записи. Удивительно, но этого вообще нельзя сделать; например, язык С допускает значения записи только при инициали­зации. В Ada, однако, можно сконструировать значение типа запись, называе­мое агрегатом (aggregate), просто задавая значение правильного типа для каж­дого поля. Связь значения с полем может осуществляться по позиции внутри записи или по имени поля:

Ada
if С1 = (-Peugeot-, Blue, 98, 23) then ...

С1 := (-Peugeot-, Red, C2.Speed, CS.Fuel);

C2 := (Model=>-Peugeot", Speed=>76,

Fuel=>46, Color=>White);
Это чрезвычайно важно, потому что компилятор выдаст сообщение об ошиб­ке, если вы забудете включить значение для поля; а при использовании от­дельных присваиваний легко просто забыть одно из полей:


Ada



Ada С1.Model :=-Peugeot-;

--Забыли С1.Color

С1.Speed := C2.Speed;

С1.Fuel := CS.Fuel;


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

C
с1. speed =c1.fuel*x;


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

Имена полей записи локализованы внутри определения типа и могут повторно использоваться в других определениях:
typedef struct {

float speed; /* Повторно используемое имя поля */


C
} Performance;

Performance p;

Car_Data с;

p.speed = (float) с.speed; /* To же самое имя, другое поле*/
Отдельные записи сами по себе не очень полезны; их значение становится очевидным, только когда они являются частью более сложных структур, таких как массивы записей или динамические структуры, создаваемые с помощью указателей (см. раздел 8.2).
Реализация
Значение записи представляется некоторым числом слов в памяти, достаточ­ным для того, чтобы вместить все поля. На рисунке 5.1 показано размещение записи Car_Data. Поля обычно располагаются в порядке их появления в опре­делении типа записи.





Доступ к отдельному полю очень эффективен, потому что величина смещения каждого поля от начала записи постоянна и известна во время компиляции. Большинство компьютеров имеет способы адресации, кото­рые позволяют добавлять константу к адресному регистру при декодирова­нии команды. После того как начальный адрес записи загружен в регистр, для доступа к полям лишние команды уже не нужны:
load R1.&C1 Адрес записи

load R2,20(R1) Загрузить второе поле

load R3,24(R1) Загрузить третье поле
Так как для поля иногда нужен объем памяти, не кратный размеру слова, компилятор может

«раздуть» запись так, чтобы каждое поле заведомо находи­лось на границе слова, поскольку доступ к не выровненному на границу сло­ву гораздо менее эффективен. На 16-разрядном компьютере такое определе­ние типа, как:
typedef struct {


C
char f 1; /* 1 байт, пропустить 1 байт */

int f2; /* 2 байта*/

char f3; /* 1 байт, пропустить 1 байт */

int f4; • /* 2 байта*/

};
может привести к выделению четырех слов для каждой записи таким образом, чтобы поля типа int были выровнены на границу слова, в то время как следу­ющие определения:
typedef struct { [с]


C
int f2; /* 2 байта*/

int f4; /* 2 байта*/

charfl ; /Мбайт*/

char f3; /* 1 байт */
потребовали бы только трех слов. При использовании компилятора, который плотно упаковывает поля, можно улучшить эффективность, добавляя фик­тивные поля для выхода на границы слова. В разделе 5.8 описаны способы явного распределения полей. В любом случае, никогда не привязывайте программу к конкретному формату записи, поскольку это сделает ее непере­носимой.

5.2. Массивы
Массив — это запись, все поля которой имеют один и тот же тип. Кроме того, поля (называемые элементами или компонентами) задаются не именами, а по­зицией внутри массива. Преимуществом этого типа данных является возмож­ность эффективного доступа к элементу по индексу. Поскольку все элементы имеют один и тот же тип, можно вычислить положение отдельного элемента, умножая индекс на размер элемента. Используя индексы, легко найти отдель­ный элемент массива, отсортировать или как-то иначе реорганизовать эле­менты.
Индекс в языке Ada может иметь произвольный дискретный тип, т.е. лю­бой тип, на котором допустим «счет». Таковыми являются целочисленные ти­пы и типы перечисления (включая Character и Boolean):

Ada
type Heat is (Off, Low, Medium, High);

type Temperatures is array(Heat) of Float;

Temp: Temperatures;
Язык С ограничивает индексный тип целыми числами; вы указываете, сколь­ко компонентов вам необходимо:

C
#define Max 4

float temp[Max];
а индексы неявно изменяются от 0 до числа компонентов без единицы, в дан­ном случае от 0 до 3. Язык C++ разрешает использовать любое константное выражение для задания числа элементов массива, что улучшает читаемость программы:


C++



const int last = 3;

float temp [last+ 1];
Компоненты массива могут быть любого типа:


C



typedef struct {... } Car_Data;

Car_Data database [100];
В языке Ada (но не в С) на массивах можно выполнять операции присваива­ния и проверки на равенство:
type A_Type is array(0..9) of Integer;


Ada
А, В, С: AJype;
if A = В then A := C; end if;
Как и в случае с записями, в языке Ada для задания значений массивов, т. е. для агрегатов, предоставляется широкий спектр синтаксических возмож­ностей :

Ada
А := (1,2,3,4,5,6,7,8,9,10);

А := (0..4 => 1 , 5..9 => 2); -- Половина единиц, половина двоек

А := (others => 0); -- Все нули
В языке С использование агрегатов массивов ограничено заданием начальных значений.

Наиболее важная операция над массивом — индексация, с помощью кото­рой выбирается элемент массива. Индекс, который может быть произволь­ным выражением индексного типа, пишется после имени массива:

type Char_Array is array(Character range 'a'.. 'z') of Boolean;


Ada
A: Char_Array := (others => False);

C: Character:= 'z';
A(C):=A('a')andA('b');
Другой способ интерпретации массивов состоит в том, чтобы рассматривать их как функцию, преобразующую индексный тип в тип элемента. Язык Ada (подобно языку Fortran, но в отличие от языков Pascal и С) поощряет такую точку зрения, используя одинаковый синтаксис для обращений к функции и для индексации массива. То есть, не посмотрев на объявление, нельзя сказать, является А(1) обращением к функции или операцией индексации массива. Преимущество общего синтаксиса в том, что структура данных может быть первоначально реализована как массив, а позже, если понадобится более сложная структура данных, массив может быть заменен функцией без измене­ния формы обращения. Квадратные скобки вместо круглых в языках Pascal и С применяются в основном для облегчения работы компилятора.

Записи и массивы могут вкладываться друг в друга в произвольном поряд­ке, что позволяет создавать сложные структуры данных. Для доступа к отдель­ному компоненту такой структуры выбор поля и индексация элемента должны выполняться по очереди до тех пор, пока не будет достигнут компо­нент:
typedef int A[1 0]; /* Тип массив */


C
typedef struct { /* Тип запись */

А а; /* Массив внутри записи */

char b;

} Rec;

Rec r[10]; /* Массив записей с массивами типа int внутри */

int i,j,k;

k = r[i+l].a[j-1]; /* Индексация, затем выбор поля,затем индексация */

/* Конечный результат — целочисленное значение */
Обратите внимание, что частичный выбор и индексация в сложной струк­туре данных дают значение, которое само является массивом или записью:

C
г Массив записей, содержащих массивы целых чисел

r[i] Запись, содержащая массив целых чисел

r[i].a Массив целых чисел

r[i].a[j] Целое
и эти значения могут использоваться в операторах присваивания и т.п.


^ 5.3. Массивы и контроль соответствия типов
Возможно, наиболее общая причина труднообнаруживаемых ошибок — это индексация, которая выходит за границы массива:
inta[10],


C
for(i = 0;

i<= 10; i

a[i] = 2*i;

Цикл будет выполнен и для i = 10, но последним элементом массива является а[9].

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

Рассмотрим случай, когда числовая ошибка заставляет переменную speed получить значение 20 вместо 30:

C
intx=10,y=50;

speed = (х+у)/3; /*Вычислить среднее! */
Проявлением ошибки является неправильное значение speed, и причина (де­ление на 3 вместо 2) находится здесь же, в команде, которая вычисляет speed. Это проявление непосредственно связано с ошибкой и, используя контроль­ные точки или точки наблюдения, можно быстро локализовать ошибку. В следующем примере:
inta[10];


C
int speed;

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

a[i] = 2*j;
переменная speed является жертвой того факта, что она была чисто случайно объявлена как раз после а и, таким образом, была изменена совершенно по­сторонней командой. Вы можете днями прослеживать вычисление speed и не найти ошибку.

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


pascal



type A_Type = array[0..9] of Integer;

A: A_Type;

A[10]:=20; (*Ошибка*)
При контроле соответствия типов ошибка обнаруживается сразу же, на своем месте, а не после того, как она «затерла» некоторую «постороннюю» память; целый класс серьезных ошибок исчезает из программ. Точнее, такие ошибки становятся ошибками этапа компиляции, а не ошибками этапа выполнения программы.

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

pascal
typeA_Type = array[0..9]of Real; (* Типы массивов *)

type B_Type= array[0..8] of Real;

А: А_Туре: (* Переменные-массивы *)

В: В_Туре;

procedure Sort(var P: А_Туре); (* Параметр-массив *)

sort(A); (* Правильно*) sort(B); (* Ошибка! *)
Два объявления типов определяют два различных типа. Тип фактического па­раметра процедуры должен соответствовать типу формального параметра, по­этому кажется, что необходимы две разные процедуры Sort, каждая для свое­го типа. Это не соответствует нашему интуитивному понятию массива и опе­раций над массивом, потому что при тщательном программировании проце­дур, аналогичных Sort, их делают не зависящими от числа элементов в масси­ве; границы массива должны быть просто дополнительными параметрами. Обратите внимание, что эта проблема не возникает в языках Fortran или С по­тому, что в них нет параметров-массивов! Они просто передают адрес начала массива, а программист отвечает за правильное определение и использование границ массива.

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


Ada



type A_Type is array(lnteger range о) of Float;
-- Объявление типа массива без ограничений

А: А_Туре(0..9); — Массив с ограничением индекса

В: А_Туре(0..8); — Массив с ограничением индекса
Сигнатура А_Туре — одномерный массив с индексами типа integer и компо­нентами типа Float; границы индексов не являются частью сигнатуры.

Как и в языке Pascal, операции индексации полностью контролируются:

Ada
А(9) := 20.5; -- Правильно, индекс изменяется в пределах 0..9

В(9) := 20.5; -- Ошибка, индекс изменяется в пределах 0..8
Важность неограниченных массивов становится очевидной, когда мы рассматриваем параметры процедуры. Так как тип (неограниченного) мас­сива-параметра определяется только сигнатурой, мы можем вызывать проце­дуру с любым фактическим параметром этого типа независимо от индексного ограничения:

Ada
procedure Sort(P: in out A_Type);

— Тип параметра: неограниченный массив

Sort(A); -- Типом А является А_Туре

Sort(B); -- Типом В также является А_Туре
Теперь возникает вопрос: как процедура Sort может получить доступ к гра­ницам массива? В языке Pascal границы были частью типа и таким образом были известны внутри процедуры. В языке Ada ограничения фактического параметра-массива автоматически передаются процедуре во время выполне­ния и могут быть получены через функции, называемые атрибутами. Если А произвольный массив, то:
• A'First — индекс первого элемента А.

• A'Last — индекс последнего элемента А.

• A'Length — число элементов в А.

• A'Range — эквивалент A'First.. A'Last.
Например:


Ada
procedure Sort(P: in out A_Type) is begin

for I in P'Range loop

for J in 1+1 .. P'Lastloop

end Sort;

Использование атрибутов массива позволяет программисту писать чрезвы­чайно устойчивое к изменениям программное обеспечение: любое изменение границ массива автоматически отражается в атрибутах.

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


    1. ^ Подтипы массивов в языке Ada


Подтипы, которые мы обсуждали в разделе 4.5, определялись добавлением ог­раничения диапазона к дискретному типу (перечисляемому или целочисленно­му). Точно так же подтип массива может быть объявлен добавлением к типу неограниченного массива ограничения индекс'.

type A_Type is array(lnteger range о) of Float;

subtype Line is A_Type(1 ..80);

L, L1, L2: Line;
Значение этого именованного подтипа можно использовать как фактиче­ский параметр, соответствующий формальному параметру исходного неогра­ниченного типа:
Sort(L);
В любом случае неограниченный формальный параметр процедуры Sort ди­намически ограничивается фактическим параметром при каждом вызове процедуры.

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

В Ada есть мощные конструкции, называемые сечениями (slices) и сдвигами

(sliding), которые позволяют выполнять присваивания над частями массивов. Оператор
L1(10..15):=L2(20..25);
присваивает сечение одного массива другому, сдвигая индексы, пока они не придут в соответствие. Сигнатуры типов проверяются во время компиляции, тогда как ограничения проверяются во время выполнения и могут быть дина­мическими:
L1(I..J):=L2(l*K..M+2);
Проблемы, связанные с определениями типа для массивов в языке Pascal, за­ставили разработчиков языка Ada обобщить решение для массивов изящной концепцией подтипов: отделить статическую спецификацию типа от ограни­чения, которое может быть динамическим.


^ 5.5. Строковый тип
В основном строки — это просто массивы символов, но для удобства програм­мирования необходима дополнительная языковая поддержка. Первое требо­вание: для строк нужен специальный синтаксис, в противном случае работать с массивами символов было бы слишком утомительно. Допустимы оба следу­ющих объявления, но, конечно, первая форма намного удобнее:
char s[]= "Hello world";

chars[] = {‘H’,’e’,’l’,’o’,’ ‘,’w’,’o’,’r’,’l’,’d’,’/0’};
Затем нужно найти некоторый способ работы с длиной строки. Вышеупо­мянутый пример уже показывает, что компилятор может определить размер I строки без явного его задания программистом. Язык С использует соглаше-I ние о представлении строк, согласно которому первый обнаруженный нуле­вой байт завершает строку. Обработка строк в С обычно содержит цикл while вида:

C
while (s[i++]!='\0')... •

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

C
char s[11]= "Hello world"; /* He предусмотрено место

для нулевого байта*/

chart[11];

strcpy(t, s); /* Копировать set. Какой длины s? */
Другие недостатки этого метода:
• Строковые операции требуют динамического выделения и освобожде­ния памяти, которые относительно неэффективны.
• Обращения к библиотечным строковым функциям приводят к повтор­ным вычислениям длин строк.
• Нулевой байт не может быть частью строки.
Альтернативное решение, используемое некоторыми диалектами языка Pascal, состоит в том, чтобы включить явный байт длины как неявный нуле­вой символ строки, чья максимальная длина определяется при объявлении:
S:String[10];


Pascal
S := 'Hello world'; (* Требуется 11 байтов *)

writeln(S);

S:='Hello';

writeln(S);
Сначала программа выведет «Hello worl», так как строка будет усечена до объявленной длины. Затем выведет «Hello», поскольку writeln принимает во внимание неявную длину. К сожалению, это решение также небезупречно, потому что возможно непосредственное обращение к скрытому байту длины и затирание памяти:


Pascal



s[0]:=15;

В Ada есть встроенный тип неограниченного массива, называемый String, со следующим определением:


Ada



type String is array(Positive range <>) of Character;
Каждая строка должна быть фиксированной длины и объявлена с индексным ограничением:


Ada

S:String(1..80);
В отличие от языка С, где вся обработка строк выполняется с использованием библиотечных процедур, подобных strcpy, в языке Ada над строками допускаются такие операции, как конкатенация «&», равенство и операции отноше­ния, подобные «<». Поскольку строго предписан контроль соответствия типов, нужно немного потренироваться с атрибутами, чтобы заставить все заработать:

Ada
S1: constant String := "Hello";

S2: constant String := "world";

T: String(1 .. S1 'Length + 1 + S2'Length) := S1 & ' ' & S2;

Put(T); -- Напечатает Hello world
Точная длина Т должна быть вычислена до того, как выполнится присваива­ние! К счастью, Ada поддерживает атрибуты массива и конструкцию для со­здания подмассивов (называемых сечениями — slices), которые позволяют выполнять такие вычисления переносимым способом.

Ada 83 предоставляет базисные средства для определения строк нефикси­рованной длины, но не предлагает необходимых библиотечных подпрограмм для обработки строк. Чтобы улучшить переносимость, в Ada 95 определены стандартные библиотеки для всех трех категорий строк: фиксированных, из­меняемых (как в языке Pascal) и динамических (как в С).


^ 5.6. Многомерные массивы
Многомерные матрицы широко используются в математических моделях фи­зического мира, и многомерные массивы появились в языках программиро­вания начиная с языка Fortran. Фактически есть два способа определения многомерных массивов: прямой и в качестве сложной структуры. Мы ограни­чимся обсуждением двумерных массивов; обобщение для большей размерно­сти делается аналогично.

Прямое определение двумерного массива в языке Ada можно дать, указав два индексных типа, разделяемых запятой:
type Two is


Ada
array(Character range <>, Integer range <>) of Integer;

T:Two('A'..'Z', 1 ..10); I: Integer;

C: Character;

T('XM*3):=T(C,6);
Как показывает пример, две размерности не обязательно должны быть одно­го и того же типа. Элемент массива выбирают, задавая оба индекса.

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


Ada
type l_Array is array( 1.. 10) of Integer;

type Array_of_Array is array (Character range <>) of l_Array;

T:Array_of_Array('A1..>ZI);
I: Integer;

С: Character;
T('X')(I*3):=T(C)(6);
Преимущество этого метода в том, что можно получить доступ к элементам второй размерности (которые сами являются массивами), используя одну операцию индексации:


Ada



Т('Х') :=T('Y'); -- Присвоить массив из 10 элементов
Недостаток же в том, что для элементов второй размерности должны быть за­даны

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

В языке С доступен только второй метод и, конечно, только для целочис­ленных индексов:

C
inta[10][20];

а[1] = а[2]; /* Присвоить массив из 20 элементов */
Язык Pascal не делает различий между двумерным массивом и массивом мас­сивов; так как границы считаются частью типа массива, это не вызывает ни­каких проблем.


^ 5.7. Реализация массивов
При реализации элементы массива размещаются в памяти последовательно. Если задан массив А, то адрес его элемента A(l) есть (см. рис. 5.2.):
addr (А) + size (element) * (/ - A.'First)
Например: адрес А(4) равен 20 + 4 * (4 - 1) = 32.

Сгенерированный машинный код будет выглядеть так:

L

oad R1,l Получить индекс

sub R1,A'First Вычесть нижнюю границу

multi R1 ,size Умножить на размер — > смещение

add R1 ,&А Добавить адрес массива — > адрес элемента

load R2,(R1) Загрузить содержимое
Вы, возможно, удивитесь, узнав, что для каждого доступа к массиву нужно столько команд! Существует много вариантов оптимизации, которые могут улучшить этот код. Сначала отметим, что если A'First — ноль, то нам не нужно вычитать индекс первого элемента; это объясняет, почему разработ­чики языка С сделали так, что индексы всегда начинаются с нуля. Даже если A'First — не ноль, но известен на этапе компиляции, можно преобразовать вычисление адреса следующим образом:
(addr (А) - size (element) * A'First) + (size (element) * i)
Первое выражение в круглых скобках можно вычислить при компиляции, экономя на вычитании во время выполнения. Это выражение будет известно во время компиляции при обычных обращениях к массиву:

Ada
А:А_Туре(1..10);

A(I):=A(J);
но не в том случае, когда массив является параметром:
procedure Sort(A: A_Type) is


Ada
begin



A(A'First+1):=A(J);



end Sort;
Основное препятствие для эффективных операций с массивом — умножение на размер элемента массива. К счастью, большинство массивов имеют про­стые типы данных, такие как символы или целые числа, и размеры их элемен­тов представляют собой степень двойки. В этом случае дорогостоящая опера­ция умножения может быть заменена эффективным сдвигом, так как сдвиг влево на n эквивалентен умножению на 2". В случае массива записей можно повысить эффективность (за счет дополнительной памяти), дополняя записи так, чтобы их размер был кратен степени двойки. Обратите внимание, что на переносимость программы это не влияет, но само улучшение эффективности не является переносимым: другой компилятор может скомпоновать запись по-другому.

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


C


int field;

} Rec;

Rec a[100];
могут оказаться более эффективными (в зависимости от качества оптимиза­ций в компиляторе) при обращении к элементам массива по указателю:
Rec* ptr;

C

for (ptr = &а; ptr < &a+100*sizeof(Rec); ptr += sizeof(Rec))

...ptr-> field...;
чем при помощи индексирования:

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

…a[i].field…
Однако такой стиль программирования чреват множеством ошибок; кроме того, такие программы тяжело читать, поэтому его следует применять только в исключительных случаях.

В языке С возможен и такой способ копирования строк:


C



while (*s1++ = *s2++)
в котором перед точкой с запятой стоит пустой оператор. Если компьютер поддерживает команды блочного копирования, которые перемещают со­держимое блока ячеек памяти по другому адресу, то эффективнее будет язык типа Ada, который допускает присваивание массива. Вообще, тем, кто программирует на С, следует использовать библиотечные функции, кото­рые, скорее всего, реализованы более эффективно, чем примитивный спо­соб, показанный выше.

Многомерные массивы могут быть очень неэффективными, потому что каждая лишняя размерность требует дополнительного умножения при вычис­лении индекса. При работе с многомерными массивами нужно также пони­мать, как размещены данные. За исключением языка Fortran, все языки хра­нят двумерные массивы как последовательности строк. Размещение


Ada



type T is array( 1 ..3, 1 ..5) of Integer;
показано на рис. 5.3. Такое размещение вполне естественно, поскольку сохраняет идентичность двумерного массива и массива массивов. Если в вычислении перебираются все элементы двумерного массива, проследите, чтобы последний индекс продвигался во внутреннем цикле:



intmatrix[100][200];


C



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

for (j = 0; j < 200; j++)

m[i][j]=…;
Причина в том, что операционные системы, использующие разбиение на страницы, работают намного эффективнее, когда адреса, по которым проис­ходят обращения, находятся близко друг к другу.

Если вы хотите выжать из С-программы максимальную производитель­ность, можно игнорировать двумерную структуру массива и имитировать од­номерный массив:

C
for (i=0; i< 1 00*200; i++)

m[]0[i]=…;
Само собой разумеется, что применять такие приемы не рекомендуется, а в случае использования их следует тщательно задокументировать.

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


Ada
for I in A' Range loop

if A(I) = Key then ...
индекс I примет только допустимые для массива значения, так что никакая проверка не нужна. Вообще, оптимизатор лучше всего будет работать, если все переменные объявлены с максимально жесткими ограничениями.

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

Ada
type A_Type is array(lnteger range о) of Integer;

procedure Sort(A: A_Type) is ...
границы также неявно должны передаваться в структуре данных, называемой дескриптором массива (dope vector) (рис. 5.4). Дескриптор массива содержит



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


^ 5.8. Спецификация представления

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


^ Вычисления над битами
В языке С есть булевы операции, которые выполняются побитно над значениями целочисленных типов: «&» (and), «|» (or), «л» (xor), «~» (not).

Булевы операции в Ada — and, or, xor, not — также могут применяться к бу­левым массивам:
type Bool_Array is array(0..31) of Boolean;


Ada
B1: Bool_Array:=(0..15=>True, 16..31 => False);

B2: Bool_Array := (0..15 => False, 16..31 => True);

B1 :=B1 orB2;
Однако само объявление булевых массивов не гарантирует, что они представ­ляются как битовые строки; фактически, булево значение обычно представ­ляется как целое число. Добавление управляющей команды


Ada



pragma Pack(Bool_Array);
требует, чтобы компилятор упаковывал значения массива как можно плот­нее. Поскольку для булева значения необходим только один бит, 32 элемента массива могут храниться в 32-разрядном слове. Хотя таким способом и обес­печиваются требуемые функциональные возможности, однако гибкости, свойственной языку С, достичь не удастся, в частности, из-за невозможно­сти использовать в булевых вычислениях такие восьмеричные или шестнад-цатеричные константы, как OxfOOf OffO. Язык Ada обеспечивает запись для таких констант, но они являются целочисленными значениями, а не булевы­ми массивами, и поэтому не могут использоваться в поразрядных вычисле­ниях.

Эти проблемы решены в языке Ada 95: в нем для поразрядных вычислений могут использоваться модульные типы (см. раздел 4.1):

Ada
type Unsigned_Byte is mod 256;

UI,U2: Unsigned_Byte;
U1 :=U1 andU2;
^ Поля внутри слов
Аппаратные регистры обычно состоят из нескольких полей. Традиционно до­ступ к таким полям осуществляется с помощью сдвига и маскирования; опе­ратор
field = (i » 4) & 0x7;
извлекает трехбитовое поле, находящееся в четырех битах от правого края слова i. Такой стиль программирования опасен, потому что очень просто сде­лать ошибку в числе сдвигов и в маске. Кроме того, при малейшем измене­нии размещения полей может потребоваться значительное изменение про­граммы.

- Изящное решение этой проблемы впервые было сделано в языке Pascal: использовать обычные записи, но упаковывать несколько полей в одно сло­во. Обычный доступ к полю Rec.Field автоматически переводится компиля­тором в правильные сдвиг и маску.

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

C
typedef struct {

int : 3; /* Заполнитель */

int f1 :1;

int f2 :2;




C
int : 3; /* Заполнитель */

int f3 :2;

int : 4; /* Заполнитель */

int f4 :1;

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


reg r;


C
[с] int i;

i = r.f2;

r.f3 = i;
Язык Ada неуклонно следует принципу: объявления типа должны быть абстрактными. В связи с этим спецификации представления (representation speci­fications) используют свою нотацию и пишутся отдельно от объявления типа. К следующим ниже объявлениям типа:

type Heat is (Off, Low, Medium, High);
type Reg is


Ada
record

F1: Boolean;

F2: Heat;

F3: Heat;

F4: Boolean;

end record;
может быть добавлена такая спецификация:

Ada
for Reg use

record

F1 at 0 range 3..3;

F2 at Orange 4..5;

F3at 1 range 1..2;

F4at 1 range 7..7;

end record;
Конструкция at определяет байт внутри записи, a range определяет отво­димый полю диапазон разрядов, причем мы знаем, что достаточно одного бита для значения Boolean и двух битов для значения Heat. Обратите внима­ние, что заполнители не нужны, потому что определены точные позиции полей.

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


^ Порядок байтов в числах
Как правило, адреса памяти растут начиная с нуля. К сожалению, архитекту­ры компьютеров отличаются способом хранения в памяти многобайтовых значений. Предположим, что можно независимо адресовать каждый байт и что каждое слово состоит из четырех байтов. В каком виде будет храниться це­лое число 0x04030201: начиная со старшего конца (big endian), т. е. так, что старший байт имеет меньший адрес, или начиная с младшего конца (little endi­an), т. е. так, что младший байт имеет меньший адрес? На рис. 5.6 показано размещение байтов для двух вариантов.


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

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


^ Производные типы и спецификации представления в языке Ada
Производный тип в языке Ada (раздел 4.6) определен как новый тип, чьи зна­чения и

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

Ada
type Unpacked_Register is

record



end record;
можно получить новый тип и задать спецификацию представления, связан­ную с производным типом:

Ada
type Packed_Register is new Unpacked_Register;

for Packed_Register use

record



end record;
Преобразование типов (которое допустимо между любыми типами, получен­ными друг из друга) вызывает изменение представления, а именно упаковку и распаковку полей слов в обычные переменные:
U: Unpacked_Register;

Р: Packed_Register;

Ada

U := Unpacked_Register(P);

Р := Packed_Register(U);
Это средство может сделать программы более надежными, потому что, коль скоро написаны правильные спецификации представления, остальная часть программы становится полностью абстрактной.

5.9. Упражнения
1. Упаковывает ваш компилятор поля записи или выравнивает их на грани­цы слова?
2. Поддерживает ли ваш компьютер команду блочного копирования, и ис­пользует ли ее ваш компилятор для операций присваивания над массивами и записями?
3. Pascal содержит конструкцию with, которая открывает область види­мости имен так, что имена полей записи можно использовать непосред­ственно:
type Rec =

record


Paskal
Field 1: Integer;

Field2: Integer;

end;

R: Rec;
with R do Field 1 := Field2; (* Правильно, непосредственная видимость *)
Каковы преимущества и недостатки этой конструкции? Изучите в Ada конструкцию renames и покажите, как можно получить некоторые аналогичные функциональные возможности. Сравните две конструк­ции.
4. Объясните сообщение об ошибке, которое вы получаете в языке С при попытке присвоить один массив другому:


C



inta1[10],a2[10]:

а1 =а2;
5. Напишите процедуры sort на языках Ada и С и сравните их. Убедитесь, что вы используете атрибуты в процедуре Ada так, что процедура будет обрабатывать массивы с произвольными индексами.
6. Как оптимизирует ваш компилятор операции индексации массива?
7. В языке Icon имеются ассоциативные массивы, называемые таблицами, в которых строка может использоваться как индекс массива:
count["begin"] = 8;
Реализуйте ассоциативные массивы на языках Ada или С.
8. Являются следующие два типа одним и тем же?


Ada



type Array_Type_1 is array(1 ..100) of Float;

type Array_Type_2 is array(1 ..100) of Float;
Языки Ada и C++ используют эквивалентность имен: каждое объявление типа объявляет новый тип, так что будут объявлены два типа. При струк­турной эквивалентности (используемой в языке Algol 68) объявления типа, которые выглядят одинаково, определяют один и тот же тип. Каковы преимущества и недостатки этих двух подходов?

9. В Ada может быть определен массив анонимного типа. Допустимо ли присваивание в следующем примере? Почему?

Ada
А1, А2: аггау( 1.. 10) of Integer;

А1 :=А2;
^ Глава 6
Управляющие структуры


Управляющие операторы предназначены для изменения порядка выполне­ния команд программы. Есть два класса хорошо структурированных управля­ющих операторов: операторы выбора (if и case), которые выбирают одну из двух или нескольких возможных альтернативных последовательностей вы­полнения, и операторы цикла (for и while), которые многократно выполняют последовательность операторов.


^ 6.1. Операторы switch и case
Оператор выбора используется для выбора одного из нескольких возможных путей, по которому должно выполняться вычисление (рис. 6.1). Обобщен­ный оператор выбора называется switch-оператором в языке С и case-onepaтором в других языках.



Switch-оператор состоит из выражения (expression) и оператора (statement) для каждого возможного значения (value) выражения:
switch (expression) {


C
case value_1:

statement_1;

break;

case value_2:

statement_2;

break;

….

}
Выражение вычисляется, и его результат используется для выбора оператора, который будет выполнен; на рис. 6. 1 выбранный оператор представляет путь. Отсюда следует, что для каждого возможного значения выражения должна су­ществовать в точности одна case-альтернатива. Для целочисленного выраже­ния это невозможно, так как нереально написать свой оператор для каждого 32-разрядного целочисленного значения. В языке Pascal case-оператор ис­пользуется только для типов, которые имеют небольшое число значений, тог­да как языки С и Ada допускают альтернативу по умолчанию (default), что по­зволяет использовать case-оператор даже для таких типов, как Character, ко­торые имеют сотни значений:


C
default:

default_statement;

break;

,

Если вычисленного значения выражения не оказывается в списке, то выпол­няется оператор, заданный по умолчанию (default_statement). В языке С, ес­ли альтернатива default отсутствует, по умолчанию подразумевается пустой оператор. Эту возможность использовать не следует, потому что читатель про­граммы не может узнать, подразумевался ли пустой default-оператор, или программист просто забыл задать необходимые операторы.

Во многих случаях операторы для двух или нескольких альтернатив иден­тичны. В языке С нет специальных средств для этого случая (см. ниже); а в Ada есть обширный набор синтаксических конструкций Для группировки альтер­натив:
С: Character;

case С is


Ada
when 'A'.. 'Z' => statement_1;

when '0'.. '9' => statement_2;

when '+' | '-' |' *' | '/' =>statement_3;

when others => statement_4;

end case;
В Ada альтернативы представляются зарезервированным ключевым словом when, а альтернатива по умолчанию называется others. Case-альтернативаможет содержать диапазон значений value_1 .. value_2 или набор значений, разделенных знаком «|».


^ Оператор break в языке С
В языке С нужно явно завершать каждую case-альтернативу оператором break, иначе после него вычисление «провалится» на следующую case-аль­тернативу. Можно воспользоваться такими «провалами» и построить конст­рукцию, напоминающую многоальтернативную конструкцию языка Ada:

char с;

switch (с) {

case 'A': case'B': ... case'Z':

statement_1 ;


C
break;

case'O': ... case '9':

statement_2;

break;

case '+'; case '-': case '*': case '/':

statement_3 :

break;

default:

statement_4;

break;
Поскольку каждое значение должно быть явно написано, switch-оператор в языке С далеко не так удобен, как case-оператор в Ada.


В обычном программировании «провалы» использовать не стоит:
switch (е) {

casevalue_1:


C
statement_1 ; /* После оператора statemerrM */

case value_2:

statement_2; /* автоматический провал на statement_2. */

break;

}
Согласно рис. 6.1 switch -оператор должен использоваться для выбора одного из нескольких возможных путей. «Провал» вносит путаницу, потому что при достижении конца пути управление как бы возвращается обратно к началу де­рева выбора. Кроме того, с точки зрения семантики не должна иметь никако­го значения последовательность, в которой записаны варианты выбора (хотя в смысле эффективности порядок может быть важен). При сопровождении программы нужно иметь возможность свободно изменять существующие ва­рианты выбора или вставлять новые варианты, не опасаясь внести ошибку. Такую программу, к тому же, трудно тестировать и отлаживать: если ошибка прослежена до оператора statement_2, трудно узнать, был оператор достигнут непосредственным выбором или в результате провала. Чем пользоваться «провалом», лучше общую часть (common_code) оформить как процедуру:
switch (e) {

case value_1 :


C
statement_1 ;

common_code();

break;

case value_2:

common_code();

break;

}


Реализация
Самым простым способом является компиляция case-оператора как после­довательности проверок:
compute R1 ,ехрг Вычислить выражение

jump_eq R1,#value_1,L1

jump_eq R1,#value_2 ,L2

… Другие значения

default_statement Команды, выполняемые по

умолчанию

jump End_Case


L1: statement_1 Команды для statement_1

jump End_Case
L2: statement_2 Команды для statement_2

jump End_Case

… Команды для других операторов

End_Case:

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

Некоторые case-операторы можно оптимизировать, используя таблицы переходов. Если набор значений выражения образует короткую непрерывную последовательность, то можно использовать следующий код (подразумевает­ся, что выражение может принимать значения от 0 до 3):
compute R1,expr

mult R1,#len_of_addr expr* длина_адреса

add R1 ,&table + адрес_начала_таблицы

jump (R1) Перейти по адресу в регистре R1

table: Таблица переходов

addr(L1)

addr(L2)

addr(L3)

addr(L4)
L1: statement_1

jump End_Case

L2: statement_2

jump End_Case

L3: statement_3

jump End_Case

L4: statement_4

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

Значение выражения обязательно должно лежать внутри ожидаемого диа­пазона (здесь от 0 до 3), иначе будет вычислен недопустимый адрес, и про­изойдет переход в такое место памяти, где может даже не быть выполнимой команды! В языке Ada выражение часто может быть проверено во время ком­пиляции:

Ada
type Status is (Off, WarmJJp, On, Automatic);

S: Status;

case S is ... -- Имеется в точности четыре значения
В других случаях будет необходима динамическая проверка, чтобы гарантиро­вать, что значение лежит внутри диапазона. Таблицы переходов совместимы даже с альтернативой по умолчанию при условии, что явно заданные вариан­ты выбора расположены непрерывно друг за другом. Компилятор просто вставляет динамическую проверку допустимости использования таблицы пе­реходов; при отрицательном результате проверки вычисляется альтернатива по умолчанию.

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


^ 6.2. Условные операторы
Условный оператор — это частный случай case- или switch-оператора, в кото­ром выражение имеет булев тип. Так как булевы типы имеют только два допу­стимых значения, условный оператор делает выбор между двумя возможными путями. Условные операторы — это, вероятно, наиболее часто используемые управляющие структуры, поскольку часто применяемые операции отноше­ния возвращают значения булева типа:

C
if (x > у)

statement_1;

else

statement_2;
Как мы обсуждали в разделе 4.4, в языке С нет булева типа. Вместо этого при­меняются целочисленные значения с условием, что ноль это «ложь» (False), a не ноль — «истина» (Тruе).

Распространенная ошибка состоит в использовании условного оператора для создания булева значения:

Ada
if X > Y then

Result = True;

else

Result = False;

end if;
вместо простого оператора присваивания:


Ada



Result := X > Y;
Запомните, что значения и переменные булева типа являются «полноправ­ными» объектами: в языке С они просто целые, а в Ada они имеют свой тип, но никак не отличаются от любого другого типа перечисления. Тот факт, что булевы типы имеют специальный статус в условных операторах, не наклады­вает на них никаких ограничений.

^ Вложенные if-операторы
Альтернативы в if-операторе сами являются операторами; в частности, они могут быть и if-операторами:
if(x1>y1)

if (x2 > у2)


C
statement_1;

else

statement_2;

else

if (хЗ > y3)

statemen_3;

else

statement_4;

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

Другая возможная проблема — «повисший» else:
if (x1 > у1)


C
if (x2 > у2)

statement_1;

else

statement_2;
Как показывают отступы, определение языка связывает else с наиболее глубоко вложенным if-оператором. Если вы хотите связать его с внешним if-оператором, нужно использовать скобки:
if(x1>y1){

if (x2 > у2)

statement_1; }

else

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

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

C
if (х > у) {



} else if (x > z) {

} else if(y < z) {

} else {

...

}

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

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

statement_list_1;


Ada
else

statement_list_2;

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

….


Ada
elsif x >z then

….

elsif у > z then



else



end if;
Реализация

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




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


C

if (!expression)


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


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


Ada



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

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

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

Ada
if (х > у) and then (у > z) and then (z < 57) then...
Точно так же or else — эквивалент укороченного вычисления для or.

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

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

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


Ada



if (а /= 0) and then (b/a > 25) then .. .
Такая ситуация часто встречается при использовании указателей (гл. 8):

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

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


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

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


C



while (s[i]. data != key)
Цикл while прост и надежен. Поскольку условие проверяется в начале цикла, мы знаем, что тело цикла будет полностью выполнено столько раз, сколько потребуется по условию. Если условие выхода сначала имеет значение False,то тело цикла не будет выполнено, и это упрощает программирование гранич­ных условий:


C



while (count > 0) process(s[count].data);
Если в массиве нет данных, выход из цикла произойдет немедленно.

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

Pascal
repeat

read(v);

put_in_table(v);

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

C
do{

v = get();

put_in_table(v);

} while (v != end_value);

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


Pascal
begin

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

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

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

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


Pascal
begin

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

if error_detected then found := True

else

begin

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

end

end
В языке С можно использовать оператор break:
while (!found) {


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

if (error_detected()) break;

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

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


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

exit when error_detected;

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

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

Ada
loop



end loop;

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


C


}
Реализация
Цикл while:


C



while (expression)

statement;
реализуется так:
L1: compute R1.expr

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

statement Тело цикла

jump L1 Перейти на проверку завершения цикла L2:
Обратите внимание, что в реализации цикла while есть две команды перехода! Интересно, что если выход находится в конце цикла, то нужна только одна команда перехода:
do{


C
statement;

} while (expression);
компилируется в
L1: statement

compute expr

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


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


C
int low, high; /* Границы цикла */
i = low; /* Инициализация индекса */

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

statement;

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

};


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

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

C

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

statement;

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


Ada



for I in Low .. High loop

statement;

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

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


Ada

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

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


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

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


Algol



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

в то время как в Ada мы должны явно запрограммировать их вычисление:
for l in 1 .. (N + 1)/2 loop


Ada



|1 =2*|-1;



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

C
for(i=j*k; (i<n)&&(j + k>m); i + = 2*j)...
В описании С определено, что оператор

C
for (expression_1 ; expression_2; expression_3) statement;
эквивалентен конструкции

C
for(expression_1 ;

while (expression_2) {

statement;

expression_3;

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

Ada
for I in expression_1 .. expression_2 loop

statement;

end loop;
эквивалентно
I = expression_1;


Ada
Temp = expression_2;

while (I < Temp) loop

statement;

I: = I + 1;

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

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


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

В языке Ada цикл

Ada
for I in expression_1 .. expression_2 loop

statement;

end loop;
компилируется в
compute R1,expr_1

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

compute R2,expr_2

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

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

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

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

statement Тело цикла

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

incr R1

store R1,l

jump L1

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

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

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

statement

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

jump L1

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

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

statement;

Это компилируется в
compute R1,expr_1

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

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

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

statement Тело цикла

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

incr R1

store R1,i

jump L1

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

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


C

int i;
void p2(void) {

i = i + 5;

}

void p1(void) {

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

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

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

C
inta[100];

int i, key;
key = get_key();

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

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

process(i);

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

Ada
Found: Integer := False;
for I in 1 ..100 loop

if A(l) = Key then

Found = I;

exit;

end if;

end loop;
Определение области действия индексов цикла в языке C++ с годами меня­лось, но конечное определение такое же, как в Ada: индекс не существует вне области цикла:
for(int i=0;i<100;i++){


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

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


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

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


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


Ada

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

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

function Find_Key(A: A_Type; Key: Integer)

return Integer is

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

begin

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

while A(l) /= Key loop

I:=I-1;

end loop;

return I;

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

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

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

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


C
х = а;

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

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

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

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

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

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

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

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

у = 0; х = а;

invariant


Eiffel
а = yb + х

variant

х

until

x< b

loop

x :=x-b;

у:=у+1;

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


^ 6.7. Операторы goto
В первоначальном описании языка Fortran был только один структурирован­ный управляющий оператор: оператор do, аналогичный циклу for. Все осталь­ные передачи управления делались с помощью условных или безусловных пе­реходов на метки, т. е. с помощью операторов, которые называются goto:
if(a.eq.b)goto12



goto 5


Fortan
4 …



12 …



5 …

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

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

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

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

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

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

^ Назначаемые goto-операторы
В языке Fortran есть конструкция, которая называется назначаемым (assigned) оператором goto. Можно определить метку-переменную и присваивать ей значение той или иной конкретной метки. При переходе по метке-переменной фактической целевой точкой перехода является значение этой переменной:
assign 5 to Label

...


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

5 ...

6 ...

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

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


6.8. Упражнения
1. Реализует ваш компилятор все case-/switch-операторы одинаково, или он пытается выбирать оптимальную реализацию для каждого оператора?
2. Смоделируйте оператор repeat языка Pascal в Ada и С.
3. Первоначально в языке Fortran было определено, что цикл выполняет­ся, по крайней мере, один раз, даже если значение low больше, чем зна­чение high! Чем могло быть мотивировано такое решение?
4. Последовательный поиск в языке С:

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

i++;

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

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

; /* Пустой оператор */
В чем различие между двумя вариантами вычислений?
5. Предположим, что в языке Ada переменная индекса может существовать за рамками цикла. Покажите, как бы это воздействовало на оптимиза­цию цикла.
6. Сравните сгенерированный для поиска код, реализованный с помощью операторов break или exit, с кодом, сгенерированным для поиска с «часовым».

7. Напишите программу поиска с «часовым», используя do-while вместо while. Будет ли это эффективнее?
8. Почему мы помещали «часового» в начало массива, а не в конец?
9. (Шолтен) В игре Го используют камни двух цветов, черные и белые. Предположим, что у вас в коробке неизвестная смесь камней, и вы вы­полняете следующий алгоритм:
while Stones_Left_in_Can loop -- пока есть камни в коробке


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

if Color(S1 )=Color(S2) then

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

else

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

end if;

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

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

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


C



void proc(int a, float b);
Такая функция имеет те же свойства, что и процедура в других языках, поэто­му мы используем термин «процедура» и при обсуждении языка С.

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


C



call proc(x,y)
тогда как в других языках просто пишется имя процедуры с фактическими па­раметрами:


C

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

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


C



int func(int a, float b);
тогда как в языке Ada используется другой синтаксис:


Ada



function Func(A: Integer; В: Float) return Integer;
Вызов функции является не оператором, а элементом выражения:


C

a = x + func(r,s) + y;
Тип результата функции не должен противоречить типу, ожидаемому в выра­жении. Обратите внимание, что в языке С во многих случаях делаются неяв­ные преобразования типов, тогда как в Ada тип результата должен точно соот­ветствовать контексту. По смыслу вызов функции аналогичен вызову проце­дуры: приостанавливается вычисление выражения; выполняются команды тела функции; затем возвращенное значение используется для продолжения вычисления выражения.

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


C
intfunc(void)

{

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

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

z = х + func(void) + у;


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

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


7.2. Параметры
В предыдущем разделе мы определили подпрограммы как сегменты кода, ко­торые можно неоднократно вызывать. Практически всегда при вызове требу­ется выполнять код тела подпрограммы для новых данных. Способ повлиять на выполнение тела подпрограммы состоит в том, чтобы «передать» ей необ­ходимые данные. Данные передаются подпрограмме в виде последовательно­сти значений, называемых параметрами. Это понятие взято из математики, где для функции задается последовательность аргументов: sin (2piК). Есть два понятия, которые следует четко различать:
^ Формальный параметр — это объявление, которое находится в объявле­нии подпрограммы. Вычисление в теле подпрограммы пишется в.терми-нах формальных параметров.
^ Фактический параметр — это значение, которое вызывающая программа передает подпрограмме.
В следующем примере:
int i,,j;

char а;

void p(int a, char b)


C
{

i = a + (int) b;

}


P(i,a);

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

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

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


Ada



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

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

Ada
Ada Proc(Second => 'X', First => 24);
Обычно этот вариант используется вместе с параметрами по умолчанию, при­чем параметры, которые не написаны явно, получают значения по умолча­нию, заданные в объявлении подпрограммы:

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

Proc(Second => 'X');
Соответствие по имени и параметры по умолчанию обычно используются в командных языках операционных систем, где каждая команда может иметь множество параметров и обычно необходимо явно изменить только некото­рые из них. Однако этот стиль программирования таит в себе ряд опасностей. Использование параметров по умолчанию может сделать программу труд­ной для чтения, потому что синтаксически отличающиеся обращения факти­чески вызывают одну и ту же подпрограмму. Соответствие по имени являет­ся проблематичным, потому что при этом зависимость объявления подпро­граммы и вызовов оказывается более сильной, чем это обычно требуется. Если при вызовах библиотечных подпрограмм вы пользуетесь только пози­ционными параметрами, то вы могли бы купить библиотеку у конкури­рующей фирмы и просто перекомпилировать или перекомпоновать про­грамму:


Ada



X:=Proc_1 (Y) + Proc_2(Z);
Однако если вы используете именованные параметры, то, возможно, вам при­дется сильно изменить свою программу, чтобы установить соответствие но­вым именам параметров:

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


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

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


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

begin


Ada
... ;

end Proc;
и вызова:

Ada

Proc(2+3*4);
Преимущества семантики copy-in:
• Copy-in является самым надежным механизмом передачи параметров. Поскольку передается только копия фактического параметра, подпро­грамма никак не может испортить фактический параметр, который, не­сомненно, «принадлежит» вызывающей программе. Если подпрограмма изменяет формальный параметр, изменяется только копия, а не ориги­нал.
• Фактические параметры могут быть константами, переменными или вы­ражениями.
• Механизм copy-in может быть очень эффективным, потому что началь­ные затраты на копирование делаются один раз, а все остальные обраще­ния к формальному параметру на самом деле являются обращениями к локальной копии. Как мы увидим в разделе 7.7, обращение к локальным переменным чрезвычайно эффективно.
Если семантика copy-in настолько хороша, то почему существуют другие ме­ханизмы? дело в том, что часто мы хотим изменить фактический параметр, несмотря на тот факт, что такое изменение «небезопасно»:
• Функция возвращает только один результат, но, если результат вычис­ления достаточно сложен, может возникнуть желание вернуть несколь­ко значений. Чтобы сделать это, необходимо задать в процедуре не­сколько фактических параметров, которым могут быть присвоены ре­зультаты вычисления. Обратите внимание, что этого часто можно избе­жать, определив функцию, которая возвращает в качестве результата за­пись.
• Кроме того, цель выполнения подпрограммы может состоять в моди­фикации данных, которые ей передаются, а не в их вычислении. Обычно это происходит, когда подпрограмма обрабатывает структуру данных. Например, подпрограмма, сортирующая массив, не вычисляет значение; ее цель состоит только в том, чтобы изменить фактический параметр. Нет никакого смысла сортировать копию массива!
• Параметр может быть настолько большим, что копировать его неэффек­тивно. Если copy-in используется для массива из 50000 целых чисел, мо­жет просто не хватить памяти, чтобы сделать копию, или затраты на ко­пирование будут чересчур большими.
Первые две ситуации легко разрешить с помощью семантики copy-out («копирование из»). Фактический параметр должен быть переменной, а под­программе передается адрес фактического параметра, который она сохраняет. Для формального параметра используется временная локальная переменная, и значение должно быть присвоено формальному параметру, по крайней ме­ре, один раз во время выполнения подпрограммы. Когда выполнение под­программы завершено, значение копируется в переменную, на которою ука­зывает сохраненный адрес. На рисунке 7.2 показана семантика copy-out для следующей подпрограммы:
procedure Proc(F: out Integer) is

begin


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

end Proc;
A: Integer;

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

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

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

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

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



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

C
int global = 4;

inta[10];
int f(int *parm)

{

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

return 6;

}
х = a[global] + f(&global);
В этом примере, если выражение вычисляется в том порядке, в котором оно записано, его значение равно а[4] + 6, но из-за совмещения имен значение выражения может быть 6 + а[5], если компилятор при вычислении выражения выберет порядок, при котором вызов функции предшествует индексации массива. Совмещение имен часто приводит к непереносимости программ.

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

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

^ Параметры в языках С и C++
В языке С есть только один механизм передачи параметров — copy-in:
int i = 4; /* Глобальная переменная */

C

void proc(int i, float f)

{

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

}

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

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

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

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

void proc(int *i, float f)

{

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

}

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

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


C++



void proc(int & i, float f)

{

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

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


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

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

{

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

}

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

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


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

{

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

}

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

proc(b); /* Адрес первого элемента */
Программисты, пишущие на С, быстро к этому привыкают, но, все равно, это является источником недоразумений и ошибок. Проблема состоит в том, что, поскольку параметр — это, фактически, указатель на отдельный элемент, то допустим любой указатель на переменную того же типа:
int i;

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

proc(&i); /* Допустим любой указатель на целое число!! */
Наконец, в языке С контроль соответствия типов никак не действует между файлами, поэтому можно в одном файле поместить


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

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

C
void proc(int i); /* Объявление процедуры */ ргос(100);
а затем месяцами искать ошибку.

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

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

Pascal
procedure proc(P_lnput: Integer; var P_0utput: Integer);
Ключевое слово var указывает, что параметр вызывается по ссылке, в про­тивном случае используется вызов по значению, даже если параметр очень большой. Параметры могут быть любого типа, включая массивы, записи или другие сложные структуры данных. Единственное ограничение состоит в том, что тип результата функции должен быть скалярным. Типы фактиче­ских параметров проверяются на соответствие типам формальных парамет­ров.

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


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

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

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

Ada
procedure Put_Key(Key: in Key_Type);

procedure Get_Key(Key: out Key_Type);

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

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

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

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


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

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


Fortran
Real X,Y

X=Y

End

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

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

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

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

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

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

Global: Integer;
procedure Proc(Parm: in Integer) is


Ada
Local: Integer;

begin

Global := Local + Parm;

end Proc;
begin -- Main

Global := 5;

Proc(7);

Proc(8);

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

С каждым объявлением связаны три свойства.
^ Область действия. Область действия переменной — это сегмент програм­мы, в котором она определена.
Видимость. Переменная видима внутри некоторого подсегмента области действия, если к ней можно непосредственно обращаться по имени.

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

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

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

Ada
begin — Main

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

end Main;
Однако область действия переменной Global включает локальную проце­дуру, поэтому обращение внутри процедуры корректно:
procedure Proc(Parm: in Integer) is

Local: Integer;

begin

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

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

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

Global: Integer;

V: Integer; -- Объявление в Main
procedure Proc(Parm: in Integer) is

Local: Integer;

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

begin

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

end Proc;
begin -- Main

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

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

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


Ada
procedure Proc_1 is

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



endProc_1;

procedure Proc_2 is

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



end Proc_2;

begin – Main



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


Ada
Global: Integer;

procedure Level_1 is

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

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

begin -- Level_2

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

end Level_2;
begin -- Level_1

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

Level_2:

end Level_1;
begin -- Main

Level_1;

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

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

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

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


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

procedure Proc(...) is

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

begin

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


Ada
if N < 0 then

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

elsif N = 0 then

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

else

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

end if;

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

end Proc;
В этом примере мы хотели бы не записывать три раза Длинное вычисление 2, а оформить его как дополнительную процедуру с одним параметром:
procedure Proc(...) is

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

procedure Long_2(l: in Integer) is

begin

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


Ada
end Long_2;

begin

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

if N<0thenl_ong_2(1);

elsif N = 0 then Long_2(2);

else Long_2(3);

end if;

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

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

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

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

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


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

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

0! = 1

n! = п х (п - 1)!

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


C
{

if (n == 0) return 1 ;

else return n * factorial(n - 1);

}
Какие свойства необходимы для поддержки рекурсии?
• Компилятор должен выдавать чистый код. Так как при каждом обраще­нии к функции factorial используется одна и та же последовательность машинных команд, код не должен изменять сам себя.
• Должна существовать возможность выделять во время выполнения про­извольное число ячеек памяти для параметров и локальных переменных.
Первое требование выполняется всеми современными компиляторами. Самоизменяющийся код — наследие более старых стилей программирования и используется редко. Обратите внимание, что если программа предназначена для размещения в постоянной памяти (ROM), то она не может изменяться по определению.

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


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

{


C
int i = n;

result = 1;

while (i != 0) {

result = result * i;

i--;

}

return result;

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

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

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

C
#define Stack_Size 100

int stack[Stack_Size];

int top = 0;

void push(int element)

{

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

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

else stack[top++] = element;

}

int pop(void)

{

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

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

else return stack[--top];

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


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

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

G: Integer;


Ada
procedure Proc_1 is

L1: Integer;

begin ... end Proc_1 ;
procedure Proc_2 is

L2: Integer;

begin... end Proc_2;

begin

Proc_1;

Proc_2;

end Main;

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

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

G: Integer;


Ada



procedure Proc_1 (P1: Integer) is

L1: Integer;
procedure Proc_2(P2: Integer) is

L2: Integer;

begin

L2 := L1 + G + P2;

end Proc_2;

begin -- Proc_1

Proc_2(P1);

end Proc_1;

begin -- Main

Proc_1 (G);

end Main;

Ргос_2 может вызываться только из Ргос_1. Это означает, что Ргос_1 еще не завершилась, ее память не освобождена, и место, выделенное для L1, должно все еще оставаться занятым (см. рис. 7.7). Конечно, Ргос_2 завершается рань­ше Ргос_1, которая в свою очередь завершается раньше Main, поэтому память может быть освобождена с помощью операции pop.
^ Записи активации
Фактически стек используется для поддержки всего вызова процедуры, а не только для размещения локальных переменных. Сегмент стека, связанный с каждой процедурой, называется записью активации (activation record) для процедуры. Вкратце, вызов процедуры реализуется следующим образом (см. рис. 7.8):

1. В стек помещаются фактические параметры. К ним можно обращаться по смещению от начала записи активации.
2. В стек помещается адрес возврата RA (return address). Адрес возврата — это адрес оператора, следующего за вызовом процедуры.
3. Индекс вершины стека увеличивается на общий объем памяти, требуе­мой для хранения локальных переменных.
4. Выполняется переход к коду процедуры.
После завершения процедуры перечисленные шаги выполняются в обрат­ном порядке:
1. Индекс вершины стека уменьшается на величину объема памяти, выде­ленной для локальных переменных.
2. Адрес возврата извлекается из стека и используется для восстановления указателя команд.
3. Индекс вершины стека уменьшается на величину объема памяти, выде­ленной для фактических параметров.
Хотя этот алгоритм может показаться сложным, на большинстве компью­теров он фактически может быть выполнен очень эффективно. Объем памя­ти для переменных, параметров и дополнительной информации, необхо­димой для организации вызова процедуры, известен на этапе компиляции, а описанная технология всего лишь требует изменения индекса стека на кон­станту,


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


C



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


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

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

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

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

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

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

C
  1   2   3   4   5   6   7   8   9



Скачать файл (2753 kb.)

Поиск по сайту:  

© gendocs.ru
При копировании укажите ссылку.
обратиться к администрации
Рейтинг@Mail.ru