Языки программирования - концепции и принципы
Предисловие
Значение языков программирования
Сказать, что хороший программист может написать хорошее программное обеспечение на любом языке, — это все равно, что сказать, что хороший пилот может управлять любым самолетом: верно, но не по существу. При разработке пассажирского самолета основными критериями являются безопасность, экономическая целесообразность и удобства; для военного самолета главное это летные качества и возможность выполнения боевой задачи; а при создании сверхлегкого самолета необходимо обеспечить низкую стоимость и простоту управления.
Роль языка в программировании принижается по сравнению с программной методологией и инструментальными средствами; и не только преуменьшается, но и полностью отвергается, когда утверждают, что хорошо разработанная система может быть одинаково хорошо реализована на любом языке. Но языки программирования — это не просто инструментальное средство;
это тот «материал», из которого создается программное обеспечение, то, что мы видим на наших экранах большую часть дня. Я верю, что язык программирования — один из наиболее, а не наименее важных факторов, которые влияют на окончательное качество программной системы. К сожалению, слишком у многих программистов нет достаточных языковых навыков. Они страстно любят свой «родной» язык программирования и не способны ни проанализировать и сравнить конструкции языка, ни оценить преимущества и недостатки современных языков и языковых понятий. Слишком часто можно услышать утверждения, демонстрирующие концептуальную путаницу: «Язык 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
В новой версии, названной 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
Три основные команды языка 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 подобна
Более сложная функция 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 очень интересен встроенный
Кроме того, в 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 используются в качестве операции сравнения на равенство. Стремление написать:
с |
является настолько общим, что многие компиляторы выдадут предупреждение, хотя оператор имеет допустимое значение.
В качестве исторического прецедента напомним известную проблему с синтаксисом языка Fortran. Большинство языков требует, чтобы слова в программе отделялись одним или несколькими пробелами (или другими пробельными символами типа табуляции), однако в языке Fortran пробельные символы игнорируются. Рассмотрим следующий оператор, который определяет «цикл до метки 10 при изменении индекса i от 1 до 100»:
Fortan |
Если запятая случайно заменена точкой, то этот оператор становится на самом деле.оператором присваивания, присваивая 1.100 переменной, имя которой образуется соединением всех символов перед знаком «=»:
Fortan |
Говорят, эта ошибка заставила ракету взорваться до запуска в космос!
2.2. Семантика
Семантика — это смысл высказывания (программы) в языке (программирования).
Если синтаксис языков понять очень легко, то понять семантику намного труднее. Рассмотрим для примера два предложения на английском языке:
The pig is in the
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) используется для обозначения
Термин проектирование программного обеспечения ( 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
В других языках типа Fortran и PL/1 нет зарезервированных ключевых слов. Каковы преимущества и недостатки зарезервированных слов?
Глава 3
Среды программирования
Язык — это набор правил для написания программ, которые являются всего лишь последовательностями символов. В этой главе делается обзор компонентов среды программирования — набора инструментов, используемых для преобразования символов в выполнимые вычисления.
Редактор - это, инструментальное средство для создания и изменения исходных файлов, которые являются символьными файлами, содержащими написанную на языке программирования программу.
Компилятор транслирует символы из исходного файла в объектной модуль, который содержит команды в машинном коде для конкретного комью-тера.
Библиотекарь поддерживает совокупности объектных файлов, называемые библиотеками.
Компоновщик, или редактор связей, собирает объектные файлы отдельных компонентов программы и
разрешает внешние ссылки от одного компонента к другому, формируя исполняемый файл.
Загрузчик копирует исполняемый файл с диска в память и инициализирует компьютер перед выполнением программы.
Отладчик — это инструментальное средство, которое дает возможность программисту управлять выполнением программы на уровне отдельных операторов для диагностики ошибок.
Профилировщик измеряет, сколько времени затрачивается на каждый компонент программы. Программист, может затем улучшить эффективность критических компонентов, ответственных за большую часть времени выполнения.
Средства тестирования автоматизируют процесс тестирования программ, создавая и выполняя тесты и анализируя результаты тестирования.
Средства конфигурирования автоматизируют создание программ и прослеживают изменения до уровня исходных файлов.
Интерпретатор непосредственно выполняет исходный код программы в отличие от компилятора, переводящего исходный файл в объектный.
Среду программирования можно составить из отдельных инструментальных средств; кроме того, многие поставщики продают интегрированные среды программирования, которые представляют собой системы, содержащие большую часть или все перечисленные выше инструментальные средства.
в чрезвычайной простоте интерфейса пользователя:
Преимущество интегрированной среды заключается в чрезвычайной простоте интерфейса пользователя: каждый инструмент инициируется нажатием единственной клавиши или выбором из меню вместо набора на клавиатуре имен файлов и параметров.
3.1. Редактор
У каждого программиста есть свой любимый универсальный редактор. Но даже в этом случае у вас может появиться желание воспользоваться специализированным, предназначенным для определенного Языка редактором, который создает всю языковую конструкцию, типа условного оператора, по одному нажатию клавиши. Преимущество такого редактора в том, что он позволяет предотвратить синтаксические ошибки. Однако любая машинистка, печатающая вслепую, скажет, что легче набирать языковые конструкции, чем отыскивать нужные пункты в меню.
В руководстве по языку могут быть указаны рекомендации для формата исходного кода: введение отступов, разбивка строк, использование верхнего/нижнего регистров. Эти правила не влияют на правильность программы, но ради будущих читателей вашей программы такие соглашения следует .соблюдать. Если вам не удалось выполнить соглашения при написании программы, то вы можете воспользоваться инструментальным средством, называемым красивая печать (pretty-printer), которое переформатирует исходный код к рекомендуемому формату. Поскольку эта программа может непреднамеренно внести ошибки, лучше соблюдать соглашения с самого начала.
3.2. Компилятор
Язык программирования без компилятора (или интерпретатора) может представлять большой теоретический интерес, но выполнить на компьютере программу, написанную на этом языке, невозможно. Связь между языками и компиляторами настолько тесная, что различие между ними расплывается, и часто можно услышать такую бессмыслицу, как:
Язык L1 эффективнее языка L2.
Правильно же то, что компилятор С1 может сгенерировать более эффективный код, чем компилятор С2, или что легче эффективно откомпилировать конструкции L1, чем соответствующие конструкции L2.
Одна из целей этой книги
Одна из целей этой книги — показать соотношение между конструкциями языка и получающимся после компиляции машинным кодом.
Структура компилятора показана на рис. 3.1. Входная часть компилятора
«понимает» программу, анализируя синтаксис и семантику согласно правилам языка. Синтаксический анализатор отвечает за преобразование последовательности символов в абстрактные синтаксические объекты, называемые лексемами. Например, символ «=» в языке С преобразуется в оператор присваивания, если за ним не следует другой «=»; в противном случае оба соседних символа «=» (т.е. «==») преобразуются в операцию проверки равенства. Анализатор семантики отвечает за придание смысла этим абстрактным объектам. Например, в следующей программе семантический анализатор выделит глобальный адрес для первого i и вычислит смещение параметра — для второго 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, затратив относительно небольшие усилия.