Text
                    ОСНОВНЫЕ
КОНЦЕПЦИИ
ЯЗЫКОВ
ПРОГРАММИРОВАНИЯ
ПЯТОЕ ИЗДАНИЕ
ВИЛЬЯМС
РОБЕРТ У. СЕБЕСТА
‘	л*'’ -‘		.	•
Л

ОСНОВНЫЕ КОНЦЕПЦИИ ЯЗЫКОВ ПРОГРАММИРОВАНИЯ Пятое издание РОБЕРТ У. СЕБЕСТА, Университет штата Колорадо в Колорадо-Спрингс Этот бестселлер, теперь в пятом издании, содержит широкое и глубокое обсуждение концепций языков программирования. Как и в предыдущих изданиях, автор описывает основные концепции языков программирования, освещая вопросы, связанные с разработкой различных языковых конструкций, на примере проектных решений, принятых в нескольких широко распространенных языках, и критически сравнивая различные возможные варианты их реализации. В книге описаны наиболее широко используемые методы описания синтаксиса и общепринятые подходы к описанию семантики языков программирования. В ходе изложения автор обсуждает также методы реализации языков программирования и проблемы, связанные с этим. ИЗМЕНЕНИЯ, СДЕЛАННЫЕ В ПЯТОМ ИЗДАНИИ • Освещается поддержка объектно-ориентированного программирования, параллельное выполнение программ и обработка исключительных ситуаций в языке Java™. • Дается более глубокое описание объектно-ориентированного программирования - расширенное описание объектно-ориентированного програмирования в данной книге появляется раньше, чем это делалось в предыдущих изданиях, одновременно с обсуждением императивных языков, не являющихся объектно-ориентированными. • Излагается более обширный материал, посвященный семантике, включая доказательство правильности готовых программ с использованием аксиоматической семантики. ОБ АВТОРЕ Роберт У. Себеста - профессор и декан факультета компьютерных наук в Университете штата Колорадо, Колорадо-Спрингс. Профессор Себеста получил степень доктора философии в области компьютерных наук в Университете штата Пенсильвания и преподает компьютерные науки более 25 лет. Его научные интересы лежат в области языков программирования, разработки компиляторов, а также методов и инструментов тестирования программного обеспечения. Профессор Себеста - автор нескольких книг, посвященных, в частности, структурному программированию на языке ассемблеров компьютеров PDP-11 и миникомпьютеров VAX. Он является членом Ассоциации по вычислительной технике (АСМ - Association for Computing Machinery) и Компьютерного общества Американского института инженеров электротехники и электроники (IEEE Computer Society) Издательский дом “Вильямс"
ПЯТОЕ ИЗД/ЖИЕ Основные концепции языков программирования
Основные концепции языков программирования Роберт У. Себеста Университет штата Колорадо в Колорадо-Спрингс Издательский дом “Вильямс” Москва ♦ Санкт-Петербург ♦ Киев 2001
ЬБК 32.973.26-018.2.75 С28 УДК 681.3.07 Издательский дом “Вильямс” Перевод с английского канд.физ.-мат.наук Д А. Илюшина. А.В. Назаренко Под редакцией канд.физ.-мат.нау к Д А Илюшина По общим вопросам обращайтесь в Издательский дом “Вильямс*’ по адрес}: info^u illiamspublishing.com. http://wuw.uilliamspublishing.com Себеста, Роберт, У. С28 Основные концепции языков программирования, 5-е изд. : Пер. с англ. — М.: Издательский дом “Вильямс". 2001. — 672 с.: ил. — Парал. тит. англ. ISBN 5-8459-0192-8 (рус.) Книга, ставшая бестселлером в США. посвящена всестороннему обсуждению основных концепций языков программирования. Автор описывает фундаменталь- ные понятия программирования на примере вопросов разработки различных языко- вых конструкций, подвергая критическому анализу их реализацию в широком спектре таких языков программирования, как FORTRAN, PASCAL, PL/1, С, C++, Ada. Java. Smalltalk, Eiffel и LISP. Материал книги охватывает все парадигмы программирования, начиная с функциональной и заканчивая объектно-ориенти- рованной. и. несомненно, представляет интерес как учебник по современным методологиям. ББК 32.973.26-018.2.75 Все названия программных продуктов являются зарегистрированными торговыми марками соответствующих фирм. Никакая часть настоящего издания ни в каких целях не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами, будь то электронные или механические, включая фотокопирование и запись на магнитный носитель, если на это нет письменного разрешения издательства Addison-Wesley Publishing Company, Inc Authorized translation from the English language edition published by Addison-Wesley Publishing Company, Inc . Copyright C 2002 All rights resen cd No part of this book may be reproduced or transmitted in any form or by any means, elec- tronic or mechanical, including photocopying, recording or by any information storage retrieval system, without per- mission from the Publisher Russian language edition published by Williams Publishing House according to the Agreement with R&l Enterprises International. Copyright £ 2001 ISBN 5-8459-0192-8 (рус.) С Издательский дом “Вильямс”. 2001 ISBN 0-201-75295-6 (англ.) C Addison-Wesley Publishing Company. Inc.. 2002
WIFTH EDITION Ж Concepts of Programming Languages Robert W. Sebesta University of Colorado, Colorado Springs ADDISON WESLEY Вззтс'* • Sa~ Zra~z sco • Чел vor< • -.o^con • Toronto • Sydney • ~z<,z • S r';aocre • Vaor-a • Mexico City • Munich • Paris • Cape "ел- • -e*; <c^g • Vontrea
Оглавление ПРЕДИСЛОВИЕ 18 ГЛАВА 1. ВВОДНЫЕ ЗАМЕЧАНИЯ 23 ГЛАВА 2. ОБЗОР ОСНОВНЫХ ЯЗЫКОВ ПРОГРАММИРОВАНИЯ 57 ГЛАВА 3. ОПИСАНИЕ СИНТАКСИСА И СЕМАНТИКИ 123 ГЛАВА 4. ИМЕНА, СВЯЗЫВАНИЕ, ПРОВЕРКА ТИПОВ И ОБЛАСТИ ВИДИМОСТИ 173 ГЛАВА 5. ТИПЫ ДАННЫХ 213 ГЛАВА 6. ВЫРАЖЕНИЯ И ОПЕРАТОРЫ ПРИСВАИВАНИЯ 275 ГЛАВА 7. СТРУКТУРЫ УПРАВЛЕНИЯ НА УРОВНЕ ОПЕРАТОРОВ 303 ГЛАВА 8. ПОДПРОГРАММЫ 345 ГЛАВА 9. РЕАЛИЗАЦИЯ ПОДПРОГРАММ 393 ГЛАВА 10. АБСТРАКТНЫЕ ТИПЫ ДАННЫХ 429 ГЛАВА 11. ПОДДЕРЖКА ОБЪЕКТНО-ОРИЕНТИРОВАННОГО ПРОГРАММИРОВАНИЯ 451 ГЛАВА 12. ПАРАЛЛЕЛЬНОСТЬ 503 ГЛАВА 13. ОБРАБОТКА ИСКЛЮЧИТЕЛЬНЫХ СИТУАЦИЙ 545 ГЛАВА 14. ФУНКЦИОНАЛЬНЫЕ ЯЗЫКИ ПРОГРАММИРОВАНИЯ 579 ГЛАВА 15. ЯЗЫКИ ЛОГИЧЕСКОГО ПРОГРАММИРОВАНИЯ 615 ЛИТЕРАТУРА 649 ПРЕДМЕТНЫЙ УКАЗАТЕЛЬ 661
2.3.3. Обзор языка FORTRAN I 65 2.3.4. Обзор языка FORTRAN II 66 2.3.5. Языки FORTRAN IV. FORTRAN 77 и FORTRAN 90 66 2.3.6. Оценка 67 2.4. Функциональное программирование: язык LISP 69 2.4.1. Истоки работ в области искусственного интеллекта и обработка списков 69 2.4.2. Процесс разработки языка LISP 70 2.4.3. Обзор языка 71 2.4.4. Оценка 72 2.4.5. Два потомка языка LISP 73 2.4.6. Родственные языки 74 2.5. Первый шаг к совершенствованию: язык ALGOL 60 74 2.5.1. Историческая ретроспектива 74 2.5.2. Начальная стадия разработки 75 2.5.3. Обзор языка ALGOL 58 76 2.5.4. Принятие отчета о языке ALGOL 58 76 2.5.5. Процесс разработки языка ALGOL 60 77 2.5.6. Обзор языка ALGOL 60 78 2.5.7. Оценка языка ALGOL 60 78 2.6. Компьютеризация коммерческих записей: язык COBOL 80 2.6.1. Исторические предпосылки 80 2.6.2. Язык FLOW-MATIC 81 2.6.3. Процесс разработки языка COBOL 81 2.6.4. Оценка 82 2.7. Начало разделения времени: язык BASIC 85 2.7.1. Процесс разработки 85 2.7.2. Обзор языка 86 2.7.3. Оценка 86 2.8. Все для всех: язык Р1У1 88 2.8.1. Исторические предпосылки 88 2.8.2. Процесс разработки 89 2.8.3. Обзор языка 89 2.8.4. Оценка 90 2.9. Два ранних динамических языка: APL и SNOBOL 91 2.9.1. Истоки и характеристики языка APL 92 2.9.2. Происхождение и характеристики языка SNOBOL 92 2.10. Возникновение абстракции данных: язык SIMULA 67 92 2.10.1. Процесс разработки 92 2.10.2. Обзор языка 93 2.11. Ортогональная структура: язык ALGOL 68 94 2.11.1. Процесс разработки 94 2.11.2. Обзор языка 94 2.11.3. Оценка 95 2.12. Несколько важных наследников семейства языков ALGOL 95 Содержание 7
2.12.1. Преднамеренная простота: язык Pascal 96 2.12.2. Машинно-независимый язык: С 98 2.12.3. Другие потомки языка ALGOL 100 2.13. Программирование, основанное на логике: язык Prolog 102 2.13.1. Процесс разработки 102 2.13.2. Обзор языка 102 2.13.3. Оценка 103 2.14. Величайший проект в истории: язык Ada 103 2.14.1. Историческая ретроспектива 104 2.14.2. Процесс разработки 104 2.14.3. Обзор языка 106 2.14.4. Оценка 106 2.14.5. Язык Ada 95 107 2.15. Объектно-ориентированное программирование: язык Smalltalk 109 2.15.1. Процесс разработки 109 2.15.2. Обзор языка 110 2.15.3. Оценка 111 2.16. Объединение императивных и объектно-ориентированных свойств: язык C++ 112 2.16.1. Процесс разработки 112 2.16.2. Обзор языка 113 2.16.3. Оценка 114 2.16.4. Родственный язык: Eiffel 114 2.17. Программирование в World Wide Web: язык Java 115 2.17.1. Процесс разработки 115 2.17.2. Обзор языка 116 2.17.3. Оценка 117 ГЛАВА 3. ОПИСАНИЕ СИНТАКСИСА И СЕМАНТИКИ 123 3.1. Введение 124 3.2. Общая задача описания синтаксиса 125 3.2.1. Устройства распознавания языков 126 3.2.2. Генераторы языков 126 3.3. Формальные методы описания синтаксиса 127 3.3.1. Форма Бэкуса-Наура и контекстно-свободные грамматики 127 3.3.2. Расширенная форма БНФ 138 3.3.3. Синтаксические графы 139 3.3.4. Грамматики и устройства распознавания языков 140 3.4. Рекурсивный нисходящий синтаксический анализ 141 3.5. Атрибутивные грамматики 143 3.5.1. Статическая семантика 144 3.5.2. Основные понятия 144 3.5.3. Определение атрибутивных грамматик 144 3.5.4. Внутренние атрибуты 145 3.5.5. Примеры атрибутивных грамматик 146 8 Содержание
3.5.6. Вычисление значений атрибутов 148 3.5.7. Оценка 149 3.6. Описание смысла программ: динамическая семантика 150 3.6.1. Операционная семантика 150 3.6.2. Аксиоматическая семантика 152 3.6.3. Денотационная семантика 162 ГЛАВА 4. ИМЕНА, СВЯЗЫВАНИЕ, ПРОВЕРКА ТИПОВ И ОБЛАСТИ ВИДИМОСТИ 173 4.1. Введение 174 4.2. Имена 174 4.2.1. Вопросы структуры 175 4.2.2. Виды имен 175 4.2.3. Специальные слова 176 4.3. Переменные 177 4.3.1. Имя 177 4.3.2. Адрес 177 4.3.3. Тип 179 4.3.4. Значение 179 4.4. Концепция связывания 179 4.4.1. Связывание атрибутов с переменными 180 4.4.2. Связывание типов 180 4.4.3. Связывание переменных с ячейками памяти и время их жизни 184 4.5. Проверка типов 187 4.6. Строгая типизация 188 4.7. Совместимость типов 189 4.8. Область видимости 192 4.8.1. Статическая область видимости 193 4.8.2. Блоки 195 4.8.3. Оценка статического обзора данных 196 4.8.4. Динамические области видимости 198 4.8.5. Оценка динамического обзора данных 199 4.9. Область видимости переменных и время их жизни 200 4.10. Среды ссылок 201 4.11. Именованные константы 203 4.12. Инициализация переменных 204 ГЛАВА 5. ТИПЫ ДАННЫХ 213 5.1. Введение 214 5.2. Элементарные типы данных 215 5.2.1. Числовые типы 216 5.2.2. Булевские типы 218 5.2.3. Символьные типы 218 5.3. Символьные строки 219 Содержание 9
5.3.1. Вопросы разработки 219 5.3.2. Строки и действия над ними 219 5.3.3. Варианты длины строк 221 5.3.4. Оценка 222 5.3.5. Реализация символьных строк 222 5.4. Порядковые типы, определяемые пользователем 224 5.4.1. Перечислимые типы 224 5.4.2. Ограниченные типы 226 5.4.3. Реализация порядковых типов, определяемых пользователем 227 5.5. Массивы 227 5.5.1. Вопросы разработки 228 5.5.2. Массивы и индексы 228 5.5.3. Связывания индексов и категории массивов 229 5 5.4. Количество индексов массива 232 5.5.5 Инициализация массива 232 5.5.6. Операции над массивами 233 5.5.7. Сечения 235 5.5.8. Оценка 236 5.5.9. Реализация типов массивов 236 5.6. Ассоциативные массивы 241 5.6.1. Структура и операции 241 5.6.2. Реализация ассоциативных массивов 242 5.7. Записи 242 5.7.1. Описания записей 242 5.7.2. Ссылки на поля записи 243 5.7.3. Операции над записями 245 5.7.4. Опенка 245 5.7.5. Реализация записей 246 5.8. Объединения 246 5.8.1. Вопросы разработки 247 5.8.2. Свободные объединения 247 5.8.3. Размеченные объединения языка ALGOL 68 247 5.8.4. Типы объединения в языке Pascal 248 5.8.5. Объединения в языке Ada 250 5.8.6. Оценка 252 5.8.7. Реализация объединений 252 5.9. Множества 253 5.9.1. Множества в языках Pascal и Modula-2 253 5.9.2. Оценка 254 5.9.3. Реализация множественных типов данных 255 5.10. Указатели 255 5.10.1. Вопросы разработки 2 ;6 5.10.2. Операции над указателями 2^6 5.10.3. Проблемы, возникающие при использовании указателей 2 ;8 5.10.4. Указатели в языке Pascal 259 5.10.5. Указатели в языке Ada 259 10 Содержание
5.10.6. Указатели в языках С и С— 260 5.10.7. Указатели в языке FORTRAN 90 261 5.10.8. Ссылки 262 5.10.9. Оценка 263 5.10.10. Реализация ссылок и хказателей 263 ГЛАВА 6. ВЫРАЖЕНИЯ И ОПЕРАТОРЫ ПРИСВАИВАНИЯ 275 6.1. Введение 276 6.2. Арифметические выражения 277 6.2.1. Порядок вычисления операторов 277 6.2.2. Порядок вычисления операндов 282 6.3. Перегруженные операторы 284 6.4. Преобразования типов 286 6.4.1. Приведение типов в выражениях 287 6.4.2. Явное преобразование типов 288 6.4.3. Ошибки в выражениях 289 6.5. Выражения отношений и бхлевские выражения 289 6.5.1. Выражения отношения 289 6.5.2. Булевские выражения 290 6.6. Сокращенное вычисление 291 6.7. Операторы присваивания 293 6.7.1. Простые присваивания 293 6.7.2. Множественные целевые объекты 294 6.7.3. Условные целевые объекты 294 6.7.4. Составные операторы присваивания 294 6.7.5. Унарные операторы присваивания 294 6.7.6. Присваивание как выражение 296 6.8. Смешанные присваивания 297 ГЛАВА 7. СТРУКТУРЫ УПРАВЛЕНИЯ НА УРОВНЕ ОПЕРАТОРОВ 303 7.1. Введение 304 7.2. Составные операторы 305 7.3. Операторы ветвления 306 7.3.1. Двухвариантные операторы ветвления 306 7.3.2. Конструкции многовариантного ветвления 311 7.4. Операторы цикла 318 7.4.1. Циклы со счет чиком 318 7.4.2. Логически управляемые циклы 326 7.4.3. Циклы с механизмами управления, размешенными пользователем 328 ’ 7.4.4. Циклы, основанные на структурах данных 330 7.5. Безусловный переход 331 7.5.1. Проблемы безусловного перехода 331 7.5.2. Виды меток 332 7.5.3. Ограничения переходов 333 Содержание 11
7^6. Защищенные команды 334 7.7. Выводы 338 ГЛАВА 8. ПОДПРОГРАММЫ 345 8.1. Введение 346 8.2. Основы подпрограмм 346 8.2.1. Общие свойства подпрограмм 346 8.2.2. Основные определения 347 8.2.3. Параметры 348 8.2.4. Процедуры и функции 350 8.3. Вопросы разработки подпрограмм 351 8.4. Среды локальных ссылок 353 8.5. Методы передачи параметров 354 8.5.1. Семантические модели передачи параметров 354 8.5.2. Модели реализации передачи параметров 355 8.5.3. Методы передачи параметров в основных языках про грам м ирован ия 360 8.5.4. Проверка типов параметров 362 8.5.5. Методы реализации передачи параметров 363 8.5.6. Многомерные массивы в качестве параметров 366 8.5.7. Вопросы разработки 368 8.5.8. Примеры передачи параметров 369 8.6. Параметры, являющиеся именами подпрограмм 373 8.7. Перегруженные подпрограммы 375 8.8. Настраиваемые подпрограммы 376 8.8.1. Настраиваемые подпрограммы в языке Ada 377 8.8.2. Настраиваемые подпрограммы в языке C++ 379 8.9. Раздельная и независимая компиляция 380 8.10. Вопросы разработки функций 382 8.10.1. Побочные эффекты функций 382 8.10.2. Типы возвращаемых значений 382 8.11. Дост\п к нелокальным средам 383 8.11.1. Блоки COMMON языка FORTRAN 383 8.11.2. Внешние объявления и модули 384 8.12. Перегруженные операторы, определяемые пользователем 385 8.13. Сопрограммы 386 ГЛАВА 9. РЕАЛИЗАЦИЯ ПОДПРОГРАММ 393 9.1. Общая семантика вызовов и возвратов 394 9.2. Реализация подпрограмм на языке FORTRAN 77 394 9.3. Реализация подпрограмм на языках, подобных языку ALGOL 397 9.3.1. Более сложные записи активации „ 397 9.3.2. Пример без рекурсии и нелокальных ссылок 400 9.3.3. Рекурсия 402 12 Содержание
9.3.4. Механизмы реализации нелокальных ссылок 405 9.4. Блоки 416 9.5. Реализация методов динамического обзора данных 418 9.5.1. Глубокий доступ 419 9.5.2. Теневой доступ 421 9.6. Реализация параметров, являющихся именами подпрограмм 422 9.6.1. Статические цепочки 422 9.6.2. Индикаторы 423 9.6.3. Ошибочное повторное обращение к среде ссылок 423 ГЛАВА 10. АБСТРАКТНЫЕ ТИПЫ ДАННЫХ 429 10.1. Понятие абстракции 430 10.2. Инкапсуляция 431 Ю.З. Введение в абстракцию данных 432 Ю.3.1. Число с плавающей точкой как абстрактный тип данных 433 Ю.3.2. Абстрактные типы данных, определяемые пользователем 433 Ю.З.З. Пример 434 Ю.4. Вопросы разработки типов 435 Ю.5. Примеры абстракции данных в разных языках 436 10.5.1. Классы в языке SIMULA 67 436 10.5.2. Абстрактные типы данных в языке Ada 437 10.5.3. Абстрактные типы данных в языке C++ 441 10.6. Параметризованные абстрактные типы данных 446 10.6.1. Язык Ada 446 10.6.2. Язык C++ 447 ГЛАВА 11. ПОДДЕРЖКА ОБЪЕКТНО-ОРИЕНТИРОВАННОГО ПРОГРАММИРОВАНИЯ 451 11.1. Введение 452 11.2. Объектно-ориентированное программирование 452 11.2.1. Введение 452 11.2.2. Наследование 453 11.2.3. Полиморфизм и динамическое связывание 455 11.2.4. Вычисления в объектно-ориентированных языках 456 11.3. Вопросы разработки объектно-ориентированных языков 457 11.3.1. Исключительность объектов 457 11.3.2. Являются ли подклассы подтипами 457 11.3.3. Реализация и наследование интерфейса 458 11.3.4. Проверка типов и полиморфизм 459 11.3.5. Одиночное и множественное наследование 459 11.3.6. Размещение в памяти и удаление из памяти объектов 460 11.3.7. Динамическое и статическое связывание 461 11.4. Обзор языка Smalltalk 461 11.4.1. Общие характеристики 461 11.4.2. Среда языка Smalltalk 462 Содержание 13
12.1.2 . Разновидности параллельности 507 12.1.3 . Почему нужно изучать параллельность 508 12.2. Введение в параллельность на уровне подпрограмм 508 12.2.1. Основные понятия 508 12.2.2. Разработка языков для поддержки параллельности 512 12.2.3. Вопросы разработки языков программирования 512 12.3. Семафоры 512 12.3.1. Введение 512 12.3.2. Синхронизация взаимодействия 513 12.3.3. Синхронизация конкуренции 515 12.3.4. Оценка 517 12.4. Мониторы 517 12.4.1. Введение 517 12.4.2. Синхронизация взаимодействия 518 12.4.3. Синхронизация конкуренции 519 12.4.4. Оценка 521 12.5. Передача сообщений 521 12.5.1. Введение 522 12.5.2. Концепция синхронной передачи сообщений 522 12.5.3. Модель передачи сообщения в языке Ada 95 523 12.5.4. Синхронизация взаимодействия 527 12.5.5. Синхронизация конкуренции 528 12.5.6. Завершение задачи 529 12.5.7. Приоритеты 530 12.5.8. Бинарные семафоры 530 12.5.9. Оценка 531 12.6. Параллельность в языке Ada 95 531 12.6.1. Защищенные объекты 531 12.6.2. Асинхронные сообщения 532 12.7. Потоки языка Java 533 12.7.1. Класс Thread 534 12.7.2. Приоритеты 535 12.7.3. Синхронизация взаимодействия 535 12.7.4. Синхронизация конкуренции 536 12.7.5. Оценка 539 12.8. Параллельность на уровне операторов 539 12.8.1. Язык High-Performance FORTRAN 539 ГЛАВА 13. ОБРАБОТКА ИСКЛЮЧИТЕЛЬНЫХ СИТУАЦИЙ 546 13.1. Введение в обработку исключительных ситуаций 547 13.1.1. Основные понятия 548 13.1.2. Вопросы разработки 550 13.1.3. Исторический обзор 553 13.2. Обработка исключительных ситуаций в языке PL/1 553 13.2.1. Обработчики исключительных ситуаций 554 Содержание 15
12.1.2 . Разновидности параллельности 507 12.1.3 . Почему нужно изучать параллельность 508 12.2. Введение в параллельность на уровне подпрограмм 508 12.2.1. Основные понятия 508 12.2.2. Разработка языков для поддержки параллельности 512 12.2.3. Вопросы разработки языков программирования 512 12.3. Семафоры 512 12.3.1. Введение 512 12.3.2. Синхронизация взаимодействия 513 12.3.3. Синхронизация конкуренции 515 12.3.4. Оценка 517 12.4. Мониторы 517 12.4.1. Введение 517 12.4.2. Синхронизация взаимодействия 518 12.4.3. Синхронизация конкуренции 519 12.4.4. Оценка 521 12.5. Передача сообщений 521 12.5.1. Введение 522 12.5.2. Концепция синхронной передачи сообщений 522 12.5.3. Модель передачи сообщения в языке Ada 95 523 12.5.4. Синхронизация взаимодействия 527 12.5.5. Синхронизация конкуренции 528 12.5.6. Завершение задачи 529 12.5.7. Приоритеты 530 12.5.8. Бинарные семафоры 530 12.5.9. Оценка 531 12.6. Параллельность в языке Ada 95 531 12.6.1. Защищенные объекты 531 12.6.2. Асинхронные сообщения 532 12.7. Потоки языка Java 533 12.7.1. Класс Thread 534 12.7.2. Приоритеты 535 12.7.3. Синхронизация взаимодействия 535 12.7.4. Синхронизация конкуренции 536 12.7.5. Оценка ’ 539 12.8. Параллельность на уровне операторов 539 12.8.1. Язык High-Performance FORTRAN 539 ГЛАВА 13. ОБРАБОТКА ИСКЛЮЧИТЕЛЬНЫХ СИТУАЦИЙ 546 13.1. Введение в обработку исключительных ситуаций 547 13.1.1. Основные понятия 548 13.1.2. Вопросы разработки 550 13.1.3. Исторический обзор 553 13.2. Обработка исключительных ситуаций в языке PL/1 553 13.2.1. Обработчики исключительных ситуаций 554 Содержание 15
13.2.2. Связывание исключительных ситуаций с обработчиками 554 13.2.3. Продолжение 554 13.2.4. Другие проектные решения 555 13.2.5. Пример 556 13.2.6. Оценка 557 13.3. Обработка исключительных ситуаций в языке Ada 558 13.3.1. Обработчики исключительных ситуаций 558 13.3.2. Связывание исключительных ситуаций с обработчиками 559 13.3.3. Продолжение 560 13.3.4. Другие проектные решения 561 13.3.5. Пример 563 13.3.6. Оценка 564 13.4. Обработка исключительных ситуаций в языке C++ 564 13.4.1. Обработчики исключительных ситуаций 564 13.4.2. Связывание исключительных ситуаций с обработчиками 565 13.4.3. Продолжение выполнения программы 566 13.4.4. Другие проектные решения 566 13.4.5. Пример 567 13.4.6. Оценка 568 13.5. Обработка исключительных ситуаций в языке Java 568 13.5.1. Классы исключительных ситуаций 568 13.5.2. Обработчики исключительных ситуаций 569 13.5.3. Связывание исключительных ситуаций с обработчиками 569 13.5.4. Продолжение выполнения программы 570 13.5.5. Другие проектные решения 571 13.5.6. Пример 572 13.5.7. Оператор finally 573 13.5.8. Оценка 574 ГЛАВА 14. ФУНКЦИОНАЛЬНЫЕ ЯЗЫКИ ПРОГРАММИРОВАНИЯ 580 14.1. Введение 581 14.2. Математические функции 582 14.2.1. Простые функции 582 14.2.2. Функциональные формы 583 14.3. Основы функциональных языков программирования 584 14.4. Первый язык функционального программирования — LISP 585 14.4.1. Типы и структуры данных 586 14.4.2. Первый интерпретатор языка LISP 586 14.5. Введение в язык Scheme 588 14.5.1. Происхождение языка Scheme 589 14.5.2. Элементарные функции 589 14.5.3. Функции для построения функций 594 14.5.4. Поток управления 595 14.5.5. Пример функции на языке Scheme 597 14.5.6. Функциональные формы 601 16 Содержание
14.5.7. Функции для создания кода 602 14.5.8. Императивные свойства языка Scheme 603 14.6. Язык COMMON LISP 603 14.7. Язык ML 605 14.8. Язык Haskell 607 14.9. Применение функциональных языков 610 14.10. Сравнение функциональных и императивных языков 611 ГЛАВА 15. ЯЗЫКИ ЛОГИЧЕСКОГО ПРОГРАММИРОВАНИЯ 616 15.1. Введение 617 15.2. Краткое введение в исчисление предикатов 617 15.2.1. Высказывания 618 15.2.2. Дизъюнктивные формы 620 15.3. Исчисление предикатов и доказательство теорем 620 15.4. Обзор логического программирования 623 15.5. Происхождение языка Prolog 624 15.6. Основные элементы языка Prolog 625 15.6.1. Термы 625 15.6.2. Факты 626 15.6.3. Правила 627 15.6.4. Цель 628 15.6.5. Процесс логического вывода в языке Prolog 628 15.6.6. Простая арифметика 631 15.6.7. Списковые структуры 634 15.7. Недостатки языка Prolog 638 15.7.1. Управление порядком выполнения резолюции 639 15.7.2. Предположение о закрытом мире 641 15.7.3. Проблема логического отрицания 642 15.7.4. Внутренние ограничения 643 15.8. Применение логического программирования 644 15.8.1. Системы управления реляционными базами данных 644 15.8.2. Экспертные системы 645 15.8.3. Системы обработки естественных языков 646 15.8.4. Образование 646 15.9. Выводы 646 ЛИТЕРАТУРА 650 ПРЕДМЕТНЫЙ УКАЗАТЕЛЬ 661 Содержание 17
П ре дис ловие Цели. общая структура и подход к изложению материала в пятом издании ‘'Концепций языков программирования*’ остались теми же, что и в четырех предыдущих изданиях. Основ- ная цель книги — предоставить читателю инструменты, необходимые для критической оцен- ки существующих и будущих языков и конструкций программирования. Дополнительной це- лью является подготовка читателя к изучению методов разработки и создания компиляторов. Книга должна также дать ответы на бесчисленные вопросы, коюрые мот возникнуть у ниппеля, шакомого исключительно с языками программирования высокого уровня. Напри- мер. почем\ С}шес1В\ет так мною я тыков программирования9 Как и для чею они создава- лись9 Насколько они похожи? Чем отличаются? Появления каких языков можно ожидать в б\ I . тем ? И почем} мы просто не можем продолжать исполыовать языки, которые уже есть? (Чшесгвхет два подхода к организации структуры книги, посвященной концепциям языков npoiраммирования. вертикальный и юризонтальный. При горизонтальном подходе каждый вы- бранный я тык аналишруезся на определенном уровне. Если используется вертикальный подход, тс- ибщие концепции и конструкции языков программирования описываются в определенной пос тедовательности. Для каждой конструкции рассматриваются вопросы ее реализации, разби- раются примеры программ, написанных на различных языках. Оба подхода имеют свои досто- инства Дтя того чтобы точно описать индивидуальные концепции каждою языка, нужно сосре- доточить на них внимание и рассмотреть их влияние на программирование и эволюцию языков программирования Однако хронологический анализ развития языков неизбежно влечет за со- бой изучение специфики языков, а также их происхождения и развития. Более того, на реализа- цию определенной функции в отдельном языке всегда оказывают влияние другие характеристи- ки пого языка. В связи с этим в книге было решено при изложении большей части материала использовать вертикальный подход, а там. где это удобно, применять горизонтальный подход. В этой книге описаны основные концепции языков программирования, вопросы, связан- ные с разработкой разнообразных языковых конструкций, а также проанализированы различ- ные подходы к реализации этих конструкций в наиболее распространенных языках програм- мирования. У казанный подход требует изучения набора тесно связанных между собой тем. Для обсуж- дения языков и языковых конструкций жизненно необходимы описательные инструменты. На- ми подробно рассмотрены самые эффективные и широко распространенные методы синтакси- ческого описания, а также введены наиболее общие методы описания семантики языков про- граммирования. Для того чтобы понять причины выбора конкретных Проектных решений в су шествующих языках программирования, следует изучать исторический контекст и конкрет- ные нужды, обусловившие этот выбор. Поскольку сложность реализации языка зачастую значи- тельно влияет на ею разработку в целом, в книге обсуждаются различные методы и проблемы реализации языков программирования. Ниже приводится краткое содержание глав пятого издания книги. Глава 1 начинается с объяснения, зачем нужно изучать языки программирования. Затем обсуждаются критерии их оценки. Мы отдаем себе отчет в том, что вводить такие критерии рискованно, но. тем не менее, принципы оценки необходимы в любом серьезном исследова- нии проблем разработки языков программирования Кроме того, в этой главе рассматривают- ся основные факторы, влияющие на разработку языков программирования, общепринятые соглашения, используемые при их разработке, а также основные подходы к реализации. При рассмотрении эволюции большинства важнейших языков, обсуждаемых в книге, в главе 2 используется горизонтальный подход. Несмотря на то что ни один из языков не опи- 18 Предисловие
сывается полностью. указываются исток», цели и значение каждсгс из них Эт<" историче- ский обзор ценен, так как он подготавливает базу для понимания теоретических и практиче- ских основ разработки современных языков Он также обосновывает дальнейшее изучение вопросов, связанных с разработкой языков и их оценкой Поскольку материал остальной час- ти книги никак не зависит от материала главы 2. без нарушения целостности изложения эта глава может быть полностью пропущена. В главе 3 рассматриваются основные методы формального описания синтаксиса языков программирования: форма EBNF (Extended Backus-Naur Form — расширенная форма Бэкхса- Наура) и синтаксические графы Затем следует описание атрисутнсй грамматики, играющей очень важную роль при разработке ко\<пилятэра. Далее анализир>ется трудная задала описа- ния семантики, даются основы трех наиболее распространенных методов описания операци- онной, аксиоматической и денотационной семантики В главах 4-13 использован вертикальный подход для подробного описания разработки основных конструкций императивных языков программирования В каждом случае представ- лено и оценено несколько альтернативных проектных решений В частности, в главе 4 рас- смотрены многие свойства переменных, а в главе 5 — более сложные типы данных, глава 6 посвящена выражениях! и операторах! присваивания: управляющие операторы описаны в гла- ве 7: подпрограммы и их реализация разобраны в главах 8 и 9: способы абстракции данных изучаются в главе 10: особенности языков, поддерживающих объектно-ориентированное программирование (наследование и динамическое связывание».— в главе 11: параллельно выполняемые программные модули описываются в главе 12: и. наконец, обработка исключи- тельных ситуаций описывается в главе 13. Мы решили использовать вертикальный подход, поскольку при горизонтальнох! подходе приходится описывать и оценивать детали некоторой конкретной конструкции в разных частях книги одновременно, что создает определенное не- удобство. Например, обсуждение в одной и той же главе различных методов обеспечения па- раллельности позволяет нах! точно сравнить и оценить эти методы. В двух последних главах (главы 14 и 15) описываются две важнейшие парадигмы про- граммирования: функциональная и логическая. Обе парадигмы вначале рассматриваются с точки зрения методологии программирования, а затех! приводится краткое введение в кон- кретный язык. В частности, глава 14 начинается с обсуждения простых математических функций, функ- циональных форм и языков функционального программирования Затехг представлено введе- ние в язык Scheme с описанием некоторых его основных функций, специальных форм, функ- циональных форм и приведено несколько примеров простых функций, написанных на этох! языке. Для иллюстрации разновидностей языков функционального программирования кратко описаны языки COMMON LISP, ML и Haskell. Завершается глава сравнением функциональ- ных и императивных языков программирования. Темой главы 15 является логическое программирование и языки логического программиро- вания. Глава начинается с введения в исчисление предикатов и объяснения их использования для доказательства теорем. Затех! следует обзор логического программирования. Основная часть главы посвящена языку Prolog, в частности, резолюции и унификации, а также содержит не- сколько примеров програмх! с подробными описаниями их работы. Изменения, внесенные в пятое издание Пятое издание данной книги подверглось значительной переработке и исправлению по сравнению с предыдущими. Большинство изменений было вызвано растущих! влиянием объ- ектно-ориентированной парадигмы программирования. Важнейшие из сделанных изменений описаны в следующих абзацах. Предисловие 19
Нарушая традицию, поддерживаемую первыми изданиями данной книги и большинством других книг, посвященных языкам программирования, в пятое издание не входит глава, посвя- щенная объектно-ориентированным языкам программирования. Книга содержит главу, посвя- щенную языковой поддержке объектно-ориентированного программирования, в частности, в ней рассматриваются наследование и динамическое связывание. Эта глава была переставлена на более подходящее для нее место — сразу после главы, посвященной абстракции данных, и стала главой 11. Глава была существенно увеличена за счет всестороннего обсуждения вопросов раз- работки объектно-ориентированных языков программирования, что создало основу для описа- ния и оценки различных языковых конструкций, обеспечивающих наследование и динамическое связывание. Две основные причины, обусловившие выбор этого нового подхода, заключаются в сле- дующем: во-первых, в настоящее время появилось огромное количество объектно- ориентированного программного обеспечения, создаваемого на языках, подобных импера- тивным языкам последних четырех десятилетий. Выражения, операторы присваивания, структура данных и управляющие структуры этих языков очень похожи на соответствующие структуры языков С и Pascal. Следовательно, нет причин отдельно обсуждать указанные свойства этих языков. Согласно нашему определению императивного языка языки C++, Ada 95 и Java являются императивными. Мы рассматриваем поддержку объектно- ориентированного программирования данными языками как начало следующей стадии разви- тия императивных языков программирования. В то время как парадигма развития объектно- ориентированного программного обеспечения значительно отличается от процедурной пара- дигмы. различия между языками, относящимися к данным двум подходам, не столь значи- тельны. В то же время различия между такими языками, поддерживающими информационно- ориентированное программирование, как Ada 83. и языками, поддерживающими объектно- ориентированное программирование, менее заметны. Второй причиной изменения подхода к обсуждению объектно-ориентированных языков программирования является то, что эта па- радигма уже не такая новая и экспериментальная, какой была несколько лет назад. В совре- менных разработках программного обеспечения этот подход доминирует, и используемые для этой цели языки программирования являются самыми распространенными на данный момент языками. Следовательно, в книге не стоит обсуждать объектно-ориентированные языковые функции в одной главе, к тому же расположенной в конце книги, — подход, до сих пор используемый по отношению к языкам логического программирования. Эту информацию легко можно подать в большинстве глав книги, что и было сделано в пятом издании. Кроме указанных, были внесены еще некоторые изменения. Появление языка Java и быст- рый рост его популярности потребовал дополнительного увеличения материала книги за счет описания некоторых его интересных свойств. В частности, обсуждение поддержки объектно- ориентированного программирования в языке Java было добавлено в главу 11, возможность параллельного функционирования — в главу 12, а обработка исключительных ситуаций— в главу 13. Кроме того, в начальные главы книги добавилось обсуждение еще нескольких его функций. Поскольку язык Miranda запатентован, в то время как язык Haskell общедоступен, обсуж- дение языка Miranda было удалено из главы 14 и оставлено только описание языка Haskell. В пятом издании вновь был расширен раздел главы 3, посвященный описанию аксиома- тической и денотационной семантики. Из-за введения в книгу описания новых языков и новых свойств старых языков обсужде- ние старых языков было сокращено. Например, был удален материал о сопрограммах языка Modula-2 и его поддержке абстрактных типов данных. Текущее состояние развивающихся языков программирования отражают другие много- численные изменения, незначительные по объему. 20 Предисловие
Указания преподавателю При преподавании языков программирования на младших курсах университета штата Ко- гало в Колорадо-Спрингс (University of Colorado at Colorado Springs) данная книга исполь- . ется следующим образом: главы 1 и 3. как правило, рассматриваются подробно. На главу 2. -е содержащую трудной для восприятия технической информации, отводится совсем немно- лекционного времени. Тем не менее, студенты находят эту главу интересной и полезной. <ак указывалось ранее, глава2 может быть просто пропущена, поскольку она не содержит “зтериала. необходимого для понимания последующих глав. Главы 4-8 и глава 10 должны относительно легко восприниматься студентами, имеюши- большой опыт работы с языками Pascal. С. С— или Ada. Главы 9. 11. 12 и 13 более слож- и требу ют обстоятельных лекций. Главы 14 и 15 являются совершенно новыми для большинства студентов младших курсов. ~ги объяснении этих глав идеальным было бы наличие языковых процессоров для языков <cheme и Prolog. Кроме того, в эти главы включено достаточно материала для того, чтобы студенты могли повозиться с простыми программами. Студенты, вероятно, не смогут усвоить последние две главы во всех деталях. Однако ас- пиранты могут сразу приступать к обсуждению неимперативных языков программирования, "гопустив части начальных глав, относящиеся к императивным языкам. Приложения В книгу включены два важных и полезных приложения. Электронный учебник с реше- чиями упражнений можно получить у торгового представителя издательства “Addison-Wesley эublishing". Также доступен набор диапозитивов к лекциям (по одному на каждую из первых ’ ? глав), имеющий формат файлов источника программы Microsoft Powerpoint. Автор создал и\ за последние несколько лет чтения курса лекций по предлагаемой книге. Файлы в формате -рограммы Powerpoint можно получить с помощью анонимного ftp-доступа на узле ::р.aw.com в каталоге /cseng'authors/sebastaconcepts4e. Подробнее об этом и других приложе- ниях вы можете узнать из файлов README или .message, находящихся в указанном каталоге. Доступность языкового процессора Процессоры для работы с некоторыми из обсуждаемых языков программирования и ин- формацию об этих языках вы можете найти на следующих Web-узлах: язык Java — узел http://java.sun.com язык Haskell — узел http://haskell.org язык Scheme — узел http://www-swiss.ai.mit/ftpdir scheme-7.4/ Обновленные версии можно найти на домашней странице данной книги: http://www.awl.com/cseng/titles/0-201-38596-l/ Благодарности Качество этой книги было значительно улучшено благодаря большому количеству предло- жений. уточнений и комментариев ее рецензентов. Рецензентами первых четырех изданий книги были Вики Алан (Vicki Allan), Генри Бауэр (Henry Bauer), Питер Брауэр (Peter Brouwer). Пао- шенг Чанг (Paosheng Chang). Джон Креншоу (John Crenshaw), Барбара Энн Грим (Barbara Ann Griem), Мери Лоу Хааг (Mary Lou Haag). Джон Мауни (Jon Mauney). Роберт Мак-Коард (Robert Предисловие 21
McCoard). Майкл Д* Мерфи (Michel G. Murphs). Эндрю Олдройд (Andrew Oldroyd), Джеффри Поляк (Jeffers Popsack). Стивен Рапкин (Steven Rapkin), Гамильтон Ричард (Hamilton Richard), losi Сейджер (Tom Sager). Джозеф Шелл (Joseph Schell) и Мэри Луиза Соффа (Mary Louise Soffa). Пятое издание рецензировали* Мэри Jos Хааг (Mars Lou Haag). университет штата Колорадо в Колорадо-Спрингс (Lniseisity of Colorado at Colorado Springs). Хайкью Ko (Hiksoo Koh), университет Lamar University: Ьрюс Максим (Bruce Maxim), университет штата Мичиган в Дебоне (University of Michigan at Dearborn): Л. Эчдрю Олдройд (L. Andrew Oldrosd). Вашингтонский университет (Washington I msersity). Ребекка Парсонс (Rebecca Parsons), университет Центральной Флориды (University of C entral Г londa): Дон Багерг (Don Bagert). Технический университет штата Техас (Texas Technical Unis ersits). Мейл Суарез-Ривас (Maite Suarez-Risas). редактор. Молли Тейлор (Molly Taylor), помощ- ник редактора, и Пат Юнабан (Pat Unubun). технический редактор, заслужили мою призна- те -ьносп. за их усилия, направленные на быстрый выход пятого издания книги, а также за иомошь в улучшении этого издания по сравнению с предыдущим. В заключение автор благодарит своих детей -- - Джейка (Jake.) и Дарси (Darcie) за их тер- не..ивое отношение к оюутствию отца, проводившего бесконечные часы в работе над пятым и зданием ланкой кнш и. Об авторе Рооерт Собес га (Robert Sebesta) является ассоциированным профессором и деканом фа- культета компьютерных наук университета штата Колорадо в Колорадо-Спрингс. Профессор (А-беста по 1учил степень бакалавра по прикладной математике в университет штата Колора- до в Ьолдере (University о! Colorado in Boulder), а степени Mai истра компьютерных наук и л<»кюра философии— в Госутарственнохт университете штата Пенсильвания (Pennsylvania Slate I msersity i. Теорию вычис штельных систем он преподает уже свыше 25 лет. К области ею профессионазьных интересов относятся проектирование и оценка языков программиро- вания. разработка компиляторов, а также методы и инструменты тестирования протраммного обеспечения. Автор является h.tohosi Ассоциации по вычислительной технике (АСМ — Association for Computing Machinery) и Компьютерного ootueciea Института инженеров по электротехнике и электронике (IEEE— Institute of Electrical and Electronics Engineers Computer Society i. 22 Предисловие
1.1. Для чего нужно изучать концепции языков программирования 1.2. Области применения программирования 1.3. Критерии оценки языков программирования 1.4. Факторы, влияющие на разработку языка 1.5. Категории языков 1.6. Компромиссы при разработке языка 1.7. Методы реализации 1.8. Среды программирования Конрад Цузе (Konrad Zuse) С 1936 по 1944 год Конрад Цузе спроектировал в Германии серию электромеханических компьюте- ров В 1945 году он разработал алгоритмический язык програм- мирования Plankalkul, который никогда не был реализован, причем полное описание этого языка до 1972 года даже не пуб линовалось Вводные замечания 23
Прежде чем начать описание концепций языков программирования, сделаем не- которые вводные замечания. Во-первых, обсудим причины, по которым и сту- денты. и профессиональные разработчики программного обеспечения должны изучать общие концепции, связанные с разработкой и оценкой языков программирования. Эта информация приводится специально для тех, кто полагает, что программисту достаточно знать один-два рабочих языка программирования. Во-вторых, рассмотрим области при- менения программирования. Затем, поскольку в книге оцениваются различные языковые свойства, представим перечень критериев, по которым принимаются решения. Далее об- суждаются два основных фактора, влияющих на структуру языка: архитектура машины и методологии разработки программ. Затем рассмотрим некоторые альтернативы, изуче- ние которых необходимо при разработке языка. Кроме того, в главе содержится обзор наиболее общих подходов к реализации языков программирования. В заключение приводится несколько примеров сред программирова- ния и обсуждается их влияние на производство программного обеспечения. 1.1. Для чего нужно изучать концепции языков программирования Студенты часто интересуются, какие выгоды они получат от изучения концепций языков программирования. Помимо всего прочего, в компьютерных науках существует множество других тем. заслуживающих серьезного изучения. Ниже приводится полный, как мы надеемся, перечень потенциальных выгод изучения языковых концепций. Больше возможностей для выражения идей. Широко распространенным явля- ется мнение, что на глубину наших мыслей влияет выразительная сила языка, на котором мы эти мысли выражаем. Люди, плохо понимающие естественный язык, не могут выражать сложные мысли, особенно на уровне абстракций. Другими словами, людям сложно осмыслить структуры, которые они не могут описать уст- но или письменно. С подобными проблемами сталкиваются и программисты при разработке программного обеспечения. Язык, на котором они программируют, налагает ограничения на виды управляющих структур, структур данных и абст- ракций. которые они могут использовать; следовательно, число форм алгоритмов, которые могут создать программисты, также ограничено. Осознанав разнообразие свойств языков программирования, можно решить по- добные проблемы. Изучение новых языковых конструкций помогает программи- стам повысить свое мастерство. Можно возразить, что изучение возможностей других языков не поможет про- граммисту. вынужденному использовать язык, в котором подобные возможности отсутствуют. Этот аргумент, однако, нас не останавливает, поскольку довольно часто языковые средства одного языка могут быть воспроизведены в других язы- ках. непосредственно их не поддерживающих. Например, после изучения таких функций обработки строк, предусмотренных в языке FORTRAN 90 (ANSI. 1992), как функция поиска подстрок INDEX, програм- мист. использующий язык Pascal (Ledgard. 1984), естественным образом перейдет к созданию подпрограмм, выполняющих те же операции. Сказанное справедливо и для многих других более сложных конструкций, обсуждаемых в данной книге. 24 Глава!. Вводные замечания
Изучение концепций языков программирования позволяет разобраться в полезных языковых свойствах и поощряет программистов к их использованию. Тот факт, что многие свойства могут быть воспроизведены в разных языках, не уменьшает значения разработки языков, обладающих наилучшим набором свойств. Всегда лучше использовать свойство, уже встроенное в язык, чем его ме- нее элегантный и более громоздкий аналог в языке, изначально это свойство не поддерживающем. Более обоснованный выбор подходящего языка. Многие профессиональные про- граммисты имеют лишь формальное образование в области компьютерных наук, а дальнейший опыт они получали самостоятельно или с помощью заочного обучения. Людей часто обучают одному-двум языкам, непосредственно имеющим отношение к текущей работе организации. Некоторые программисты прошли формальное обу- чение в далеком прошлом. Изучавшиеся ими языки уже давно не используются, а многие свойства, доступные в современных языках программирования, известны не очень широко. В результате, многие программисты продолжают использовать при- вычный язык, даже если он совсем не подходит для работы над новым проектом. Если бы они знали другие языки программирования, в частности их особые свойст- ва, то выбор рабочего языка был бы более осознанным. Повышаются способности к изучению новых языков. Программирование — мо- лодая дисциплина. Методологии, средства разработки программного обеспечения и языки программирования все еще находятся в состоянии развития. Это делает раз- работку программного обеспечения захватывающим занятием, подразумевающим необходимость непрерывного обучения. Процесс изучения новых языков програм- мирования может быть длительным и трудным, особенно для программиста. >ве- ренно себя чувствующего только с одним-двумя языками и никогда не изучавшего концепций языков программирования в целом. Один раз досконально разобравшись в основных концепциях языков программирования, вы поймете, как эти концепции реализованы в структуре изучаемого вами языка программирования. Например, программистам, понимающим концепцию абстракции данных, намного легче разобраться в структуре абстрактных типов данных языка Java (Gosling et al., 1996), чем людям, не знакомым с этим понятием. Это относится и к естествен- ным языкам. Чем лучше вы знаете грамматику вашего родного языка, тем легче вам дастся изучение второго языка. Более того, положительным побочным эффек- том изучения второго языка станет лучшее понимание первого. И последнее, знание лексики и основных концепций языков программирования для практикующих программистов необходимо, поскольку только так они смогут читать и понимать справочники по языкам программирования и литературу, по- священную языкам и компиляторам. Углубляется понимание важности реализации. При изучении концепций язы- ков программирования необходимо затронуть вопросы, связанные с реализацией языков. Разобравшись в этих вопросах, вы поймете, почему язык разработан именно таким образом. В свою очередь, это позволит более рационально исполь- зовать язык. Программист может значительно повысить свое мастерство, если научится правильно выбирать конструкции языков программирования и оценивать последствия своего выбора. 1.1. Для чего нужно изучать концепции языков программирования 25
Некоюрые виды ошибок могут наГпи и \ciранить только программисты, знающие с«'<нвс1с1в\1ощие детали реализации языка. Кроме того, понимание проблем, свя- злнне’Ч с реализацией языка. позволяет отчетливо представлять, каким образом компькмер Во1по.тияе1 различные языковые конструкции. В свою очередь, это по- зво.1Яс1 ючнее оненизь относительную эффективность альтернативных конструк- ции. ко!ирые moist быть выбраны при разрабо1ке программы. Например, про- ; р: 1мисты. не разбирающийся в реализации рекурсии, не знают что рекурсивные • Г И’мы. лак правило медленнее зквивален )ны\ теративных. »• знаются uO кзибности к разработке новых языков Возможная необходи- мое. ь ли щния нового языка ciyдешу може! показаться далекой. Однако экспер- ты вр-‘\!л gi времени разрабатываю! различные язым! программирования. Напри- мер. болbnhinc।во сисим lipoiр immhuiо обеспечения требуют определенного взаимодействия с пользовалелем. .\о«я бы ввода данных и команд. В простых си- туациях вводится несколько значении данных, а выходные данные выдаются в тривиальном формате. С другой стороны, пользователь может быть вынужден проходить по нескольким уровням меню и вводить большое количество разнооб- разных команд, как при работе с текстовым процессором. Создание интерфейса пользователя в подобных системах — сложная проблема Форма этого интерфейса создается разработчиком системы, а крп1епии его оценки очень похожи на крите- рии. используемые при оценке структуры языка программирования. Следователь- но. критический разбор языков программирования поможет при конструировании таких сложных систем, ботее того, он позволит пользователям изучить и оценить подобные программные продукты. Повсеместное распространение вынисаитеанной техники Ответить на во- прос. зачем нужно изучать концепции языков программирования, поможет общий обзор вычислительной техники Обычно можно определить, почему конкретный язык программирования стал популярным, но при этом далеко не очевидно, по крайней мере в ретроспективе, что самый популярный язык — самый лучший. Иногда причиной широкого распространения языка становится недостаточное знание пользователями общих концепций языков программирования. Например, в начале 1960-х годов многие полагали, что будет лучше заменить язык FORTRAN языком ALGOL 60 (Backus et al.. 1962). поскольку, помимо всего прочего, последний элегантнее и содержит более эффективные управляющие структуры. Подоб- ное предложение не поддержала часть программистов и менеджеров, руководивших разработками программного обеспечения, которые не вполне понимали концепции языка ALGOL 60. Им казалось, что его описание трудно даже просто прочитать (так оно и было), а понять — еше сложнее Они не приняли во внимание выгод блочной структуры, рекурсии и великолепно организованных управляющих операторов, поэтому они и не увидели преимуществ языка ALGOL 60 перед языкам FORTRAN. Разумеется, как показано в главе 2. на отказ от использования языка ALGOL 60 по- влияли многие факторы, но неосведомленность пользователей компьютеров также сыг- рала свою роль. Вообще, если люди, выбирающие язык программирования, будут полнее информиро- ваны. то можно надеяться, что лучшие языки быстрее вытеснят худшие. 26 Глава!. Вводные замечания
1.2. Области применения программирования Компьютеры применяются во множестве различных облас.еи >ч::о~^.?ю'тся как для управления атомными электростанциями, так и для хранения зи”исси о лнч&л чеко- вых книжках. Из-за такого разнообразия использования компьчэтерсь разработка языков программирования также ведется в различных направлениях В этом газдетс мы кратко рассмотрим несколько областей применения компьютеров и укажем ислоль?.юшиеся в этих областях языки программирования 1.2.1. Научные приложения Первые цифровые компьютеры, появившиеся в 1940-\ ^с-стч'овх-’чь и фак- гически изобретались для научных целей Обычно научные приложение иощют дело с простыми структурами данных и значительным количеством ар?фме'гичесч;:\ вычисле- ний, выполняемых над числами с плавающей точкой. Наиболее част? у потребляемыми структурами данных являются массивы и матрицы, из управляющих структур чаще дру- гих используются циклы со счетчиком и условные операторы Языки грогрзммирсвания высокого уровня, созданные для применения в научных исследованиях, разрабатывались именно для удовлетворения таких потребностей. Конкурентом этих языков быт язык ас- семблера. поэтому главной была эффективность Первым языков созданным для науч- ных приложений, был язык FORTRAN Язык ALGOL 60 и большинство е;с поюмков также предназначались для подобных целей, хотя при этом они могли ислольх-вазься и в других родственных областях Однако для научных приложений особо важна ^ффимив- ность. а ни один из созданных языков не оказался лучше языка FORTRAN 1.2.2. Коммерческие приложения Использование компьютеров в области коммерции началось в -ч? )-\ гсдах Для этой цели были разработаны специальные компьютеры и языки программирования Первым удачным языком высокого уровня для коммерческих целей бы; СОВО! 1 XN4!. ’). появившийся в I960 году и по сегодняшний день являющийся в этой области < ,чч>:м ши- роко используемым языком Деловые языки программирования отличаются ’‘W.o - стями генерации подробных отчетов, точными способами описания и. хранения сс^и”:- ных чисел и символьных данных, а также возможностью определять арифмеч*1 сечи действия с десятичными числами С появлением микрокомпьютеров возникли новые способы использование теров в деловой сфере, особенно в малом бизнесе. Для коммерческих целей бы разра- ботаны два специальных инструмента, широко используемых сейчас в малых нимгыотс- рах: системы крупноформатных электронных таблиц и системы баз данных Кроме языка COBOL, существует лишь несколько языков программирования. ^иен- тированных на коммерческую сферу применения. В книге эти языки не рассме. ривают- ся, за исключением главы 2. в которой излагается история развития языка СоК 1.2.3. Искусственный интеллект Искусственный интеллект (ИИ)— обширная область применения компьютеров от- личаюшаяся использованием символьных, а не числовых вычислений Под символьными вычислениями подразумевается манипулирование не с числами, а с символами Кроме 1.2. Области применения программирования 27
ioio. символьные вычисления удобнее производить с использованием связных списков idii 1ых. а не массивов. Символьные вычисления иногда требуют большей гибкости, чем г ие области программирования. Например, в некоторых приложениях искусственного ишеллскта очень удобно иметь возможность создавать и реализовывать фрагменты про- I рам мы в процессе ее выполнения. Первым широко используемым языком программирования, созданным для примене- ние в области искусственного интеллекта, был язык функционального программирова- ния LISP (McCarthx et al.. 1965). появившийся в 1959 году. Большинство программ в этой области были написаны именно на языке LISP или на одном из родственных языков. Од- нако в начале 1970-х годов возник альтернативный подход к решению этих задач — язык логического программирования Prolog (Clocksin and Mellish, 1997). Диалект языка LISP и '.i названием Scheme и язык Prolog рассматриваются соответственно в главах 14 и 15. 1.2.4. Системное программирование Операционные системы и все инструменты программной поддержки компьютерных систем называются системным программным обеспечением (system software). Оно ис- пользуется практически повсеместно и. следовательно, должно эффективно функциони- ровать. В связи с этим языки программирования, применяемые в этой области, должны обеспечивать быстрое выполнение программ. Более того, они должны иметь низкоуров- невые свойства, позволяющие писать программы, осуществляющие взаимодействие с внешними устройствами. В 1960-70-х годах такие производители компьютеров, как корпорация IBM, компа- ния Digital и компания Burroughs (теперь UNISYS), разработали для системного про- граммного обеспечения специальные машинно-ориентированные языки высокого уров- ня Для универсальных вычислительных машин корпорации IBM таким языком является PL S (диалект языка PL 1): для компьютеров компании Digital— это язык BLISS, нахо- дящийся всего на один уровень выше языка ассемблера; компания Burroughs в тех же це- лях использовала язык Extended ALGOL. Операционная система UNIX практически полностью написана на языке С (ANSI, bS9j. что позволяет относительно просто переносить ее на различные машины. Некото- рые характерные черты языка С способствуют его использованию в системном програм- мировании. Он низкоуровневый, эффективно функционирует и не обременяет пользова- теля больших! количеством ограничений. Люди, занимающиеся системным программи- рованием. обычно великолепные программисты и не верят в то, что подобные О1раничения им нужны. Тем не менее, некоторые считают язык С слишком опасным для использования в больших и важных системах программного обеспечения. 1.2.5. Языки подготовки сценариев Языки подготовки сценариев развивались постепенно в течение последних 25 лет. Про- I раммы на этих языках представляют собой исполняемые файлы, называемые сценария- ми. которые содержат перечень команд. Первым из таких языков был язык sh (сокр. от англ, shell — оболочка), первоначально состоявший из небольшого набора команд, интер- претируемых как вызовы системных подпрограмм, выполнявших служебные функции, на- пример. управление файлами и их простую фильтрацию. Затем к этой основе были добав- лены переменные, операторы потока управления, функции и многие другие возможности, и в результате получился завершенный язык программирования. Один из наиболее мощных и 28 Глава!. Вводные замечания
широко известных языков подготовки сценариев — язык ksr. (Bolsky and Кот. 1995). раз- работанный Дэвидом Корном (Da\ id Кот) из компании Bell Laboratory Еще один язык подготовки сценариев, awk (Aho et al.. 1988). был разработан сотруд- никами компании Bell Laboratory Алом Ахо (Al Aho). Питером Уайнбергером (Peter Wienberger). Брайаном Керниганом (Brian Kemighan). Вначале этот язык предназначался для генерации отчетов, но позже стал универсальным. Расширяемый язык подготовки сценариев tel был разработан в университете штата Калифорния в Беркли (University of California at Berkeley) Джоном Остераутом (John Ousterhout) (Ousterhout. 1994). Сейчас язык tel объединен с языком позволяющим создавать приложения в системе X Window. Разработанный Ларри Уоллом (Larry Wall) язык Perl сначала представлял со- бой комбинацию языков sr. и (Wall aet al.. 1996). После этого он получил значи- тельное развитие и сейчас является мощным, .хотя и несколько примитивным, языком программирования. Этот язык часто продолжают называть языком подготовки сценари- ев, но мы предпочитаем считать его странным, но вполне завершенным языком програм- мирования. С появлением W’orld Wide Web популярность языка Perl резко возросла, в основном благодаря тому, что он является практически идеальным языком для програм- мирования интерфейса CGI (Common Gateway Interface — общий шлюзовой интерфейс). Вообще говоря, языки подготовки сценариев внесли не очень большой вклад в разви- тие более традиционных языков программирования. Однако язык Perl имеет несколько интересных свойств, которые будут описаны далее в книге. 1.2.6. Специализированные языки программирования За последние 40 лет появилось множество специализированных языков программи- рования. В их число входит язык RPG. используемый для генерации деловых отчетов, язык APT, созданный для управления программируемыми устройствами, и язык GPSS. разработанный для моделирования систем. Специализированные языки программирова- ния не будут рассматриваться в книге, в основном из-за их узкой направленности, а так- же из-за того, что их трудно сравнивать с другими языками программирования. 1.3. Критерии оценки языков программирования Как указывалось ранее, цель книги — тщательно изучить концепции, лежащие в основе различных конструкций и возможностей языков программирования. Мы также оценим эти концепции, обратив особое внимание на то. как они влияют на разработку и эксплуатацию программного обеспечения. Для этого нам необходимы критерии оценки. Очевидно, пере- чень таких критериев неизбежно будет спорным, поскольку практически невозможно найти хотя бы двух специалистов по вычислительной технике, имеющих общую точку зрения на относительную ценность того или иного свойства языка. Несмотря на это. большинство ученых согласится с важностью критериев, предлагаемых ниже. Некоторые из характеристик, воздействующих на наиболее важные из этих критериев, указаны в таблице 1.1, а собственно критерии рассматриваются в следующих разделах. 1.3. Критерии оценки языков программирования 29
Таблица 1.1. Критерии оценки языков программирования и влияющие на них характеристики Критерии \о; актсристика Читабельность Легкость создания Надежность Простота Ортогональность • • • Управляющие структуры • • • I ииы и структуры данных • • • Синтаксическая структура Потдержка абстракции • • Выразительность • • Проверка типов • Отработка исключительных • ситуаций Ограниченное совмещение имен • 1.3.1. Читабельность Одним из важнейших критериев оценки языка программирования является легкость тения и понимания программ, написанных на нем. До 1970-х годов разработка про- граммного обеспечения в основном сводилась к созданию кода. Однако в 1970-х годах была создана концепция жизненного цикла программного обеспечения (Booch, 1987). Значение собственно кодирования команд снизилось, а основной частью жизненного цикла программного обеспечения стали считать его эксплуатацию, особенно с точки зрения стоимости. Поскольку легкость эксплуатации в основном определяется читабель- ностью программ, то читабельность стала важной характеристикой качества программ и языков программирования. Читабельность необходимо рассматривать только в контексте определенной при- кладной области. Если, к пример}, программа, описывающая вычисления, была написана на языке, для этого не предназначенного, то она может оказаться неестественной и запу- г энной. так что читать ее будет крайне трудно. В следующих подразделах описываются характеристики, способствующие улучше- нию читабельности языка программирования 1.3.1.1. Простота На читабельность языка программирования сильное влияние оказывает его простота. Прежде всего, язык, содержащий большое количество элементарных конструкций, изу- чить сложнее, чем язык, в котором их меньше. Программисты, вынужденные использо- вать большой язык, стремятся изучить лишь некоторое его подмножество и пренебречь остальными свойствами Такой способ изучения иногда оправдывают тем. что количест- во конструкций в языке слишком велико, однако этот аргумент неверен. Проблемы при чтении программы возникают всякий раз. когда автор и читатель программы изучали разные подмножества языка. Второй характеристикой языка программирования, снижающей читабельность про- грамм. является множественность свойств, т.е. наличие нескольких способов совершения какого-либо действия. Например, пользователь программирующий на языке С, может прибавить единицу кислому числу четырьмя различными способами: 30 Глава!. Вводные замечания
count = count ч 1 count +•= 1 count + + + + count Значения двух последних выражении несколько отличаются друг от друга и с.т шанс- кий остальных выражений, однако применение всех четырех приведет к одинаковым ре- зультатам. Все эти варианты рассматриваются в главе 6. Третьей потенциальной проблемой является перегрузка операторов, т.с. наличие у символа, обозначающего оператор, нескольких значений. Это свойство полезно однако может ухудшить читабельность программы, особенно если пользователи имеют право создавать собственные перегруженные операторы и делают это без разумных основании Например, вполне допустимо использовать оператор как для целочисленных «итера- ций. так и для операций с плавающей точкой. Такая перегрузка фактически упрощает язык, уменьшая число операторов. Предположим, однако, что программист определил оператор “*'* с двумя операндами, представляющими собой одномерные массивы. имея в виду суммирование всех элементов обоих массивов. Поскольку обычно сложение векто- ров имеет совершенно иной смысл, это собьет с толку как автора, так и чигатетей про- граммы. Еще большую путаницу может вызвать программа, в которой пользователь оп- ределит оператор “ + " с двумя векторными операндами, имея в виду вычисление разно- сти между первыми элементами этих векторов. Тема перегрузки операторов освещается далее в главе 6. Упрощение языка программирования может, конечно, зайти слишком далеко На- пример, изучая следующий раздел, вы можете заметить, что форма и смысл большинства операторов языка ассемблера являются эталонами простоты. Однако эта чрезмерная простота снижает читабельность программ, написанных на языке ассемблера. Из-за от- сутствия более сложных управляющих операторов эти программы имеют менее ясную структуру. Кроме того, вследствие простоты операторов их количество в программе, на- писанной на языке ассемблера, намного превышает количество операторов в эквива- лентной программе, написанной на языке высокого уровня Те же самые аргументы можно привести и при рассмотрении менее экстремального случая: языков высокого уровня с неадекватными управляющими операторами и структурами данных. 1.3.1.2. Ортогональность Ортогональность языка программирования означает, что управляющие опер,поры и структуры данных языка можно выразить с помощью относительно небольшою количе- ства элементарных конструкций (primitive constructs), пользуясь ограниченным числом способов. Более того, любая возможная комбинация этих элементарных конструкций разрешена и имеет смысл. Рассмотрим, например, типы данных. Предположим, чю язык содержит четыре элементарных типа данных: целый (integer), число с плавающей точкой (float), число с двойной точностью (double) и символьный (character), а также тва типа операторов: массив (array) и указатель (pointer) (poi nter). Если операторы обоих типов могут применяться к самим себе и к четырем элементарным типам данных, то можно определить большое количество структур данных. Однако, если указатели не moixt ука- зывать на массивы, многие из этих возможностей реализовать не удастся. Смысл термина “ортогональность языка программирования’* не зависит оз контекста, в котором это свойство проявляется в программе. (Название ‘‘ортогональность’ про- изошло от математического понятия ортогональных независимых векторов.) Ортою- 1.3. Критерии оценки языков программирования 31
нальность следует из симметрии отношений между элементарными элементами. Указа- 1 е in могут хранить адрес любой переменной или структуры данных. Отсутствие ортого- на п.ности ведет к появлению исключений из правил языков программирования. Использование ортогональности как структурной концепции мы можем проиллюст- рировать. сравнив один из аспектов языков ассемблера мейнфреймов корпорации IBM и серии суперминикомпьютеров компании VAX. Рассмотрим простую ситуацию: сложе- ние двух 32-битных целых чисел, хранящихся либо в памяти, либо в регистрах, и замена одной из этих величин суммой. В мейнфреймах корпорации IBM для этой цели исполь- зукхся две команды, имеющие следующую форму: Regl, memory__cell .'-.г Regl, Reg2 Здесь Regl и Reg2 представляют собой регистры, a memory_cell — ячейку памя- ти. Эти команды имеют следующую семантику: ?eagl «—содержимое(Regl) + содержимое(memory_cell) reagl «—содержимое(Regl) + содержимое(Reg2) Команда сложения 32-битных целых чисел в компьютерах компании VAX выглядит так: ADDL operand_l, operand_2 Эта команда имеет следующую семантику: грегапо_2 «—содержимое(operand_l) + содержимое(operand_2) В этом случае второй операнд может быть как регистром, так и ячейкой памяти. Структура команд компьютеров VAX ортогональна в том смысле, что отдельная ко- манда в качестве операнда может использовать как регистры, так и содержимое ячейки памяти. Существуют два способа определения операторов, которые затем можно комби- нировать всеми возможными способами. Структура, использующаяся в компьютерах IMB. не ортогональна, поскольку из четырех возможных комбинаций операндов разре- шены только две. а еще две требуют дополнительных команд А и AR. Следовательно, стру ктура мейнфреймов корпорации IBM более ограничена и, значит, сложнее в исполь- зовании. Вы не можете, например, сложить две величины и сохранить сумму в ячейке памяти. Более того, изучение структуры мейнфреймов корпорации IBM осложняется ог- раничениями и дополнительными командами. Ортогональность тесно связана с простотой: чем более ортогональной является структура языка, тем меньше исключений из правил. Меньше исключений— значит, язык более систематичен, его легче изучать, а программы — легче читать и писать. Лю- бой человек, глубоко изучавший английский язык, может подтвердить, как трудно за- помнить его многочисленные исключения из правил (например, употребление буквы i перед буквой е, кроме случая, когда эта комбинация следует за буквой с). В качестве примера недостатка ортогональности в языках высокого уровня, прояв- ляющегося в виде исключений, рассмотрим следующие правила языка С. Несмотря на то что этот язык содержит две разновидности структур данных: массивы и записи (под об- щим названием struct), записи могут возвращаться функциями, а массивы — нет. Членом структуры может быть переменная любого типа, за исключением переменной, имеющей тип void, и структуры того же типа. Элемент массива может иметь любой тип. за исключением типа void, кроме того, они не могут быть функциями. Параметры 32 Глава!. Вводные замечания
передаются по значению, если они не являются массивами. Если параметр представляет собой массив, то он передается по ссылке (поскольку имя массива без индексов интер- претируется в программах на языке С как адрес первого элемента массива). Простейшее выражение, содержащее операцию сложения, например а + b обычно означает извлечение значений величин а и b из памяти и их сложение. Однако, ес- ли переменная а окажется указателем, то извлеченное значение переменной о может изме- ниться до того, как будет выполнена операция сложения. Если, к примеру, переменная а указывает на величину, размером в два байта, то перед сложением значение переменной b умножается на 2. Тип переменной а, являющийся левым контекстом выражения + b вызывает изменение значения переменной b до сложения этой величины со значением переменной а. Излишняя ортогональность также может стать источником проблем. Вероятно, са- мым ортогональным языком программирования является ALGOL 68 (van Wijngaarden, 1969). Каждая языковая конструкция этого языка имеет некий тип. и на эти типы не на- ложено никаких ограничений. Кроме того, большинство конструкций вычисляют неко- торые значения. Такая свобода комбинирования порождает крайне сложные конструк- ции. Например, поскольку результатом всех вычислений является адрес ячейки памяти, то в левой части оператора присваивания могут находиться как условный оператор, так и объявления и другие операторы. Такая крайняя форма ортогональности приводит к из- лишней сложности. Более того, поскольку языки программирования требуют большого количества элементарных конструкций, высокая степень ортогональности приводит к неконтролируемому росту количества возможных комбинаций. Таким образом, даже ес- ли эти комбинации просты, их общее число приводит к усложнению языка. Следовательно, простота языка отчасти является результатом комбинирования отно- сительно небольшого числа элементарных конструкций и ограниченного применения концепции ортогональности. Некоторые полагают, что языки функционального программирования предлагают хо- рошее соотношение простоты и ортогональности. В таких языках функционального про- граммирования, как LISP, вычисления преимущественно производятся посредством применения функций к заданным параметрам. Напротив, в таких императивных языках программирования, как С, Pascal и Java, вычисления, как правило, определяются пере- менными и операторами присваивания. Языки функционального программирования по- тенциально наиболее просты, поскольку они могут выполнять все что угодно с помощью простой конструкции, а именно: вызова функции, который может легко комбинироваться с другими вызовами функций. Вследствие этой элегантности некоторые исследователи отдают предпочтение языкам функционального программирования как основной альтер- нативе таким сложным языкам нефункционального программирования, как О+ (Ellis and Stroustrup, 1990). Однако следует заметить, что широкому распространению языков функционального программирования мешают другие факторы, например, их низкая эф- фективность. 1.3. Критерии оценки языков программирования 33
1.3.1.3. Управляющие операторы Революция в структурном программировании, произошедшая в 1970-х годах, отчасти была вызвана плохой читабельностью языков, разработанных в 1950-60-х годах, в кото- рых ощущался недостаток управляющих операторов. В частности, широко распростра- ненным стало мнение, что беспорядочное использование операторов безусловного пере- хода goto значительно снижает читабельность программы. Программа, которую можно прочесть от начала до конца, значительно проще для понимания, чем программа, для прослеживания хода выполнения которой читателю требуется переходить от одного опе- ратора к другому, находящемуся в любом месте программы. Однако в некоторых языках использование операторов goto для возврата назад просто необходимо; например, эти операторы нужны для построения циклов WHILE в языке FORTRAN 77. Улучшить чита- бельность программы могут следующие ограничения, налагаемые на использование опе- раторов безусловного перехода to. Операторы безусловного перехода должны предшествовать точке, в которую пе- редается управление, за исключением случая, когда они используются для созда- ния циклов. Точки перехода никогда не должны находиться слишком далеко. Количество операторов безусловного перехода должно быть ограничено. Использовавшимся в начале 1970-х годов версиям языка BASIC и FORTRAN не дос- тавало управляющих операторов, которые налагали бы строгие ограничения на исполь- зование операторов безусловного перехода, поэтому писать удобочитаемые программы на этих языках было трудно. Однако большинство языков программирования, появив- шихся позднее, имело достаточное количество управляющих операторов, так что потреб- ность в операторе безусловного перехода практически отпала. Следовательно, управ- ляющие операторы языка уже не так влияют на его читабельность, как это было раньше. 1.3.1.4. Типы и структуры данных Существенный вклад в улучшение читабельности программы вносят адекватные средства определения типов и структур данных. Предположим, что в качестве индикато- ра в некотором языке используется числовой тип данных, поскольку булевского типа данных в этом языке нет. В таком случае, например, присваивание sum_is_too_big = 1 будет непонятным, в то время как в языке, имеющем булевский тип данных, мы можем записать выражение sum_is_too_big = true Смысл этого выражения абсолютно понятен. Аналогично, тип данных, называемый за- писью. обеспечивает более читабельный способ хранения информации о сотрудниках, чем набор сходных массивов, в каждом из которых хранится отдельный элемент данных, как это бывает, если в языке не предусмотрена поддержка записей. В языке FORTRAN 77, на- пример, информация о сотрудниках может храниться в следующих массивах: CHARACTER (LEN = 30) NAME (100) INTEGER AGE (100), EMPLOYEE_NUMBER (100) REAL SALARY (100) 34 Глава!. Вводные замечания
Таким образом, информация о каждом сотруднике представлена элементами этих че- тырех массивов, имеющих одинаковые индексы. 1.3.1.5. Анализ синтаксической структуры Синтаксис, или форма, элементов языка программирования оказывает значительное влияние на читабельность программы. Ниже перечислены три элемента синтаксической структуры языка, воздействующей на читабельность программы. Формы идентификаторов. Ограничение длины идентификаторов до очень ма- леньких размеров уменьшает читабельность. Если размер идентификатора не мо- жет превышать шести символов, как в языке FORTRAN 77. то часто просто не- возможно использовать осмысленные названия переменных. Еще большей край- ностью является созданный Национальным институтом стандартизации США (ANSI — American National Standards Institute) язык BASIC (ANSI, 1978b). в кото- ром идентификатор может состоять только из одной буквы или сочетания, со- стоящего из буквы и следующей за ней цифры. К вопросу о формах идентификаторов мы еще вернемся в главе 4. Специальные слова. На внешний вид программы и. следовательно, на ее чита- бельность значительно влияет форма специальных слов языка (например, begin, end и for). Особенно важен, главным образом в управляющих структурах, спо- соб образования составных операторов, или групп операторов. В некоторых язы- ках для формирования групп используются согласованные пары специальных слов или символов. В языке Pascal, например, для всех управляющих структур, кроме оператора repeat, используется пара специальных слов begin-end. причем даже там, где она может быть пропущена (свидетельство недостатка ортогональ- ности в языке Pascal). В языке С с той же целью используются фигурные скобки. В обоих языках недостатком является единообразное завершение группы опера- торов. поскольку довольно трудно определить, какая именно группа замыкается очередным оператором end или символом }. В языках FORTRAN 90 и Ada эта проблема не так остра, поскольку для каждого типа группы операторов использу- ется отдельный синтаксис замыкания. В языке Ada, например, для замыкания кон- струкции ветвления используется оператор end if, а для замыкания цикла — оператор end loop. Это — пример противоречия между простотой языка Pascal, приводящей к малому количество зарезервированных слов, и лучшей читабельно- стью языка Ada, обеспеченной многочисленными зарезервированными словами. Другим важным вопросом является использование специальных слов языка в ка- честве имен переменных в программе. Если такая возможность есть, то программа может оказаться очень запутанной. Например, в языке FORTRAN 90 такие специ- альные слова, как DO и END. можно использовать в качестве имен переменных, так что их появление в программе может не означать ничего особенного. Форма и значение. Облегчить чтение программы могут также операторы, само появление которых хотя бы отчасти указывает на их цели. Семантика, или смысл, может следовать непосредственно из синтаксиса, или формы. В некоторых случа- ях этот принцип нарушается двумя языковыми конструкциями, идентичными или похожими по форме, но различными по смыслу, который может зависеть от кон- текста. В языке С. например, значение зарезервированного слова static зависит 1.3. Критерии оценки языков программирования 35
от контекста, в котором оно появляется. Если оно использовано при определении некоторой переменной внутри функции, это означает, что данная переменная соз- дается во время компиляции. Если слово static используется при определении переменной вне всех функций, это означает, что она является видимой только в файле, содержащем данное описание. Следовательно, эта переменная не может экспортироваться из данного файла. Одна из основных жалоб на команды оболочки операционной системы UNIX (Kemighan and Pike, 1984) связана с тем, что названия этих команд не всегда связаны с их предназначением. Например, название используемой в системе UNIX команды grep может быть расшифровано только при наличии предварительных знаний или должной смекалки, а также опыта работы с текстовым редактором ed системы UNIX. Для начи- нающих пользователей системы UNIX появление указанной команды ни о чем не гово- рит. (В текстовом редакторе ed команда /стандартное выражение/ выполняет поиск подстроки, совпадающей с указанным стандартным выражением. Если предварить эту команду атрибутом д, то ее выполнение станет глобальным, определяя весь редактируе- мый файл как область поиска. Употребление атрибута р после указанной команды при- ведет к печати строки, содержащей искомое выражение. Таким образом, команда g/стандартное выражение/р, которая сокращается до grep (по первым буквам анг- лийских слов, образующих эту команду), печатает все строки файла, содержащие под- строку, совпадающую с искомым стандартным выражением.) 1.3.2. Легкость создания программ Легкость создания программ характеризует удобство языка для создания программ в выбранной области. Легкость создания программ в большинстве случаев определяется те- ми же характеристиками, что и читабельность. Это объясняется тем, что в процессе созда- ния программы автор вынужден часто перечитывать уже написанные части программы. Так же, как и читабельность, легкость создания программ должна рассматриваться в контексте конкретной области применения языка. Просто неразумно сравнивать легкость использования двух языков для решения одной задачи, когда один из языков был разра- ботан именно для этого, а другой — нет. Например, легкость создания программ на язы- ках COBOL (ANSI, 1985) и APL (Gilman and Rose, 1976) совершенно различается при обработке двумерных структур, поскольку язык APL подходит для этой цели идеально. А создавать сложные финансовые отчеты намного легче на языке COBOL, поскольку он разрабатывался специально для этой цели, а язык APL — нет. В следующих подразделах описаны важнейшие факторы, влияющие на легкость ис- пользования языка. Т.3.2.1. Простого и ортогональность Если язык содержит много разнообразных конструкций, то некоторые программисты могут просто не знать каждую из них. Это приводит к неправильному использованию одних свойств и игнорированию других. В результате программисты пишут менее изящ- ные и менее эффективные программы, чем могли бы. Как отметил в 1973 году Хоар (Ноаге), некоторые неизвестные свойства могут быть использованы случайно, что при- водит к странным результатам. Следовательно, использовать небольшое количество эле- ментарных конструкций и согласованные между собой правила их комбинирования (т.е. ортогональность) намного лучше, чем применять большое количество примитивов. 36 Глава!. Вводные замечания
В этом случае программист может решить сложную проблему, изучив лишь небольшой набор элементарных конструкции. С другой стороны, слишком ортогональная структура усложняет использование языка. Если можно использовать любую комбинацию элементарных конструкций, то ошибки, до- пущенные при создании программы, могут просто остаться незамеченными. Это приведет к логическим противоречиям в программе, которые не сможет выявить компилятор. 1.З.2.2. Поддержка абстракции Кратко говоря, абстракция— это возможность определять, а затем использовать сложные структуры или операции, игнорируя при этом многие детали. Абстракция — это ключевая концепция разработки современных языков программирования. Следовательно, высокая степень абстракции, допускаемая языком программирования и его выражениями, значительно облегчает его использование. Языки программирования поддерживают две различные категории абстракции: абстракцию процессов и абстракцию данных. Простым примером абстракции процесса может служить подпрограмма, реализую- щая алгоритм сортировки, которая несколько раз вызывается в разных местах главной программы. Если бы этой подпрограммы не было, то команды, выполняющие сортиров- ку, следовало бы внедрять везде, где это нужно, что очень сложно и утомительно. Кроме того, в этом случае детали алгоритма сортировки загромоздили бы основную программу, делая неясным ее общий смысл. В качестве примера абстракции данных можно рассмотреть двоичное дерево, храня- щее в своих узлах целочисленные данные. На языке FORTRAN 77 такое дерево обычно реализуется в виде трех параллельных целочисленных массивов, в которых два целых числа используются в качестве индексов для идентификации положения данных на дере- ве. На языках C++ и Java эти деревья можно реализовать, описав узел дерева в форме простого класса, содержащего два указателя и целое число. Такое описание является бо- лее естественным, поэтому написать программу, использующую двоичное дерево, на языке, поддерживающем такую абстракцию, легче, чем на языке FORTRAN 77. Это оз- начает только то, что следует выбирать язык, наиболее близкий к расматриваемой пред- метной области. Очевидно, что полная поддержка абстракции значительно влияет на легкость исполь- зования языка. 1.3.2.3. Выразительность Выразительность языка может относиться к нескольким различным характеристикам. В языке, подобном языку APL. она означает наличие очень мощных операторов, позво- ляющих производить большое количество вычислений с помощью очень маленькой про- граммы. В более широком смысле выразительность позволяет производить вычисления более удобными и менее громоздкими способами. Например, в языке С запись count++ удобнее и короче записи count = count + 1. Аналогично, булевские операторы and и then в языке Ada помогают описать сокращенное вычисление булевских выражений. В языке Pascal циклы со счетчиком проще создавать с помощью оператора for. чем с помощью оператора while. Очевидно, что все перечисленные возможности облегчают использование языка. 1.3. Критерии оценки языков программирования 37
1.3.3. Надежность Программа называется надежной, если она соответствует своему предназначению в любых условиях. В следующих подразделах описаны языковые свойства, имеющие зна- чительное влияние на надежность программ, написанных на данном языке. 1.3.3.1. Проверка типов Проверка типов — это обычная проверка совместимости типов в программе, осуще- ствляемая при компиляции либо при выполнении программы. Проверка типов является важным фактором надежности языка. В целях экономии рекомендуется проводить про- верку в процессе компиляции. Более того, чем раньше в программе будет обнаружена ошибка, тем дешевле обойдется ее устранение. Структура языка Ada требует проверки типов практически всех переменных и выражений в процессе компиляции, если только пользователь не дал явного указания заблокировать проверку типов. Такой подход фак- тически устраняет ошибки типов при выполнении программы, написанной на языке Ada. Более подробно типы и проверка типов описаны в главах 4 и 5. Одним из примеров того, как отказ от проверки типов при компиляции или при вы- полнении может привести к многочисленным ошибкам в программе, служит использо- вание параметров подпрограмм в первоначальной версии языка С (Kemighan and Ritchie, 1978). В этом языке совпадение типов фактического и формального параметров при вы- зове функции не проверялось. Переменная типа int могла использоваться в качестве действительного параметра при вызове функции, ожидавшей формального параметра, имевшего тип float, и ни компилятор, ни система поддержки выполнения программ не могли выявить это несоответствие. Это, естественно, приводило к проблемам, источник которых было трудно определить. (Для решения таких проблем в операционную систему UNIX была включена сервисная программа lint, выполнявшая проверку типов в про- граммах, написанных на языке С.) Подробнее о подпрограммах и методах передачи па- раметров рассказывается в главе 8. В языке Pascal диапазон изменения индексов массива является частью типа перемен- ной, представляющей собой массив. Следовательно, проверка диапазона изменения ин- дексов является частью проверки типов, хотя она должна производиться во время вы- полнения программы. Поскольку в языке Pascal контролируется большинство типов, то в нем выполняется также проверка диапазона изменения индексов. Такая проверка крайне важна для обеспечения надежности программы, поскольку индексы, выходящие за пре- делы допустимого диапазона, часто создают проблемы, проявляющиеся намного позже того момента, когда действительно произошло нарушение. Аналогичная проверка диапа- зона индексов проводится также в языках Ada и Java. 1.3.3.2. Обработка исключительных ситуаций Обработка исключительных ситуаций позволяет перехватывать ошибки и другие необычные ситуации во время выполнения программы, принимать соответствующие меры, а затем продолжать работу. Это значительно повышает надежность программ. Языки Ada, C++ и Java позволяют обрабатывать исключительные ситуации, однако во многих широко используемых языках, таких как С или FORTRAN, эти возможности практически отсутст- вуют. Более подробно обработка исключительных ситуаций рассмотрена в главе 13. 38 Глава 1. Вводные замечания
1.3.3.3. Совмещение имен Совмещение имен — это наличие нескольких разных имен у одной и той же ячейки памяти. В наше время широко распространено мнение, что совмещение имен — опасная функция языка программирования. Большинство языков программирования допускают не- которые виды совмещения имен — например, в языке С указатели и члены объединения могут ссылаться на одну и ту же переменную. В обоих случаях две различные переменные в программе могут ссылаться на одну и ту же ячейку памяти. Некоторые разновидности со- вмещения имен, как описывается в главах 4 и 8, могут запрещаться структурой языка. В некоторых языках совмещение имен используется для компенсации недостатков языковых средств, поддерживающих абстракцию данных. В других языках совмещение имен значительно ограничивается для повышения надежности. Т.3.3.4. Легкость чтения и использования На надежность программы влияет как легкость ее чтения, так и легкость ее создания. В программе, написанной на языке, не поддерживающем естественные способы выражения требуемых алгоритмов, обязательно будут использоваться неестественные методы, а такие методы наверняка не будут корректно работать во всех возможных ситуациях. Чем легче написать программу, тем выше вероятность того, что она будет правильно работать. Читабельность влияет на надежность программы и на стадии создания, и на стадии ее эксплуатации. Программу, которую сложно прочесть, одинаково трудно будет писать и редактировать. 1.3.4. Стоимость Суммарная стоимость языка программирования зависит от многих его характеристик. Во-первых, в стоимость входят затраты на обучение программистов данному языку. Эта сумма зависит от простоты и ортогональности языка, а также от опыта программи- стов. Как правило, более мощные языки изучать труднее. Во-вторых, в сумму входит стоимость создания программ на данном языке. Она зави- сит от легкости использования языка, зависящей, в свою очередь, от того, насколько он подходит для конкретного приложения. Первые попытки разработать и реализовать язы- ки высокого уровня были вызваны желанием уменьшить затраты на создание программ- ного обеспечения. Стоимость подготовки программистов и создания программ может быть значительно уменьшена за счет выбора хорошей среды программирования. Подробнее среды про- граммирования рассматриваются в разделе 1.7 данной главы. Третьим фактором, влияющим на стоимость языка, являются затраты на компиляцию программ. Основным препятствием для использования языка Ada на первых порах была именно чрезмерно высокая стоимость работы его компиляторов первого поколения. С появлением более совершенных компиляторов языка Ada острота этой проблемы была снижена. Четвертым фактором является стоимость выполнения программы, написанной на данном языке. Она значительно зависит от структуры языка. Язык, требующий много- численных проверок типов во время выполнения программы, будет препятствовать бы- строй работе программы независимо от качества компилятора. Эффективность выполне- ния программ была основной целью при разработках ранних языков программирования, однако сейчас ее считают менее важной. 1.3. Критерии оценки языков программирования 39
Между стоимостью компиляции и скоростью выполнения программы может быть достигнут простой компромисс. Совокупность способов, которые компилятор может ис- пользовать для уменьшения размера и/или увеличения скорости выполнения программы, называется оптимизацией. Если оптимизация применяется в небольших объемах или иг- норируется вообще, то компиляцию можно выполнить намного быстрее, чем в случае, когда для создания оптимизированной программы прилагаются значительные усилия. В свою очередь, дополнительная работа компилятора, направленная на оптимизацию ко- да, ускорит выполнение программы. Выбор между этими двумя альтернативами опреде- ляется средой, в которой используется компилятор. В лаборатории для начинающих сту- дентов-программистов, затрачивающих большую часть времени на компиляцию про- грамм, время выполнения которых незначительно (их программы невелики и должны правильно отработать только один раз), следует осуществлять незначительную оптими- зацию кода или не применять ее совсем. В производственной среде, где завершенные программы выполняются многократно, денег на оптимизацию жалеть не стоит. Пятым фактором, влияющим на стоимость языка, являются затраты на систему реа- лизации языка. Одна из причин быстрой популярности языка Java — бесплатное распро- странение систем компиляции/интерпретации вскоре после разработки первой версии языка. Язык, система реализации которого либо слишком дорога, либо работает только на дорогостоящем аппаратном обеспечении, имеет меньше шансов получить широкое распространение. Шестой фактор — стоимость низкой надежности. Если программное обеспечение бу- дет давать сбои в таких жизненно важных системах, как атомные электростанции или рентгеновские аппараты, то стоимость окажется очень высокой. Сбои в некритических системах также могут быть дорогими, если несовершенные системы программного обес- печения приведут к срыву потенциальных сделок или проигрышу судебных процессов. Последней мы рассмотрим стоимость эксплуатации Программ, в которую входят вне- сение исправлений и осуществление модификаций с целью добавления новых возможно- стей. Стоимость сопровождения программного обеспечения зависит от многих характе- ристик языка, но, в первую очередь, от его читабельности. Поскольку поддержка зачас- тую производится не авторами программного обеспечения, то плохая читабельность может крайне усложнить эту задачу. Важность удобства эксплуатации программного обеспечения трудно переоценить. Исследователями были получены оценки, свидетельствующие о том, что затраты на экс- плуатацию больших систем программного обеспечения, существующих относительно долго, могут быть в 2-4 раза больше затрат на их разработку (Sommerville, 1992). Из перечисленных факторов, влияющих на стоимость языка, три являются основны- ми: разработка программы, ее сопровождение и надежность. Поскольку указанные фак- торы зависят от легкости создания и использования программы, то эти два критерия оценки также очень важны. Разумеется, существует множество других критериев оценки языков программирова- ния. Одним из них, например, является мобильность, определяющая легкость, с которой программу можно переносить из одной реализации в другую. На мобильность значи- тельно влияет уровень стандартизации языка. Некоторые языки, например BASIC, со- всем не стандартизированы, что затрудняет перенос программ, написанных на этих язы- ках. из одной реализации в другую. Стандартизация — долговременный и сложный про- цесс. Комитет начал работу по выработке унифицированной версии языка C++ в 1989 году. К концу 1998 года этот процесс еще не был завершен. 40 Глава!. Вводные замечания
Еще два критерия оценки языка — универсальность (применимость к широкому кру- гу задач) и четкость (полнота и точность официального описания языка). Большинство критериев, в частности читабельность, легкость создания и надежность, не являются ни строго определенными, ни точно измеримыми. Тем не менее, эти кон- цепции полезны и дают ценную возможность выявить суть структуры и оценки языков программирования. Последнее замечание: критерии разработки языка имеют разный вес при рассмотрении с разных точек зрения. Разработчиков систем реализации языков в основном интересует сложность реализации конструкций и свойств языка. Пользователей языка в первую оче- редь волнует легкость создания, а во вторую— читабельность. Разработчики языков про- граммирования придают особое значение элегантности и возможности широкого исполь- зования языка. Кроме того, указанные характеристики иногда противоречат одна другой. 1.4. Факторы, влияющие на разработку языка Кроме характеристик, описанных в разделе 1.3, существует еще несколько факторов, влияющих на разработку языка программирования. Важнейшие из них— архитектура компьютера и методологии программирования. 1.4.1. Архитектура компьютера Базовая архитектура компьютера оказывает решающее влияние на разработку языка. Большинство популярных языков последних 35 лет разрабатывались на основе сложив- шейся архитектуры компьютера, названной по имени одного из ее авторов — Джона фон Неймана (John von Neumann)— неймановской архитектурой. Эти языки программирова- ния называются императивными. В компьютере Неймана данные и программы хранят- ся в одной и той же памяти. Центральный процессор (CPU — central processor unit), вы- полняющий команды, от памяти отделен. Следовательно, команды и данные должны на- правляться, или передаваться, процессору из памяти. Результаты операций, выполнен- ных процессором, должны возвращаться в память. На неймановской архитектуре ком- пьютера основаны практически все цифровые вычислительные машины, созданные по- сле 1940-х годов. Общая структура компьютера Неймана показана на рис. 1.1. Главными элементами императивных языков программирования, основанных на неймановской архитектуре компьютера, являются переменные, которые моделируют ячейки памяти; операторы присваивания, основанные на операции пересылки данных; а также итеративная форма повторений, являющаяся наиболее эффективным методом в этой архитектуре. Операнды выражений передаются из памяти в процессор, а результат вычисления выражения возвращается в ячейку памяти, представляемую левой частью оператора присваивания. Поскольку команды хранятся в соседних ячейках памяти, то итерации на компьютере Неймана выполняются быстро. Эта эффективность не способ- ствует использованию рекурсий для повторения операций, хотя применение рекурсии часто более естественно. 1.4. Факторы, влияющие на разработку языка 41
Устройства ввода-вывода Центральный процессор Рис. 1.1. Архитектура компьютера по Нейману Как указывалось ранее, в функциональных, или прикладных, языках программирования вычисления в основном производятся посредством применения функций к заданным пара- метрам. Программирование на функциональном языке может осуществляться без тех видов переменных, которые используются в императивных языках программирования, без опера- торов присваивания и без итераций. Хотя многие ученые в области компьютерных наук приводят миллионы доводов в пользу таких функциональных языков программирования, как LISP, не похоже, что эти языки заменят императивные языки программирования, по крайней мере, до тех пор, пока не будет разработан компьютер с архитектурой, отличной от неймановской, эффективно выполняющий программы, написанные на функциональных языках программирования. Среди людей, недовольных этим фактом, выделяется Джон Бэ- кус. главный разработчик исходной версии языка FORTRAN (Backus, 1978). Машины с параллельной архитектурой, появившиеся за последние 15 лет, дают на- дежду, что программы, написанные на функциональных языках, будут выполняться бы- стрее, но этого совершенно недостаточно для того, чтобы сделать их конкурентоспособ- ными по сравнению с программами, написанными на императивных языках программи- рования. Действительно, несмотря на то что существуют элегантные способы исполь- зования параллельной архитектуры для выполнения программ на функциональных язы- ках программирования, большинство машин с параллельной архитектурой используется для выполнения программ, написанных на императивных языках, в частности программ, написанных на диалектах языка FORTRAN. 1.4.2. Методологии программирования В конце 1960-х-начале 1970-х годов началось интенсивное изучение структурного программирования и его применения для разработки программного обеспечения и язы- ков программирования. Важной причиной этих исследований было смещение основных статей расходов с аппаратного на программное обеспечение, произошедшее вследствие 42 Глава!. Вводные замечания
уменьшения стоимости микросхем и увеличения стоимости программ. Увеличение про- изводительности работы программистов было относительно небольшим. Кроме того, с помощью компьютеров решались все большие и сложные задачи. Программы писались не только для простого решения систем уравнений, моделирующих траектории спутни- ков, как в начале 1960-х годов, но и для таких задач, как управление большими нефтепе- регонными заводами и всемирная система бронирования авиабилетов. Новые методологии разработки программного обеспечения, возникшие в результате исследований, проведенных в 1970-х годах, были названы нисходящим проектированием и пошаговым уточнением. Основными обнаруженными недостатками языков програм- мирования были неполная проверка типов и нехватка управляющих операторов (приводившая к широкому использованию операторов goto). В конце 1970-х годов началось смещение центра внимания от процедурно-ориен- тированных к информационно-ориентированным методологиям разработки программ. Проще говоря, информационно-ориентированные методы придавали особое значение структуре данных, сосредоточившись на использовании абстрактных типов данных для решения проблем. Для того чтобы эффективно использовать абстракцию данных при разработке про- граммного обеспечения, нужно было обеспечить ее поддержку языками программирова- ния. Первым языком, предназначенным для ограниченной поддержки абстракции дан- ных, был язык SIMULA 67 (Birtwistle et al., 1973), хотя это и не принесло ему популярно- сти. Преимущества абстракции данных начали осознаваться только в начале 1970-х годов. Тем не менее, большинство языков, созданных с конца 1970-х годов, имеют эту возможность. Подробно абстракция данных рассматривается в главе 10. Последним этапом эволюции информационно-ориентированного программного обеспечения, начавшимся в начале 1980-х, стало возникновение объектно-ориенти- рованной методологии разработки языков. Объектно-ориентированная методология со- стоит из трех основных частей: абстракции данных, инкапсулирующей в одном объекте данные вместе с методами их обработки и скрывающей доступ к данным, а также насле- дования и динамического связывания. Наследование — это мощная концепция, значи- тельно усилившая возможность многократно использовать существующее программное обеспечение и. соответственно, резко повысившая производительность труда при разра- ботке программ. Наследование— это важный фактор увеличения популярности объект- но-ориентированных языков программирования. Более гибко использовать наследование позволяет динамическое связывание, осуществляемое в ходе выполнения программы. Объектно-ориентированное программирование развивалось одновременно с языком Smalltalk (Goldberg and Robson, 1983), поддерживающим его концепцию. Несмотря на то что язык Smalltalk не получил такого распространения, как некоторые другие языки, под- держка объектно-ориентированного программирования теперь включена в популярнейшие императивные языки программирования, к которым относятся языки Ada 95 (AARM, 1995), Java и C++. Концепции объектно-ориентированного программирования также нашли свое применение в функциональном программировании— в языке CLOS (Bobrow, 1988) и в логическом программировании— в языке Prolog++. Языковая поддержка объектно- ориентированного программирования подробно рассматривается в главе 11. Процедурно-ориентированное программирование в некотором смысле является противоположностью информационно-ориентированного программирования. Методы последнего сейчас преобладают в разработке программного обеспечения, но и методы процедурно-ориентированного программирования еще не забыты. Наоборот, в последние 1.4. Факторы, влияющие на разработку языка 43
годы значительная часть исследований ведется именно в области процедурно-ориен- тированного программирования, особенно это относится к области параллельного выполнения программ. В результате этих научно-исследовательских работ возникла необходимость разработать языковые средства, позволяющие создавать параллельные программные модули и управлять ими. Такими свойствами обладают языки Ada и Java. Таким образом, развитие программирования снова требует от языков новых возможностей. Более подробно вопросы, связанные с параллельностью, рассматриваются в главе 12. 1.5. Категории языков Часто языки программирования делят на четыре типа: императивные, функциональ- ные, логические и объектно-ориентированные. Мы уже обсуждали характеристики им- перативных и функциональных языков программирования. Кроме того, выше описано, как наиболее популярные объектно-ориентированные языки программирования про- изошли от императивных. Несмотря на то что парадигма разработки объектно-ориен- тированного программного обеспечения значительно отличается от процедурно- ориентированной парадигмы, обычно используемой в императивных языках программи- рования, вполне возможно расширить возможности императивных языков за счет объ- ектно-ориентированных свойств. Например, выражения, операторы присваивания и управляющие операторы в языках С и Java практически идентичны. (С другой стороны, массивы, подпрограммы и семантика этих языков различаются значительно.) Логический язык программирования — пример языка, основанного на продукцион- ных правилах. В императивном языке программирования алгоритмы описываются очень подробно с обязательным указанием порядка выполнения команд или операторов. В языке, основанном на продукционных правилах, не существует определенного порядка применения правил, и система реализации языка должна сама выбрать нужный порядок выполнения команд, который приведет к желаемому результат}'. Такой подход к разра- ботке программного обеспечения радикально отличается от подходов, используемых в остальных трех группах языков программирования и, безусловно, требует абсолютно иных языков. Самый популярный язык логического программирования Prolog и собст- венно логическое программирование описываются в главе 15. С языками программирования иногда путают такие языки разметки, как HTML. Од- нако эти языки не описывают вычислений— они всего лишь задают общий вид доку- мента. Тем не менее, к языкам разметки применимы многие методы разработки и крите- рии оценки, описываемые в данной главе. Помимо всего прочего, вполне очевидна важ- ность читабельности и легкости создания кодов разметки. 1.6. Компромиссы при разработке языка Описанные в разделе 1.3 критерии оценки языков программирования создают основу для разработки языка. К сожалению, эта основа внутренне противоречива. В своей про- ницательной статье, посвященной вопросам разработки языков программирования, Хоар отметил: “Существует так много важных, но противоречивых критериев, что их согласо- вание между собой и удовлетворение является основной задачей проектирования” (Ноаге, 1973). 44 Глава!. Вводные замечания
Примером пары конфликтующих между собой критериев могут служить надежность и стоимость выполнения программы. Например, в языке Ada выполняется проверка всех ссылок на элементы массива, для того чтобы гарантировать, что индекс или индексы на- ходятся в допустимых пределах. Это значительно повышает стоимость выполнения про- грамм. написанных на языке Ada. содержащих большое количество ссылок на элементы массивов. В языке С проверка пределов изменения индексов не предусмотрена, так что программы на языке С выполняются быстрее, чем семантически эквивалентные про- граммы на языке Ada. хотя надежность выше у последних. Разработчики языка Ada по- жертвовали эффективностью выполнения в пользу надежности. В качестве другого примера конфликтующих между собой критериев, прямо приво- дящих к поиску компромиссов при разработке, рассмотрим язык APL. Этот язык содер- жит мощный набор операторов для действий с массивами. Из-за большого количества этих операторов для их представления в язык APL пришлось включить значительное число новых символов. Кроме того, многие операторы языка APL могут использоваться в отдельных длинных и сложных выражениях. Такая высокая степень выразительности языка позволяет легко писать программы, содержащие большое количество операций с массивами. Действительно, огромное количество вычислений может быть описано в очень компактной программе. Недостатком является плохая читабельность программ, написанных на языке APL. Компактное и лаконичное выражение с математической точ- ки зрения, конечно, красиво, но для всех, кроме автора, создает сложности при чтении. Известный автор Даниель Мак-Кракен (Daniel McCracken) однажды отметил, что чтение и анализ программы из четырех строк, написанной на языке APL. заняли у него четыре часа (McCracken, 1970). Таким образом, разработчики языка APL пожертвовали чита- бельностью в пользу легкости создания программ. Противоречия между гибкостью и безопасностью обычны для языков программирова- ния. Записи в языке Pascal разрешают ячейке памяти в разное время содержать переменные разных типов. Например, ячейка может содержать либо указатель, либо целое число. Таким образом, с помещенным в такую ячейку указателем можно выполнять действия, опреде- ленные для целых величин. Это создает лазейку в проверке типов, позволяющую програм- ме выполнять арифметические действия с указателями, что иногда бывает удобным. Одна- ко следует заметить, что бесконтрольно использовать ячейки памяти опасно. Существует множество примеров конфликтов между критериями разработки (и оцен- ки) языка; некоторые из них очевидны, другие— едва различимы. Отсюда следует, что решить задачу выбора конструкций и свойств языка невозможно без компромиссов. 1.7. Методы реализации Как указано в разделе 1.4.1, двумя основными компонентами компьютера являются его оперативная память и процессор. Оперативная память используется для хранения программ и данных. Процессор — это совокупность электронных схем, обеспечивающих реализацию набора элементарных операций, или машинных команд, т.е. арифметических и логических операций. В большинстве компьютеров некоторые из этих команд, иногда называемых макрокомандами, реализуются с помощью набора низкоуровневых команд, называемых микрокомандами. Поскольку микрокоманды всегда скрыты от программи- ста, то их обсуждение, как правило, не включается в обсуждение программного обеспе- чения. По этой причине микрокоманды мы далее обсуждать не будем. 1.7. Методы реализации 45
Набор макрокоманд компьютера называется его машинным языком. При отсутствии остального программного обеспечения большая часть аппаратного обеспечения компью- тера “понимает” только его машинный язык. Теоретически можно спроектировать и соз- дать компьютер, использующий некий язык высокого уровня в качестве машинного, но это было бы очень сложно и дорого. Более того, он был бы крайне негибким, поскольку такой компьютер сложно (хотя и можно) использовать совместно с другими языками вы- сокого уровня. Более практичное решение — аппаратно реализовать язык очень низкого уровня, что обеспечит выполнение наиболее распространенных элементарных операций и потребует системного программного обеспечения для взаимодействия с программами, написанными на языках высокого уровня. Система реализации языка не может рассматриваться как единственное программное обеспечение компьютера. Кроме нее нужен также большой набор программ, поддержи- вающих языковые конструкции более высокого уровня, чем конструкции машинного языка. Такой набор программ называется операционной системой. Языковые конструк- ции, поддерживаемые операционной системой, осуществляют управление системными ресурсами, операциями ввода-вывода, системой управления файлами, редакторами тек- ста и/или программ, а также обеспечивают выполнение большого количества других не- обходимых функций. Поскольку системам реализации языка требуется большое количе- ство возможностей операционной системы, они взаимодействуют с ней. а не с процессо- ром (на машинном языке). Уровни операционной системы и систем реализации языков программирования нахо- дятся выше уровня машинного языка. Эти системы можно представить в виде виртуаль- ных компьютеров, обеспечивающих взаимодействие с пользователем, находящимся на более высоком уровне. Например, операционная система и компилятор языка С создают виртуальный компьютер языка С. С другим компилятором машина может стать другим виртуальным компьютером. Большинство вычислительных систем создает несколько различных виртуальных компьютеров. Пользовательские программы образуют следую- щий уровень, находящийся над уровнем виртуальных компьютеров. Многоуровневое представление компьютера показано на рис. 1.2. Системы реализации первых языков программирования высокого уровня были созда- ны в конце 1950-х годов и считались одними из сложнейших систем программного обес- печения того времени. В 1960-х годах были проведены интенсивные научно- исследовательские работы, направленные на анализ и формализацию процесса конст- руирования этих реализаций языков высокого уровня. Наибольшие успехи были достиг- нуты в области синтаксического анализа, главным образом из-за того, что эта часть про- цесса реализации является приложением части теории автоматов и теории формальных языков, которые в то время были хорошо изучены. 46 Глава!. Вводные замечания
Виртуальный Ada Рис. 1.2. Многоуровневый интерфейс виртуальных компьютеров, создавае- мый стандартной вычислительной системой 1.7.1. Компиляция Языки программирования могут реализовываться одним из трех методов. Например, программа может переводиться на машинный язык и выполняться непосредственно ком- пьютером. Такая реализация называется компиляцией. Преимущество этого метода — очень быстрое выполнение программы после завершения процесса трансляции. Боль- шинство промышленных реализаций таких языков, как С, COBOL и Ada, выполняются с помощью компилятора. Язык, который компилятор переводит в машинный, называется исходным языком. Процесс компиляции протекает в несколько этапов, важнейшие из которых показаны на рис. 1.3. 1.7. Методы реализации 47
Результаты (необязательно) Рис. 1.3. Процесс компиляции Лексический анализатор объединяет символы исходной программы в лексические единицы. Лексические единицы программы — это идентификаторы, служебные слова, операторы и знаки пунктуации. Поскольку комментарии исходной программы компиля- тором не используются, то лексический анализатор их игнорирует. Синтаксический анализатор получает от лексического анализатора лексические еди- ницы и использует их для создания иерархических структур, называемых деревьями син- таксического анализа. Эти деревья представляют собой синтаксическую структуру про- 48 Глава!. Вводные замечания
граммы. Однако в большинстве случаев никакой структуры дерева синтаксического ана- лиза не создается, а просто генерируется и используется информация, необходимая для его создания. К лексическим единицам и деревьям синтаксического анализа мы еще вер- немся в главе 3. Генератор промежуточных команд создает программы на особом языке, промежуточ- ном между языком исходной программы и машинным языком. (Отметим, что термины “язык" и “код” — часто взаимозаменяемы.) Промежуточные языки зачастую очень похожи на языки ассемблера и иногда действительно ими являются. В остальных случаях промежу- точный код имеет более высокий уровень, чем язык ассемблера. Неотъемлемой частью ге- нератора промежуточного кода является семантический анализатор, выполняющий поиск тех ошибок, выявление которых в процессе синтаксического разбора затруднительно либо невозможно, например, ошибок, связанных с несовместимостью типов. Простейшие примеры синтаксического анализа и генерации промежуточного кода рассмотрены в главе 3. Оптимизация, улучшающая программы путем уменьшения их размеров или ускоре- ния их выполнения, или и того, и другого одновременно, часто является дополнительной частью процесса компиляции. В действительности некоторые компиляторы вообще не способны выполнять сколько-нибудь существенную оптимизацию. Такие компиляторы могут использоваться в ситуации, когда скорость компиляции программы значительно важнее скорости выполнения оттранслированной программы. В качестве примера такой ситуации можно привести вычислительную лабораторию для начинающих программи- стов. Однако в большинстве коммерческих и промышленных ситуаций скорость выпол- нения значительно важнее скорости компиляции, так что оптимизация желательна. По- скольку многие разновидности оптимизации не могут выполняться на машинных языках, то для выполнения основной оптимизации используется промежуточный язык. Генератор кода переводит оптимизированную программу с промежуточного языка на машинный. Таблица символов используется в качестве базы данных для процесса компиляции. Изначально в этой таблице находится информация о типах и атрибутах всех определен- ных пользователем имен, встречающихся в программе. Эту' информацию в таблицу сим- волов помещают лексические и синтаксические анализаторы, а используют семантиче- ский анализатор и генератор кода. Как указывалось выше, хотя машинный код, генерируемый компилятором, может выполняться непосредственно аппаратным обеспечением, почти всегда требуется, чтобы он выполнялся вместе с какими-то еще программами. Для выполнения большинства пользовательских программ необходимы также программы операционной системы. К числу наиболее часто используемых из них относятся программы ввода-вывода. Ком- пьютер вызывает требуемые системные программы при необходимости. Прежде чем созданная компилятором программа на машинном языке будет выполнена, требуется найти и связать с программой пользователя нужную программу операционной системы. Операция связывания соединяет программу пользователя и системную программу между собой, помещая адрес входа в системную программу на место ее вызова из программы пользователя. Пользовательский и системный код, взятые вместе, часто называют загру- зочным модулем. Процесс сборки системных программ и связывания их с программой пользователя называется редактированием связей и загрузкой (loading), или просто ре- дактированием связей (linking). Эти действия выполняет системная программа под на- званием редактор связей (linker). 1.7. Методы реализации 49
Пользовательские программы часто должны связываться не только с системными программами, но и с предварительно откомпилированными пользовательскими про- граммами. находящимися в библиотеках. Таким образом, редактор связей может связы- вать данную программу не только с системными программами, но и с другими програм- мами пользователя. Выполнение программы на машинном коде на компьютере с неймановской архитек- турой происходит в ходе процесса, называемого циклом выборки-исполнения коман- ды. Как отмечалось в разделе 1.4.1. программы находятся в памяти, но выполняются процессором. Каждая команда, подлежащая выполнению, должна пересылаться из памя- ти в процессор. Адрес следующей команды, подлежащей выполнению, хранится в реги- стре. называемом счетчиком программы. Цикл выборки-исполнения команды можно просто описать с помощью следующего алгоритма: хнхциализировать счетчик программы повторять постоянно выбрать команду, указываемую счетчиком программы увеличить счетчик для указания на следующую команду расшифровать команду выполнить команду конец повторения Этап “расшифровки команды” описанного выше процесса подразумевает изучение команды, чтобы определить, какое действие она обозначает. Выполнение программы за- вершается при достижении команды остановки, хотя такая команда в реальных компью- терах выполняется очень редко. Вместо этого, операционная система передает управле- ние программе на время ее выполнения, а после завершения программы управление вновь возвращается операционной системе. Отметим, что в вычислительных системах, в памяти которых может одновременно находиться несколько программ, этот процесс зна- чительно сложнее. Поскольку время выполнения команды, подлежащей выполнению, часто меньше времени, затрачиваемого на ее передачу процессору, то скорость работы компьютера определяется скоростью соединения между процессором и памятью компьютера. Это со- единение называется неймановским узким местом и является основным фактором, ог- раничивающим скорость работы машин с неймановской архитектурой. Именно наличие неймановского узкого места стало одной из основных причин исследований и разработок в области параллельных компьютеров. 1.7.2. Чистая интерпретация При использовании диаметрально противоположных методов реализации программа может интерпретироваться с помощью другой программы, называемой интерпретато- ром. вообще без трансляции. Интерпретатор программно моделирует работу машины, цикл выборки-исполнения которой работает с командами на языках высокого уровня, а не с машинными командами. Разумеется, такое программное моделирование создает виртуальную вычислительную машину, реализующую язык. Достоинством этого подхода, получившего название чистой интерпретации, или просто интерпретации, является легкость реализации многих операций отладки на уров- не исходной программы, поскольку все сообщения об ошибках, возникающих в процессе выполнения, могут ссылаться на модули исходной программы. Например, при обнару- 50 Глава!. Вводные замечания
женин индекса массива, находящегося вне допустимого диапазона значений, в сообще- нии об ошибке будет указана строка исходной программы и имя массива. С другой сто- роны, у этого метода имеется серьезный недостаток— выполнение интерпретируемых программ от 10 до 100 раз медленней, чем компилируемых. Главным источником замед- ления является расшифровка операторов языка высокого уровня, значительно более сложных, чем команды на машинном языке (хотя они могут записываться и меньшим числом команд, чем эквивалентные программы на машинном языке). Следовательно, уз- ким местом чистого интерпретатора является не соединение между памятью и процессо- ром, а расшифровка операторов. Еще одним недостатком чистой интерпретации является тот факт, что для нее часто требуется больше памяти. Кроме исходной программы в процессе интерпретации долж- на участвовать еще и таблица символов. Более того, исходная программа может хранить- ся в памяти в виде, ориентированном на легкий доступ и модификацию программы, а не на компактный размер. Процесс интерпретации довольно сложен для программ, написанных на сложных языках, посколь- ку смысл каждого выражения и оператора должен определяться непосредственно во время выполнения исходной программы. Чистая интерпретация приме- няется для языков с более простой структурой. На- пример, системами с чистой интерпретацией иногда реализуются языки APL и LISP. Большинство ко- манд операционных систем, содержащихся, напри- мер, в сценариях оболочек операционной системы UNIX или .ВАТ-файлах системы DOS, также реали- зуются посредством чистой интерпретации. Более сложные языки, такие как FORTRAN и С. редко реа- лизуются с помощью чистых интерпретаторов. Графически процесс чистой интерпретации представлен на рис. 1.4. 1.7.3. Смешанные системы реализации Некоторые системы реализаций языков представляют собой компромиссный вариант между компилятором и чистой интерпретацией: они транслируют программу с языка вы- сокого уровня на промежуточный язык, разработанный для обеспечения более легкой интерпретации. Этот метод работает быстрее, чем чистая интерпретация, поскольку вы- ражения исходного языка расшифровываются только один раз, и называется смешанной системой реализации (hybrid implementation system). Процесс, используемый в смешанной системе реализации, показан на рис. 1.5. В нем вместо трансляции промежуточных команд в машинные коды происходит их интерпре- тация. Примером языка, реализованного с помощью описанной системы, может служить язык Perl. Этот язык происходит от интерпретируемых языков программирования sb и awk, однако он является частично компилируемым, что позволяет обнаруживать ошибки до начала интерпретации и упрощать ее. 1.7. Методы реализации 51
Изначально все реализации языка Java были смешанными. Его промежуточная форма, названная байтовым кодом, обеспечивает переносимость языка на любую машину, содер- жащею интерпретатор байтовых кодов и соответствующую систему поддержки исполнения программ. Все вместе это называется Java Virtual Machine ("виртуальная вычислительная машина на языке Java**)* Сейчас появились системы, транслирующие байтовые коды языка Java в машинные коды для ускорения их выполнения. Однако аплеты языка Java всегда за- грежаются с Web-сервера только в форме байтового кода. Иногда разработчик системы реализации языка может создавать и компилирующую, и интерпретирующую системы реализации. В таких случаях интерпретатор используется для разработки и отладки программы. После отладки программ они компилируются для ускорения их выполнения. Результаты Рис. 1.5. Смешанные системы реализации 52 Глава!. Вводные замечания
1.8. Среды программирования Среда программирования — это совокупность инструментов, используемых при разра- ботке программного обеспечения. Этот набор обычно состоит только из файловой систе- мы, текстового редактора, редактора связей и компилятора. Дополнительно он может включать большое количество инструментальных комплексов с единообразным интерфей- сом пользователя. В этом случае разработка и эксплуатация программного обеспечения значительно улучшается. Следовательно, характеристики языка программирования — это не только мера возможностей, предоставляемых системой для разработки программного обеспечения. Ниже кратко представлены некоторые среды программирования. Старейшей средой программирования считается UNIX — машинно-независимая опе- рационная система с разделением времени, начавшая распространяться в середине 1970-х годов. Она предоставляет многочисленные мощные инструментальные средства, предна- значенные для производства программного обеспечения и эксплуатации разнообразных языков. В прошлом основным недостатком системы UNIX было отсутствие единообраз- ного интерфейса ее инструментов. Это затрудняло ее изучение и использование. Однако сейчас работа с системой UNIX часто осуществляется с помощью графического интер- фейса, устанавливаемого поверх нее. Во многих случаях этим интерфейсом является среда программирования Common Desktop Environment (CDE). На IBM-совместимых микрокомпьютерах используется среда Borland C++. содержащая встроенный компилятор, текстовый редактор, отладчик и файловую систему, причем дос- туп к ним осуществляется с помощью графического интерфейса. Одним из удобных свойств данной среды программирования является то. что при обнаружении компилятором синтаксической ошибки происходит переключение на текстовый редактор, причем курсор помешается в то место исходной программы, где была обнаружена ошибка. Язык и интегрированная среда программирования Smalltalk продуманнее, сложнее и мощнее, чем среда Borland C++. Система Smalltalk первой стала использовать оконный интерфейс и мышь для обеспечения единообразного доступа ко всем инструментам. К обсуждению этого языка мы еще вернемся в главе 11. Последнюю стадию развития сред разработки программного обеспечения представ- ляет собой среда Microsoft Visual C++. Это объемный и продуманный набор инструмен- тов разработки программного обеспечения, доступных посредством оконного интерфей- са. Эта система, а также подобные ей Visual BASIC. Delphi и Java Development Kit ком- пании Sun Microsystems предлагают легкий способ создания графических интерфейсов для программ пользователя. Не вызывает сомнений, что для большинства разработок программного обеспечения, по крайней мере в ближайшем будущем, будут использоваться мощные среды програм- мирования. Это. очевидно, приведет к увеличению производства программного обеспе- чения и. возможно, к улучшению его качества. 1.8. Среды программирования 53
Р е з ю м е Изучение языков программирования полезно по многим причинам: оно позволяет ис* пользовать более разнообразные конструкции при создании программ, более осмыслен- но выбирать языки программирования, подходящие для разрабатываемых проектов, а также облегчает изучение новых языков программирования. Компьютеры применяются для решения задач, возникающих в различных областях. Разработка и оценка отдельного языка программирования в значительной мере опреде- ляется областью его применения. В число важнейших критериев оценки языков программирования входят читабель- ность. легкость создания программ, надежность и общая стоимость. Эти критерии и бу- дут тем базисом, на котором мы построим наше дальнейшее изучение и оценку различ- ных свойств языков программирования. Основное влияние на разработку языка оказывает вычислительная архитектура и ме- тодологии разработки программного обеспечения. Разработка языка программирования— это искусство проектирования, в котором должен быть достигнут компромисс между огромным количеством свойств, конструкций и возможностей. Основными методами реализации языка программирования являются компиляция, чистая интерпретация и смешанная реализация. Среды программирования стали важной частью систем разработки программного обеспечения, в которых языки программирования являются лишь одним из многих ком- поненюв. Вопросы 1. Почему программисту полезно иметь представление о разработке языков про- граммирования. даже если сам он никогда не будет разрабатывать ни один язык про1раммирования? 2. Какие выгоды могут принести знания свойств языков программирования всему компьютерному сообществу? 3. Какой язык программирования последние 35 лет занимает ведущее место в науч- ных вычислениях? 4. Какой язык программирования последние 35 лет занимает ведущее место в ком- мерческих приложениях? 5. Какой язык программирования последние 35 лет занимает ведущее место в созда- нии искусственного интеллекта? 6. На каком языке написана операционная система UNIX? 7. Что плохого, если в языке слишком много функций? 8. Как может перегрузка операторов понизить читабельность программы? 9. Приведите пример недостатка ортогональности в языке С. 10. В каком языке ортогональность используется в качестве основного критерия раз- работки0 54 Глава!. Вводные замечания
11. Какой основной управляющий оператор используется для создания более сложных управляющих операторов в языках, их не имеющих? 12. Какая трудность возникает при чтении программ, написанных на языках, исполь- зующих одно и то же замыкающее служебное слово для разных типов управляю- щих структур? 13. Какая конструкция языка программирования обеспечивает абстракцию процесса? 14. Что означает надежность программы? 15. Почему важна проверка типов параметров подпрограммы? 16. Что такое “совмещение имен"? 17. Что такое “обработка исключительных ситуаций”? 18. Как влияет читабельность языка на легкость создания программ на нем? 19. Как стоимость компилятора для данного языка связана с его структурой? 20. Что за последние 40 лет оказало наибольшее влияние на разработку языков про- граммирования? 21. Как называется категория языков программирования, структура которых обуслов- лена неймановской архитектурой компьютера? 22. Какие два недостатка языков программирования были обнаружены в результате проведения научно-исследовательских работ в 1970-х годах? 23. Назовите три основных свойства объектно-ориентированных языков программи- рования. 24. Какой язык первым стал поддерживать три основных свойства объектно-ориенти- рованного программирования? 25. Назовите два критерия разработки языка, непосредственно конфликтующих между собой. 26. Назовите три общих метода реализации языка программирования. 27. Что обеспечивает более быстрое выполнение программы— компилятор или чис- тая интерпретация? 28. Какова роль таблицы символов в компиляторе? 29. Что делает редактор связи? 30. В чем состоит важность неймановского узкого места? 31. Какие преимущества предлагает реализация языка методом чистой интерпретации? 32. Какие недостатки имеет операционная система UNIX при использовании ее в каче- стве среды разработки программного обеспечения? Вопросы 55
У Л р О- г.< Л : >8 •’ '- миммииивпмммммимт111П“Т'^*^ы^г^т1!У1тпт1т,гтг1Г'П| 1. Верите ли вы, что на наши мыслительные способности влияет наш язык? Аргумен- тируйте ваше мнение. 2. Какие свойства конкретных языков программирования вы не понимаете? 3. Какие аргументы вы можете привести в пользу создания общего языка для всех областей программирования? 4. Какие аргументы вы можете привести против создания общего языка для всех об- ластей программирования? 5. Назовите и обоснуйте другие критерии оценки языков программирования (в до- полнение к перечисленным в данной главе). 6. Какие, по вашему мнению, обычные операторы языков программирования являют- ся наиболее трудными при чтении программы? 7. В языке Modula-2 оператор END используется для замыкания всех управляющих структур. Назовите достоинства и недостатки такого проектного решения. 8. Некоторые языки, особенно С и Java, различают прописные и строчные буквы в именах идентификаторов. Какие плюсы и минусы имеет такое проектное решение? 9. Объясните различные факторы, влияющие на стоимость языка программирования. 10. Какие аргументы можно привести в пользу создания эффективных программ даже на относительно недорогом аппаратном обеспечении? 11. Опишите несколько компромиссов между эффективностью и безопасностью в из- вестных вам языках программирования. 12. Какими, по вашему мнению, основными свойствами должен обладать совершен- ный язык программирования? 13. Как был реализован первый изученный вами язык высокого уровня: посредством чистой интерпретации, смешанной реализации или компилятора? (Для ответа на этот вопрос может потребоваться дополнительный анализ.) 14. Опишите достоинства и недостатки используемых вами сред программирования. 15. Как операторы объявления типов простых переменных влияют на читабельность языка, с учетом того, что в некоторых языках они не требуются. 16. Оцените известный вам язык программирования, использовав критерии, указанные в данной главе. 17. В языке Pascal точка с запятой используется для разделения выражений, а в языке С — для их завершения. Какой из способов, по вашему мнению, естественнее и менее вероятно вызовет синтаксическую ошибку? Аргументируйте ваш ответ. 18. Некоторые языки, например Pascal и С, используют разделители для ограничения комментариев с двух сторон. Другие языки, такие как FORTRAN и Ada, использу- ют пару символов для указания на начало и конец комментария. Взвесьте достоин- ства и недостатки обоих подходов в соответствии с приведенными в данной главе критериями. 56 Глава!. Вводные замечания
Джон Бэкус (John Backus) Нанятый корпорацией IBM Джон Бэкус в начале 1950-х годов разработал систему псевдокодов Speedcoding для компьютеров IBM 701. В пери- од с 1954 по 1957 год он воз- главлял команду разработчи- ков языка FORTRAN — родо- Е - j 2.1. Язык PlankalkOI Конрада Цузе 2.2. Минимальное программирование на аппаратном уровне* псевдокоды 2.3. Компьютер IBM 704 и язык FORTRAN 2.4. Функциональное программирование: язык LISP 2.5. Первый шаг к совершенствованию язык ALGOL 60 2.6. Компьютеризация коммерческих записей, язык COBOL 2.7. Начало разделения времени: язык BASIC 2.8. Все для всех: язык PL/I 2.9. Два ранних динамических языка: APL и SNOBOL 2.10. Возникновение абстракции данных: язык SIMULA 67 2.11. Ортогональная структура: язык ALGOL 68 2.12. Несколько важных наследников семейства начальника практически всех императивных языков про- граммирования С 1958 по 1960 годы Бэкус участвовал в разработке языка ALGOL. 2.13. 2.14. языков ALGOL Программирование, основанное на логике: язык Prolog Величайший проект в истории: язык Ada 2.15. Объектно-ориентированное программирование: язык Smalltalk 2.16. Объединение императивных и объектно- ориентированных свойств: язык C++ 2.17. Программирование в World Wide Web: язык Java Обзор основных языков программирования 57
В этой главе хронологически прослеживается развитие некоторых языков про- граммирования, исследуется среда, в которой каждый из них разрабатывался, особое внимание обращается на вклад языка в компьютерные науки и причины, привед- шие к его возникновению. Глава не содержит полного описания языков, рассматривают- ся только новые свойства, которыми они отличаются друг от друга. Особый интерес вы- зывают свойства, оказавшие наибольшее влияние на языки программирования, разрабо- танные позднее, или на компьютерные науки в целом. В данную главу не включено подробное обсуждение какого-либо языкового свойства или концепции; эти вопросы будут рассмотрены в следующих главах. Мы считаем, что крат- кого неформального объяснения свойств будет достаточно для нашего обзора истории развития этих языков. Выбор языков, обсуждаемых в этой главе, был субъективным, и многие читатели с сожалением отметят отсутствие одного или нескольких излюбленных языков. Тем не менее, для того чтобы этот исторический обзор не выходил за разумные пределы, было необходимо исключить из рассмотрения несколько языков, высоко ценимых некоторыми программистами. Наш выбор основывался на оценке важности каждого языка для разви- тия языков программирования и компьютерных наук в целом. В этой главе также рас- сматриваются некоторые другие языки, упоминающиеся далее в книге. В главе содержатся листинги 11 полностью завершенных примеров программ, все они написаны на различных языках. Ни одна из этих программ в главе не описывается; они приводятся просто для иллюстрации внешнего вида программы на конкретном язы- ке. Читатели, знакомые с любым из распространенных императивных языков, смогут прочесть и понять большинство команд этих программ, за исключением программ, на- писанных на языках LISP, COBOL и Smalltalk. Пример программы на языке LISP рас- сматривается в главе 14; на языке Smalltalk— в главе И. Программы на языках FORTRAN, ALGOL 60, PL/1, BASIC, Pascal, C, Ada и Java решают одну и ту же задачу. Генеалогическое древо развития языков программирования высокого уровня, рас- сматриваемых в этой главе, приведено на рис. 2.1. 2.1. Язык Plankalkiil Конрада Цузе Первый из обсуждаемых в этой главе языков в некоторых отношениях крайне необы- чен. Во-первых, он никогда не был реализован. Более того, хотя он был разработан в 1945 году, его описание не публиковалось вплоть до 1972 года. Из-за всеобщего игнори- рования языка Plankalkiil некоторые его возможности не использовались в других языках программирования в течение 15 лет после его разработки. 2.1.1. Исторические предпосылки Между 1936 и 1945 годами немецкий ученый Конрад Цузе (Konrad Zuse) на основе электромеханических реле создал ряд сложных и изощренных компьютеров. К 1945 году война привела к разрушению всех этих компьютеров, за исключением одной из послед- них моделей — компьютера Z4. Цузе отправился в удаленное баварское селение Хин- терштейн (Hinterstein), а остальные члены его исследовательской группы разъехались в разные стороны. 58 Глава 2. Обзор основных языков программирования
1957 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 Рис. 2.1. Генеалогия распространенных языков программирования высокого уровня Работая самостоятельно. Цузе направил свои усилия на разработку языка для выра- жения вычислений, продолжив проект, начатый им в 1943 году с целью написания док- торской диссертации. Новый язык он назвал Plankalkiil. что означает ’’программное ис- числение”. В объемной рукописи, датированной 1945 годом, но не п\отковавшейся до 1972 года (Zuse, 1972), Цузе дал определение языка Plankalkiil и использовал этот язык для написания алгоритмов решения множества задач. 2.1.2. Обзор языка Язык Plankalkiil был необыкновенно полным и обладал несколькими очень перспек- тивными свойствами в области структур данных. Простейшим типом данных в языке Plankalkiil был отдельный бит. На основе этого типа создавались типы для представления целых чисел и чисел с плавающей точкой. Для представления чисел с плавающей точкой использовались дополнительный код числа в двоичной системе и схема “скрытого бита”, в настоящее время применяемая для того, чтобы не хранить в памяти старший значащий бит в нормализованной мантиссе числа. 2.1. Язык Plankalkul Конрада Цузе 59
Кроме этих обычных скалярных типов, язык Plankalkiil содержал массивы и записи. При работе с записями для включения в них других записей в качестве элементов допус- калось использование рекурсии. Хотя язык и не содержал явного оператора goto, он имел оператор цикла, подобный оператору for языка Pascal. В языке также имелась команда Fin, верхний индекс кото- рой означал выход из внутреннего цикла на указанное число вложенных итерационных циклов или к началу нового итерационного цикла. Язык Plankalkiil содержал оператор ветвления, не содержащий оператора else. Интереснейшей особенностью программ Конрада Цузе было включение математиче- ских выражений, показывающих отношения между переменными в программе. Эти вы- ражения устанавливали, что именно в процессе выполнения должно быть истинным в тех точках программы, где находились данные выражения. Сами эти выражения очень по- хожи на утверждения, используемые в современном языке программирования Eiffel (Meyer. 1992) и аксиоматической семантике, рассматриваемой в главе 3. Рукопись Цузе содержала программы, которые были значительно сложнее программ, написанных до 1945 года. В ней были приведены программы сортировки массивов це- лых чисел: проверки связности заданного графа; программы, выполняющие действия с целыми числами и с числами с плавающей точкой, в том числе извлечение квадратного корня; программы, которые производили синтаксический анализ логических формул, со- держащих скобки и операторы с шестью различными уровнями приоритета. Вероятно, самыми примечательными были принадлежащие Цузе 49 страниц алгоритмов игры в шахматы (игры, в которой сам Цузе специалистом не был). Если бы в начале 1950-х годов какой-нибудь специалист по компьютерным наукам обнаружил созданное Цузе описание языка Plankalkiil. то единственным аспектом, кото- рый бы затруднил реализацию этого языка в описанном виде, была бы форма записи. Каждый оператор состоял из двух-трех строк команд. Первая строка была очень похожа на операторы современных языков программирования. Вторая строка, которая была не- обязательной. содержала список индексов массивов, упоминающихся в первой строке. Интересно отметить, что тот же метод указания индексов использовался Чарльзом Бэб- биджем (Charles Babbage) в программах, написанных им для его “аналитической маши- ны" в середине XIX столетия. Последняя строка каждого выражения на языке Plankalkiil содержала названия типов переменных, упоминаемых в первой строке. На первый взгляд, подобная запись выглядит устрашающе. Для иллюстрации описанной формы записи ниже приводится пример оператора, при- сваивающего значения выражения А (4 ) +1 переменной А (5). Строка, помеченная бук- вой V. содержит индексы, а строка, помеченная буквой S, — типы данных. Запись 1. п в данном примере означает целое число, состоящее из п битов: I А + 1 => А V । 4 5 S I l.n 1.п Сейчас мы можем только гадать, по какому пути пошло бы развитие языков про- граммирования и компьютеров, если бы работа Конрада Цузе стала бы широко извест- ной в 1945 или даже в 1950 году. Также интересно предположить, как отличалась бы сделанная Цузе работа, если бы она выполнялась в мирное время в коллективе ученых, а не в фактической изоляции в Германии 1945 года. 60 Глава 2. Обзор основных языков программирования
2.2. Минимальное программирование на аппаратном уровне: псевдокоды Компьютеры, появившиеся в конце 1940-х и начале 1950-х годов, были далеко не та- кими удобными, как современные. Кроме того что они были очень медленными, нена- дежными, дорогими и имели крайне малый объем памяти, программировать на них было очень сложно из-за недостатка программной поддержки. Не существовало языков программирования высокого уровня или. по крайней мере, языков ассемблера, поэтому программирование выполнялось в машинных кодах, что было кропотливой и подверженной ошибкам работой. Среди возникавших проблем можно назвать использование целочисленных кодов для указания команд. Например, команда ADD (“прибавить”) должна была определяться кодом 14. а не напрашивающим- ся текстовым обозначением, даже состоящим из одного символа. Это значительно ус- ложняло чтение программы. Более существенной проблемой являлась абсолютная адре- сация, усложнявшая модификацию программ. Предположим, что у нас в памяти имеется программа в машинных кодах. Многие команды такой программы ссылаются на другие ячейки памяти вне программы, обычно на справочные данные, или указывают цели ко- манд ветвления. Введение команды не в конец программы, а в произвольное место внут- ри нее, сводит на нет правильность ссылок на адреса, находящиеся за точкой ввода но- вой команды, поскольку для предоставления места новой команде эти адреса должны соответствующим образом увеличиться. Для того чтобы введение новой команды не на- рушало структуры уже существующей программы, программист должен найти и изме- нить все команды, ссылающиеся на адреса, находящиеся за дополнительно введенной командой. Подобная проблема возникает и при удалении команды. Правда, в этом случае в машинных языках часто используется команда “холостая операция*’, которой можно заменить удаленную команду, не нарушив при этом общей структуры программы. Эти стандартные проблемы, существующие во всех машинных языках, и были основ- ной причиной изобретения ассемблеров и языков ассемблера. Кроме того, в то время за- дачи, связанные с программированием, были в основном вычислительными. Для их ре- шения требовалось выполнять арифметические операции над числами с плавающей точ- кой, а также операции индексации для более удобного использования массивов. Тем не менее, ни одна из указанных возможностей не была включена в архитектуру компьюте- ров конца 1940-х и начала 1950-х годов. Для устранения этих недостатков, естественно, нужно было разрабатывать языки более высокого уровня. 2.2.1. Язык Short Code Первый из этих новых языков, названный Short Code, был разработан Джоном Мочли (John Mauchly) в 1949 году для компьютера BIN АС. Позднее язык Short Code был пере- несен на компьютер UN1VAC I и в течение нескольких лет был одним из основных средств программирования этих машин. Несмотря на то что об исходном языке Short Code известно очень мало (полное его описание никогда не публиковалось), сохранилось руководство по программированию для версии UN1VAC I (Remington-Rand. 1952). Ра- зумным будет предположить, что эти две версии были очень похожи. Слова языка UN1VAC 1 состояли из 72 бит. сгруппированных в 12 шестибитовых байт. Язык Short Code состоял из закодированных версий математических выражений, которые затем предстояло вычислять. Команды представляли собой двухбайтовые вели- 2.2. Минимальное программирование на аппаратном уровне: псевдокоды 61
чины, и большинство выражений умещались в одном слове. Вот несколько примеров команд языка Short Code: 03 = 04 / 06 модуль 07 + 08 пауза 09 ( In (п+2)-я степень 2п корень (п+2)-й степени 4п если <= п 58 печать и табуляция Переменные, или ячейки памяти, именовались двухбайтовыми кодами, так как ячейки памяти должны были использоваться как константы. Например, величины Х0 и Y0 могли быть переменными. Оператор ХС = SQRT(ABS(Y0)) можно было закодировать в слово 00 Х0 03 20 06 Y0. Заглавный код 00 использо- вался в качестве дополнения для заполнения слова. Интересно, что команды умножения не с> шествовало; на эту операцию указывало простое размещение двух операторов ря- дом. как это принято записывать в алгебре. Язык Short Code не был переведен в машинные коды; он был реализован с помощью чистого интерпретатора. Со временем подобный процесс был назван автоматическим программированием. Он сильно упростил процесс программирования, правда, за счет времени, затрачиваемого на выполнение программы. Интерпретация языка Short Code была приблизительно в 50 раз медленнее, чем интерпретация машинных кодов. 2.2.2. Система Speedcoding В это время в других местах были разработаны императивные системы, которые расширяли машинный язык, дополняя его действиями над числами с плавающей точкой. Примером такой системы может служить система Speedcoding, разработанная Джоном Бэкусом для компьютера IBM 701 (Бэкус, 1954). Интерпретатор системы Speedcoding эффективно преобразовывал 701-й компьютер в виртуальную трехадресную вычисли- тельную машину, выполняющую действия над числами с плавающей точкой. Система включала в себя псевдокоманды, выполняющие четыре арифметические операции над числами с плавающей точкой, а также такие операции, как извлечение квадратного кор- ня. вычисление синуса, арктангенса, экспоненты и логарифма. Условные и безусловные переходы и преобразования ввода-вывода также являлись частью виртуальной архитек- туры. Для того чтобы представить себе ограниченность подобной системы, примите во внимание то, что после загрузки интерпретатора оставалось всего 700 слов доступной оперативной памяти, и команда сложения двух чисел требовала 4,2 миллисекунды на ее выполнение. С другой стороны, система Speedcoding содержала такую новую возмож- ность. как автоматическое увеличение индексных регистров. В аппаратном обеспечении эта возможность не реализовывалась вплоть до появления в 1962 году компьютеров UN1VAC1107. Благодаря наличию таких свойств умножение матриц в системе Speedcoding можно было выполнить, затратив всего 12 команд. Бэкус утверждал, что за- дача. на программирование которой в машинных кодах уйдет две недели, при использо- вании системы Speedcoding может быть решена за несколько часов. 62 Глава 2. Обзор основных языков программирования
2.2.3. Система "компиляции" UNIVAC Между 1951 и 1953 годами команда, возглавляемая Грейс Хоппер (Grace Hopper), создала на компьютере UNIVAC серию “компилирующих" систем, названных А-0. А-1 и А-2, в основе которых лежали машинные коды, реализующие псевдокоды, аналогично тому, как язык ассемблера реализует макросы. Исходная программа, написанная на псевдокоде для этих “компиляторов", была еще слишком примитивной, хотя это уже бы- ло значительным усовершенствованием по сравнению с машинным кодом, поскольку сделало исходные программы короче. Независимо от этой команды в 1952 год} подоб- ный подход был предложен Уилксом (Wilkes). 2.2.4. Смежная работа Практически в то же время были разработаны и другие средства облегчения про- граммирования. В Кембриджском университете Дэвид Дж. Уилер (David J. Wheeler) раз- работал метод использования блоков перемещаемых адресов для частичного решения задачи абсолютной адресации (Wheeler. 1950). а позднее Морис В. У ил кис (Maurice V. Wilkes) (также в Кембридже) осуществил эту идею при разработке програм- мы ассемблера, которая могла бы объединять избранные подпрограммы и распределять память (Wilkes et al., 1951. 1957). Это было действительно важное и фундаментальное достижение. Кроме того, мы должны упомянуть, что языки ассемблера, совершенно отличавшиеся от описанных псевдокодов, хотя и эволюционировали на протяжении начала 1950-х го- дов, но на разработку языков высокого уровня не оказали существенного влияния. 2.3. Компьютер IBM 704 и язык FORTRAN Безусловно, одно из величайших достижений в вычислительной технике обязано сво- им появлением внедрению в 1954 году компьютера IBM 704. поскольку возможности именно этой машины подтолкнули к разработке языка FORTRAN. Можно утверждать, что если бы не корпорация IBM со своим компьютером IBM 704 и языком FORTRAN, то очень скоро появилась бы какая-нибудь другая организация, имеющая подобный компь- ютер и соответствующий язык высокого уровня. Тем не менее, первой была вес же кор- порация IBM, которая не только предвидела такое развитие событий, но и нашла ресур- сы для того, чтобы взяться за это дело. 2.3.1. Историческая ретроспектива Одной из основных причин того, что императивные системы с конца 1940-х до сере- дины 1950-х годов считались вполне приемлемыми, было отсутствие на существ} ющих компьютерах аппаратных средств, выполняющих действия над числами с плавающей точкой. Подобные действия должны были моделироваться на программном уровне, что значительно увеличивало время их выполнения. Из-за того, что на программною обра- ботку чисел с плавающей точкой уходило очень большое время работы процессора, за- траты на интерпретацию и моделирование индексной адресации были относительно не- существенными. Пока действия над числами с плавающей точкой выполнялись на про- граммном уровне, расходы на интерпретацию были допустимыми. Впрочем, многие программисты того времени никогда не использовали системы интерпретации, отдавая 2.3. Компьютер IBM 704 и язык FORTRAN 63
предпочтение эффективному ручному программированию на машинных кодах. Сообще- ние о появлении системы IBM 704. имеющей на аппаратном уровне как индексную адре- сацию. так и команды для работы с плавающей точкой, возвестило о конце эры интер- претации (по крайней мере, в области научных вычислений). Несмотря на то что язык FORTRAN считается первым компилируемым языком высо- кою уровня, вопрос о том. кто действительно заслуживает чести быть названным созда- телем первого подобного языка, все еще остается открытым. Кнут (Knuth) и Пардо (Pardo) (1977) приписывают эту честь Алику Гленни (Alick Е. Glennie) за создание ком- пилятора Autocode для компьютера Mark I в Манчестере. Гленни разработал этот компи- лятор в британском Форт-Холстеде (Fort Halstead), принадлежащем Королевскому науч- но-исследовательскому обществу Вооруженных Сил (Royal Armaments Research Establishment). В рабоче.м состоянии компилятор был с сентября 1952 года. Но Джон Бэ- к\с (Wexelblat. 1981) придерживается мнения, что созданный Гленни компилятор Autocode был настолько низкоуровневым и машинно-ориентированным, что его нельзя считать в полной мере системой компиляции. Бэкус считает, что первыми авторами ком- пилятора нужно считать Ленинга (Laning) и Цирлера (Zierler) из Массачусетского техно- логического института (MIT — Massachusetts Institute of Technology). Система Ленинга и Цирлера (Laning and Zierler, 1954) была первой алгебраической системой трансляции, созданной для реализации. Под словом “алгебраическая” мы под- разумеваем то. что эта система транслировала арифметические выражения, используя вызовы математических функций, а также содержала индексированные ссылки на пере- менные. Реализована эта система была на принадлежащем институту7 М1Т компьютере Whirlwind, который летом 1952 года существовал в форме экспериментального прототи- па. а к маю 1953 года принял форму, более пригодную для использования. Для кодиро- вания каждой формулы или выражения, встречающихся в программе, описываемый транслятор генерировал вызов подпрограммы. Исходный язык был легкочитаемым, и единственными действительно машинными командами были команды ветвления. Не- смотря на то что работа по созданию этого транслятора предшествовала работе по соз- данию языка FORTRAN, она так и не вышла за рамки Массачусетского технологическо- го института. Несмотря на эти ранние работы, первым компилируемым языком высокого уровня, получившим широкое распространение, стал язык FORTRAN, хронология развития ко- торого приводится в следующих подразделах. 2.3.2. Процесс разработки Планы по разработке языка FORTRAN существовали еще до извещения о создании системы IBM 704 в мае 1954 года. Работавшие в корпорации IBM Джон Бэкус и его группа в ноябре 1954 года опубликовали отчет, озаглавленный “The IBM Mathematical FORmula TRANslating System: FORTRAN" (IBM, 1954). В этом документе описывается первая версия языка FORTRAN, которую мы называем FORTRAN 0, и которая предше- ствовала его реализации. В работе смело утверждалось, что язык FORTRAN так же эф- фективен. как программы, закодированные вручную, и программировать на нем так же просто, как в системе интерпретируемых псевдокодов. Еще одной вспышкой оптимизма в данном документе было утверждение, что язык FORTRAN будет устранять ошибки программирования и содержать систему отладки. На основе этих предпосылок в первом 64 Глава 2. Обзор основных языков программирования
компиляторе языка FORTRAN предусматривалась лишь незначительная проверка нали- чия синтаксических ошибок. Язык FORTRAN разрабатывался в следующей обстановке. 1. Компьютеры все еше были небольшими, медленными и относительно ненадеж- ными. 2. Использовались компьютеры в основном для научных расчетов. 3. Не существовало эффективных способов программирования компьютеров. 4. Из-за относительно высокой стоимости компьютеров по сравнению с оплатой ра- боты программистов основной задачей первых компиляторов языка FORTRAN была высокая скорость выполнения сгенерированного объектного кода. В такой среде и формировались характеристики ранних версий языка FORTRAN. 2.3.3. Обзор языка FORTRAN I Язык FORTRAN 0 видоизменялся на протяжении всего периода реализации, начав- шегося в январе 1955 года и завершившегося выходом компилятора в апреле 1957 года. Реализованный язык, который мы называем FORTRAN 1, описан в первом руководстве пользователя “Programmer's Reference Manual”, опубликованном в октябре 1956 года (IBM, 1956). В языке FORTRAN I содержалось форматирование ввода-вывода, имена переменных, которые не должны были превышать шести символов (в FORTRAN 0 — двух), определяемые пользователем подпрограммы, которые, правда, не могли компили- роваться раздельно, условный оператор IF и цикл DO. Язык FORTRAN 0 содержал логический оператор IF, оперировавший с булевскими выражениями, в которых операторы отношений записывались в их алгебраической фор- ме,— например, использовался знак “>’* для обозначения отношения “больше". По- скольку алфавит компьютера IBM 704 не содержал таких символов, как “>", то в даль- нейшем от этих операторов отношения нужно было отказаться. Машина содержала трех- вариантную команду ветвления, основанную на сравнении величины из ячейки памяти с величиной в регистре, поэтому исходный логический оператор IF был заменен арифме- тическим, имевшим форму: IF (арифметическое выражение) Nl, N2, N3 Здесь с помощью Nl, N2 и N3 обозначены метки операторов. Если значение выражения оказывалось отрицательным, то выполнялся переход на метку N1. если оно равнялось нулю— на метку N2, а если было положительным— на метку N3. Этот оператор и по сегодняшний день является частью языка FORTRAN. Оператор цикла языка FORTRAN 1 имел следующую структуру: DO N1 переменная = первое_значение, последнее_значение Здесь N1 — метка последнего оператора цикла, а первым считался оператор, следующий за оператором DO. Помимо оператора IF. компьютер IBM 704 имел отдельную команду для реализации оператора DO. Поскольку эта команда была создана для циклов с последующей провер- кой условия, оператор DO языка FORTRAN 1 также был сконструирован подобным обра- зом. Цикл с предварительной проверкой условия мог быть реализован на компьютере IBM 704. но это потребовало бы введения дополнительной машинной команды, что вви- 2.3. Компьютер IBM 704 и язык FORTRAN 65
ду ориентированности структуры языка FORTRAN на максимальную эффективность и стало причиной отказа от его реализации. Все управляющие операторы языка FORTRAN I основывались на командах компью- тера IBM 704. Сейчас уже не ясно, была ли форма управляющих структур языка FORTRAN I навязана конструкторами компьютера IBM 704, или же разработчики языка FORTRAN I предложили использовать такую форму команд в компьютере IBM 704. В языке FORTRAN I не существовало операторов, определяющих типы данных. Счита- лось. что переменные, начинавшиеся с букв I, J, К, L, И и N, принадлежат к типу целых чи- сел, тогда как все остальные подразумевались числами с плавающей точкой. Такая услов- ность при выборе букв связана с тем, что тогда целые числа использовались преимущест- венно в качестве индексов, а ученые, как правило, использовали для этой цели буквы /,у и к. Еще три буквы были щедро добавлены разработчиками языка FORTRAN. Наиболее дерзким заявлением группы разработчиков языка FORTRAN в процессе ра- боты над языком было то, что машинные коды, порождаемые компилятором, будут поч- ти столь же эффективными, что и написанные вручную. Эти заявления более, чем что- либо другое, способствовали скептическому отношению со стороны потенциальных пользователей и препятствовали возникновению значительного интереса к языку до его фактического выпуска. Тем не менее, всех удивило то, что создатели языка FORTRAN практически достигли поставленной цели с точки зрения эффективности. Большая часть 18 человеко-лет напряженной работы по созданию первого компилятора была посвящена оптимизации, но результаты были в высшей степени эффективными. Первый успех языка FORTRAN отражен в обзоре, датированном апрелем 1958 года. К этому времени около половины команд, написанных для компьютеров IBM 704, были реализованы в языке FORTRAN, и это несмотря на то, что годом ранее отношение к языку со стороны вычислительного сообщества было крайне скептическим. 2.3.4. Обзор языка FORTRAN II Компилятор FORTRAN II начал распространяться весной 1958 года. Он исправил множество ошибок системы компиляции языка FORTRAN I, а также добавил в язык не- сколько существенных свойств, важнейшим из которых была независимая компиляция подпрограмм. При отсутствии такого свойства любое изменение требовало перекомпи- ляции всей программы. Отсутствие в языке FORTRAN 1 возможности независимой ком- пиляции вместе с низкой надежностью компьютера IBM 704 ограничивало объем про- граммы, который не должен был превышать 300-400 строк (Wexelblat, 1981). Програм- мы. имевшие больший объем, имели мало шансов завершить компиляцию до сбоя в работе машины. Возможность включения в программу подпрограмм, предварительно откомпилированных на машинных языках, значительно сокращала процесс компиляции. 2.3.5. Языки FORTRAN IV, FORTRAN 77 и FORTRAN 90 Язык FORTRAN III был разработан, но так никогда и не получил широкого распро- странения. Язык FORTRAN IV, напротив, стал одним из самых используемых в то время языков программирования. Развитие этого языка происходило в период с 1960 по 1962 год. и он оставался стандартной версией языка FORTRAN вплоть до выхода в 1978 году сообщения о выпуске языка FORTRAN 77 (ANSI. 1978а). По многим параметрам язык FORTRAN IV был значительно лучше языка FORTRAN 11. Среди важнейших его ново- 66 Глава 2. Обзор основных языков программирования
введений было явное объявление типов переменных, логический оператор IF. а также возможность передавать одни подпрограммы другим в качестве их параметров. Язык FORTRAN 77 поддерживал большинство свойств языка FORTRAN IV. кроме то- го, в него были добавлены обработка символьных строк, логические операторы управления циклами, а также возможность использования оператора ELSE в структуре выбора IF. Название FORTRAN 90 используется для обозначения позднейшей версии языка FORTRAN (ANSI, 1992). Язык FORTRAN 90 разительно отличается от языка FORTRAN 77. Наиболее значительные изменения кратко описываются ниже. Встроен набор функций, выполняющий действия с массивами. В их число входят: DOTPRODUCT, MATMUL, TRANSPOSE. MAXVAL. MINVAL, PRODUCT и SUM, о назначении которых можно догадаться по названиям (“скалярное произведение", “умножение матриц", “транспонирование", “максимальная величина", “минимальная величина", “произведение" и “сумма"). Это только некоторые из наиболее полезных доступных функций. Если массивы были объявлены как ALLOCATABLE, то они могут по команде динами- чески размещаться в памяти и удаляться из нее. Последняя возможность является ради- кальным изменением по сравнению с предыдущими версиями языка FORTRAN, имев- шими только статические данные. Была введена форма записей, названных производны- ми типами. Частью языка FORTRAN 90 стали также указатели. Были добавлены новые управляющие операторы: оператор многовариантного ветв- ления CASE, оператор EXIT — для преждевременного выхода и оператор CYCLE — для передачи управления на начало цикла без выхода из цикла. Подпрограммы могут быть рекурсивными, а также иметь необязательные и ключевые параметры. Была добавлена возможность использования модулей, аналогичных пакетам языка Ada. Модули могут содержать объявления данных и подпрограммы, каждую из которых для управления внешним доступом можно описать с помощью спецификаторов PRIVATE либо PUBLIC. В описании языка FORTRAN 90 появилась новая концепция, позволявшая удалять свойства языка, присутствовавшие в ранних версиях. Хотя язык FORTRAN 90 и обладал всеми возможностями языка FORTRAN 77, было составлено два списка свойств, кото- рые могли быть ликвидированы в следующей (после языка FORTRAN 90) версии языка FORTRAN. В этом списке были указаны такие операторы, как арифметический оператор IF и назначенный оператор GOTO. Список свойств, не рекомендуемых к употреблению, включал свойства, от которых планировалось отказаться во второй версии, которая по- следует за языком FORTRAN 90. В этом списке находились операторы COMMON, EQUIVALENCE, вычисляемый оператор GOTO, а также операторные функции. 2.3.6. Оценка Команда разработчиков первоначального варианта языка FORTRAN полагала, что разработка языка — только необходимая прелюдия перед насущной задачей разработки транслятора. Более того, им никогда не приходило на ум, что язык FORTRAN мог бы использоваться на компьютерах производства других корпораций, а не корпорации IBM. Действительно, они были вынуждены продумывать построение компиляторов языка FORTRAN для других машин, создаваемых корпорацией IBM, только потому, что пре- емник компьютера IBM 704, компьютер IBM 709, был выпушен до выпуска компилятора языка FORTRAN, предназначенного для компьютера IBM 701. Тот факт, что язык 2.3. Компьютер IBM 704 и язык FORTRAN 67
FORTRAN был реально использован на компьютерах, а все последующие языки про- граммирования своим появлением в той или иной степени обязаны языку FORTRAN, яв- ляется действительно впечатляющим в свете скромных притязаний его разработчиков. Одним из свойств языка FORTRAN 1 и всех его последующих версий, за исключени- ем языка FORTRAN 90. позволявшим создание высоко оптимизированных компилято- ров, было то, что типы и ячейки памяти для всех переменных фиксировались до выпол- нения программы. В процессе выполнения программы новые переменные не вводились, и распределение памяти не производилось. Таким образом, гибкость программирования была принесена в жертву простоте и эффективности. Подобное решение исключило воз- можность создания рекурсивных подпрограмм и затруднило реализацию структур дан- ных, которые могли бы динамически увеличиваться или изменять вид. Разумеется, про- граммы, создаваемые во время разработки ранних версий языка FORTRAN, были вычис- лительными по своей природе и простыми по сравнению с современными програм- мными проектами. Следовательно, жертва была не так уж и велика. Трудно переоценить глобальный успех языка FORTRAN: он действительно разитель- но и навечно изменил способ использования компьютеров. Это, разумеется, произошло в значительной мере благодаря тому, что он оказался первым широко используемым язы- ком высокого уровня. По сравнению с концепциями и языками, разработанными позже, ранние версии языка FORTRAN значительно проигрывают, как и следовало ожидать. Все-таки выпущенному Фордом в 1910 году автомобилю “Model Т” далеко до созданно- го в 1998 году автомобиля “Ford Mustang’. Тем не менее, несмотря на недостаточность языка FORTRAN, движущая сила вложенных в него огромных инвестиций, помимо дру- гих факторов, вывела его в число самых используемых языков высокого уровня. Алан Перлис (Alan Perlis), один из разработчиков языка ALGOL 60, в 1978 году ска- зал: “Язык FORTRAN — это lingua franca (смешанный язык из элементов романских, греческого и восточных языков, служащий для общения в восточном Средиземноморье; также* язык с широкой сферой употребления, жаргон. — Прим.перев.) компьютерного мира. Это язык улиц, в лучшем, а не вульгарном смысле этого слова. Он выжил и будет жить, поскольку он был создан для того, чтобы быть замечательно полезной частью жизненно важной коммерческой деятельности’’ (Wexelblat, 1981). Ниже следует пример программы на языке FORTRAN 90. С ПРИМЕР ПРОГРАММЫ НА ЯЗЫКЕ FORTRAN 90 С ВВОД: ЦЕЛОЕ ЧИСЛО LIST_LEN, МЕНЬШЕ 100, ЗА КОТОРЫМ С СЛЕДУЕТ НАБОР ЦЕЛЫХ ЧИСЕЛ, В КОЛИЧЕСТВЕ С LIST_LEN С ВЫВОД: КОЛИЧЕСТВО ВВЕДЕННЫХ ВЕЛИЧИН, КОТОРЫЕ БОЛЬШЕ ИХ СРЕДНЕГО С АРИФМЕТИЧЕСКОГО INTEGER INTLIST(99) INTEGER LIST_LEN, COUNTER, SUM, AVERAGE, RESULT RESULT = 0 SUM = 0 READ *, LIST_LEN IF ((LIST—LEN .GT. 0) .AND. (LIST_LEN .LT. 100)) THEN С СЧИТЫВАНИЕ ВХОДНЫХ ВЕЛИЧИН В МАССИВ И ВЫЧИСЛЕНИЕ ИХ СУММЫ DO 10 COUNTER = 1, LIST_LEN READ *, INTLIST(COUNTER) SUM = SUM + INTLIST(COUNTER) 68 Глава 2. Обзор основных языков программирования
10 CONTINUE С ВЫЧИСЛЕНИЕ СРЕДНЕГО AVERAGE = SUN / LISU_LEN С ПОДСЧЕТ ЧИСЛА ВЕЛИЧИН, КОТОРЫЕ БОЛЬТЕ СРЕДНЕГО DO 20 COUNTEP = 1, LIST TEN if (iNTLEST'.counter: 7gt. average: then RESULT = RESULT -1 END IF 20 CONTINUE С ВЫВОД РЕЗУЛЬТАТОВ PRINT -,’ЧИСЛО ВЕЛ;-ЧИН,КОТОРЫЕ БОЛЬТЕ СРЕДНЕГО,РАВНО', *RESULT ELSE PRINT *, ’ОШИБКА. — ВВЕДЕНА НЕВЕРНАЯ ДЛИНА. СЛИСКА' END IF STOP END 2.4. Функциональное программирование: язык LISP Первый язык функционального программирования был изобретен для поддержки языковых средств обработки списков, потребность в которых возникла с появлением первых работ в области искусственного интеллекта. 2.4.1. Истоки работ в области искусственного интеллекта и обработка списков Интерес к искусственному интеллекту' начал появляться в середине 1950-х годов в различных областях. Потребность в нем ощущалась частично в лингвистике, частично в философии, а частично— в математике. Лингвисты сосредоточились на обработке тек- стов на естественном языке. Психологов интересовало моделирование способностей че- ловека к запоминанию и воспроизведению воспоминаний, а также другие фундаменталь- ные процессы человеческого мышления. Математики изучали механизм протекания та- ких процессов размышления, как доказательство теорем. Все эти исследования пришли к одному выводу: должны быть созданы некоторые методы, которые позволили бы ком- пьютерам обрабатывать символьные данные, хранящиеся в виде связных списков. Отме- тим также, что в то время практически все вычисления производились с числовыми дан- ными, хранящимися в виде массивов. Концепция обработки списков была разработана Алленом Ньюелом (Allen Newell). Дж. Шоу (J. С. Shaw) и Гербертом Саймоном (Herbert Simon), Впервые она была опубли- кована в классической работе, описывавшей одну' из первых программ искусственного интеллекта, программу Logical Theorist, а также язык, на котором она могла быть реали- зована (Newell and Simon. 1956). Язык, названный 1PL-I (Information Processing Language — язык обработки информации), никогда не был внедрен. Следующая версия этого языка IPL-1I была реализована на компьютере Johnniac корпорации Rand Corporation. Развитие языка IPL продолжалось до 1960 года, в котором было опублико- вано описание языка IPL-V (Newell and Tonge. 1960). Широкому распространению язы- ков семейства 1PL мешал их низкий уровень. Для гипотетического компьютера, реал изо- 2.4. Функциональное программирование: язык LISP 69
ванного посредством интерпретаторов с включенными в него командами обработки спи- сков. эти языки фактически были языками ассемблера. Первая их реализация была вы- полнена на ничем не примечательной машине Johnniac, что также не очень способство- вало распространению языков семейства 1PL. Вкладом языков семейства 1PL в развитие языков программирования были их спи- сковые структуры и наглядная демонстрация того, что обработка списков вполне воз- можна и полезна. Корпорация IBM заинтересовалась искусственным интеллектом в середине 1950-х го- дов и в качестве демонстрационной области выбрала доказательство теорем. В то время полным ходом шла разработка языка FORTRAN. Высокая стоимость компилятора языка FORTRAN I убедила корпорацию IBM в том, что обработку списков проще присоединить к языку FORTRAN, чем выделять ее в отдельный язык. Так и возник язык FLTP (FORTRAN List Processing Language — язык обработки списков на базе языка FORTRAN), который был разработан и реализован в качестве расширения языка FORTRAN. Язык FLPL исполь- зовался для доказательства теорем в области планиметрии, которая тогда рассматривалась как простейшая область для механического доказательства теорем. 2.4.2. Процесс разработки языка USP Летом 1958 года отделом информационных исследований корпорации IBM (IBM Information Research Department) был нанят Джон Мак-Карти (John McCarthy) из инсти- тута MIT. Его целью на это лето было исследование символьных вычислений и разра- ботка конструктивных требований для проведения подобных вычислений. В качестве пробной проблемной области он выбрал дифференцирование алгебраических выраже- ний. При изучении этой области появился набор требований к языку. Среди них были методы управления выполнением математических функций: рекурсия и условные выра- жения. Язык FORTRAN 1, единственный существующий на то время язык высокого уровня, ни одной из этих функций не имел. Другие требования возникли при исследовании символьного дифференцирования и заключались в необходимости наличия связных списков, динамически размещаемых в памяти, и некоторого способа неявного удаления списков из памяти, использование кото- рых в программе уже не предполагалось. Мак-Карти просто не мог позволить явным опе- раторам освобождения памяти загромождать его изящный алгоритм дифференцирования. Поскольку язык FLPL не поддерживал рекурсию, условные выражения, динамическое выделение памяти или неявное ее освобождение, Мак-Карти стало ясно, что нужен но- вый язык. Когда осенью 1958 года Мак-Карти вернулся в институт М1Т, он вместе с Марвином Мински (Marvin Minsky) основал проект, посвященный искусственному интеллекту, MIT Al Project, финансировавшийся Исследовательской электротехнической лабораторией (Research Laboratory for Electronics). Первой важной работой нового проекта было созда- ние системы обработки списков. Ее планировалось использовать для первоначальной реализации предложенной Мак-Карти программы, названной Advice Taker. Это прило- жение стало стимулом к развитию языка обработки списков LISP. Первую версию этого языка иногда называют чистым языком LISP, поскольку он представляет собой язык функционального программирования в чистом виде. Эволюция чистого языка LISP опи- сывается в следующем разделе. 70 Глава 2. Обзор основных языков программирования
2.4.3. Обзор языка 2.4.3.1. Структуры данных В чистом языке LISP существовало только два типа структур данных: атомы и спи- ски. Атомы могли быть либо символами, принимавшими форму идентификаторов, либо числовыми константами. Естественно использовать для хранения символьной информа- ции связные списки, что и было сделано в языке IPL-II. Подобные структуры позволяют вставки и удаления в любом месте (операции, которые тогда считались неотъемлемой частью обработки списков). Правда, в конце концов было обнаружено, что подобное в языке LISP требуется очень редко. Списки определялись путем разграничения составляющих их элементов круглыми скобками. Простые списки, в которых элементы разделены на атомы, имеют следующую форму: (А В С D) Структуры со вложенными списками также определялись круглыми скобками. Например, список (А (В С) D (Е (F G) ) ) состоит из четырех элементов. Первый — это атом А, второй — подсписок (В С), тре- тий— атом D, а четвертый— подсписок (Е (F G)), содержащий в качестве второго элемента подсписок (F G). Списки, как правило, представляются в виде односвязных списковых структур, каждый узел которых содержит два указателя и представляет собой элемент списка. Первый указа- тель узла, соответствующего атому, указывает на представление атома, т.е. на его символь- ное или числовое значение. Первый указатель узла, соответствующего элементу подсписка, указывает на первый узел подсписка. В обоих случаях второй указатель узла указывает на следующий элемент списка. Ссылаются на список, указывая его первый элемент. Внутренние представления двух списков, упомянутых ранее, изображены на рис. 2.2. Отметим, что элементы списка показаны горизонтально. Последний элемент списка не имеет последующего элемента, так что он связан с нулевым указателем NIL. С помощью подобной же структуры показаны подсписки. 2.4.3.2. Процессы в функциональном программировании Язык LISP создавался как язык функционального программирования. Все вычисления в функциональной программе производятся путем применения функций к аргументам. Ни операторы присваивания, ни переменные, с избытком имеющиеся в императивных языках программирования, не нужны в программах, написанных на языках функцио- нального программирования. Более того, итеративные процессы могут определяться с помощью рекурсивных вызовов функций, что делает ненужными циклы. Эти основные концепции функционального программирования отличают его от программирования на императивных языках. 2.4. Функциональное программирование: язык LISP 71
В с D G Рис. 2.2. Внутреннее представление двух списков в языке LISP 2.4.3.3. Синтаксис языка USP Язык LISP значительно отличается от императивных языков как из-за того, что он явля- ется функциональным языком, так и из-за того, что программы, написанные на этом языке, выглядят совершенно иначе, чем программы, написанные на таких языках, как FORTRAN или С. Синтаксис языка С, например, является сложной смесью английского и алгебры, то- гда как синтаксис языка LIST — эталон простоты. Рассмотрим снова список (А В С D) Если интерпретировать его в терминах данных, то это — список из четырех элемен- тов. Если же рассматривать его как программу, то это значит, что функция А применяет- ся к трем параметрам: В, С и D. 2.4.4. Оценка Язык LISP всецело доминировал в сфере искусственного интеллекта четверть столетия, и все еще остается самым распространенным языком в этой области. Устранено большин- ство причин, создавших языку LISP репутацию неэффективного. Скомпилированы многие современные реализации, и результирующие программы выполняются значительно быст- рее, чем интерпретация их исходного кода. Помимо успехов в области искусственного ин- теллекта, язык LISP оказался пионером функционального программирования, доказавшего свою жизнеспособность в качестве области исследования языков программирования. Как указывалось в главе 1, многие исследователи языков программирования полагают, что функциональное программирование значительно лучше подходит для разработки про- граммного обеспечения, чем использование императивных языков. На протяжении 1970-х и в начале 1980-х годов было разработано и использовано множество различных диалектов языка LISP. Это привело к знакомой проблеме перено- 72 Глава 2. Обзор основных языков программирования
симости. Для исправления возникшей ситуации была создана стандартная версия языка, названная COMMON LISP (Steele. 1984). Подробно язык Scheme (диалект языка LISP) и функциональное программирование в целом рассмотрены в главе 14. Ниже следует пример программы на языке LISP. ; Пример функции языка LSIP ; Следующая программа определяет предикативную функцию языка ; LISP, принимающую в качестве аргументов два списка и ; возвращающую значение TRUE, если оба списка идентичны, и ; значение NIL (FALSE) в противном случае ; (DEFUN equal_lists (lisl lis2) (COND ((ATOM lisl) (EQ lisl lis2)) ((ATOM lis2) NIL) ((equal_list (CAR lisl) (CAR lis2)) (equal_list (CDR lisl) (CDR lis2))) (T NIL) ) ) 2.4.5. Два потомка языка LISP В наше время широко используются два диалекта языка LISP— язык COMMON LISP и язык Scheme. Оба кратко рассмотрены в следующих подразделах. 2.4.5.1. Язык Scheme Язык Scheme был разработан в институте MIT в середине 1970-х годов (Sussman and Steele, 1975). Для него характерен небольшой размер, использование исключительно статического обзора данных (рассматриваемого в главе 4). а также обработка функций как объектов первого класса. В таком качестве функции языка Scheme могут быть значе- ниями выражений и элементами в списках, присваиваться переменным, передаваться в качестве параметров и возвращаться в качестве результата применения функции к своим аргументам. В ранних версиях языка LISP всех этих возможностей не было, также в них не использовался статический обзор данных. Как небольшой язык с простыми синтаксисом и семантикой язык Scheme вполне приго- ден для таких учебных приложений, как курсы функционального программирования и об- щие вводные курсы программирования. Подробнее язык Scheme описывается в главе 14. 2.4.5.2. Язык COMMON LISP Язык COMMON LISP (Steele, 1984) был создан в попытке объединить в одном языке свойства нескольких диалектов языка LISP, разработанных в начале 1980-х годов, в том числе и языка Scheme. Будучи такой комбинацией, язык COMMON LISP представляет собой большую и сложную структуру. Тем не менее, его основой является чистый язык LISP, так что его синтаксис, основные функции и принципиальная природа происходят от этого языка. Признав гибкость, обеспечиваемую динамическим обзором данных, и простоту, пре- доставляемую статическим обзором, разработчики языка COMMON LISP использовали оба способа. По умолчанию используется статический обзор переменных, но если объя- вить переменную как special, ее область видимости становится динамической. 2.4. Функциональное программирование: язык LISP 73
Язык COMMON LISP содержит большое число типов и структур данных, среди кото- рых записи, массивы, комплексные числа и строки символов. Также в нем есть форма пакетов, используемая для сборки в единое целое набора функций и данных, которая обеспечивает, таким образом, управление доступом. К языку COMMON LISP мы еще вернемся в главе 14. 2.4.6. Родственные языки Язык ML (Metalanguage) изначально был создан в 1980-х годах Робином Милнером (Robin Milner) в Эдинбургском университете как метаязык для системы верификации программ, названной Logic for Computable Function (LCF) (Milner at al., 1990). Главным образом, язык ML — это язык функционального программирования, но он поддерживает также императивное программирование. В языке ML функции являются более общими, чем в императивных языках: они передаются между подпрограммами как параметры, они также могут быть полиморфными, что означает возможность получения параметров разных типов при разных вызовах. В отличие от языков LISP и Scheme, тип каждой пе- ременной и каждого выражения языка ML может определяться в процессе компиляции. Типы связываются с объектами, а не с именами. Типы выражений логически выводятся из контекста выражений, как это показано в главе 6. В отличие от языков LISP и Scheme, язык ML не использует функциональный синтак- сис со скобками, возникший вместе с лямбда-выражениями. Синтаксис языка ML скорее похож на синтаксис таких императивных языков, как Pascal и С. Язык Miranda был разработан Дэвидом Тернером (David Turner) в Университете Кента в Кентербери, Великобритания (University of Kent in Canterbury), в начале 1980-х (Turner, 1986). Этот язык частично основан на языках ML, SASL и KRC. Язык Haskell (Hudak and Fasel, 1992) в значительной мере основан на языке Miranda. Подобно языку Miranda, он яв- ляется чисто функциональным языком, не содержащим ни переменных, ни операторов присваивания. Еще одной отличительной чертой языка Haskel) является использование “ленивого” вычисления. Никакое выражение не вычисляется до тех пор, пока не потребует- ся его значение, что приводит к нескольким неожиданным возможностям языка. Языки ML и Haskell кратко описываются в главе 14. 2.5. Первый шаг к совершенствованию: язык ALGOL 60 Язык ALGOL 60 оказал значительное влияние на последующие языки программирова- ния и, как следствие, занимает центральное место в любом историческом обзоре языков. 2.5.1. Историческая ретроспектива Язык ALGOL 60— результат попытки создания универсального языка. На конец 1954 года в обращении уже более года находилась алгебраическая система Ленинга и Цирлера, и был опубликован первый отчет по языку FORTRAN. Реально язык FORTRAN был создан в 1957 году, к тому же году были разработаны еще несколько языков высоко- го уровня. Самыми известными из них были язык IT, разработанный в Карнеги-Тех (Camegy Tech) Аланом Перлисом (Alan Perlis), и два языка для компьютеров UNIVAC: MATH-MATIC и UNICODE. Рост количества языков программирования затруднял связи 74 Глава 2. Обзор основных языков программирования
между пользователями. Более того, все новые языки возникали на основе отдельных ар- хитектур, некоторые создавались для компьютеров UNIVAC. а некоторые — для машин корпорации IBM серии 700. В ответ на такой количественный рост языков несколько ос- новных компьютерных групп в Соединенных Штатах, в том числе SHARE (научная группа корпорации IBM) и USE (UNIVAC Scientific Exchange — промышленная группа обмена научными разработками). 10 мая 1957 года подали прошение в Ассоциацию по вычислительной технике (АСМ — Association for Computing Machinery) с просьбой сформировать комитет для изучения ситуации и создания универсального языка про- граммирования. Хотя кандидатом на такое звание мог быть язык FORTRAN, но истори- чески так не произошло, поскольку в то время он находился в исключительном владении корпорации IBM. Ранее, в 1955 году. Общество прикладной математики и механики (GAMM) также сформировало комитет по разработке единого универсального машинно-независимого ал- горитмического языка для использования на всех типах компьютеров. Желание иметь та- кой язык частично было вызвано европейской боязнью господства корпорации IBM. Тем не менее, появившиеся к концу 1957 года в Соединенных Штатах языки высокого уровня убедили подкомиссию общества GAMM в том. что их работа должна быть расширена для включения американских разработок, и письмо с соответствующим предложением было направлено в ассоциацию АСМ. После того как в апреле 1958 года Фриц Бауэр (Fritz Bauer) из общества GAMM представил ассоциации АСМ формальный план, указанные две группы официально согласились принять участие в проекте по разработке нового языка. 2.5.2. Начальная стадия разработки Общество GAMM и ассоциация АСМ решили, что совместная конструкторская рабо- та должна вестись на собрании, на которое каждая группа пошлет четырех представите- лей. Эта встреча, состоявшаяся в период с 27 мая по 1 июня 1958 года в Цюрихе, нача- лась с формулирования следующих целей нового языка. Синтаксис языка должен быть максимально близок к общепринятым математиче- ским обозначениям, и программы, написанные на нем. должны быть читабельны- ми сами по себе с минимальными дополнительными пояснениями. Язык должен использоваться для описания вычислительных процессов в публика- циях. Программы на новом языке должны механически транслироваться на машинные языки. Первая цель означает возможность использования языка для научного программиро- вания, которое в то время было основной сферой приложения вычислительной техники. Вторая цель была чем-то совершенно новым в компьютерном бизнесе. Необходимость последней цели для любого языка программирования очевидна. В зависимости от точки зрения встреча в Цюрихе породила либо важные результаты, либо бесконечные споры. В действительности, справедливо и то, и другое. Сама по себе встреча привела к многочисленным компромиссам как между отдельными лицами, так и между двумя сторонами Атлантики. В некоторых случаях компромиссы были не так уж и существенны в сравнении с глобальной целью, если они не касались различных сфер влияния. Одним из примеров может служить вопрос использования запятой (европейский метод) или точки (американский метод) в десятичной записи числа. 2.5. Первый шаг к совершенствованию: язык ALGOL 60 75
2.5.3. Обзор языка ALGOL 58 Язык, разработанный на встрече в Цюрихе, был назван IAL (International Algorithmic Language— интернациональный алгоритмический язык). В процессе конструкторских работ было предложено название ALGOL (ALGOrithmic Language— алгоритмический язык), но это название было отклонено, поскольку оно не отражало интернационального состава комитета. Впрочем, в течение следующего года название все-таки было измене- но на ALGOL, а сам язык получил впоследствии имя ALGOL 58. По многим параметрам язык ALGOL 58 был наследником языка FORTRAN, что вполне естественно. В нем обобщались многие свойства языка FORTRAN, а также вво- дились некоторые новые конструкции и концепции. Одни обобщения делались с целью создания языка, не привязанного к отдельной машине, другие — для увеличения гибко- сти и мощности языка. В результате получилась редкая комбинация простоты и изы- сканности. В языке ALGOL 58 была формализована концепция типов данных, хотя явного объ- явления требовали только переменные, не являющиеся числами с плавающей точкой. В нем была реализована идея составных операторов, которые затем были включены в большинство языков. Среди прочих были обобщены следующие свойства языка FORTRAN: разрешено использование идентификаторов произвольной длины (в отличие от имеющегося в языке FORTRAN ограничения на шесть символов): разрешено исполь- зование массивов любой размерности (в отличие от имеющегося в языке FORTRAN ог- раничения на три измерения); нижний предел массива мог определяться программистом (в то время как в языке FORTRAN он всегда имел значение 1); разрешены вложенные операторы ветвления (чего не было в языке FORTRAN). В языке ALGOL 58 была использована несколько необычная форма оператора при- сваивания. В языке PlankalkUl Цузе использовал следующую форму оператора присваи- вания: выражение => величина Хотя во время обсуждаемых событий описание языка Plankalkiil и не было еще опуб- ликовано. некоторые европейские члены комитета по созданию языка ALGOL 58 были знакомы с этим языком. Комитет позаимствовал из языка PlankalkUl форму оператора присваивания, но из соображений ограниченного использования символов знак ‘‘больше” был заменен двоеточием. Затем, в основном из-за настойчивых требований американцев, полный оператор принял вид выражение := величина Европейцы предпочитали обратную форму. 2.5.4. Принятие отчета о языке ALGOL 58 Публикация в декабре 1958 года отчета о языке ALGOL 58 (Perlis and Samelson) была встречена с большим энтузиазмом. В Соединенных Штатах новый язык представлялся скорее набором идей в области структуры языка программирования, чем универсальным языком программирования. Действительно, отчет о языке ALGOL создавал впечатление не законченного продукта, а чернового документа, выставленного на международное об- суждение. Тем не менее, этот отчет лег в основу трех основных работ по разработке и реализации языков программирования. В Университете штата Мичиган был создан язык 76 Глава 2. Обзор основных языков программирования
MAD (Arden et al.. 1961). Американской военно-морской электронной группой (U. S. Naval Electronics Group) был создан язык NEL1AC (Huskey et al.. 1963). Корпорация no разработке систем System Development Corporation разработала и реализовала язык JOVIAL (Shaw. 1963). Название последнего языка— аббревиатура от “Jules’ Own Version of the International Algebraic Language" (Собственная версия интернационального алгебраического языка, разработанная Жюлем), а сам он является единственной версией языка, основанного на языке ALGOL 58 и получившего широкое распространение (Жюлем был Жюль Шварц (Jules 1. Schwartz), один из разработчиков языка JOVIAL). Язык JOVIAL стал широко используемым, поскольку он четверть столетия был офици- альным языком научных разработок ВВС США. Остальная часть вычислительного сообщества Соединенных Штатов не была так бла- госклонна к новому языку. На первых порах корпорация IBM и ее главная научная груп- па SHARE, похоже, приняли язык ALGOL 58. Корпорация IBM начала его реализацию вскоре после публикации отчета, и группа SHARE сформировала подкомитет SHARE IAL для изучения этого языка. Позднее подкомитет предложил ассоциации АСМ стан- дартизировать язык ALGOL 58, и таким образом корпорация IBM реализовала его на всех компьютерах 700-й серии. Тем не менее, энтузиазм вскоре спал. Весной 1959 года и корпорация IBM, и группа SHARE, имевшие уже опыт работы с языком FORTRAN, ре- шили, что с них достаточно проблем и расходов, чтобы начинать работу над новым язы- ком. В середине 1959 года и корпорация IBM, и группа SHARE, вложившие немалые средства в язык FORTRAN, решили поддержать именно этот проект и сделать его язы- ком научных разработок для 700-й серии машин корпорации IBM. отказавшись, таким образом, от языка ALGOL 58. 2.5.5. Процесс разработки языка ALGOL 60 Ha протяжении 1959 года язык ALGOL 58 горячо обсуждался как в Европе, так и Со- единенных Штатах. В европейском издании ALGOL Bulletin и в Communications of the АСМ было опубликовано большое количество предложений, касающихся изменений и дополнений к этому языку. В 1959 году на Интернациональной конференции по обра- ботке информации (International Conference on Information Processing) была представлена работа Цюрихского комитета, в которой Бэкус предложим новую форму для описания синтаксиса языков программирования, позднее названную формой Бэкуса-Наура (BNF — Backus-Naur form). Подробно она описана в главе 3. В январе 1960 года состоялась второе собрание комитета по разработке языка ALGOL, на этот раз в Париже. Это собрание должно было обсудить 80 предложений, официально представленных на рассмотрение. При этом в разработку языка ALGOL был глубоко вовлечен Питер Наур (Peter Naur) из Дании, несмотря на то. что он не являлся членом Цюрихской группы. Именно Наур создал и издавал ALGOL Bulletin. Наур долгое время изучал работу Бэкуса. вводившую понятие формы BNF. и пришел к заключению, что эту форму можно использовать для формального описания результатов собрания 1960 года. После внесения в форму BNF нескольких относительно небольших Изменений Наур создал с ее помощью описание вновь предложенного языка и передал его комитету 1960 года в начале совещания. 2.5. Первый шаг к совершенствованию: язык ALGOL 60 77
2.5.6. Обзор языка ALGOL 60 Хотя собрание 1960 года длилось всего шесть дней, изменения, внесенные в язык ALGOL 58, были значительными. Среди важнейших новых разработок были следующие. Была введена концепция блочной структуры. Это позволило программистам лока- лизовать части программ, вводя новые среды данных, или области видимости. Появилась возможность передавать параметры подпрограммам двумя новыми способами: передача по значению и передача по имени. Появилась возможность создания рекурсивных процедур. Описание языка ALGOL 58 не совсем ясно освещало этот вопрос. Отметим, что. хотя для импера- тивных языков подобная рекурсия и была новой, язык LISP в 1959 году уже со- держал рекурсивные функции. Появились автоматические (stack-dynamic) массивы. Автоматическим массивом на- зывается такой массив, для которого диапазон или диапазоны изменения индексов определяются значениями переменных. Размер массива устанавливается во время его размещения в памяти. Это происходит в процессе выполнения программы при объявлении массива. Автоматические массивы подробно описаны в главе 5. Были предложены, но не приняты еще несколько свойств, которые могли бы значи- тельно повлиять или на успех, или на провал данного языка. Важнейшими среди них бы- ли операторы ввода-вывода с форматированием, которые не были приняты, поскольку считалось, что они будут значительно машинно-зависимыми. Отчет о языке ALGOL 60 был опубликован в мае 1960 года (Naur, 1960). В описании языка все еще оставалось много неопределенностей, и на апрель 1962 года в Риме была назначено третье собрание комитета, на рассмотрение которого и передавались все во- просы. На этом собрании группа занималась только разрешением затруднений, никаких новых дополнений в язык не вносилось. Результаты собрания были опубликованы под названием “Revised Report on the Algorithmic Language ALGOL 60” (“Переработанный отчет об алгоритмическом языке ALGOL 60”) (Backus at al., 1962). 2.5.7. Оценка языка ALGOL 60 Если судить по одним показателям, то язык ALGOL 60 имел ошеломительный успех, если по другим — был крупнейшим провалом. Успехом, проявившимся практически не- медленно, было то, что он стал единственным официальным средством представления алгоритмов в научной литературе и свыше 20 лет оставался таковым. Каждый импера- тивный язык программирования, созданный после 1960 года, что-то позаимствовал у языка ALGOL 60. Большинство из них прямо или косвенно являются потомками языка ALGOL 60; в качестве примеров можно назвать языки PL/1, SIMULA 67, ALGOL 67, ALGOL 68, С, Pascal, Ada, C++ и Java. В процессе создания языков ALGOL 58/ALGOL 60 многое было сделано впервые. Впервые интернациональная группа попыталась разработать язык программирования. Язык ALGOL был первым машинно-независимым языком. Он также был первым языком с формально описанным синтаксисом. Это успешное использование формы BNF поло- жило начало нескольким важным отраслям теории вычислительных систем: формаль- ным языкам, теории синтаксического анализа и теории разработки компиляторов. В ито- ге, структура языка ALGOL 60 повлияла на машинную архитектуру. Одним из самых 78 Глава 2. Обзор основных языков программирования
выдающихся примеров этого является тот факт, что расширенная версия этого языка ис- пользовалась в качестве системного языка в серии универсальных вычислительных ма- шин компании Burrough: В5000. В6000 и В7000. которые были сконструированы с аппа- ратно-реализованным стеком для эффективной реализации блочной структуры и рекур- сивных процедур языка. Рассмотрим обратную сторону медали: в Соединенных Штатах язык ALGOL 60 нико- гда не получил широкого распространения или хотя бы значительного использования. Да- же в Европе он так и не стал доминирующим языком. Причин его неприятия множество. Во-первых, некоторые свойства языка ALGOL 60 стали слишком гибкими; они затрудняли понимание и делали его реализацию неэффективной. Лучшим примером этого служит ме- тод передачи подпрограммам параметров по имени, подробно описываемый в главе 8. Сложности реализации языка ALGOL 60 сформулировал Рутишаузер (Rutishauser. 1967), сказавший, что очень немногие (если таковые вообще есть) реализации содержат язык ALGOL 60 полностью. Отсутствие в языке операторов ввода-вывода было еще одной существенной причи- ной его неприятия. Зависимый от реализации ввод и вывод затруднял перенос программ на другие компьютеры. Один из важнейших связанных с языком ALGOL 60 вкладов в компьютерные нау- ки — форма BNF — также не способствовал широкому распространению языка. Хотя сейчас эта форма рассматривается как простое и элегантное средство описания синтак- сической структуры, в I960 году она казалась странной и сложной. В заключение отметим, что. хотя и существовало множество других проблем, закре- пившийся в среде пользователей язык FORTRAN, а также отсутствие поддержки со сто- роны корпорации IBM, были, вероятно, решающими факторами провала языка ALGOL 60 как языка широкого применения. Работа над языком ALGOL 60 в действительности никогда не завершалась в том смысле, что неясности и неопределенности всегда были частью описания этого языка (Knuth, 1967). Ниже следует пример программы на языке ALGOL 60. comment Пример программы на языке ALGOL 60 Ввод: Целое число listlen, меньше 100, за которым следует набор целых чисел в количестве listlen Вывод: Количество введенных величин, которые больше их среднего арифметического; begin integer array intlist [1:99]; integer listlen, counter, sum, average, result; sum := 0; result := 0; readint (listlen); if (listlen > 0) a (listlen < 100) then begin comment Считывание входных величин в массив и вычисление суммы; for counter := 1 step 1 until listlen do begin readint (intlist[counter]); 2.5. Первый шаг к совершенствованию: язык ALGOL 60 79
sum := sum + intlist[counter] end; comment Вычисление среднего арифметического; average := sum / listlen; comment Вычисление количества входных величин, которые больше ::х среднего; for counter := 1 step 1 until listlen do if intlist[counter] > average then result := result + 1; comment Вывод результатов; printstring("Число величин, которые больше их среднего:); printint (result) end else printstring (’’Ошибка — введена неверная длина списка”); end Происхождение языка ALGOL 60 представлено на рис. 2.3. FORTRAN 1(1957) ALGOL 58 (1958) • ALGOL 60 (1960) Рис. 2.3. Генеалогия языка ALGOL 60 2.6. Компьютеризация коммерческих записей: язык COBOL История языка COBOL действительно необычна. Хотя использовался он больше, чем какой бы то ни было язык программирования, его влияние на структуру последующих языков было очень незначительным, за исключением языка PL/1. Возможно, он и по се- годняшний день является самым широко используемым языком программирования, хотя трудно сказать, так это или нет. Вероятно, важнейшей причиной слабого влияния языка COBOL можно назвать то. что очень немногие после его появления пытались разрабо- тать новый язык для коммерческих приложений. Последнее может быть следствием того, что возможности языка COBOL вполне удовлетворяют потребности этой области. Дру- гой причиной малого влияния можно назвать то. что значительная часть роста коммер- ческих вычислений последних 15 лет приходится на малый бизнес. В этой области раз- рабатывается очень мало программного обеспечения. Вместо этого большая часть ис- пользуемого программного обеспечения заказывается в виде готовых пакетов для различных коммерческих приложений. 2.6.1. Исторические предпосылки Возникновение языка COBOL в чем-то схоже с возникновением языка ALGOL 60 в том смысле, что этот язык тоже разрабатывался группой людей, собиравшихся на отно- сительно короткий промежуток времени. В 1959 году положение в сфере коммерческих вычислений было подобно состоянию в области научных расчетов, существовавшему 80 Глава 2. Обзор основных языков программирования
несколькими годами ранее во время разработки языка FORTRAN. В 1957 году был реа- лизован компилируемый язык для коммерческих приложений FLOW-MATIC, но он при- надлежал одному производителю, компании UNIVAC. и был разработан для компьюте- ров именно этой компании. Другой язык. AIMACO. использовался ВВС США. но он был разновидностью языка FLOW-MATIC, незначительно отличавшейся от оригинала. Кор- порация IBM разработала язык программирования для коммерческих приложений COMTRAN (COMmercial TRANsIator— коммерческий транслятор), но этот язык не реа- лизован до сих пор. Еще несколько конструкторских работ по созданию языков тогда только намечались. 2.6.2. Язык FLOW-MATIC Происхождение языка FLOW-MATIC стоит хотя бы краткого обсуждения, поскольку этот язык был прародителем языка COBOL. В декабре 1953 года Грейс Хоппер из ком- пании Remington-Rand UNIVAC высказала предположение, оказавшееся пророческим. Она утверждала, что “математические программы следует писать с помощью математи- ческих обозначений, программы обработки данных следует писать на английском" (Wexelblat, 1981). К сожалению, в 1953 году было невозможно убедить непрограммистов в том. что можно создать компьютер, понимающий английские слова. Только в 1955 го- ду подобное предложение получило шансы на финансирование со стороны руководства компании UNIVAC, но даже тогда потребовался пробный объект для принятия оконча- тельного решения. Частью рекламы был запуск небольшой программы, вначале исполь- зовавшей английские ключевые слова, затем французские, а после этого — немецкие. Руководство компании UNIVAC признало демонстрацию убедительной, что и стало ре- шающим фактором при принятии ими плана Хоппер. 2.6.3. Процесс разработки языка COBOL Первое официальное собрание, посвященное универсальному языку для коммерче- ских приложений, финансируемое Министерством обороны США. состоялось в Пента- гоне 28 и 29 мая 1959 года (точно через год после собрания в Цюрихе, посвященного разработке языка ALGOL). Единодушно было выработано мнение, что новый язык, по- лучивший название CBL (Common Business Language— общий коммерческий язык), должен иметь следующие основные характеристики. Большинство согласилось, что нужно максимально использовать английский язык, хотя некоторые и выступали за бо- лее широкое использование математических обозначений. Использование языка должно было быть легким, даже за счет потерн мощности, чтобы расширить круг тех. кто может программировать на компьютере. Кроме того, предполагалось, что менеджеры смогут читать программы, если использовать в языке программирования английский язык. На- конец, проект не должен был чрезмерно ограничиваться проблемами его реализации. Поскольку была проведена большая подготовительная работа, создавать универсаль- ный язык следовало немедленно. Кроме того, компании RCA и Sylvania уже работали над собственными языками коммерческих приложений. При таких обстаятельствах было решено провести краткое изучение существующих языков программирования, для чего был сформирован комитет Short Range Committee. Ранее были приняты решения о разделении выражений языка на две категории — описания данных и выполняемых операций — ио размещении выражений этих двух ка- тегорий в разных частях программ. Один из самых жарких споров в комитете Short 2.6. Компьютеризация коммерческих записей: язык COBOL 81
Range Committee велся вокруг введения индексов. Многие члены комитета доказывали, что индексы — слишком сложное понятие для математически малообразованных людей, занимающихся обработкой данных. Подобные аргументы приводились и при обсужде- нии возможности включения математических выражений. В окончательном отчете коми- тета, завершенном к декабрю 1959 года, описывался язык, позднее названный языком COBOL 60. Спецификации языка COBOL 60, опубликованные правительственной типографией Goverment Printing Office в апреле 1960 года (Department of Defense, 1960), были описаны как “исходные". Исправленные версии были опубликованы в 1961 и 1962 годах (Department of Defense, 1961, 1962). В 1968 году язык был стандартизирован Националь- ным институтом стандартизации США (ANSI). Следующие две переработанные версии языка были стандартизированы тем же институтом в 1974 и 1985 годах. В наше время язык продолжает эволюционировать. 2.6.4. Оценка Язык COBOL положил начало большому количеству новых концепций, некоторые из которых в конечном итоге появились и в других языках. Например, использованный в языке COBOL глагол DEFINE (“определить”) был первой высокоуровневой языковой конструкцией для макросов. Более важный факт: иерархические структуры данных, впервые появившиеся в языке PlankalkUl, были реализованы в языке COBOL. Эти струк- туры были включены практически во все императивные языки, разработанные с тех пор. Кроме того, язык COBOL был первым языком, в котором имена стали действительно осмысленными, поскольку в этом языке допускалось использование как длинных имен (до 30 символов), так и символов соединения слов (тире). Вообще, раздел данных — сильная сторона структуры языка COBOL, тогда как раз- дел процедур — относительно слабая. В разделе данных подробно определяется каждая переменная, в том числе количество десятичных разрядов и связанное с ним положение точки в десятичной записи. Записи файлов описывались на таком же детальном уровне, как строки, выводимые на принтер, что сделало язык COBOL идеальным для печати бух- галтерских отчетов. Вероятно, самой слабой стороной раздела процедур было недоста- точное количество функций. Отметим, что до стандарта 1974 года все версии языка COBOL не допускали подпрограмм с параметрами. Наш последний комментарий относительно языка COBOL: это был первый язык про- граммирования, использование которого поддерживалось Министерством обороны США. Эта поддержка возникла после его первоначальной разработки, поскольку язык COBOL не был создан специально для Министерства обороны. Несмотря на свое качест- во, язык COBOL, вероятно, не смог бы выжить без этой поддержки. Недостаточная про- изводительность его первых компиляторов просто сделала бы его использование слиш- ком дорогим. В конечном итоге, разумеется, люди больше узнали о разработке компиля- торов, да и компьютеры стали быстрее, дешевле и имеют больше памяти. Все эти факторы в совокупности привели язык COBOL к успеху как внутри, так и вне Министер- ства обороны. Его появление привело к электронной механизации бухгалтерских дел, что по любым меркам было революцией. Ниже следует пример программы на языке COBOL. Эта программа считывает файл BAL-FWD-FILE, содержащий инвентаризационную информацию о некотором наборе изделий. Среди прочего, каждая запись по изделию содержит количество имеющихся из- 82 Глава 2. Обзор основных языков программирования
делий (BAL-ON-HAND) и момент возобновления заказа на изделие (BAL-REORDER- POINT), т.е. момент, когда число изделий, требующих доставки, превышает количество число имеющихся изделий. Программа порождает файл REORDER-LISTING, содержа- щий список изделий, требующий повторного заказа. IDENTIFICATION DIVISION. PROGRAM-ID. PRODUCE-REORDER-LISTING. ENVIRONMENT DIVISION. CONFIGURATION SECTION. SOURCE-COMPUTER. DEC-VAX. OBJECT-COMPUTER. DEC-VAX. INPUT-OUTPUT SECTION. FILE-CONTROL. SELECT BAL-FDW-FILE ASSIGN TO READER. SELECT REORDER-LISTING ASSIGN TO LOCAL-PRINTER. DATA DIVISION. FILE SECTION. FD BAL-FDW-FILE LABEL RECORDS ARE STANDARD RECORD CONTAINS 80 CHARACTERS. 01 BAL-FDW-CARD. 02 BAL-ITEM-NO PICTURE IS 9(5) . 02 BAL-ITEM-DESC PICTURE IS X(20) . 02 FILLER PICTURE IS X(5) . 02 BAL-UNIT-PRICE PICTURE IS 999V99 02 BAL-REORDER-POINT PICTURE IS 9(5) . 02 BAL-ON-HAND PICTURE IS 9(5) . 02 BAL-ON-ORDER PICTURE IS 9(5) . 02 FILLER PICTURE IS X(30). FD REORDER-LISTING LABEL RECORD ARE STANDARD RECORD CONTAINS 132 CHARACTERS. 01 REORDER-LINE. 02 RL-ITEM-NO PICTURE IS Z(5). 02 FILLER PICTURE IS X(5). 02 RL-ITEM-DESC PICTURE IS X(20). 02 FILLER .PICTURE IS X(5) . 02 RL-UNIT-PRICE PICTURE IS ZZZ.99 02 FILLER PICTURE IS X(5). 02 RL-AVAILABLE-STOCK PICTURE IS Z(5). 02 FILLER PICTURE IS X(5). 02 RL-REORDER-POINT PICTURE IS Z(5). 02 FILLER PICTURE IS X(71). WORKING-STORAGE SECTION. 01 SWITCHES. 02 CARD-EOF-SWITCH PICTURE IS X. 2.6. Компьютеризация коммерческих записей: язык COBOL 83
лЭР.К—г It.LDS. и 2 AVAILABLE-STOCK PICTURE IS 9(5). OPEN INPUT BAL-FDW-FILE. OPEL' OUTPUT REORDER-LISTING. MO’rE “И” TO CARD-EOF-SWITCH. PERFORM 3 CO-PRODUCE-REORDER-LINE UNTIL CARD-EOF-SWITCH IS EQUAL TO “Y”. CLOSE BAL—DW-FILE. CLOSE REORDER-LISTING. STOP RUN. : , C-PR0DUCE-RE3RDER-LINE. PERFORM 2CO-READ-INVENTORY-RECORD. IF CARD-EOF-SWITCH IS NOT EQUAL TO “Y“ PERFORM 120-CALCULATE-AVAILABLE-STOCK IF AVAILABLE-STOCK IS LESS THAN BAL-REORDER-POINT PERFORM 130-PRINT-REORDER-LINE. :IC-READ-INVENTORY-RECORD. READ BAL-FDW-FILE RECORD AT END MOVE "Y" TO CARD-EOF-SWITCH. L2 0-CALCULAT E-AVAILAB LE-STOCK. ADD BAL-ON-HAND BAL-ON-ORDER GIVING AVAILABLE-STOCK. PRINT-REORDER-LINE. MOVE SPACE MOVE BAL-ITEM-NO MOVE BAL-IТЕМ-DESC MOVE BAL-UNIT-PRICE MOVE AVAILABLE-STOCK MOVE BAL-REORDER-POINT WRITE REORDER-LINE. TO REORDER-LINE. TO RL-ITEM-NO. TO RL-ITEM-DESC. TO RL-UNIT-PRICE. TO RL-AVAILABLE-STOCK TO RL-REORDER-POINT. Происхождение языка COBOL представлено на рис. 2.4 • FLOW-MATIC (1957) • COBOL (1960) Рис. 2.4. Генеаюгия языка COBOL 84 Глава 2. Обзор основных языков программированы
2.7. Начало разделения времени: язык BASIC Еше одним примером широко распространенного языка программирования, не полу- чившего значительного признания, является язык BASIC. Подобно языку COBOL, он в значительной степени игнорировался специалистами по компьютерным наукам. Так же. как и язык COBOL, в своих ранних версиях язык BASIC был элегантен и содержал толь- ко ограниченный набор управляющих операторов Язык BASIC был очень популярным языком программирования на микрокомпьюте- рах в конце 1970-х и начале 1980-х годов. Это было следствием его основных характери- стик. Он легок для изучения начинающими, особенно теми, кто не связан с наукой, его небольшие диалекты могут быть реализованы на компьютерах с очень малым количест- вом памяти. Возрождение языка BASIC произошло в начале 1990-х годов с выходом языка Visual BASIC (Microsoft. 1991). 2.7.1. Процесс разработки Язык BASIC (Beginner's All-purpose Symbolic Instruction Code — универсальный сим- вольный набор команд для начинающих) был разработан в Дартмутском колледже (Dartmouth College) (теперь Дартмутском университете (Dartmouth University)) в Нью- Гемпшире двумя математиками. Джоном Кемени (John Kemeny) и Томасом Курцом (Thomas Kurtz), которые в начале 1960-х годов были связаны с производством компиля- торов для различных диалектов языков FORTRAN и ALGOL 60. Их студенты, изучавшие естественные науки, испытывали трудности в работе с указанными языками. Однако Дартмут был преимущественно гуманитарным учреждением, где студенты, изучавшие естественные и технические науки, составляли приблизительно 15 процентов. Весной 1963 года было решено разработать новый язык, предназначенный главным об- разом для студентов, изучавших гуманитарные науки. В качестве метода доступа к ком- пьютеру этот новый язык должен был использовать терминалы. К языку были предъяв- лены следующие требования. 1. Он должен быть легким для изучения и использования студентами, не изучающи- ми естественные науки. 2. Он должен быть приятным и дружественным. 3. Язык должен обеспечивать быстрый обмен информацией для выполнения домаш- ней работы. 4. Он должен позволять как свободный, так и конфиденциальный доступ. 5. Время пользователя должно быть важнее времени работы компьютера. Последняя цель была действительно революционной концепцией. Она опиралась, по крайней мере частично, на убежденность в том. что со временем компьютеры станут значительно дешевле, что. в общем-то, и произошло. Комбинация второй, третьей и четвертой целей породила концепцию разделения вре- мени в языке BASIC. В начале 1960-х достичь поставленных целей можно было только с помощью одновременного индивидуального доступа многих пользователей к компьюте- ру посредством терминалов. Летом 1963 года Кемени начал работу над компилятором для первой версии языка BASIC, используя для этого удаленный доступ к компьютеру GE 225. Разработка и про- 2.7. Начало разделения времени: язык BASIC 85
граммирование операционной системы для языка BASIC начались осенью 1963 года. В 4 часа утра 1 мая 1964 года была напечатана и запушена первая программа с использо- ванием языка с разделением времени BASIC. В июне количество терминалов системы возросло до 11, а к осени — до 20. 2.7.2. Обзор языка Исходная версия языка BASIC была очень небольшой и, как ни странно, не была ин- терактивной: не существовало средств получения входных данных с терминала. Про- граммы печатались, компилировались и запускались пакетно-ориентированным спосо- бом. Исходный язык BASIC имел только 14 различных типов операторов и один тип данных — числа с плавающей точкой. Поскольку считалось, что только некоторые из пользователей, на которых был ориентирован язык, будут принимать во внимание разли- чия между целым типом и типом с плавающей точкой, этот тип был назван “числа”. Во- обще, это был очень ограниченный язык, хотя и довольно простой для изучения. 2.7.3. Оценка Важнейшим аспектом исходного языка BASIC было его первенство в использовании метода удаленного доступа к компьютеру посредством терминала. В то время терминалы только-только стали доступны. До этого большинство программ вводились в компьютер с помощью перфокарт либо бумажной ленты. Большинство конструкций языка BASIC произошли от языка FORTRAN при некото- ром влиянии синтаксиса языка ALGOL 60. Позднее он развивался различными путями при незначительных усилиях по его стандартизации или совсем без них. Институт ANSI выпустил минимальный стандарт для языка BASIC (Minimal BASIC) (ANSI, 1978b), представлявший только необходимый минимум свойств языка. Фактически исходный язык BASIC был очень похож на язык Minimal BASIC. Хотя это и может показаться неожиданным, корпорация Digital Equipment Corporation использовала детально разработанную версию языка BASIC, названную языком BASIC- PLUS, с целью написания в 1970-х годах значительной части их крупнейшей операцион- ной системы RSTS для миникомпьютеров PDP-11. Среди прочего язык BASIC критиковался за плохую структуру программ, написанных на нем. Согласно нашему критерию оценки язык действительно стоит очень немногого. Очевидно, ранние версии языка не были и не должны были быть средством для написа- ния серьезных программ мало-мальски значительного размера. Последующие версии, правда, были более приспособлены для подобных задач. Наиболее вероятной причиной успеха языка BASIC можно назвать легкость его изу- чения и реализации даже на очень маленьких компьютерах. Существуют две современные версии языка BASIC, не получившие, правда, широко- го распространения: язык QuickBASIC (Bradley, 1989) и язык Visual BASIC. Оба они за- пускаются на персональных компьютерах. Язык Visual BASIC основан на языке QuickBASIC, но он был создан для разработки систем программного обеспечения, ис- пользующих оконный интерфейс пользователя. Язык Visual BASIC также мог использо- ваться в качестве языка программирования для написания сценариев интерфеса компью- терной графики (CGI — Computer Graphics Interface). Ниже следует пример программы на языке QuickBASIC. 86 Глава 2. Обзор основных языков программирования
REK .р^.мер программы на языке QuickBASIC REM Ввод: Целое число listlen, меньше 100, за которым REM следует набор целых чисел в количестве REM listlen REM Вывод: Количество введенных величин, которые больше их REM среднего арифметического DIM intlist(99) result = 0 sum = 0 INPUT listlen IF listlen > 0 AND listlen < 100 THEN REM Считывание входных величин в массив и вычисление суммы FOR counter = 1 ТО listlen INPUT intlist(counter) sum - sum + intlist(counter) NEXT counter REM Вычисление среднего арифметического average = sum / listlen REM Вычисление количества входных величин, которые больше их REM среднего FOR counter = 1 ТО listlen IF intlist(counter) > average THEN result = result + 1 NEXT counter REM Вывод результатов; PRINT "Число величин, которые больше их среднего, равно"; result ELSE PRINT "Ошибка — введена неверная длина списка" END IF END Происхождение языка BASIC представлено на рис. 2.5. Л ALGOL 60 (1960) FORTRAN IV (1962) / ^^^*VbASIC(1964) ° QuickBASIC (1988) и Visual BASIC (1990) Рис. 2.5. Генеалогия языка BASIC 2.7. Начало разделения времени: язык BASIC 87
2.8. Все для всех: язык PL/I Язык PL'I представляет собой первую масштабную попытку разработки языка, кото- рый бы использовался в широком спектре областей. Все предшествовавшие и большин- < то последующих языков программирования концентрировались на какой-то одной об- мети применения (наука, искусственный интеллект или коммерция). 2.8.1. Исторические предпосылки Подобно языку FORTRAN язык PL/I разрабатывался как продукт корпорации IBM. В начале 1960-х годах промышленные пользователи компьютеров сформировали два от- ельных лагеря. С точки зрения корпорации IBM научные программисты могли исполь- ничать либо универсальный компьютер 7090, либо компьютер с ограниченными возмож- ностями 1620. которые выпускались указанной корпорацией. Эта группа в значительной <чепени использовала тип данных с плавающей точкой и массивы. Основным языком в их работе был язык FORTRAN, хотя параллельно рассматривались и некоторые языки ассемблера. Эти пользователи имели собственную группу SHARE и поддерживали сла- бые связи с программистами, работавшими над коммерческими приложениями. Для коммерческих приложений использовались большие или малые компьютеры, IBM 7080 и IBM 1401. Для этих приложений требовались типы данных для десятичных чисел и символьных строк, а также детально разработанные и эффективные свойства ввода-вывода. Данная группа пользователей использовала язык COBOL, хотя в начале 1чоЗ года переход с языков ассемблера на язык COBOL был далек от завершения. Эта каичория пользователей также имела свою собственную группу пользователей GUIDE и изредка налаживала контакты с научными пользователями. В начале 1963 года проектировщики корпорации IBM почувствовали, что ситуация начинает меняться. Указанные группы двигались навстречу друг другу по путям, кото- рые неизбежно должны были привести к возникновению проблем. Ученые начали соби- рагь большие файлы данных, требующих обработки. Для этого нужны были изощренные и эффективные средства ввода-вывода. Люди же, занимавшиеся коммерческими прило- жениями. для построения систем управления информацией начали использовать регрес- сионный анализ, что требовало чисел с плавающей точкой и массивов. Стало казаться, чю внедрение компьютерной техники вскоре потребует двух отдельных компьютеров, поддерживающих два значительно отличающихся языка программирования. Эти предчувствия вполне естественно привели к возникновению концепции разра- бо|ки единого универсального компьютера, который бы мог выполнять как действия над числами с плавающей точкой, так и над над десятичными числами, и, следовательно, оыл бы применим и в нахке. и в коммерции. Так зародилась концепция серии компьюте- ров IBM System/360. Наряду с этой концепцией возникла идея о языке программирова- ния. который был бы применим и для коммерческих, и для научных приложений. В зна- чи!ельной степени в язык были включены возможности системного программирования и обработки списков. Следовательно, новый язык должен был заменить языки FORTRAN, COBOL и LISP, а в области системных приложений — еще и язык ассемблера. 88 Глава 2. Обзор основных языков программирования
2.8.2. Процесс разработки Разработка началась с образования в октябре 1963 года корпорацией IBM и группой SHARE комитета Advanced Language Development Committee of the SHARE FORTRAN Project. Этот новый комитет быстро собрался и сформировал подкомитет 3<->3 Committee, названный так из-за наличия в нем трех человек со стороны корпорации IBM и трех со стороны группы SHARE. Для разработки языка комитет 3<н>3 Committee соби- рался на три-четыре дня через неделю. Как и при работе комитета Short Range Committee, разрабатывавшего язык COBOL, завершение исходной разработки была запланировано на удивительно близкую дату По- видимому, независимо от целей разработки языка в начале 1960-х широко распростра- ненно мнение, что это задание может быть выполнено за три месяца. Первую версию языка PL/I, позже названную FORTRAN VI. предполагалось завершить к декабрю, что составляло менее трех месяцев со дня основания комитета. Комитет, приведя в оправда- ние две различные причины задержки, передвинул срок окончания работ сначала на ян- варь, а затем и на февраль 1964 года. Исходная концепция разработки состояла в том. что новый язык должен был быть расширенной версией языка FORTRAN IV. совместимой с ним. но это намерение было быстро отброшено, как и название FORTRAN VI. Вплоть до 1965 года этот язык был из- вестен как NPL, что являлось аббревиатурой названия New Programming Language (новый язык программирования). Первый опубликованный отчет о языке NPL был пре- доставлен на собрании группы SHARE в марте 1964 года. Более полное описание после- довало в апреле, а работоспособная версия была опубликована в декабре 1964 года (IBM. 1964) группой разработчиков компилятора из выбранной для реализации лаборатории IBM Hursley Laboratory в Великобритании. В 1965 году чтобы избежать путаницы с аб- бревиатурой Национальной физической лаборатории (NPL— National Physical Laboratory) в Великобритании, название языка было изменено на PL I. Если бы компиля- тор разрабатывался за пределами Соединенного Королевства, название, вероятно, оста- лось бы прежним. 2.8.3. Обзор языка Возможно, лучшим описанием языка PL/I. состоящим из одного предложения, будет следующее: он содержит то. что считалось лучшими частями языка ALGOL 60 (рекурсия и блочная структура), языка FORTRAN IV (раздельная компиляция со связью посредст- вом глобальных данных) и языка COBOL 60 (структуры данных, средства ввода-вывода и генерации отчетов), а также несколько новых конструкций, каким-то образом соеди- ненных в единое целое. Мы не будем пытаться даже в конспективной форме описать все свойства этого языка или хотя бы его наиболее противоречивые конструкции. Вместо этого мы кратко назовем несколько вкладов, сделанных этим языком в фонд знаний в области языков программирования. Язык PL/1 был первым языком, имевшим следующие возможности. Программы могли порождать параллельно выполняемые задачи. Хотя сама идея была хороша, но ее реализация в языке PL/I была скверной. Появилась возможность выявления и обработки 23 видов различных исключи- тельных ситуаций или ошибок в процессе выполнения. 2.8. Все для всех: язык PL/I 89
Процедуры могли использоваться рекурсивно, но для более эффективного програм- мирования нерекурсивных процедур эта возможность могла быть заблокирована. В качестве типа данных были включены указатели. Появилась возможность обращаться к пересекающимся разделам массивов. На- пример, к третьей строке матрицы можно было обращаться как к вектору. 2.8.4. Оценка Любая оценка языка PL/I должна начинаться с упоминания о претенциозности работ по его созданию. Оглядываясь назад, кажется наивным полагать, что такое количество конструкций можно было успешно объединить вместе. Тем не менее, это суждение сле- дует смягчить признанием того, что опыт разработки языков в то время был очень не- большим. Вообще говоря, структура языка PL/I была основана на допущении, что любая полезная конструкция, которая может быть реализована, должна включаться в язык, при этом вопросу, как большое количество функций сможет ужиться вместе, уделялось мало внимания. Эдсгер Дийкстра (Edsger Dijkstra) в своей лекции на церемонии вручения премии Тьюринга (Turing Award Lecture (Dijkstra, 1972)) подверг сильнейшей критике сложность языка PL/I: “Я совершенно не вижу, как мы можем охватить умом наши уве- личивающиеся в размере программы, в то время как из-за своей явной переусложненно- сти язык программирования — наш основной инструмент, прошу отметить! — уже вы- ходит из-под нашего интеллектуального контроля”. Помимо сложности, возникшей из-за большого размера, язык PL/I страдал от боль- шого количества конструкций, которые сейчас считаются неудачно разработанными. Среди них можно отметить указатели, обработку исключительных ситуаций, параллель- ность, хотя мы должны обратить внимание и на то, что в каждой из указанных конструк- ций было что-то новое. С точки зрения использования язык PL/I должен считаться, по крайней мере частич- но, успешным. В 1970-х годах он в значительной мере использовался как в коммерции, так и в науке. В это же время он широко использовался в качестве учебного средства, главным образом в несколько сокращенных формах, таких как язык PL/C (Cornell, 1977) и язык PL/CS (Conway and Constable, 1976). Ниже следует пример программы на языке PL/I. /* ПРИМЕР ПРОГРАММЫ НА ЯЗЫКЕ PL/I ВВОД: ЦЕЛОЕ ЧИСЛО LISTLEN, МЕНЬШЕ 100, ЗА КОТОРЫМ СЛЕДУЕТ НАБОР ЦЕЛЫХ ЧИСЕЛ В КОЛИЧЕСТВЕ LISTLEN ВЫВОД: КОЛИЧЕСТВО ВВЕДЕННЫХ ВЕЛИЧИН, КОТОРЫЕ БОЛЬШЕ ИХ СРЕДНЕГО АРИФМЕТИЧЕСКОГО */ PLIEX: PROCEDURE OPTIONS (MAIN); DECLARE INTLIST (1:99) FIXED. DECLARE (LISTLEN, COUNTER, SUM, AVERAGE, RESULT) FIXED; SUM = 0; RESULT = 0; GET LIST(LISTLEN); IF (LISTLEN > 0) & (LISTLEN < 100) THEN DO; /* СЧИТЫВАНИЕ ВХОДНЫХ ДАННЫХ В МАССИВ И ВЫЧИСЛЕНИЕ ИХ СУММЫ */ DO COUNTER = 1 ТО LISTLEN; 90 Глава 2. Обзор основных языков программирования
GET LIST (INTLIST (COUNTER)); SUM = SUM + INTLIST (COUNTER); END; /* ВЫЧИСЛЕНИЕ СРЕДНЕГО */ AVERAGE = SUM / LISTLEN; /* ПОДСЧЕТ ЧИСЛА ВЕЛИЧИН, КОТОРЫЕ БОЛЬШЕ СРЕДНЕГО */ DO COUNTER = 1 ТО LISTLEN; IF INTLIST (COUNTER) > AVERAGE THEN RESULT = RESULT -rl; END; /* ВЫВОД РЕЗУЛЬТАТОВ */ PUT SKIP LIST (’ЧИСЛО ВЕЛИЧИН, КОТОРЫЕ БОЛЬШЕ СРЕДНЕГО:'); PUT LIST (RESULT); END; ELSE PUT SKIP LIST (’ОШИБКА - ВВЕДЕНА НЕВЕРНАЯ ДЛИНА СПИСКА’); END PLIEX Происхождение языка PL/I представлено на рис. 2.6. Рис. 2.6. Генеалогия языка PL/I 2.9. Два ранних динамических языка: APL и SNOBOL Поскольку языки, рассматриваемые в данном разделе, значительно отличаются от язы- ков, рассматривавшихся ранее, то и сама структура раздела отличается от структуры пре- дыдущих разделов. Ни язык APL, ни язык SNOBOL не основывались ни на одном из пред- шествовавших языков, они также не оказали существенного влияния на последующие язы- ки (хотя язык J и основан на языке APL, а язык ICON (Griswold and Griswold, 1983)— на языке SNOBOL). И язык APL, и язык SNOBOL используются нами для иллюстрации и противопоставления некоторых их свойств соответствующим свойствам более традицион- ных языков. Именно по этой причине они кратко описываются в данном разделе. По возникновению и по предназначению языки APL и COBOL значительно различа- ются. Тем не менее, их объединяют две общие характеристики: динамическая проверка типов и динамическое распределение памяти. По существу, переменные в обоих языках не имеют типов. Переменная получает тип при присвоении ей значения, при этом она получает тип присваиваемой величины. Память выделяется переменной только при при- своении ей значения, поскольку заранее определить требуемое количество памяти не- возможно. 2.9. Два ранних динамических языка: APL и SNOBOL 91
2.9.1. Истоки и характеристики языка APL Язык APL (Polinka and Pakin. 1975) был разработан около 1960 года Кеннетом Айвер- соном (Kenneth Е. Iverson) из корпорации IBM. Изначально он разрабатывался не для реализации в качестве языка программирования, а в качестве средства описания архитек- туры компьютера. Впервые язык APL был описан в книге, давшей ему имя, “A Programming Language” (Iverson. 1962). Первая его реализация была создана корпора- цией IBM в середине 1960-х годов Язык APL содержит большое количество мощных операторов, что создавало пробле- мы при его реализации. Первой областью использования языка APL были печатные тер- миналы корпорации IBM. В этих терминалах содержались специальные печатающие ша- ры. обеспечивавшие печать разрозненных символов, требующихся языку. Большое ко- личество операторов языка APL позволяет манипулировать с массивами как со скалярными величинами. Например, транспонирование любой матрицы выполняется с помощью одного оператора. Большой набор операторов обеспечивает значительную вы- разительность. одновременно затрудняя чтение программ, написанных на языке APL. Многие считали, что лучшим использованием языка APL было бы “одноразовое” про- граммирование. Хотя программы можно писать очень быстро, от них пришлось бы из- бавляться после использования, поскольку модифицировать их было бы затруднительно. Язык APL был популярен около 30 лет и на данный момент все еше используется, хо- тя и не очень широко. Более того, на протяжении своей жизни он не претерпел сколько- нибудь существенных изменений. 2.9.2. Происхождение и характеристики языка SNOBOL Язык SNOBOL (произносится как “сноубол ') (Criswold et al., 1971) был разработан в начале 1960-х годов тремя со (рудниками компании Bell Laboratories: Д.Фарбером (D.J.Farber). Р.Грисволдом (R.E.Griswold) и Ф.Поленски (F.Polensky) (Farber at al., 1964). Он был создан специально для обработки текстов. Ядро языка SNOBOL — это набор мощных операций для сопоставления образцов строк. Одним из ранних применений языка SNOBOL было создание текстовых редакторов. Поскольку динамическая природа языка SNOBOL делала его медленнее других языков, то в наше время он нечасто используется с подобной целью. Тем не менее, язык SNOBOL все еще жив и поддерживается языками, используе- мыми для разнообразных задач обработки текстов в многочисленных приложениях. 2.10. Возникновение абстракции данных: язык SIMULA 67 Хотя язык SIMULA 67 так и не получил широкого распространения и слабо повлиял на вычислительную технику и программирование того времени, некоторые из введенных в нем концепций делают его важным. 2.10.1. Процесс разработки Язык SIMULA I был разработан двумя норвежцами. Кристеном Нигаардом (Krysten Nygaard) и Оле-Джоханом Далом (Ole-Johan Dahl), между 1962 и 1964 годами в Норвеж- ском вычислительном центре (Norwegian Computing Center, NCC). В первую очередь их 92 Глава 2. Обзор основных языков программирования
интересовало использование компьютеров для моделирования, хотя они также работали и в области исследования операций. Язык SIMULA I был разработан исключительно для моделирования систем и впервые был реализован в конце 1964 года на компьютере UNIVAC 1107. Сразу после завершения реализации языка SIMULA I Нигаард и Дал начали работ} над расширением языка с целью введения в него абсолютно новых свойств и изменения некоторых уже существующих конструкций для того, чтобы сделать язык полезным для более универсальных приложений. В результате этой работы появился язык SIMULA 67. проект которого был впервые публично представлен в марте 1967 года (Dahl and Nygaard, 1967). Мы ограничимся рассмотрением только языка SIMULA 67. хотя в языке SIMULA 1 также встречается несколько интересных свойств языка SIMULA 67. 2.10.2. Обзор языка Язык SIMULA 67 представляет собой расширенную версию языка ALGOL 60 с его блочной структурой и структурой управляющих операторов. Основным недостатком языка ALGOL 60 (да и других языков того времени) при использовании для моделирования были их подпрограммы. Для моделирования требовались подпрограммы, позволяющие переза- пускать их с места, на котором их выполнение было ранее прервано. Подпрограммы с та- ким типом управления называются сопрограммами, поскольку и вызывающая и вызывае- мая подпрограммы в некотором смысле равноправны, а не состоят в жестких иерархиче- ских отношениях, обычных для императивных языков программирования. Для поддержки сопрограмм в языке SIMULA 67 была разработана конструкция клас- са. Это было важное усовершенствование, поскольку оно положило начало нашим поня- тиям об абстракции данных. Основополагающая идея класса состоит в том. что структу- ра данных и процедуры, обрабатывающие ее. объединяются вместе. Более того, описа- ние класса— это только шаблон структуры данных, и в таком качестве описание класса отличается от его экземпляра, так что программа может создавать и использовать любое количество экземпляров отдельного класса. Экземпляры класса могут содержать локаль- ные данные, и программы, выполняемые во время создания экземпляра, которые могут инициализировать некоторые структуры данных, принадлежащие экземплярам класса. Более подробно классы и экземпляры классов представлены в главе 10. Интересно отметить, что важная концепция абстракции данных не была выявлена и приписана к конструкции класса вплоть до 1972 года, когда Хоар (Ноаге) (1972) распознал эту связь Происхождение языка SIMULA 67 показано на рис. 2.7. о ALGOL 60 (1960) о SIMULA 1(1964) о SIMULA 67 (1967) Рис. 2.7. Генеалогия языка SIMULA 6" 2.10. Возникновение абстракции данных: язык SIMULA 67 93
2.11. Ортогональная структура: язык ALGOL 68 Язык ALGOL 68 был источником нескольких новых идей в области структуры языка, некоторые из которых впоследствии переняли другие языки. Мы рассматриваем этот язык именно по этой причине, несмотря но то, что он так и не получил широкого рас- пространения ни в Европе, ни в Соединенных Штатах. Поскольку некоторые конструк- ции языка ALGOL 68 описаны в дальнейших главах, в этом разделе мы остановимся только на его самом важном вкладе в компьютерные науки. 2.11.1. Процесс разработки Развитие семейства языков ALGOL не закончилось с выходом в 1962 году перерабо- танного отчета, хотя результаты следующего этапа разработки языка были опубликова- ны только через шесть лет. Получившийся в результате язык ALGOL 68 (van Wijngaarden et al., 1969) разительно отличался от своего предшественника. Одним из интереснейших нововведений языка ALGOL 68 является его основной структурный критерий— ортогональность. Вернемся к нашему обсуждению ортого- нальности в главе 1. Использование в языке ALGOL 68 ортогональности приводит к не- скольким новым свойствам, два из которых описаны в следующем разделе. 2.11.2. Обзбр языка Одним из важных результатов ортогональности языка ALGOL 68 было введение оп- ределяемых пользователем типов данных. Ранние языки, например FORTRAN, содержа- ли только несколько основных структур данных. Язык PL/1 обладал большим количест- вом структур данных, что затрудняло его изучение и реализацию, но он, очевидно, не мог обеспечить соответствующую структуру данных для каждой области применения. ALGOL 68 предоставлял несколько элементарных типов и структур и позволял пользо- вателю комбинировать эти основные элементы для создания большого количества различ- ных структур. Возможность определять новые типы данных была с некоторыми уточне- ниями включена во все основные императивные языки программирования, разработанные позднее. Типы данных, определяемые пользователем, полезны из-за того, что они позволя- ют пользователю создавать абстракции данных, которые максимально соответствуют кон- кретным задачам. Все аспекты типизации данных рассматриваются в главе 5. Еще одним новшеством в области типов данных было использование в языке ALGOL 68 динамических массивов, которые в главе 4 описываются с точки зрения ди- намической памяти. Динамический массив— это массив, в котором при объявлении не устанавливаются предельные значения индексов. Распределение памяти, требуемой ди- намическому массиву, происходит при присваивании ему значения. В языке ALGOL 68 динамические массивы называются flex. Например, объявление flex [1:0] int list показывает, что переменная list — это динамический массив целых чисел с одним ин- дексом, нижнее значение которого равно 1, распределение памяти при этом не происхо- дит. Присвоение переменной множества значений list := (3, 5, 6, 2) приводит к выделению переменной list памяти, достаточной для хранения четырех це- лых чисел, при этом значения индексов массива меняют на реальные значения [1:4]. 94 Глава 2. Обзор основных языков программирования
2.11.3. Оценка Язык ALGOL 68 содержит значительное количество новых свойств. Его использование ортогональности, утрированное, по мнению некоторых, было, тем не менее, революцион- ным. Многие свойства, введенные языком ALGOL 68. стали частью последующих языков. Впрочем, язык ALGOL 68 повторил одну из ошибок языка ALGOL 60. что значительно повлияло на отсутствие широкого признания языка. Язык был описан с использованием элегантного и лаконичного, но к тому же неизвестного, метаязыка. Прежде чем приступить к чтению описания языка (van Wijngaarden et al.. 1969). следовало изучить новый метаязык, названный грамматикой Вийнгаардена. Чтобы больше усложнить условия изучения, разра- ботчики языка придумали набор слов для объяснения языка и его грамматики. Например, ключевые слова были названы indicant, извлечение подстроки — trimming, а процесс выполнения процедуры — coercion of deproceduring, ко- торый мог быть meek, firm или каким-либо еще. Естественным будет противопоставить структуры языков PL/1 и ALGOL 68. Удобство написания на языке ALGOL 68 достигалось за счет принципа ортогональности: несколь- ко основных концепций и неограниченное использование механизмов комбинирования. Удобству использования языка PL/1 способствовало большое число фиксированью кон- струкций. Язык ALGOL 68 расширил элегантную простоту' языка ALGOL 60. тогда как язык PL/1 просто объединил свойства нескольких языков. Разумеется, необходимо пом- нить, что целью языка PL/I было создание единого инструмента для широкого класса за- дач; а язык ALGOL 68 был рассчитан только на научные приложения. Язык PL/I был признан более широко, чем язык ALGOL 68. в значительной степени благодаря поддержке со стороны корпорации IBM и проблемам, связанным с понимани- ем и реализацией языка ALGOL 68. Реализация была сложной задачей для обоих языков, но язык PL/1 имел ресурсы корпорации IBM, использованные для создания компилятора, а язык ALGOL 68 подобного покровителя не имел. Происхождение языка ALGOL 68 показано на рис. 2.8. f ALGOL60 (I960) * ALGOL 68 (1968) ' Рис. 2.8. Генеалогия языка ALGOL 68 2.12. Несколько важных наследников семейства языков ALGOL Все императивные языки, в том числе и такие императивно-объектно-ориентированные, как Java и C++, разработанные после 1960 года, чем-то в своей структуре обязаны языку ALGOL 60 и/или ALGOL 68. В этом разделе рассмотрены некоторые из них. В разделе опушено обсуждение языка Ada, которое будет проведено в разделе 2.14, C++, который бу- дет рассмотрен в разделе 2.16, и Java, который описан в разделе 2.17. 2.12. Несколько важных наследников семейства языков ALGOL 95
2.12.1. Преднамеренная простота: язык Pascal 2.12,1,1, Исторические предпосылки Никлаус Вирт (Niklaus Wirth) был членом группы International Federation of Information Processing (1FIP) Working Group 2.1 (рабочая группа 2.1 Международной фе- дерации по обработке информации), которая в середине 1960-х годах была создана для продолжения разработки языка ALGOL. В августе 1965 года Вирт и Хоар внесли свой вклад в эту работу, представив группе довольно скромный план, касающийся дополне- ний и изменений в языке ALGOL 60 (Wirth and Hoare, 1966). Большинство членов груп- пы отклонили его как слишком незначительный. Вместо него была разработана более сложная редакция, которая, в конечном итоге, и стала языком ALGOL 68. Вирт с не- большой группой разработчиков считал, что отчет о языке ALGOL 68 не должен публи- коваться. обосновывая свое мнение как сложностью самого языка программирования, так и сложностью метаязыка, использованного для его описания. Эта точка зрения позже была отчасти подкреплена тем, что документация по языку ALGOL 68, а следовательно, и сам язык, действительно считались в компьютерном мире слишком трудными. Предложенная Виртом и Хоаром версия языка ALGOL 60 была названа языком ALGOL-W. Она была реализована в Стенфордском университете и изначально использо- валась в качестве учебного средства, правда только в небольшом количестве университе- тов. Основными особенностями языка ALGOL-W были метод передачи параметров по значению и результату, а также оператор case для многовариантного ветвления. Метод передачи параметров по значению результата является альтернативой используемому в языке ALGOL 60 методу передачи параметров по имени. Оба метода рассмотрены в гла- ве 8. Оператор case описан в главе 7. Следующим значительным проектом Вирта, также основанным на языке ALGOL 60, был его крупнейший успех — язык Pascal. Исходное описание языка Pascal было опуб- ликовано в 1971 году (Wirth, 1971). В процессе реализации эта версия была несколько 1вменена и описана в 1973 году (Wirth, 1973). Отметим, что свойства, часто приписы- ваемые языку Pascal, фактически произошли от более ранних языков. Например, опреде- ляемые пользователем типы данных впервые были введены в языке ALGOL 68, оператор case— в языке ALGOL-W, а записи в языке Pascal подобны структурированным пере- менным в языках COBOL и PL/I. 2.12.1.2. Оценка Язык Pascal имеет большое влияние на обучение программированию. В 1970 году большинство студентов, изучавших естественные, технические и компьютерные науки, знакомились с программированием с помощью языка FORTRAN, хотя в некоторых уни- верситетах для этой цели использовались язык PL/1, языки, основанные на языке PL/I, и язык ALGOL-W. С середины 1970-х самым широко используемым для обучения языком стал Pascal. Произошедшее было довольно естественно, поскольку язык Pascal специаль- но создавался для обучения программированию, хотя его популярность было невозмож- но предсказать. Поскольку язык Pascal создавался как язык для обучения, в нем отсутствовали некото- рые свойства, необходимые для различных видов приложений. Лучшим примером этого служит отсутствие возможности написания подпрограммы, которая бы принимала в каче- стве параметра массив переменной длины. Другим примером является отсутствие какой бы 96 Глава 2. Обзор основных языков программирования
то ни было раздельной компиляции. Эти различия естественным образом привели к воз- никновению многочисленных нестандартных диалектов, таких как язык Turbo Pascal. Популярность языка Pascal, как в сфере обучения программированию, так и в других областях, главным образом основана на его замечательной комбинации простоты и выра- зительности. Хотя, как мы увидим из последующих глав, для языка Pascal характерна неко- торая ненадежность, по сравнению с другими языками (особенно с языками FORTRAN и С), он все же является относительно надежным языком. С середины 1990-х годов популяр- ность языка Pascal стала падать как в промышленности, так и в университетах. Ниже следует пример программы на языке Pascal. {Пример программы на языке Pascal Ввод: Целое число listlen, меньше 100, за которым следует набор целых чисел в количестве listlen Вывод: Количество введенных величин, которые больше их среднего арифметического} program pasex (input, output); type intlisttype = array [1..99] of integer; var intlist : intlisttype; listlen, counter, sum, average, result : integer; begin result := 0; sum := 0; readln (listlen); if ((listlen > 0) and (listlen < 100)) then begin {Считывание входных величин в массив и вычисление суммы} for counter := 1 to listlen do begin readln (intlist[counter]); sum := sum + intlist[counter] end; {Вычисление среднего арифметического} average := sum / listlen; {Вычисление количества входных величин, которые больше их среднего} for counter := 1 to listlen do if (intlist[counter] > average) then result := result + 1; {Вывод результатов} writein(’Число величин, которые больше их среднего:’, result) end {относится к условию if ((listlen > 0 ...)) then ...} else writein (’Ошибка - введена неверная длина списка’); end. Происхождение языка Pascal представлено на рис. 2.9. 2.12. Несколько важных наследников семейства языков ALGOL 97
ALGOL 60 (1960) ALGOLW(1966)< \ \ ALGOL 68 (1968) ’ Pascal (1971) Puc. 2.9. Генеалогия языка Pascal 2.12.2. Машинно-независимый язык: С Подобно языку Pascal, язык С немногое добавил в ранее известные языковые свойст- ва, но получил очень широкое распространение. Несмотря на то что изначально язык С создавался для системного программирования, он также нашел применение и в большом количестве других областей. 2.12.2.1. Исторические предпосылки Среди предков языка С известны языки CPL, BCPL, В и ALGOL 68. Язык CPL был разработан в Кембриджском университете в начале 1960-х годов. Язык BCPL— простой системный язык, разработанный в 1967 году Мартином Ричардсом (Richards, 1969). Первая работа над операционной системой UNIX была выполнена в конце 1960-х го- дов Кеном Томпсоном (Ken Thompson) из компании Bell Laboratories. Первая ее версия была написана на языке ассемблера. Первым высокоуровневым языком, реализованным под операционной системой UNIX, был язык В, основанный на языке BCPL. Язык В был разработан и реализован Томпсоном в 1970 году. Ни язык BCPL, ни язык В не содержат типов данных, что довольно странно для язы- ков высокого уровня, хотя уровень обоих языков значительно ниже, чем у таких языков, как Pascal. То, что язык не содержит типов данных, означает, что все данные рассматри- ваются как машинные слова, которые, хотя и крайне просты, но приводят ко многим сложностям и ненадежности. Например, существует проблема определения арифметики, в которой должно вычисляться выражение: целочисленной или с плавающей точкой. В одной реализации языка BCPL перед операндами с плавающей точкой указывались точ- ки. Операнды, которым не предшествовали точки, рассматривались как целочисленные. Альтернативным вариантом могло бы быть использование различных символов для опе- раций с плавающей точкой. Эта проблема, а также несколько других, привели к разработке на основе языка В но- вого языка, предусматривавшего типы данных. Изначально названный NB, позже пере- именованный в С, этот новый язык был разработан и реализован в 1972 году Деннисом Ритчи (Dennis Ritchie) из компании Bell Laboratories (Kemighan and Ritchie, 1978). Язык ALGOL 68 оказал на язык С значительное влияние, как через язык BCPL, так и непо- средственно. Это можно заметить по его операторам for и switch, операторам при- сваивания и его обращению с указателями. 98 Глава 2. Обзор основных языков программирования
Единственным “стандартом’' для языка С в первые 15 лет его существования была книга Кернигана и Ритчи (1978). Со временем язык медленно эволюционировал при участии раз- личных разработчиков, вводящих дополнительные свойства. В 1989 году институт ANSI выпустил официальное описание языка С (ANSI, 1989), в котором содержалось большое количество свойств, уже включенных в язык разработчиками средств его реализации. В середине и конце 1980-х годов была разработана новая версия языка С, названная языком C++ (Ellis and Stroustrup, 1990). Его история и некоторые из наиболее значитель- ных свойств описаны в разделе 2.16. Подробнее о поддержке языком C++ абстракции данных написано в главе 10. Поддержка языком C++ объектно-ориентированного про- граммирования рассмотрена в главе 11. 2.12.2.2. Оценка Язык С обладает достаточным количеством управляющих операторов и возможно- стей структуризации данных, чтобы он мог использоваться в различных прикладных об- ластях. Кроме того, он содержит богатый набор операторов, обеспечивающих высокую степень выразительности. Одной из важнейших причин и любви, и неприязни к языку С является отсутствие полной проверки типов. Например, можно написать функции, для параметров которых не производится проверка типов. Люди, которым язык С нравится, высоко ценят его гиб- кость; те же, кто его недолюбливает, находят его слишком ненадежным. Главной причи- ной значительного роста его популярности в 1980-х годах является то, что он представ- ляет собой часть широко используемой операционной системы UNIX. Эта привязка к системе UNIX обеспечивает недорогой (часто просто бесплатный вместе с системой UNIX) и достаточно хороший компилятор, доступный программистам на большом коли- честве типов компьютеров. Ниже следует пример программы на языке С. /* Пример программы на языке С Ввод: Целое число listlen, меньше 100, за которым следует набор целых чисел в количестве listlen Вывод: Количество введенных величин, которые больше их среднего арифметического */ void main () { int intlist[98], listlen, counter, sum, average, result; sum = 0; result = 0; scant ("%d", &listlen); if ((listlen > 0) && (listlen < 100)) { /* Считывание входных величин в массив и вычисление суммы */ for (counter = 0; counter < listlen; counter++) { scant( "%d”, &intlist[counter]); sum = sum + intlist[counter]; ) /* Вычисление среднего арифметического */ average = sum / listlen; /* Вычисление количества входных величин, которые больше их среднего */ for (counter = 0; counter < listlen; counter++) if (intlist[counter] > average) result++; 2.12. Несколько важных наследников семейства языков ALGOL 99
/* Вывод результатов */ printf("Число величин, которые больше их среднего:%d\n", result); } else printf ("Ошибка - введена неверная длина списка\п"); ) Происхождение языка С представлено на рис. 2.10. 2.12.3. Другие потомки языка ALGOL В этом разделе кратко описываются истоки и характеристики других потомков языка ALGOL, которые будут упоминаться далее в книге. 2.12.3.1. Язык Modula-2 После языка Pascal Никлаус Вирт разработал язык Modula, появившийся в результате его экспериментов с параллельностью (Wirth, 1976). Для языка Modula никогда не вы- пускался компилятор, и его разработка была прекращена вскоре после его публикации. Тем не менее, сам Вирт от разработки языка не отказался. Он сместил акцент на созда- ние языка, который должен был стать единым языком новой компьютерной системы, позже названной Lilith. В то время как компьютер сам по себе коммерческого успеха не имел, этот успех имел язык Modula-2 (Wirth, 1985). 100 Глава 2. Обзор основных языков программирования
Основными характерными свойствами языка Modula-2, структура которого основы- валась на языках Pascal и Modula. были модули, обеспечивающие поддержку абстракт- ных типов данных, использование процедур как типов, низкоуровневые средства сис- темного программирования и сопрограммы, а также некоторые синтаксические свойства, которые были улучшены по сравнению с соответствующими свойствами языка Pascal. Язык Modula-2 стал широко использоваться в конце 1980-х годах и в начале 1990-х годах в качестве учебного языка в университетах. Также он использовался, по крайней мере несколько лет, в различных промышленных отраслях. 2.12.3.2. Язык Modula-З Язык Modula-З был совместно разработан Исследовательским центром корпорации Digital Equipment (Systems Research Center of Digital Equipment Corporation) в Пало-Альто и Исследовательским центром компании Olivetti (Olivetti Research Center) в Менло-Парк в конце 1980-х годах (Cardelli et al., 1989). Он основан на языках Modula-2, Mesa (Mitchell et а)., 1979), Cedar (Lampson, 1983) и Modula-2+ (Rovner, 1986). К языку Modula-2 были добавлены классы и объекты для поддержки объектно-ориентированного программирования, обработка исключительных ситуаций, “сборка мусора” и параллель- ность. Несмотря на то что это мощный и хорошо сконструированный язык, надежды на его широкое распространение довольно мало. Хотя этот язык и финансируется компани- ей Digital, пользовательская база языка Modula-2, на которой мог бы начать расти язык Modula-З, уже практически исчезла. 2.12.3.3. Язык Oberon Язык Oberon, частично основанный на языке Modula-2, является последним языком, созданным Никлаусом Виртом. Последняя версия языка Oberon названа Oberon-2 (M6ssenbock. 1993). Страстное увлечение Вирта простотой в языках программирования ошеломляюще откровенно проявилось в проекте языка Oberon. Хотя с целью получения языка Oberon к языку Modula-2 были добавлены некоторые свойства, значительно большее количество свойств было удалено, что сделало язык Oberon абсолютной противоположностью язы- ков C++ и Ada 95, если говорить об этом с точки зрения сложности и размера. Основным свойством, добавленным к языку Modula-2, было расширение типов, под- держивающих объектно-ориентированное программирование. В числе удаленных свойств находятся: вариантные записи, непрозрачные типы, перечислимые типы, подти- пы, тип CARDINAL, нецелочисленные индексы массивов, оператор with, оператор for. Поразительно, но язык Oberon значительно меньше и проще своего предшественника. 2.12.3.4. Язык Delphi Язык Delphi, подобно языку C++, является смешанным языком, созданным путем введения в существующий императивный язык, помимо других свойств, объектно- ориентированной поддержки. Язык Delphi происходит от языка Pascal. Большое количе- ство различий между языками C++ и Delphi является результатом различий между поро- дившими их языками и сопутствующими им культурами программирования. Поскольку язык С мощный, но потенциально ненадежный, такое же определение применимо и к языку C++, по крайней мере это касается проверки диапазона изменения индексов мас- сива, арифметики указателей и многочисленных приведений типов. Аналогично тому, как язык Pascal элегантнее и надежнее языка С, язык Delphi элегантнее и надежнее языка 2.12. Несколько важных наследников семейства языков ALGOL 101
С-+. Язык Delphi также менее сложен, чем язык C++. Например, Delphi не позволяет ис- пользовать определяемою пользователем перегрузку операторов, настариваемые подпро- граммы и параметризованные классы, в то время как все это являются частью языка C++. Язык Delphi, подобно языку Visual C++, предоставляет программистам графический интерфейс пользователя (GUI — Graphical User Interface) и простые способы создания GUI-интерфейсов для приложений, написанных на языке Delphi. Язык Delphi был разра- ботан, финансировался и продавался компанией Borland, той же компанией, что создала язык Turbo Pascal. 2.13. Программирование, основанное на логике: язык Prolog Говоря просто, логическое программирование— это использование формальной ло- гической записи для сообщения компьютеру вычислительных процессов. В современных языках логического программирования в качестве формы записи используется исчисле- ние предикатов. Программирование на языках логического программирования является непроцедур- ным. Программы на таких языках не устанавливают точно, как должен вычисляться ре- зультат, а только описывают его форму. Для этого языкам логического программирова- ния необходимы четкие средства предоставления компьютеру как соответствующей ин- формации, так и процесса логического вывода. Исчисление предикатов обеспечивает компьютер основной формой передачи информации, а метод доказательства, названный резолюцией и изначально разработанный Робинсоном (Robinson, 1965), предоставляет способы логического вывода. 2.13.1. Процесс разработки В начале 1970-х годов Ален Колмерье (Alain Colmerauer) и Филипп Руссель (Phillippe Roussel) из группы по разработке искусственного интеллекта (Artificial Intelligence Group) Марсельского университета (University of Aix-Marseille) вместе с Робертом Ко- вальски из отдела искусственного интеллекта (Department of Artificial Intelligence) Эдин бургского университета (University of Edinburgh) разработали базовую структуру язык< Prolog. Основными компонентами языка Prolog были метод задания высказываний ис- числения предикатов и реализация ограниченной формы резолюции. Исчисление преди катов и резолюция описываются в главе 15. Первый интерпретатор языка Prolog бы; разработан в 1972 году в Марселе. Реализованная версия языка была описана Русселем (Roussel, 1975). Само название языка произошло от сокращения словосочетания “programming /ogic”. 2.13.2. Обзор языка Программы языка Prolog состоят из набора утверждений. Видов утверждений в языке Prolog немного, правда, все они могут оказаться сложными. Одним из обычных применений языка Prolog является его использование в качестве базы данных со встроенной логикой. Эта область применения предоставляет простую основу для обсуждения языка Prolog. 102 Глава 2. Обзор основных языков программирования
База данных программы языка Prolog состоит из двух типов утверждений: фактов и правил. Примерами фактов могут служить следующие выражения: mother (joanne, jake). father (vern, joanne) . Они утверждают, что объект joanne приходится матерью (mother) объекту jake, а объект vern — отцом (father) объекту' j oanne. Рассмотрим теперь пример правила: grandparent(X, Z) parent(X, Y}t parent (Y, Z) . Оно утверждает, что для конкретных значений переменных X, Y и Z из факта, что объект X является родителем объекта Y. а объект Y — родителем объекта Z, можно сде- лать вывод о том, что объект X приходится объекту' Z дедушкой или бабушкой (grandparent). База данных языка Prolog может интерактивно запрашиваться целевыми утвержде- ниями, например: father (bob, darcie). В нем спрашивается, является ли объект bob отцом (father) объекта darcie. При получении такого запроса (или цели) система Prolog использует резолюцию, чтобы опреде- лить истинность данного утверждения. Если система сможет заключить, что утверждение . верно, она выдаст ответ “истина”. В противном случае, будет выдан ответ “ложь”. 2.13.3. Оценка Существует относительно небольшая группа специалистов по компьютерным наукам, которые верят, что логическое программирование представляет собой наилучший способ избежать применения императивных языков программирования, а также решить огром- ную проблему производства большого количества надежного программного обеспече- ния, необходимого на сегодняшний день. До настоящего времени, правда, существуют две основные причины, почему логическое программирование не становится широко ис- пользуемым. Во-первых, доказано, что, как и другие неимперативные подходы, логиче- ское программирование крайне неэффективно. Во-вторых, оно зарекомендовало себя в качестве эффективного метода только в нескольких относительно небольших приклад- ных областях: некоторых видах систем управления базами данных и некоторых областях искусственного интеллекта. Логическое программирование и язык Prolog подробно рассмотрены в главе 15. 2.14. Величайший проект в истории: язык Ada Язык Ada— результат самого обширного и самого дорогостоящего из когда-либо предпринимавшихся проектов по.созданию языка программирования. Язык Ada разраба- тывался для Министерства обороны США, так что состояние существовавшей вычисли- тельной среды способствовало определению его формы. 2.14. Величайший проект в истории: язык Ada 103
2.14.1. Историческая ретроспектива к 1974 году около половины областей применения компьютеров в Министерстве обороны приходилось на встроенные системы. Последний термин означает, что аппарат- ное обеспечение такой системы встраивалось в устройство, управляемое или обслужи- ваемое этой системой. При этом быстро росла стоимость программного обеспечения, в основном из-за увеличения сложности систем. В проектах Министерства обороны ис- пользовались более 450 различных языков программирования, ни один из которых Ми- нистерством стандартизован не был. Каждый подрядчик мог определить новый, отли- чающийся от предыдущих, язык программирования. Из-за такого количества языков по- вторное использование прикладного программного обеспечения было очень редким. Более того, не было инструментальных средств разработки программного обеспечения (поскольку они обычно зависели от конкретного языка) Большинство языков не подхо- дили для использования в области встроенных систем. Поэтому в 1974 году армия, флот и ВВС независимо друг от друга предложили разработать язык высокого уровня для встроенных систем. 2.14.2. Процесс разработки Обратив внимание на это широко распространенное мнение, директор Отдела иссле- дований и проектирования Министерства обороны (Defense Research and Engineering) Малкольм Кури (Malcolm Currie) в январе 1975 года сформировал группу HOLWG (High- Order Language Working Group— группа по разработке первоочередного языка), кото- рою изначально возглавил подполковник ВВС Уильям Уайтекер (William Whitaker). В группе HOLWG имелись представители всех военных служб и служб связи с Великобри- танией, Францией и Западной Германией. Исходным заданием группы было следующее. Определить требования к новому высокоуровневому языку, заказанному Мини- стерством обороны. Оценить существующие языки и определить их жизнеспособность. Предложить принятие или реализацию минимального набора языков программи- рования. В апреле 1975 года группа выпустила список требований к новому языку, названный Strawman document (Department of Defense, 1975a). (Здесь и далее фамилии в названиях документов отмечают этапы реализации плана создания нового языка программирова- ния: Strawman— “соломенный человек”, Woodman— “деревянный”, Tinman — “оловянный”, Ironman — “железный”, Steelman — “стальной” и Stoneman — “каменный". — Прим. перев.) Этот документ был распространен по родам войск, феде- ральным агентствам, избранным промышленным и образовательным структурам и заин- тересованным сторонам в Европе. Вслед за указанным документом в августе 1975 года последовал Woodman document (Department of Defense, 1975b), а в январе 1976 года— Tinman document (Department of Defense, 1976). В последнем рассматривался полный набор требований к языку, а также необходимые его характеристики. Основным автором всех этих документов был Дэвид Фишер (David Fisher) из Института анализа обороноспособности (Institute for Defense Analysis). Группа, принимавшая участие в проекте, была значительной и насчитывала более 200 человек, представляющих более 40 организаций помимо Министерства оборо- 104 Глава 2. Обзор основных языков программирования
ны. В январе 1977 года требования документа Tinman document были заменены требова- ниями документа Ironman requirements document (Department of Defense. 1977). который имел практически идентичное содержание, но несколько отличался форматом. В апреле 1977 года в качестве основы для неограниченных условий подряда был ис- пользован документ Ironman document, затем этот документ был предан огласке, сделав, таким образом, язык Ada первым языком, созданным на основе конкурсного контракта. В июне 1977 года четверо из заявленных подрядчиков— компании Softech. SRI International. Cii Honeywell/Bull и Intermetrics — были выбраны для перехода, независимо и параллельно, к фазе 1 разработки языка. Все четыре из предложенных в результате проектов были основаны на языке Pascal. Когда в феврале 1978 года шестимесячная фаза 1 была завершена, ее результаты оце- нили 400 добровольцев из 80 экспертных команд, разбросанных по всему миру. В ре- зультате этой оценки два финалиста— компании Intermetrics и Cii Honeywell/Bull — бы- ли выбраны для продолжения разработки в фазе 2. В июне 1978 года была выпущена следующая итерация документа требований — Steelman document (Department of Defense. 1978). В конце фазы 2 была выполнена еще одна двухмесячная опенка и в мае 1979 года по- бедителем была признана структура языка, предложенная компанией Cii Honeywell'Bull. Любопытно отметить, что победителем оказался единственный иностранный подрядчик из четырех финалистов. Руководителем французской команды разработчиков Cii Honeywell/Bull была тогда Джин Ичбиа (Jean Ichbiah). Весной 1979 года Джек Купер (Jack Cooper) из Командования материальным обеспе- чением ВМФ США (Navy Materiel Command) предложил для нового языка название Ada. которое позднее и было принято. Августа Ада Байрон (1815-1851). графиня Лавлейс (Augusta Ada Bayron. countess of Lovelace), математик и дочь поэта лорда Байрона, обще- признанна в качестве первого программиста в мировой истории. Она работала с Чарль- зом Бэббиджем на его механических компьютерах, названных “Разностными и аналити- ческими машинами" (Difference and Analytical Engines), создавая программы для отдель- ных вычислительных алгоритмов. Фаза 3 разработки языка Ada началась после выбора проекта-победителя. Его струк- тура и логическое обоснование были опубликованы ассоциацией АСМ в сообщении "SIG PLAN Notices" (ACM, 1979) и распространены для изучения более чем 10 000 чи- тателям. Открытое испытание и конференция по оценке языка состоялись в октябре 1979 года в Бостоне при участии более 100 организаций из Соединенных Штатов и Европы. К ноябрю из 15 стран было получено более 500 сообщений, касавшихся нового языка. Среди этих сообщений гораздо чаше встречались предложения о незначительных изме- нениях, чем о радикальной переработке или полном неприятии языка. Основываясь на этих сообщениях, в феврале 1980 года была выпущена следующая версия требований к спецификации — документ Stoneman document (Department of Defense. 1980a). Пересмотренная и исправленная версия структуры языка была завершена к июлю 1980 года и принята как M1L-STD 1815, стандартное справочное руководство по языку Ada (“Ada Language Reference Manual”). Число 1815 было выбрано как год рождения Августы Ады Лавлейс. Другая переработанная версия справочника “Ada Language Reference Manual" вышла в свет в июле 1982 года. В 1983 году институт ANSI стандар- тизировал язык Ada. “Окончательная" официальная версия языка вышла в 1983 году (Goos and Hartmanis, 1983). После этого разработка языка Ada была заморожена по край- ней мере на 5 лет. 2.14. Величайший проект в истории: язык Ada 105
2.14.3. Обзор языка В данном разделе кратко рассматриваются четыре основных свойства языка Ada. По- скольку этот язык используется во многих примерах далее в книге, то остальные свойст- ва мы будем описывать по ходу дела. Пакеты языка Ada представляют собой средство выделения в отдельные модули объ- ектов данных, спецификации типов данных и процедур, что, в свою очередь, обеспечива- ет поддержку использования абстракции данных в структуре программы, как это описа- но в главе 10. Язык Ada содержит обширные средства обработки исключительных ситуаций, что позволяет программисту получать управление после обнаружения одной из многочис- ленных исключительных ситуаций или ошибок времени выполнения программы. Под- робнее об обработке исключительных ситуаций рассказывается в главе 13. Блоки программ в языке Ada могут быть настраиваемыми. Например, можно напи- сать процедуру сортировки, использующую неуточненный тип сортируемых данных. До использования такой настраиваемой процедуры ей должно быть приписано значение оп- ределенного типа. Для этого оператор вызывает создание компилятором разновидности процедуры, использующей данный тип. Наличие таких настраиваемых компонентов уве- личивает количество блоков программ, которые могут быть использованы повторно, не требуя дублирования со стороны программистов. Настраиваемые блоки рассмазривают- ся в главах 8 и 10. Язык Ada также обеспечивает параллельное выполнение особых программных бло- ков, названных заданиями, используя для этого механизм рандеву. Последнее название обозначает метод взаимодействия и синхронизации параллельных процессов в языке Ada. Параллельность подробно рассмотрена в главе 12. 2.14.4. Оценка Важнейшими аспектами разработки языка Ada считаются следующие. Поскольку разработка языка проводилась на конкурентной основе, не было огра- ничено количество участников. Язык Ada воплощает большинство концепций разработки программного обеспе- чения и разработки языков программирования, существовавших на конец 1970-х годов. Хотя можно подвергать сомнению фактические методы, использованные для внедрения этих свойств, а также разумность включения такого большого ко- личества свойств, большинство согласится, что эти свойства полезны. Разработка компилятора для языка Ada была сложнейшей задачей, несмотря на то, что большинство людей не догадываются об этом. Только в 1985 году, почти че- рез четыре года после завершения разработки языка, начали появляться действи- тельно практичные компиляторы языка Ada. Наиболее серьезной критике в первые годы существования языка Ada подвергались его объемность и сложность. В частности Хоар утверждал, что этот язык не должен ис- пользоваться в областях, где основным требованием является надежность (Ноаге, 1981), хотя для применения именно в подобных областях язык и создавался. Однако другие люди превозносили его как вершину достижений в области разработки языков програм- мирования. 106 Глава 2. Обзор основных языков программирования
Ниже следует пример программы на языке Ada. — Пример программы на языке Ada - - Ввод: Целое число listlen, меньше 100, за которым следует набор целых чисел в количестве listlen — Вывод: Количество введенных величин, которые больше их среднего арифметического with TEXT-IO; use TEXT_IO; procedure ADA_EX is package INT_IO is new INTEGER_IO (INTEGER); use INT-IO type INT_LIST_TYPE is array (1..99) of INTEGER; INT_LIST : INT-LIST-TYPE; LIST__LEN, SUM, AVERAGE, RESULT : INTEGER; begin RESULT := 0; SUM := 0; GET (LIST—LEN); if (LIST—LEN > 0) and (LIST_LEN < 100) then - - Считывание входных величин в массив и вычисление суммы for COUNTER := 1 .. LIST_LEN loop GET (INT—LIST(COUNTER)); SUM := SUM + INT_LIST(COUNTER); end loop; — Вычисление среднего арифметического AVERAGE := SUM / LIST_LEN; — Вычисление количества входных величин, которые больше их - - среднего for COUNTER := 1 .. LIST_LEN loop if INT—LIST(COUNTER) > AVERAGE then RESULT := RESULT + 1; end if; end loop; - - Вывод результатов PUT (’’Число величин, которые больше их среднего, равно”); PUT (RESULT); NEW-LINE; else PIT—LINE (’’Ошибка — введена неверная длина списка”); end if; end ADA—EX; 2Л4.5. Язык Ada 95 Работа по исправлению языка Ada началась в 1988 году с создания учреждением Ada Joint Program Office проекта Ada 9X. Проект Ada 9X имел три фазы: определение требо- ваний к переработанному языку, реальная разработка описания переработанного языка и переход к его использованию. Все требования были опубликованы Министерством обо- роны (Department of Defense, 1990). 2.14. Величайший проект в истории: язык Ada 107
Требования к обновленному языку7 сосредоточились на четырех областях: создание интерфейсов (особенно графических интерфейсов пользователя), поддержка объектно- ориентированного программирования, более гибкие библиотеки, а также усовершенст- вование механизмов управления совместно используемыми данными. Два из важнейших новых свойств исправленной версии языка Ada. названной Ada 95. кратко описаны в следующих абзацах. Вообще, в остальной части книги мы будем ис- пользовать название Ada 83 для обозначения исходной версии языка, а название Ada 95 (действительно употреблявшееся)— для его более поздней версии. При обсуждении свойств, общих для обоих языков, мы будем использовать название Ada. Стандарт языка Ada 95 был определен в справочном руководстве AARM (1995). Существующий в языке Ada 83 механизм образования типов в языке Ada 95 был рас- ширен. что позволяет создавать новые компоненты, образованные из меченых базовых ти- пов. Эго обеспечивало наследование— ключевую составную часть объектно-ориентиро- ванных языков программирования. Динамическое связывание вызовов подпрограмм с их определениями выполнялось с помощью координации, которая основывалась на значении признака производных типов посредством межклассовых типов. Это свойство обеспечива- ет полиморфизм, еще одно основное свойство объектно-ориентированного программиро- вания. Все указанные свойства языка Ada 95 рассмотрены в главе 11. Механизм рандеву языка Ada 83 обеспечивал только неуклюжие средства распреде- ления данных между параллельными процессами. Для управления доступом к совместно используемым данным следовало ввести новое задание. Защищенные объекты языка Ada 95 предложили этому привлекательную альтернативу. Совместно используемые данные выделялись в синтаксическую структуру, управляющую всем доступом к данным с помошью рандеву или вызова подпрограммы. Новые свойства языка Ada 95 — парал- лельность и совместное использование данных — подробно рассмотрены в главе 12. Широко распространено мнение, что популярность языка Ada 95 будет падать, по- скольку его использование в военных системах программного обеспечения Министерст- ву обороны США уже не требуется. Хотя, разумеется, существуют и другие причины, препятствующие росту популярности языка Ada. Происхождение языка Ada представлено на рис. 2.11. f Pascal (1971) Ada 83 (1983) Ada 95 (1995) Рис. 2.11. Генеалогия языка Ada 108 Глава 2. Обзор основных языков программирования
2.15. Объектно-ориентированное программирование: язык Smalltalk Как уже обсуждалось в главе 1, объектно-ориентированное программирование со- держит в качестве одной из трех основных характеристик абстракцию данных, осталь- ными двумя являются наследование и динамическое связывание. В ограниченной форме наследование появилось в языке SIMULA 67, в котором мож- но было определять иерархию классов. Наследование обеспечивало эффективный метод повторного использования программ. Существующая в объектно-ориентированном программировании концепция управле- ния модулями приближенно выражается идеей, происходящей от языка SIMULA 67 и за- ключающейся в том, что программы моделируют реальный мир. Поскольку большая часть реального мира населена объектами, моделирование такого мира должно включать в себя моделирование объектов. По сути, язык, основанный на концепциях моделирова- ния реального мира, нуждается только во введении в него модели объектов, которые мо- гут отправлять и получать сообщения, а также реагировать на получаемые сообщения. Объектно-ориентированное программирование решает задачи с помощью идентифи- кации объектов, принадлежащих реальному миру, и действий, которые необходимо вы- полнить над этими объектами, с последующим моделированием этих объектов, их про- цессов и требуемых связей между объектами. Абстрактные типы данных, динамическое связывание и наследование— вот концепции, которые делают решение объектно- ориентированной задачи не только возможным, но также удобным и эффективным. 2.15.1. Процесс разработки Концепции, приведшие к разработке языка Smalltalk, были впервые сформулированы в докторской диссертации Алана Кэя (Alan Kay), выполненной в конце 1960-х годов в Университете штата Юта (University of Utah) (Kay, 1969). Кэй на удивление точно пред- видел будущую доступность мощных настольных компьютеров. Напомним, что первые микрокомпьютерные системы поступили в продажу лишь в середине 1970-х годов и имели очень отдаленное отношение к машинам, представляемым Каем, которые должны были выполнять миллион или более операций в секунду' и содержать несколько мегабай- тов памяти. Подобные машины в виде рабочих станций стали широко доступны только в начале 1980-х годов. Кэй полагал, что настольные компьютеры смогут использоваться непрограммистами, и, следовательно, им понадобятся очень мощные средства интерфейса. Компьютеры конца 1960-х годов были в основном пакетно-ориентированными и использовались ис- ключительно профессиональными программистами и учеными. Для использования не- программистами, как установил Кэй, компьютер должен иметь высокую степень инте- рактивности и использовать изощренную графику в пользовательском интерфейсе. Не- которые графические концепции были результатом работы Сеймура Пейперта (Seymour Papert) с языком LOGO, в котором графика помогала детям использовать компьютеры (Papert, 1980). Изначально Кэй представлял себе систему, названную им Dynabook, которая должна была стать универсальным информационным процессором. Частично эта система осно- вывалась на языке Flex, который Кэй помогал разрабатывать. Сам язык Flex базировался главным образом на языке SIMULA 67. Система Dynabook была основана на парадигме 2.15. Объектно-ориентированное программирование: язык Smalltalk 109
обычного стола, на котором лежит много бумаг, часть из которых перекрывается. В фо- кусе внимания всегда находится верхний лист бумаги, тогда как остальные временно не видны. Эту картину дисплей системы Dynabook должен был моделировать с помощью окна на экране. Пользователь взаимодействовал бы с подобным дисплеем посредством клавиатуры и прикосновений пальцами к экрану. После того как эскизный проект систе- мы Dynabook принес ему докторскую степень, целью Кэя стало желание увидеть подоб- ную машину в действии. Это желание привело Кэя в Исследовательский центр компании Xerox в Пало-Альто (Xerox Palo Alto Research Center, Xerox PARC), где он изложил свою идею о системе Dynabook. В результате его приняли на работу в этот центр, и впоследствии возникла на- учно-исследовательская группа Learning Research Group at Xerox. Первой задачей группы стала разработка языка, поддерживающего программную парадигму Кэя, а также ее реа- лизация на лучшем из доступных тогда персональных компьютеров. В результате этой работы появилась система “Interim" Dynabook, состоящая из аппаратного обеспечения Xerox Alto и программного обеспечения Smalltalk-72. Вместе они образовали исследова- тельский инструмент для дальнейших разработок. С этой системой было проведено большое количество исследовательских проектов, в том числе и несколько эксперимен- тов по обучению детей программированию. Параллельно с этими экспериментами шли дальнейшие разработки, приведшие к возникновению ряда языков, последним из кото- рых был рассматриваемый в данной книге язык Smalltalk-80. С развитием языка росла и мощь аппаратного обеспечения, на котором он реализовывался. К 1980 году и язык, и аппаратное обеспечение компании Xerox практически соответствовали представлениям Алана Кэя. 2.15.2. Обзор языка Программными модулями языка Smalltalk являются объекты. Объекты — это струк- туры, инкапсулирующие локальные данные и набор операций, называемых методами, которые доступны другим объектам. Метод определяет реакцию объекта на определен- ное сообщение, соответствующее данному методу. Мир языка Smalltalk заселен только объектами, в число которых входит все: от целочисленных констант до больших слож- ных систем программного обеспечения. Все вычисления на языке Smalltalk выполняются одним и тем же универсальным спо- собом: объекту отправляется сообщение, вызывающее один из его методов. Ответом на сообщение является объект, возвращающий требуемую информацию или просто инфор- мирующий отправителя о завершении запрошенной обработки. Главное различие между сообщением и подпрограммой заключается в том, что сообщение посылается объекту данных, который затем обрабатывается с помощью программного кода, связанного с этим объектом, в то время как вызов подпрограммы, как правило, посылает данные на обработку в саму подпрограмму. С точки зрения моделирования, которое никогда не отделялось от языка Smalltalk, этот язык является моделью совокупности компьютеров (объектов), которые сообщают- ся друг с другом (с помощью сообщений). Каждый объект представляет собой абстрак- цию компьютера, т.е. он хранит данные и предоставляет возможности обработки для ма- нипулирования этими данными. Кроме того, объекты могут посылать и получать сооб- щения. По существу, это и есть основные возможности компьютеров: хранить, обрабатывать данные и обмениваться ими друг с другом. 110 Глава 2. Обзор основных языков программирования
В языке Smalltalk абстракциями объектов являются классы, которые очень похожи на классы языка SIMULA 67. Могут создаваться экземпляры класса, которые затем ставятся объектами программы. Каждый объект имеет собственные локальные данные и пред- ставляет собой отдельный экземпляр своего класса. Различаются объекты одного класса состоянием своих локальных переменных. Как и в языке SIMULA 67. в языке Smalltalk может быть создана иерархия классов. Подклассы данного класса являются его уточнением, наследуя функциональные возмож- ности и локальные переменные родительского класса, или суперкласса. Подклассы могут выделять новую локальную память, добавлять новые функциональные возможности, а также изменять и скрывать унаследованные. Как кратко описывалось в главе 1, язык Smalltalk— это не только язык, но еще и полная среда разработки программного обеспечения. Интерфейс данной среды в значи- тельной степени графический, что затрудняет использование многочисленных перекры- вающихся окон, всплывающих меню и устройств ввода типа “мышь”. 2.15.3. Оценка Язык Smalltalk выполнил великую задачу, активизировав два отдельных аспекта про- граммирования. Во-первых, сисГемы с оконным управлением, являющиеся сейчас ос- новным методом связи с пользователем, произошли от системы Smalltalk. Во-вторых, в наши дни основные методологии разработки программного обеспечения и языки про- граммирования являются объектно-ориентированными. Хотя некоторые идеи объектно- ориентированных языков программирования происходят от языка SIMULA 67, развиты они были только в языке Smalltalk. Очевидно, что язык Smalltalk оказал на компьютер- ный мир большое и долговременное влияние. Ниже приводится пример описания класса в языке Smalltalk. ’’Пример программы на языке Smalltalk” "Ниже следует описание класса, реализация которого может на- рисовать равносторонний многоугольник с любым количеством сторон" class name Polygon superclass Object instance variable names ourPen numSides sideLength "Методы класса" "Создать экземпляр" new А super new getPen "Взять перо для рисования многоугольника" getPen ourPen <- Pen new defaultNib: 2 "Методы экземпляра" "Нарисовать многоугольник" draw numSides timeRepeat: [ourPen go: sideLength; turn: 360 // numSides] 2.15. Объектно-ориентированное программирование: язык Smalltalk 111
"Установить длину сторон" length: len sideLength <- len "Установить число сторон" sides: num numSides <- num Происхождение языка Smalltalk представлено на рис. 2.12. О ALGOL 58 (1958) о ALGOL 60 (1960) и SIMULA 1(1964) о SIMULA 67 (1967) о Smalltalk-80 (1980) Рис. 2.12. Генеалогия языка Smalltalk 2.16. Объединение императивных и объектно- ориентированных свойств: язык C++ Происхождение языка С рассматривалось в разделе 2.12; происхождение языка Smalltalk — в разделе 2.15. Язык C++ представляет собой надстройку над языком С, под- держивающую большинство возможностей, открытых языком Smalltalk. Чтобы улуч- шить императивные свойства языка С и приобрести поддержку объектно-ориентирован- ного программирования, язык C++ прошел через ряд последовательных модификаций. 2.16.1. Процесс разработки Первый шаг на пути от языка С к языку C++ был сделан в 1980 году Бьярни Страустру- пом (Bjame Stroustrup) из компании Bell Laboratories. Изменения заключались во введении проверки и преобразования типов параметров функций и, что представляется более важ- ным. классов, родственных аналогичным структурам языков SIMULA 67 и Smalltalk. По- мимо этого в язык были включены производные классы, управление открытым/закрытым доступом к унаследованным компонентам, конструктор и деструктор, а также дружествен- ные классы. В течение 1981 года были добавлены inline-подставляемые функции, парамет- ры по умолчанию и перегрузка операторов присваивания. Полученный язык был назван языком С with Classes, а его описание выполнено Страуструпом (Stroustrup, 1983). Полезно рассмотреть несколько целей языка С with Classes. Основной его целью бы- ло — предоставить язык, на котором программы могли бы формироваться тем же обра- 112 Глава 2. Обзор основных языков программирования
зом, что и в языке SIMULA 67; т.е. с классами и наследованием. Кроме того, важно было не потерять производительность по сравнению с языком С. Таким образом, язык С with Classes мог использоваться для тех же приложений, что и язык С, при этом были сохра- нены практически все ненадежные свойства языка С. Например, введение проверки диа- пазона изменения индексов массива даже не рассматривалось, поскольку это привело бы к значительному снижению производительности по сравнению с языком С. К 1984 году этот язык был расширен за счет включения виртуальных функций, обес- печивающих динамическое связывание вызовов функций с их описаниями; перегрузки операторов и имен функций; а также ссылочных типов данных. Эта версия языка, на- званная C++, была описана Страуструпом (Stroustrup, 1984). В 1985 году появилась первая доступная реализация языка С4-^ — система Cfront. которая транслировала программы на языке C++ в программы на языке С. Эта версия системы Cfront вместе с реализованной на ее основе версией языка С*+ получила общее название Release 1.0. Описание полученной системы было выполнено Страуструпом (Stroustrup, 1986). Между 1985 и 1989 годами язык C++ продолжал эволюционировать, ориентируясь главным образом на реакцию пользователей его первой распространенной реализации. Следующая версия была названа Release 2.0. Ее реализация с помощью системы Cfront была выполнена в июне 1989 года. В язык была добавлена поддержка множественного наследования (классы, имеющие несколько родительских классов) и абстрактных классов, и внесены другие изменения. Абстрактные классы рассматриваются в главе 11. Версия Release 3.0 была выпущена в конце 1980-х годов. Она содержала специфика- торы шаблонов, предоставлявшие собой параметризованные типы, и обработку исклю- чительных ситуаций. Версии Release 2.0 и Release 3.0 были описаны Эллисом и Страуст- рупом (Ellis and Stroustrup, 1990). 2.16.2. Обзор языка Для поддержки объектно-ориентированного программирования необходим механизм классов/объектов, происходящий от языка SIMULA 67 и являющийся основным свойст- вом языка Smalltalk. Язык C++ предоставлял пользователям набор встроенных классов, а также возможность самостоятельно определять классы. Классы языка С-*-+ — типы дан- ных, экземпляры которых, подобно классам языка Smalltalk, могут создаваться в любом количестве. Результатами создания экземпляров в языке C++ являются просто объекты, или объявления данных. В описании класса указываются объекты данных (данные- члены) и функции (функции-члены). Классы могут называть один или несколько роди- тельских классов, обеспечивая наследование и множественное наследование, соответст- венно. Классы наследуют данные-члены и функции-члены родительского класса, опре- деленные в качестве наследуемых. Операторы в языке C++ могут перегружаться, что означает возможность приписы- вать существующим операторам новый смысл и применять их к определяемым пользо- вателем типам данных. Функции языка C++ также могут перегружаться, что позволяет определять одноименные функций, изменяя либо количество, либо тип используемых в них параметров. Динамическое связывание в языке C++ обеспечивается функциями виртуального класса. Эти функции определяют операции, зависящие от типов, используя для этого пе- регруженные функции, а также набор классов, связанных между собой методами насле- дования. Указатель на объект класса А может указывать и на объекты классов, насле- 2.16. Объединение императивных и объектно-ориентированных свойств... 113
дующих класс А. Когда подобный указатель указывает на перегруженную виртуальную функцию, функция, соответствующая текущему типу, выбирается динамически. На функции и классы может действовать спецификатор шаблона, что означает воз- можность их параметризации. Например, функция может быть записана в виде шаблон- ной функции, что позволяет иметь ее версии для разнообразных типов параметров. По- добную гибкость имеют также и классы. Язык C++ содержит обработку исключительных ситуаций, которая значительно отли- чается от аналогичного свойства языка Ada. Одним из отличий является невозможность обработки исключительных ситуаций, обнаруживаемых на аппаратном уровне. Обработ- ка исключительных ситуаций в языке C++ рассмотрена в главе 13. 2.16.3. Оценка Язык C++ быстро стал и остается очень популярным языком. Одним из факторов его популярности является доступность хороших и недорогих компиляторов. Кроме того, язык С+* практически полностью совместим с предшественником — языком С (что по- зволяет компилировать большинство программ, написанных на языке С, как программы, написанные на языке C++ и связывать команды языка C++ с командами языка С). Нако- нец, программисты сейчас настойчиво интересуются объектно-ориентированным про- граммированием, а язык C++ — его инструмент. Недостатком же языка C++ является его объемность и сложность, т.е. он страдает от тех же изъянов, что и язык PL/I. Язык C++ унаследовал большинство ненадежных мо- ментов языка С, что делает его менее надежным, чем такие языки, как Delphi, Ada и Java. Наконец, частично из-за его происхождения от языка С, язык C++ более, чем язык ALGOL 68, похож на язык PL/I тем, что он представляет собой скорее набор идей, соб- ранных вместе, чем результат комплексного плана по разработке языка. К объектно-ориентированным свойствам языка C++ мы еще вернемся в главе 11. Происхождение языка C++ показано на рис. 2.10. 2.16.4. Родственный язык: Eiffel Язык Eiffel представляет собой еще один смешанный язык, содержащий как импера- 1ивные, так и объектно-ориентированные свойства (Meyer, 1992). Язык Eiffel был разра- ботан одним человеком, Бертраном Мейером (Bertrand Meyer), французом, проживаю- щим в Калифорнии. Этот язык содержит свойства поддержки абстрактных типов данных, наследования и динамического связывания и полностью поддерживает объектно- ориентированное программирование. Самым характерным свойством языка Eiffel явля- ется встроенное в него использование утверждений для создания принудительного “контракта” между подпрограммами и вызывающими их операторами. Эта идея появи- лась еще в языке Plankalkiil, но игнорировалась большинством языков, разработанных после него. Сравним языки Eiffel и C++. Язык Eiffel меньше и проще языка C++ при практически одинаковой выразительности и легкости написания программ. Причины вы- сокой популярности языка C++ на фоне ограниченного использования языка Eiffel опре- делить нетрудно. Язык C++ позволяет компаниям — разработчикам программного обес- печения легко переходить к объектно-ориентированному программированию, поскольку в большинстве случаев их сотрудники уже знакомы с языком С. Язык же Eiffel не имеет такого легкого способа восприятия. Кроме того, в первые несколько лет широкого ис- пользования языка C++ система Cfront была доступной и недорогой, в то время как в 114 Глава 2. Обзор основных языков программирования
первые годы существования языка Eiffel его компиляторы были менее доступными и бо- лее дорогими. Язык С+4- имел за плечами престижную компанию Bell Laboratories, тогда как за языком Eiffel стоял Бертранд Мейер и его относительно небольшая компания по разработке программного обеспечения Interactive Software Engineering. 2.17. Программирование в World Wide Web: язык Java Разработчики языка Java взяли в качестве основы язык С-н-. удалили многочисленные конструкции, что-то изменили, что-то добавили. В результате язык сохранил мощь и гибкость языка C++, но стал меньше, проще и надежнее. 2.17.1. Процесс разработки Язык Java разрабатывался хпя целого набора прикладных областей, в частности про- граммирования таких встроенных бытовых электронных устройств, как тостеры, микро- волновые печи и интерактивные телевизионные системы. Может показаться, что при разработке программного обеспечения для микроволновой печи надежность не важна. Если на печь будет установлено неправильно функционирующее программное обеспече- ние, это не создаст смертельной опасности ни для кого и, вероятно, не приведет к круп- ным судебным разбирательствам. Тем не менее, если программное обеспечение кон- кретной модели будет признано дефектным после продажи миллиона единиц товара, то их изъятие может обернуться потерей значительной суммы. Следовательно, надежность является важной характеристикой при разработке программного обеспечения для быто- вых электронных приборов. В 1990 году компания Sun Microsystems пришла к выводу, что ни один из двух рас- смотренных языков программирования (С и C++) не подходит для разработки про- граммного обеспечения бытовых электронных приборов. Несмотря на то что язык С был относительно небольшим, он не обеспечивал поддержки объектно-ориентированного программирования, которая тогда считалась необходимой. Язык C++ поддерживал объ- ектно-ориентированное программирование, но его размер и сложность были восприняты как слишком большое препятствие. Кроме того, тогда полагали, что ни язык С, ни язык C++ не обеспечивают требуемый уровень надежности. Язык Java должен был обеспечить большую простоту и надежность, чем язык C++. Несмотря на то что изначальным стремлением разработчиков языка Java было созда- ние языка для бытовой электроники, ни один из товаров, вместе с которыми он исполь- зовался в начальные годы своего развития, не появился на рынке. Когда после 1993 года широкое распространение получила World Wide Web, чему в значительной степени спо- собствовали графические броузеры, язык Java оказался полезным инструментом для про- граммирования в Web. За первые несколько лет открытого использования основным применением языка Java стало именно программирование в Web. Команду разработчиков языка Java возглавил Джеймс Гослинг (James Gosling), кото- рый ранее создал редактор emacs для операционной системы Unix, а также систему управления окнами NeWS. 2.17. Программирование в World Wide Web: язык Java 115
2.17.2. Обзор языка Как мы упоминали ранее, язык Java был основан на базе языка C++, но создавался, в частности, как язык, меньший по размерам, более простой и надежный. В языке Java имеются и классы, и типы. Элементарные типы — это не объекты, основанные на клас- сах. В их число входят все скалярные типы, в том числе типы для целых чисел, чисел с плавающей точкой, булевских и символьных данных. Доступ к объектам обеспечивается с помощью ссылок, а доступ к величинам элементарных типов — как к скалярным вели- чинам таких императивных языков, как языки С и Ada. Массивы в языке Ada являются экземплярами встроенного класса, а в языке C++ — нет, хотя многие пользователи языка C++ создают для массивов интерфейсные классы, чтобы ввести такие свойства, как про- верка диапазона изменения индексов массивов, которое задано в языке Java неявно. В языке Java отсутствуют указатели, но ссылочные типы предоставляют некоторые их возможности. Эти ссылки используются для указания на экземпляры классов — фак- тически это единственный способ сделать ссылку на экземпляр класса. Хотя, на первый взгляд, указатели и ссылки очень похожи, существует важное семантическое различие. Указатели указывают на ячейку памяти, а ссылки — на объект. Это делает бессмыслен- ной любую арифметику ссылок, отменяя эту подверженную ошибкам привычку. На про- граммисте часто лежит ответственность за разграничение значений указателя и величи- ны, на которую он указывает, особенно это справедливо для языков, в которых указатели иногда приходится явно разыменовывать. Ссылки же всегда разыменовывают по мере необходимости. Таким образом, они ведут себя аналогично скалярным переменным. В языке Java имеется встроенный булевский тип, используемый преимущественно для условных выражений в его управляющих операторах (например, if и while). В от- личие от языков С и C++ в условных выражениях не могут использоваться арифметиче- ские выражения. В языке Java отсутствуют записи, объединения и перечислимые типы. Одним из существенных различий между языком Java и большинством современных языков, поддерживающих объектно-ориентированное программирование, в том числе языками Ada и C++, является невозможность написания на языке Java независимых под- программ. Все подпрограммы языка Java являются методами и определяются в классах. В языке Java не существует конструкции, которая вызывала бы функцию или подпро- грамму. Более того, методы могут вызываться только через классы или объекты. Другим важным различием между языками C++ и Java является поддержка языком C++ множественного наследования непосредственно в описании класса. Некоторые считают, что множественное наследование слишком все усложняет и запутывает. Язык Java поддерживает только единичное наследование, хотя некоторые выгоды использова- ния множественного наследования могут быть получены благодаря конструкции его ин- терфейса. Язык Java имеет относительно простую форму параллельности, управляемую с по- мощью модификатора synchronize, который может появляться в методах и блоках. В любом случае это вызывает присоединение блокировки. Блокировка страхует от взаимо- исключающего доступа или выполнения. В языке Java относительно просто создавать параллельные процессы, которые в этом языке называются потоками. Эти потоки могут запускаться, приостанавливаться, возобновляться и останавливаться, причем все эти ме- тоды наследуются от родительского класса для всех потоков — класса Thread. В языке Java используется неявное удаление объектов из динамической памяти (все экземпляры классов языка Java, или объекты, размещаются в динамической памяти), 116 Глава 2. Обзор основных языков программирования
часто называемое “сборкой мусора”. Это освобождает программиста от необходимости самому заботиться об освобождении памяти, когда в ней отпадает необходимость. Программы, написанные на языках, требующих явного освобождения памяти, часто страдают от того, что некоторые называют “утечкой памяти*', означающей выделение памяти без последующего ее освобождения. Это может привести к исчерпанию всей дос- тупной памяти. В отличие от языков С и C++ в языке Java имеется приведение типов (неявное преоб- разование типов) только в сторону расширения типа (от “меньшего" типа к “большему*). Таким образом, приведение типа int в тип float будет выполнено, а типа float в тип int — нет. 2.17.3. Оценка Разработчики языка Java удачно удалили чрезмерные и/или ненадежные свойства языка C++. Например, удаление половины приведений типов, имевшихся в языке С-+, несомненно повысило его надежность. Проверка диапазона изменения индексов при доступе к массивам также сделала язык надежнее. Введение параллельности, а также библиотек классов для создания аплетов, графических интерфейсов пользователя и рабо- ты в сети компьютеров расширило область приложений, которые могли быть написаны на данном языке. С другой стороны, язык Java все еще остается слишком сложным. Отсутствие в нем множественного наследования привело к возникновению несколько необычной структу- ры. Например, для запуска аплета как задания, которое может выполняться параллельно с другими, требуется собрать вместе атрибуты аплетов и потоков, а этот союз не обяза- тельно окажется приятным. Мобильность языка Java, по крайней мере в его промежуточной форме, часто припи- сывается структуре языка, но это неверно. Любой язык может быть переведен в проме- жуточную форму и “запуститься" на платформе, имеющей виртуальную машин) для этой промежуточной формы. Цена такого типа мобильности — стоимость интерпрета- ции, которая, обычно, на порядок выше, чем стоимость выполнения в машинных кодах. Разумеется, многие приложения могут выполняться быстрее и, следовательно, требовать наличия компиляторов, которые бы транслировали исходную программу в машинные коды. Однако такие компиляторы для языка Java появляются медленно. Поэтом), но крайней мере сейчас, более объемные приложения все еще пишутся на таких языках, как C++, Delphi и Ada. Сейчас язык Java широко используется для программирования Web-страниц. До его по- явления любое значительное вычисление, требуемое для создания Web-страниц, выполня- лось с помощью интерфейса CG1 и некоторых приложений, запущенных на Web-сервере. Аплеты языка Java представляют собой небольшие программы, запускаемые на Web- клиенте при обнаружении последним вызова аплета в HTML-программе отображаемой страницы. При вызове аплета его промежуточная форма загружается клиентом с сервера и интерпретируется. Результат работы аплета отображается в виде Web-страницы. Рост использования языка Java происходит значительно быстрее, чем любого другого языка программирования. Одной из причин этого является его вклад в программирова- ние тщательно разработанных Web-страниц. Другой причиной является бесплатность и легкость получения через Web-системы компилятора/интерпретатора для языка Java. Понятно, что одной из причин быстрого роста популярности языка Java является его 2.17. Программирование в World Wide Web: язык Java 117
простота, за что структура языка Java и полюбилась программистам. Наконец, некоторое время существовала группа программистов на языке C++, возражавшая против того, что они считали проблемами языка C++. Язык Java предложил им альтернативу: мощность языка C++ и уменьшение количества проблем. Ниже следует пример программы на языке Java. // Пример программы на языка Java // Ввод: Целое число listlen, меньше 100, за которым // следует набор целых чисел в количестве // listlen // Вывод: Количество введенных величин, которые больше их // среднего арифметического import j ava.ic.*; class IntSort { public static void main(String args[]) throws lOException { DatalnputStream in = new DatalnputStream(System.in); int Listlen, counter, sum = 0, average, result = 0; int[] intlist = int[99]; listlen = Integer.parselnt(in.readLine()); if ((listlen > 0) && (listlen < 100)) { /* Считывание входных величин в массив и вычисление суммы */ for (counter = 0 ; counter < listlen; counter++) { Intlist[counter] = Integer.valueOf(in.readLine ;) ) .intValue(); sum += intlist[counter]; /* Вычисление среднего арифметического */ average- = sum / listlen; /* Вычисление количества входных величин, которые больше их среднего */ for (counter = 0; counter < listlen; counters) if (intlist [counter] > average) results-»; /* Вывод результатов */ System.out.ptintIn( "ХпКоличество чисел, которые больше их среднего:” + result); } //*♦ конец блока выбора if ((listlen >0) ... else System.out.ptintln( ’’Ошибка — введена неверная длина списка\п”); } //★★ конец метода main } //** конец класса IntSort 118 Глава 2. Обзор основных языков программирования
Резюме Мы изучили развитие некоторых важнейших языков программирования и среды, в которых они создавались. Эта глава, мы надеемся, станет фундаментом для глубокого обсуждения важных свойств современных языков программирования. Дополнительная литературе Важнейшим источником исторической информации о развитии языков программирования является книга “History of Programming Languages" под редакцией Ричарда Векселблата (Wexelblat, 1981). В ней содержится описание сред и предпосылок к развитию 13 важ- ных, по мнению самих разработчиков, языков программирования. Подобная же работа возникла в результате второй “исторической" конференции, вышедшей на этот раз в ви- де специального выпуска ACM SIGPLAN Notices (АСМ. 1993а). В этой работе рассмат- риваются история и эволюция еще 13 языков программирования. Доклад “Early Development of Programming Languages" (Knuth and Pardo. 1977). являю- щийся частью энциклопедии “Encyclopedia of Computer Science and Technology", представляет собой великолепную 85-страничную работ}', в которой подробно описа- но развитие многих языков, в том числе и языка FORTRAN. В работе содержатся примеры программ, иллюстрирующие свойства многих описываемых языков. Еще одной книгой, представляющей большой исторический интерес, является “Programming Languages: History and Fundamentals", написанная Джин Саммет (Sammet. 1969). В этой книге на 785 страницах подробно описываются 80 языков программирования 1950-60-х годов. Саммет также выпустила несколько скорректи- рованных версий этой книги, например (Sammet, 1976). Вопросы 1. В каком году был разработан язык PlankalkUl? В каком году была опубликована эта разработка? 2. Какие две общие структуры данных были включены в язык PlankalkUl? 3. Как в начале 1950-х годов реализовывались псевдокоды? 4. Система Speedcoding была создана для преодоления двух существенных недостат- ков аппаратного обеспечения компьютеров начала 1950-х. Каких? 5. Почему в начале 1950-х годов медлительность интерпретации программ считалась приемлемой? 6. Какие два важных свойства аппаратного обеспечения появились в компьютерах IBM 704? 7. В каком году началась разработка языка FORTRAN? 8. Какой была основная область применения компьютеров во время создания языка FORTRAN? 9. Что было источником всех управляющих логических операторов языка FORTRAN 1? Вопросы 119
10. Какое самое значительное свойство было добавлено к языку FORTRAN 1 с целью получения языка FORTRAN II? 11. Какой управляющий логический оператор был введен в язык FORTRAN IV для по- лучения языка FORTRAN 77? 12. В какой версии языка FORTRAN впервые появились динамические переменные? 13. В какой версии языка FORTRAN впервые появилась обработка символьных строк? 14. Почему в конце 1950-х годов лингвистов интересовала область искусственного ин- теллекта? 15. Когда и кем был разработан язык LISP? 16. По каким параметрам языки Scheme и COMMON LISP являются полной противо- положностью друг другу? 17. Какой диалект языка LISP использовался в некоторых университетах для вводных курсов программирования? 18. Какие две профессиональные организации сообща разработали язык ALGOL 60? 19. В какой версии языка ALGOL появилась блочная структура? 20. Какой упущенный элемент языка ALGOL 60 разрушил его шансы на широкое ис- пользование? 21. Какой язык был разработан для описания синтаксической структуры языка ALGOL 60? 22. На основе какого языка создан язык COBOL? 23. В каком году началась разработка языка COBOL? 24. Какая структура данных, появившаяся в языке COBOL, была позаимствована из языка PlankalkOl? 25. Какая организация внесла наибольший вклад в ранний успех языка COBOL (с точ- ки зрения объема его использования)? 26. На какую пользовательскую группу была рассчитана первая версия языка BASIC? 27. Почему язык BASIC является важным языком начала 1980-х годов? 28. Какие два языка призван был заменить язык PL/I? 29. Для какой новой серии компьютеров разрабатывался язык PL/1? 30. Какие свойства языка SIMULA 67 сейчас являются важными частями некоторых объектно-ориентированных языков программирования? 31. Почему честь нововведения структуризации данных принадлежит языку ALGOL 68, но часто приписывается языку Pascal? 32. Какой структурный критерий в значительной степени использовался в языке ALGOL 68? 33. В каком языке впервые появился оператор case? 34. Какие операторы языка С копируют подобные операторы языка ALGOL 68? 35. Какие две характеристики языка С делают его менее надежным, чем язык Pascal? 120 Глава 2. Обзор основных языков программирования
36. Что представляют собой непроцедурные языки? 37. Какие два типа утверждений хранятся в базе данных языка Prolog? 38. Для какой основной области разрабатывался язык Ada? 39. Как в языке Ada называются параллельные программные блоки? 40. Какая конструкция языка Ada обеспечивает поддержку абстрактных типов данных? 41. Из чего состоит мир языка Smalltalk? 42. Какие три концепции составляют основу объектно-ориентированного программи- рования? 43. Почему в язык С+- включены ненадежные свойства языка С? 44. Что общего имеют языки Ada и COBOL? 45. Каково первое применение языка Java? 46. Назовите две причины большей надежности языка Java по сравнению с языком C++. У п р а ж н е н и я 1. Какие, по вашему мнению, свойства языка Plankalkiil оказали бы наибольшее влияние на язык FORTRAN 0, если бы разработчики языка FORTRAN были знако- мы с языком Plankalkiil? 2. Определите возможности созданной Бэкусом системы 701 Speedcoding и сравните их с соответствующими возможностями современного микрокалькулятора. 3. Напишите краткую историю систем А-0, А-1 и А-2, разработанных Грейс Хоппер и ее коллегами. 4. В качестве исследовательского проекта сравните средства языка FORTRAN 0 с со- ответствующими средствами системы Ленинга и Цирлера. 5. Какая из трех целей, поставленных перед комитетом разработчиков языка ALGOL, по вашему мнению, была самой трудно достижимой в то время? 6. Выскажите аргументированное предположение о наиболее частой синтаксической ошибке в программах, написанных на языке LISP? 7. Язык LISP начинал как чистый функциональный язык, но постепенно приобретал все больше и больше императивных функций. Почему? 8. Подробно опишите три важнейшие, по вашему мнению, причины отсутствия ши- рокого распространения языка ALGOL 60. 9. Почему, по вашему мнению, в языке COBOL было разрешено использование длинных идентификаторов, тогда как в языках FORTRAN и ALGOL — нет? 10. Какую вы слышали главную причину редкого использования языка BASIC специа- листами по компьютерным наукам? 11. Укажите, какими основными побуждениями руководствовалась корпорация IBM при разработке языка PL/1? Упражнения 121
12. Были ли основные стимулы корпорации IBM при разработке языка PL/1 верными в свете истории развития компьютеров и языков программирования после 1964 года? 13. Опишите своими словами концепцию ортогональности в разработке языков про- граммирования. 14. Назовите основную, по вашему мнению, причину более широкого использования языка PL/I, чем языка ALGOL 68. 15. Какие аргументы можно назвать “за” и “против” языков, не содержащих типы ти- пы данных? 16. Существуют ли языки логического программирования помимо языка Prolog? 17. Как вы относитесь к утверждению, что слишком сложные языки очень опасны для использования, и все языки должны быть маленькими и простыми? 18. Считаете ли вы разработку языка комитетом хорошей идеей? Аргументируйте ва- ше мнение. 19. Языки постоянно эволюционируют. Какие, по вашему мнению, ограничения свой- ственны изменениям в языках программирования? Сравните ваши ответы с эволю- цией языка FORTRAN. 20. Укажите в таблице все основные усовершенствования в области языков програм- мирования, в том числе даты их появления, языки, в которых они впервые прояви- лись, и фамилии разработчиков. 122 Глава 2. Обзор основных языков программирования
3.1. Введение 3.2. Общая задача описания синтаксиса 3.3. Формальные методы описания синтаксиса 3.4. Рекурсивный нисходящий синтаксический анализ 3.5. Атрибутивные грамматики 3.6. Описание смысла программ: динамическая семантика Грейс Хоппер (Grace М. Hopper) Грейс Хоппер, офицер ВМФ США и бывшая сотрудница компании UNIVAC, в первой половине 1950-х годов разработала ряд “компилирующих” систем, ис- пользованных для программиро- вания коммерческих приложений. К 1958 году эти системы вопло- тились в первом языке програм- мирования высокого уровня для коммерческих приложений — языке FLOW-MATIC, на основе которого, большей частью, был создан язык COBOL. Грейс Хоп- пер также участвовала в разра- ботке языка COBOL в качестве консультанта исполнительного комитета конференции CODASYL. Описание синтаксиса и семантики 123
В данной главе рассматриваются следующие темы. Во-первых, даются определе- ния терминов синтаксис и семантика. Затем подробно обсуждается наиболее распространенный метод описания синтаксиса: контекстно-свободные грамматики (формы Бэкуса-Наура (Backus-Naur Form)). Далее следует описание синтаксических гра- фов и краткое введение в рекурсивный нисходящий синтаксический анализ, который яв- ляется обычной техникой синтаксического разбора, основанного непосредственно на контекстно-свободных грамматиках. Далее описываются атрибутивные грамматики, ко- юрые могут использоваться для описания как синтаксиса, так и статической семантики языков программирования. В завершение вводятся три формальных метода описания се- мам шки— операционная, аксиоматическая и денотационная семантики. Из-за сложно- с । и. свойственной методам описания семантики, их обсуждение будет кратким, хотя по каждом} из трех названных методов можно написать целую книгу (что и было сделано некоторыми авторами). 3.1. Введение Задача разработки краткого, но доступного описания языка программирования сложна, но ее решение необходимо для успешного применения языка. Первыми языками, имеющи- ми лаконичное формальное описание, были языки ALGOL 60 и ALGOL 68; правда, в обоих сл\чаях описание было сложным для понимания (отчасти из-за использования новой фор- мы записи). В результате языки проиграли с точки зрения степени их восприятия. С другой стороны, некоторые языки страдают от наличия большого количества диалектов, появив- шихся вследствие доступного, но неформального и неточного описания. Одной из проблем, возникающих при описании языка, является несхожесть людей, для коюрых это описание предназначено. Большинство новых языков программирова- ния тщательно изучается потенциальными пользователями еще в ходе разработки. Успех лой обратной связи напрямую зависит от ясности описания языка. Разработчики систем реализации языков программирования, очевидно, должны уметь определять способ образования выражений, операторов и программных единиц языка, а также ожидаемый эффект от их выполнения. Сложность работы, связанной с реализаци- ей я зыка, также зависит от ясности и точности описания языка. Наконец, пользователи языка должны иметь возможность узнать по справочному ру- ководств}, как именно программировать системы программного обеспечения. Учебники и обучающие курсы дают только общее представление о языке, но единственным авто- ритетным печатным источником информации о языке обычно является именно справоч- ное р> ководство. Илчение языков программирования, подобно изучению естественных языков, может oLaib разделено на исследование синтаксиса и семантики. Синтаксис языка программи- рования — это форма, а семантика — смысл его выражений, операторов и программ- ных единиц. Например, оператор if языка С имеет следующий синтаксис: xf (<выражение>) <оператор> I го семантика состоит в том. что если текущее значение выражения истинно, то бу- дет выполнен указанный оператор. Несмотря на то что для удобства обсуждения семантика и синтаксис часто разделя- ются. они тесно связаны друг с другом. В хорошо разработанном языке программирова- 124 Глава 3. Описание синтаксиса и семантики
ния семантика непосредственно следует из синтаксиса; т.е. форма оператора может в значительной степени определять его смысл. Описывать синтаксис намного легче, чем семантику, отчасти из-за существования краткой и общепринятой формы записи, тогда как для описания семантики подобной формы еще не создано. 3.2. Общая задача описания синтаксиса Как естественные языки (например, английский), так и искусственные (например, Java) представляют собой совокупности строк, состоящих из символов некоторого алфа- вита. Строки, состоящие из символов языка, называются его предложениями (sentenses), или утверждениями (statements). Синтаксические правила языка определяют, какие именно строки, состоящие из символов алфавита, существуют в языке. Английский язык, например, обладает большим и сложным набором правил для определения синтаксиса его предложений. По сравнению с ним языки программирования, даже самые объемные и сложные, очень просты с синтаксической точки зрения. В формальные описания синтаксиса языков программирования для простоты часто не включаются описания синтаксических единиц самого нижнего уровня. Эти элементар- ные единицы называются лексемами (lexemes). Описание лексем может даваться лекси- ческой спецификацией, обособленной от синтаксического описания языка. В число лек- сем языка программирования входят его идентификаторы, литеральные константы, опе- раторы и специальные слова. Можно считать, что программа представляет собой строки, состоящие не из символов, а из лексем. Грамматическая лексема (token) в некотором языке является разновидностью его лексем. Например, идентификатор представляет собой грамматическую лексему, которая может состоять из лексем, или экземпляров, таких как sum и total. В некоторых слу- чаях грамматическая лексема состоит из одной-единственной возможной лексемы. На- пример, грамматическая лексема для символа арифметического оператора +, которая может называться оператор__сложения, имеет только одну возможную лексему. Рассмот- рим следующий пример оператора языка С: index = 2 * count + 17; Лексемы и грамматические лексемы этого оператора следующие: Лехаемы Граммаяические лексемы index идентификатор = знак_равенства 2 целочисленнаЯ—КОнстанта * оператор_умножения count идентификатор + оператор_сложения 17 целочисленная_константа ; точка_с_запятой Приводимые в этой главе описания языков очень просты, и большинство из них со- держит описание лексем. 3.2. Общая задача описания синтаксиса 125
3.2.1. Устройства распознавания языков В общем случае языки могут формально определяться двумя различными способами: путем распознавания (recognition) и путем порождения (generation). (Следует отметить, что ни один из этих способов не дает определения, удобного для людей, пытающихся изучать или хотя бы использовать язык программирования.) Предположим, что у нас есть язык L, использующий алфавит символов Z. Для формального определения языка L посредством метода распознавания нам потребовалось бы создать механизм R. называе- мый устройством распознавания и способный читать строки, состоящие из символов ал- фавита I. Механизм R следует создать так, чтобы он показывал, принадлежит ли данная введенная строка языку L. В действительности, механизм R должен был бы либо прини- мать. либо отклонять заданную строку. Такие устройства подобны фильтрам, отделяю- щим правильные предложения от неправильных. Если механизм R при введении строки символов алфавита I принимает ее только в случае ее принадлежности к языку L, то ме- ханизм R является описанием языка L. Поскольку самые полезные языки, предназначен- ные для практического использования, бесконечны, распознавание может показаться длительным и неэффективным процессом. Впрочем, устройства распознавания не ис- пользуются для перечисления всех предложений языка. Синтаксический анализатор, являющийся частью компилятора, представляет собой устройство распознавания языка, транслируемого компилятором. В таком качестве уст- ройству распознавания не нужно проверять все возможные строки символов, принадле- жащие некоторому множеству, для того чтобы определить, принадлежит ли каждая из них данному языку. Вместо этого ему всего лишь требуется определить принадлежность данной программы к языку. По сути, синтаксический анализатор просто определяет, яв- ляется ли данная программа синтаксически правильной. 3.2.2. Генераторы языков Генератор языка может использоваться в качестве устройства для создания предло- жений языка. Представьте себе кнопку, нажатие которой порождает предложение на данном языке. Поскольку конкретное предложение, возникающее при нажатии кнопки этого устройства, предсказанию не поддается, то генератор кажется мало полезным для описания языка. Тем не менее, люди предпочитают устройствам распознавания языка некоторые формы генераторов, поскольку их легче читать и понимать. Часть компилято- ра, предназначенная для проверки синтаксиса, наоборот, не так полезна для программи- ста в качестве средства описания языка, поскольку ее можно использовать только мето- дом проб и ошибок. Например, для определения с помощью компилятора правильного синтаксиса отдельного оператора программист может только предложить компилятору предполагаемый вариант и посмотреть, примет ли его компилятор. С другой стороны, часто можно определить, верен ли синтаксис отдельного оператора, сравнив его со структурой генератора. Существует тесная связь между формальными устройствами порождения и распозна- вания одного языка. Открытие этого факта оказалось одним из самых плодотворных в компьютерных науках. Большая часть из того, что нам известно сейчас о формальных языках и теории разработки компиляторов, является следствием этого открытия. В сле- дующем разделе мы еше вернемся к взаимосвязи между устройствами порождения и устройствами распознавания языков. 126 Глава 3. Описание синтаксиса и семантики
3.3. Формальные методы описания синтаксиса В данном разделе обсуждаются механизмы порождения формальных языков, широко используемые для описания синтаксиса языков программирования. Эти механизмы часто называются грамматиками. 3.3.1. Форма Бэкуса-Наура и контекстно-свободные грамматики Во второй половине 1950-х годов два человека — Джон Бэкус и Ноам Хомски (Noam Chomsky) — независимо друг от друга изобрели одну и ту же форму записи, которая впоследствии стала широко используемым методом формального описания синтаксиса языков программирования. 3.3.1.1. Контекстно-свободные грамматики В середине 1950-х годов выдающийся лингвист Хомски описал четыре класса порож- дающих устройств, или грамматик, определяющих четыре класса языков (Chomsky. 1956, 1959). Два класса из четырех, названные контекстно-свободной и регулярной грамматиками, оказались полезными для описания синтаксиса языков программирова- ния. С помощью регулярных грамматик можно описывать грамматические лексемы язы- ков программирования. Языки программирования в целом, за редким исключением, опи- сываются с помощью контекстно-свободных грамматик. Поскольку Хомски был лин- гвистом, то в первую очередь его интересовала теоретическая природа естественных языков. В то время он не интересовался искусственными языками, используемыми для общения с компьютерами. Так продолжалось до тех пор, пока его работа не была ис- пользована в области языков программирования. 3.3.1.2. Истоки формы Бэкуса-Наура Вскоре после появления работы Хомски о классах языков группа ACM-GAMM начала разработку языка ALGOL 58. В 1959 году Джоном Бэкусом, выдающимся членом группы ACM-GAMM, на международной конференции была представлена работа по описанию этого языка (Backus, 1959), ставшая вехой в истории языков программирования. В этой ра- боте была введена новая формальная запись для описания синтаксиса языка программиро- вания. Позже эта новая запись была несколько модифицирована Питером Науром для опи- сания языка ALGOL 60 (Naur, 1960). Переработанный метод описания синтаксиса стал из- вестен как форма Бэкуса-Наура (Backus-Naur Form), или просто БНФ. Форма БНФ представляет собой очень естественный способ описания синтаксиса. Похожее средство фактически использовалось Панини (Panini) для описания санскрита за несколько веков до нашей эры (Ingerman, 1967). Несмотря на то что использование формы БНФ в отчете о языке ALGOL 60 не было с го- товностью воспринято пользователями компьютеров, вскоре эта форма стала и все еше оста- ется самым популярным методом краткого описания синтаксиса языка программирования. Примечательно, что форма БНФ практически идентична порождающим устройствам контекстно-свободных языков, названных контекстно-свободными грамматика- ми (context-free grammars). Далее в главе мы будем называть контекстно-свободные грамматики просто грамматиками. Более того, мы будем использовать термины “форма БНФ” и “грамматика” как взаимозаменяемые. 3.3. Формальные методы описания синтаксиса 127
3.3.1.3. Основы Метаязык (metalanguage)— это язык, используемый для описания другого языка. Форма БНФ является метаязыком для языков программирования. Для описания синтаксических структур форма БНФ использует абстракции. Простой оператор присваивания языка С может, например, представляться абстракцией <присвоить>. (Угловые скобки часто используются для разделения названий абстрак- ций.) Фактическое определение оператора <присвоить> можно представить в виде <лрисвоить> —> <переменная> = <выражение> Символ слева от стрелочки, называемый, соответственно, левой стороной выражения (ЛСВ), является определяемой абстракцией. Текст, находящийся справа от стрелочки, является определением левой стороны выражения. Он называется правой стороной вы- ражения (ПСВ) и состоит из смеси лексем, граматических лексем и ссылок на другие аб- стракции. (Фактически, грамматические лексемы также являются абстракцией.) Все оп- ределение называется правилом (rule), или продукцией (production). Очевидно, что, прежде чем абстракция <присвоить> в приведенном выше примере станет пригодной к использованию, должны быть определены абстракции <переменная> и <выражение>. Отдельное правило устанавливает, что абстракция <присвоить> определяется как эк- земпляр абстракции <переменная>, за которой следует лексема =, за которой, в свою очередь, следует экземпляр абстракции <выражение>. Одним из примеров предложения, синтаксическая структура которого описывается этим правилом, является следующее: total = subl + sub2 Абстракции, входящие в форму БНФ, или грамматику, часто называются нетерми- нальными символами (nonterminal symbols), или просто нетерминалами (nonterminals), а лексемы и грамматические лексемы в правилах называются терминальными символами (terminal symbols), или терминалами (terminals). Форма БНФ, или грамматика (grammar), просто является набором правил. Нетерминальные символы могут иметь несколько различных определений, представ- ляющих две (или большее количество) возможные синтаксические формы в языке. Множественные определения записываются одним правилом, в котором различные оп- ределения разделены символом |, означающим логическое ИЛИ. Например, оператор if языка Pascal может быть описан правилами: <услсвнь:й_оператор> —> if <логическое_выражение> then <оператор> <условный_оператор> —> if <логическое_выражение> then <оператор> else <олератор> или <условный_оператор> —> if <логическое_выражение> then <оператор> I if <логическое_выражение> then <ог.ератор> else <оператор> Несмотря на простоту форма БНФ — это довольно мощное средство описания по- давляющего большинства языков программирования. В частности, она может описывать списки одинаковых конструкций, порядок, в котором должны появляться различные 128 Глава 3. Описание синтаксиса и семантики
конструкции, вложенные структуры любой глубины, приоритет оператора и ассоциатив- ность операторов. 3.3.1.4. Описание списков Списки переменной длины часто записываются с использованием эллипсиса (...). на- пример 1, 2, .... Форма БНФ не содержит эллипсиса, поэтому возникает потребность в альтернативном методе для описания списков синтаксических элементов языков про- граммирования (например, списка идентификаторов, входящих в оператор объявления данных). Самой распространенной альтернативой является рекурсия. Правило называет- ся* рекурсивным (recursive), если его левая часть входит в его правую часть. Использо- вание рекурсии для описания списков иллюстрируется следующим правилом: <список_идентификаторов> —> идентификатор I идентификатор, <список_идентификаторов> Данное правило определяет абстракцию <список_идентификаторов> либо как отдельную грамматическую лексему (идентификатор), либо как идентификатор, за которым последова- тельно следуют запятая и другой экземпляр абстракции <список_идентификаторов>. В боль- шинстве грамматик, приводимых в качестве примера далее в главе, для описания списков ис- пользуется рекурсия. 3.3.1.5. Грамматики и правила вывода Форма БНФ является порождающим устройством для определения языков. Предло- жения языка создаются с помощью последовательности правил, которая начинается со специального нетерминального символа грамматики, называемого начальным симво- лом (start symbol). Создание предложения называется выводом (derivation). В граммати- ке для полного языка начальный символ представляет собой полную программу и обыч- но называется <программа>. Для иллюстрации выводов в листинге 3.1 приведена про- стая грамматика. <программа> —> begin <слисок_операторов> end <список_операторов> —> <оператор> I <сператор> ; <слисок_олераторов> <оператор> —> <переменная> := <выражение> <переменная> —> А 1 В I С <выражение> —> <переменная> + <переменная> I <переменная> - <переменная> I <переменная> Язык, описанный в листинге 3.1. имеет только одну операторную форму— присваи- вание. Программа состоит из специального слова begin, за которым следует список операторов, разделенных точками с запятыми, и специального слово end. Выражение — это либо отдельная переменная, либо две переменные, разделенные операторами или Единственными существующими в языке именами переменных являются символы А. В и С. 3.3. Формальные методы описания синтаксиса 129
Ниже следует пример вывода программы на этом языке. <лрограмма> = > begin <список_операторов> end = > begin <оператор> ; <список_операторов> end = > begin <переменная> := <выражение> ; <список_операторов> end = > begin А := <выражение> ; <список_операторов> end = > begin А := <переменная> + <переменная> ; <список_операторов> end = > begin А := В + <переменная> ; <список_операторов> end = > begin А := В + С ; <список_операторов> end = > begin А := В + С ; <оператор> end = > begin А := В + С ; <переменная> := <выражение> end = > begin А := В + С ; В := <выражение> end = > begin А := В + С ; В := <переменная> end = > begin А:=В+С;В:=С end Этот вывод, как и все выводы, начинается с начального символа, в данном случае с символа <программа>. Символ “=>” читается как “порождает”. Каждая последующая строка приведенной последовательности выводится из предыдущей строки с помощью замены одного из нетерминальных символов одним из нетерминальных определений. Каждая строка в выводе, в том числе и строка <программам называется сентенциаль- ной формой (sentential form). В данном выводе всегда заменяется крайний слева нетер- минальный символ предыдущей формы высказывания. Выводы, использующие такой порядок замены, называются левосторонними выводами (leftmost derivations). Вывод продолжается до тех пор, пока сентенциальная форма не перестанет содержать нетерми- нальные символы. Сентенциальная форма, состоящая только из терминальных символов, или лексем, является порожденным предложением. Вывод может быть как левосторонним, так и правосторонним, а также может исполь- зовать другой, отличный от этих двух, порядок замены. Отметим, что на язык, порож- даемый грамматикой, порядок вывода никак не влияет. Выбирая альтернативные правые стороны правил замены нетерминальных символов в выводе, можно порождать различные предложения языка. Весь язык может быть соз- дан путем перебора всех существующих комбинаций альтернатив. Этот язык, как и большинство других, бесконечен, так что за конечное время все предложения языка по- родить невозможно. В листинге 3.2 приведен пример еще одной грамматики для части типичного языка программирования. <присвоить> —> <идентификатор> := <выражение> <идентификатор> —> А | В | С <выражение> —> <идентификатор> + <выражение> I <идентификатор> * <выражение> I ( <выражение> ) | <идентификатор> 130 Глава 3. Описание синтаксиса и семантики
Грамматика, приведенная в листинге 3.2, описывает операторы присваивания, у кото- рых правыми сторонами являются арифметические выражения с операторами умноже- ния и сложения, а также круглыми скобками. Например, оператор А := В * ( А + С ) порождается следующим левосторонним выводом: <присвоить> => <идентификатор> := <выражение> => А := <выражение> => А := <идентификатор> * <выражение> => А := В * <выражение> => А := В * ( <выражение> ) => А := В * ( <идентификатор> + <выражение> ) => А := В * ( А + <выражение> ) => А := В * ( А + <идентификатор> ) => А := В * ( А + С ) 3.3.1.6. Деревья синтаксического анализа Одно из самых привлекательных свойств грамматик состоит в естественности их описания иерархической синтаксической структуры предложений определяемого ими языка. Указанные иерархические структуры называются деревьями синтаксического анализа (parse tree). Например, дерево синтаксического анализа, приведенное на рис. 3.1, показывает структуру полученного выше оператора присваивания. < присвоите <идентификатор> := <выражение> А <цдентификатор> Рис. 3.1. Дерево синтаксического анализа простого оператора А := В * (А + С) Каждый внутренний узел дерева синтаксического анализа помечен нетерминальным символом; а каждый лист — терминальным символом. Каждое поддерево дерева синтак- сического анализа описывает один экземпляр абстракции оператора. 3.3. Формольные методы описания синтаксиса 131
3.3.1.7. Неоднозначность Грамматика, порождающая предложение, для которого существует несколько деревь- ев синтаксического анализа, называется неоднозначной (ambigous). Рассмотрим грамма- тику, показанную в листинге 3.3, незначительно отличающуюся от грамматики, приве- денной в листинге 3.2. <присвоить> —> <идентификатор> := <выражение> <идентификатор> —» А ! В | С <выражение> —» <выражение> + <выражение> I <выражение> * <выражение> I ( <выражение> ) 1 <идентификатор> Грамматика, показанная в листинге 3.3, является неоднозначной, поскольку предло- жение А:=В+С*А имеет два разных дерева синтаксического анализа, показанных на рис. 3.2. < присвоите <присвоить> <идентификатор> := <выражение> <идентификатор> := <выражение> А <выражение> + < выражение > <идентификатор> <выражение> • <выражение> I I I В <идентификатор> <идентификатор> С А Рис. 3.2. Два различных дерева синтаксического анализа для одного выражения А := В + С* А Неоднозначность появляется вследствие определения грамматикой несколько мень- шей синтаксической структуры, чем это было сделано грамматикой, приведенной в лис- тинге 3.2. Данная грамматика в отличие от предыдущей, позволяет дереву синтаксиче- ского анализа выражения расти не только вправо, но и влево. Синтаксическая неодно- значность структур языка представляет собой проблему, поскольку семантику таких структур компиляторы часто определяют, исходя из их синтаксической формы. В част- ности, изучив дерево синтаксического анализа, компилятор решает, какие команды гене- рировать для оператора. Если структура языка допускает существование нескольких де- 132 Глава 3. Описание синтаксиса и семантики
ревьев синтаксического анализа, то значение структуры не может определяться одно- значно. Исследование этой проблемы с помощью двух конкретных примеров проводится в следующих трех разделах. 3.3.7.8. Приоритет оператора Как указывалось ранее, грамматика так описывает определенную синтаксическую структуру, что смысл этой структуры может отчасти определяться деревом синтаксиче- ского анализа. В частности, тот факт, что оператор в арифметическом выражении поро- ждается ниже на дереве синтаксического анализа (и. следовательно, должен вычисляться первым), можно использовать, чтобы указать на более высокий приоритет этого опера- тора. чем у оператора, порождаемого выше. Например, на первом из представленных на рис. 3.2 деревьев синтаксическою анализа оператор умножения генерируется ниже по дереву, что может указывать на его более высокий приоритет по сравнению с операто- ром сложения того же выражения. Впрочем, на втором из изображенных на рис. 3.2 де- ревьев синтаксического анализа демонстрируется противоположное. Следовательно, эти два дерева синтаксического анализа отображают противоречивую информацию о при- оритетности операторов. Отметим, что хотя грамматика, приведенная в листинге 3.2. и является однозначной, она имеет необычный приоритет операторов. В этой грамматике на дереве син1аксиче- ского анализа предложения, состоящего из нескольких операторов, независимо от их конкретного вида, крайний справа оператор выражения находится в самой нижней точке, тогда как остальные операторы постепенно перемещаются вверх по дереву при продви- жении к крайнему слева оператору выражения. Например, на дереве синтаксического анализа, изображенного на рис. 3.1, оператор сложения является крайним справа опера- тором выражения и самым нижним оператором на дереве синтаксического анализа и по- лучает. таким образом, более высокий приоритет при выполнении, чем расположенный левее оператор умножения. Для того чтобы отделить операторы сложения и умножения друг от друга, следует написать грамматику так, чтобы они располагались на дереве синтаксического анализа в порядке от высшего до низшему. Такое упорядочение может поддерживаться вне зави- симости от порядка появления операторов в выражении. Правильный порядок устанав- ливается с помощью отдельной абстракции для операндов операторов, имеющих раз- личные приоритеты. Это требует дополнительных нетерминальных символов и некото- рых новых правил. Пример подобной грамматики приведен в листинге 3.4. <присвоить> —» <мдентификатор> <выражение> <идентификатор> —> А I В I С <выражение> —» <выражение> + <терм> I <терм> <терм> —» <терм> * <множитель> I <множитель> <множитель> —> ( <выражение> ) I <идентификатор> 3.3. Формальные методы описания синтаксиса 133
Грамматика, показанная в листинге 3.4, порождает тот же язык, что и грамматики, показанные в листингах 3.2 и 3.3, но в ней указан привычный приоритет операторов ум- ножения и сложения. Ниже следует вывод выражения А := В + С * А с использованием грамматики, показанной в листинге 3.4. <присвоить> => <идентификатор> := <выражение> => А : = <выражение> => А : = <выражение> + <терм> => А : = <т 'ерм> + <терм> => А : = <множитель> + <терм> => А : = <идентификатор> + <терм> => А : = В + <терм> = > А : = В + <терм> * <множитель> = > А : = В т <терм> * <множитель> => А : = в + <идентификатор> * <множитель> => А : = в + С * <множитель> => А : = в 1- С * <идентификатор> => А : = в + С * А На рис. 3.3 показано дерево синтаксического анализа, которое с использованием грамматики листинга 3.4 однозначно определяется для приведенного выше предложения. <множитель> <множитель> <идентификатор> I I <идентификатор> <идентификатор> А в с Рис, 3,3, Использование однозначной грамматики позволяет однозначно определить дерево синтакси- ческого анализа выражения А . = В + С * А Связь между деревьями синтаксического анализа и выводами очень тесная: одно мо- жет быть легко получено из другого. Любой вывод на основе однозначной грамматики имеет одно дерево синтаксического анализа, хотя это дерево может соответствовать раз- ным выводам. Например, нижеследующий вывод предложения А := В + С * А отличается от данного выше вывода того же выражения. Это — правосторонний вывод, тогда как 134 Глава 3. Описание синтаксиса и семантики
предыдущий был левосторонним выводом. Тем не менее, оба эти выводы представляют- ся одним и тем же деревом синтаксического анализа. <присвоить> => <идентификатор> => <идентификатор> => <идентификатор> := <выражение> := <выражение> := <выражение> + <терм> + <терм> * <множитель> => <идентификатор> := <выражение> + <терм> * <идентификатор> => <идентификатор> := <выражение> + <терм> * => <идентификатор> : = <выражение> + <множитель> => <идентификатор> := <выражение> + <идентификатор> * А => <идентификатор> := <выражение> + С * А => <идентификатор> := <терм> + С м г А => <идентификатор> := <множитель> + С * А => <идентификатср> := <идентификатор> + С * А => <идентификатор> := В + С * А =>А:=В+С*А 3.3.1.9. Ассоциативность операторов Еще одним интересным вопросом, касающимся грамматик выражений, является во- прос, верно ли описана ассоциативность операторов; т.е. в правильном ли порядке появ- ляются соседние операторы, имеющие одинаковый приоритет, на деревьях синтаксиче- ского анализа выражений, содержащих несколько таких операторов? Ниже представлен пример такого оператора присваивания: А := В + С + А Дерево синтаксического анализа этого предложения, определяемое грамматикой, представленной на листинге 3.4, показано на рис. 3.4. Приведенное на рис. 3.4 дерево синтаксического анализа показывает, что левый опе- ратор сложения находится ниже правого. Такой порядок является правильным, если сложение считать левоассоциативным, как обычно. В большинстве случаев ассоциатив- ность сложения в компьютере несущественна (в математике сложение ассоциативно, что означает равнозначность правого и левого ассоциативных порядков, т.е. (А + В) + С = А + (В + С)). Целочисленная машинная арифметика также ассоциативна. В не- которых случаях, правда, сложение с плавающей точкой не является ассоциативным. Предположим, что число с плавающей точкой хранится с точностью до семи цифр, и нужно сложить одиннадцать чисел, одно из которых равно 107, а остальные равны 1. Ес- ли меньшие числа (единицы) будут по одному добавляться к большему, то последнее они никак не изменят, поскольку меньшие числа приходятся на восьмой разряд большого числа. Впрочем, если сначала выполнить сложение всех малых чисел, а результат доба- вить к большому, то результат с точностью в семь знаков составит 1.000001*10 . Вычи- тание и деление не ассоциативны как в математике, так и в компьютерной технике. Сле- довательно, правильная ассоциативность может оказаться существенной для выражений, содержащих какое-либо из указанных действий. 3.3. Формальные методы описания синтаксиса 135
< присвоите <выражение> + <терм> <множи1ель> <терм> <множитель> <идентификатор> <множитель> <идентификатор> А <иденгификатор> С В Рис. 3.4. Дерево синтаксического анализа предложения А := В + С + А, иллюстрирую- щее ассоциативность сложения Если в правиле формы БНФ левая часть содержится в начале правой, правило назы- вается леворекурсивным (leftrecursive). Такая левая рекурсия устанавливает левую ассо- циативность. Например, левая рекурсивность правил грамматики листинга 3.4 приводит к ее левой ассоциативности по отношению к умножению и сложению. В большинстве языков, имеющих такую возможность, оператор возведения в степень является правоассоциативным. Для указания на правоассоциативность используется пра- вая рекурсия. Грамматическое правило называется праворекурсивным (rightrecursive), если его левая часть появляется в правом конце его правой части. Например, правила <множитель> —> <показатель> ** <множитель> ! <показатель> <показатель> —> ( <вь;ражение> ) 1 <идентификатср> могут использоваться для описания оператора возведения в степень как правоассоциа- тивного оператора. 3.3.1.10. Однозначная грамматика для оператора if- then-else Повторим правила формы БНФ, представленные в разделе 3.3.1.3, для отдельной формы оператора if-then-elee: <условный_оператор> —> if <логическое_выражение> then <оператор> 136 Глава 3. Описание синтаксиса и семантики
I xf <логическое_выратечие> then <опесатор> else <олератср> Если, помимо этого, у нас есть правило <оператор> —> <условньз4_сператор>, то наша грамматика неоднозначна. Простейшая сентенциальная форма, иллюстрирующая эту неоднозначность, имеет вид: if <логическое_зыраженке> then if <логическое_выражение> then <оператср> else <оператор> Приведенные на рис. 3.5 два дерева синтаксического анализа показывают неодно- значность этой формы высказывания. Практические проблемы, связанные с проблемой оператора else, подробно рассматриваются в главе 7. if <логическое_выражсние> then <оператор> <условный_оператор> <логическое_выражение> then <оператор> <условный_оператор> if <логическое_выражение> then <оператор> elae <оператор> Рис. 3.5. Два различных дерева синтаксического аначиза для одной и той же формы высказывания Разработаем теперь однозначную грамматику, описывающую данный оператор if. В большинстве языков правило для конструкций if состоит в следующем: оператор else (если он есть) соответствует ближайшему предыдущему оператору then, для ко- торого такое соответствие еще не установлено. Следовательно, между оператором then 3.3. Формольные методы описания синтаксиса 137
и соответствующим ему оператором else не может существовать оператора if без опе- ратора else. Таким образом, в данной ситуации мы должны различать операторы, для которых соответствие найдено, и те, для которых оно не найдено. В последнем случае операторами, для которых соответствующая пара не найдена, будут операторы else, которых будет меньше, чем существующих операторов if, при этом для остальных опе- раторов соответствие должно быть найдено. Проблема описанной выше грамматики за- ключается в том, что в ней все операторы считаются синтаксически эквивалентными, т.е. совпадающими между собой. Для отражения существования различных видов операторов должны использоваться различные абстракции, или нетерминальные символы. Однозначная грамматика, осно- ванная на описанных идеях, приведена ниже: <оператор> —> <согласованное_выражение> I <несогласован.чое_выражение> <согласованное_выражение> —> if <логическое_выражение> then <согласованное__выражение> else <согласованное_выражение> I любое выражение, не содержащее оператор if <несогласованное_выражение> —» if <логическое__выражение> then <оператор> I if <логическое_выражение> then <согласованное_выражение> else <несогласованное__выражение> Если использовать эту грамматику, то для предложения, приведенного на рис. 3.5, существует только одно возможное дерево синтаксического анализа. 3.3.2. Расширенная форма БНФ Из-за некоторых незначительных неудобств, присущих форме БНФ, в нее были вне- сены некоторые дополнения. Версии формы БНФ. для которых таких дополнений было сделано больше всего, стали называться расширенными формами БНФ, или просто РБНФ (EBNF — extended BNF), хотя сами эти формы не совсем идентичны. Данные расширения не увеличивают описательную силу формы БНФ; они всего лишь увеличи- вают удобство ее чтения и использования. Различные версии формы РБНФ, как правило, включают три дополнения. Первое из них обозначает необязательную часть правой стороны правила, которая помешается в квадратные скобки. Например, оператор выбора языка С может быть описан следующим образом. <выбор> —» if ( <выражение> ) <оператор> [else <оператор>]; Без использования квадратных скобок синтаксическое описание данного оператора потребовало бы двух правил. Вторым дополнением является использование в правой части правила фигурных ско- бок, указывающих на то, что заключенная в скобки часть может повторяться неограни- ченное число раз или вообще пропускаться. Это дополнение позволяет создавать списки с помощью одного правила, не используя рекурсию и дополнительное правило. Напри- мер, список идентификаторов, разделенных запятыми, может быть описан так: <сг1исок_идентификаторов> —> <идентификатор> {, <идентификатор>} 138 Глава 3. Описание синтаксиса и семантики
Это правило представляет собой замену рекурсии формой подразумеваемой итера- ции; заключенная в скобки часть может повторяться любое число раз. Третье обычное дополнение связано с многовариантным выбором. Когда из группы должен выбираться один элемент, варианты выбора помешаются в круглые скобки и разделяются оператором ИЛИ ( | ). Например, следующее правило описывает оператор for языка Pascal. <оператор_цикла> —> for <переменная> := <выражение> (to I downto) <выражение> do <сператор> Повторим, что для описания этой структуры потребовалось бы два правила формы БНФ. Квадратные, фигурные и круглые скобки в дополнениях формы РБНФ являются метасимволами. Это означает, что они являются способами обозначения, а не терми- нальными символами в синтаксических объектах, которые они помогают описывать. Ес- ли эти метасимволы являются также терминальными символами описываемого языка, то последние должны подчеркиваться отдельно. форма БНФ: <выражение> —> <выражение> t <терм> I <выражение> - <терм> I <терм> <терм> —» <терм> * <множитель> I <терм> / <множитель> I <множитель> форма РБНФ: <выражение> —> <терм> {(+|-) <терм>} <терм> —» <множитель> {(*!/) <множитель>} Некоторые версии формы РБНФ допускают приписывание к правой фигурной скобке числовых верхних индексов, чтобы указать на верхний предел числа возможных повто- ров фрагмента, заключенного в скобки. В некоторых версиях также используется верх- ний индекс + для указания на одно или несколько повторений. Например, правила <состаЕной__оператор> —> begin <оператор> {<оператор>) end и <составной_оператор> —> begin {<оператор>}+ end эквивалентны. 3.3.3. Синтаксические графы Граф (graph) представляет собой набор узлов, некоторые из которых соединяются линиями, называемыми ребрами. Ориентированный граф (directed graph)— граф с ориентированными ребрами; т.е. на одном из их концов имеется стрелка, указывающая направление. Ограниченной формой ориентированного графа является дерево синтакси- ческого анализа. На ориентированном графе представляется информация о правилах форм БНФ и РБНФ. Такие графы называются синтаксическими графами (syntax praphs), синтакси- 3.3. Формальные методы описания синтаксиса 139
'некими диаграммами или синтаксическими схемами. Для каждой синтаксической еди- ницы используется отдельный синтаксический граф, так же. как нетерминальный символ в грамматике. Дзя представления терминальных и нетерминальных символов правой части грамма- шчсского правила на синтаксическом графе используются различные типы узлов. Пря- моугольные узлы содержат название синтаксической единицы (нетерминального симво- ла). Терминальные символы находятся в кругах или эллипсах. На рис. 3.6 показан синтаксис оператора if языка Ada в форме РБНФ и в форме оин- гаксического графа. Отметим, что и квадратные, и фигурные скобки в описании с помо- щью формы РБНФ являются метасимволами, а не терминальными символами, исполь- зуемыми в языке Ada. УСЛОВНЫЙ- оператор иначе \условный_оператор>->- if <условие> then <операторы> {<иначе>} [else 5операторы> end if <иначе> --► eleif <условие> then <операторы> Рис. 3.6. Синтаксический граф и описание оператора xf языка Ada с помощью формы р:;нф Использование графического представления для описания синтаксиса предоставляет те же выгоды, что и использование графическою представления для описания чего-либо: оно увеличивает читабельность, позволяя нам наглядно представлять описываемый объ- ект в двух измерениях. 3.3.4. Грамматики и устройства распознавания языков Ранее в этой главе мы говорили о существовании тесной связи между устройствами по- рождения и распознавания языка. Действительно, при наличии контекстно-свободной |рамматики су щес!вует алгоритм создания устройства распознавания языка, порождаемого этой грамматикой. Для этого построения было разработано большое количество систем программного обеспечения. Подобные системы позволяют быстро создавать синтаксиче- ский анализатор, являющийся частью компилятора нового языка. Одним из наиболее ши- роко используемых генераторов синтаксических анализаторов является устройство уасс (yet another compiler-compiler — еще один компилятор компиляторов) (Jonhson, 1975). 140 Глава 3. Описание синтаксиса и семантики
Синтаксические анализаторы языков программирования, часто называемые програм- мами синтаксического анализа, создают деревья синтаксического анализа для данной программы. В некоторых случаях дерево синтаксического анализа создается неявно, но во всех случаях информация, содержащаяся в дереве синтаксического анализа, возникает во время самого анализа. Программы синтаксического анализа делятся по принципу направления, в котором они создают деревья синтаксического анализа. Существуют два обширных класса про- грамм синтаксического анализа: нисходящие, в которых дерево порождается от корня вниз к листьям, и восходящие, в которых дерево создается от листьев вверх к корню. Пример нисходящего алгоритма синтаксического анализа кратко описан в разделе 3.4. 3.4. Рекурсивный нисходящий синтаксический анализ Как отмечалось в разделе 3.3.1.7, контекстно-свободная грамматика служит основой синтаксического анализатора, или программы синтаксического анализа, являющегося частью компилятора. Рассмотрим основанный на контекстно-свободной грамматике нисходящий анализатор грамматики, который мы будем называть программой рекурсив- ного нисходящего синтаксического анализа. Синтаксический анализ — это процесс отслеживания или создания дерева синтакси- ческого анализа для данной введенной строки. Основной идеей рекурсивного нисходя- щего синтаксического анализа является существование подпрограммы для каждого не- терминального символа грамматики. Обязанности подпрограммы, относящейся к кон- кретному нетерминальному символу, следующие: при введении строки она создает эскизный вариант дерева синтаксического анализа, корнем которого является данный не- терминальный символ, а листья соответствуют введенной строке. В действительности, подпрограмма рекурсивного нисходящего синтаксического анализа— это устройство синтаксического анализа языка (набора строк), который может порождаться его нетер- минальными символами. Во многих случаях язык, подвергаемый синтаксическому ана- лизу, содержит вложенные синтаксические единицы (например, выражения и операто- ры), так что подпрограмма, выполняющая синтаксический анализ, часто является рекур- сивной. Предположим, что каждый нетерминальный символ содержит одно правило, возможно, с несколькими правыми частями, разделенными операторами ИЛИ. Рассмотрим следующее описание элементарных арифметических выражений в форме РБНФ: <выражение> —> <терм> {(+I-) <терм>} <терм> —> <множитель> {(*1/) <множитель>} <множитель> —> <идентификатор> | ( <выражение> ) Если подпрограммам рекурсивного синтаксического анализа требуется узнать, какое следующее входное обозначение должно быть подвергнуто анализу, то для его получения вызывается подпрограмма лексического анализатора. Напомним, в главе 1 говорилось, что лексический анализатор служит входом синтаксического анализатора. Он объединяет входные символы в лексемы и возвращает грамматические лексемы, связанные с этими лексемами. В приведенной ниже функции рекурсивного синтаксического анализа ехрг эта функция называется соответствующим именем lexical. Она получает следующую грам- матическую лексему и помещает ее в глобальную переменную next token. 3.4. Рекурсивный нисходящий синтаксический анализ 141
Ниже приведена подпрограмма рекурсивного синтаксического анализа, написанная на языке С для первого из приведенных в этом разделе правил. void ехрг() { term(); /* синтаксический анализ первого терма */ while (next_token == plus_code | | next—token == minus_code) lexical(); /* ввод следующей грамматической лексемы */ term(); /* синтаксический анализ следующего терма */ } } В представленной выше функции синтаксического анализа предполагается, что каж- дая рекурсивная функция синтаксического анализа сохраняет следующую входную грамматическую лексему в переменной next—token. Таким образом, всякий раз в на- чале выполнения функции синтаксического анализа гарантируется, что переменная next—token содержит крайнюю левую грамматическую лексему, которая еще не ана- лизировалась. Разбираемая функцией ехрг часть языка состоит из одного или нескольких элемен- тов (term), разделенных операторами сложения или вычитания. Такой язык порождает- ся абстракцией <выражение>. Следовательно, вначале вызывается функция синтаксиче- ского анализа термов. Затем функция ехрг продолжает вызывать эту функцию до тех пор, пока будут обнаруживаться элементарные значения оператор__сложения или опера- тор_вычитания (синтаксический анализ которых пропускается с помощью вызова функ- ции lexical). Эта функция рекурсивного синтаксического анализа проще большинства ей подобных, поскольку ее правила содержат только одну правую сторону. Более того, эта функция не содержит никаких средств обнаружения или устранения синтаксических ошибок. Общий процесс создания программы нисходящего рекурсивного синтаксического анализа для данного нетерминального символа начинается с написания кода для опреде- ления, какая именно правая часть правила для данного нетерминального символа соот- ветствует строке входных обозначений. Это решение принимается на основе первого терминального символа введенной строки, порождаемого нетерминальным символом. Код для каждой правой части правила относительно прост. Каждый терминальный сим- вол сравнивается со следующим обозначением. Если они не совпадают, то возникает синтаксическая ошибка, в противном случае для перехода на следующее входное обо- значение вызывается лексический анализатор. Вызов подпрограммы синтаксического анализа выполняется для каждого нетерминального символа. Функция для нетерминального символа <множитель> должна делать выбор между двумя правыми частями его правила. Она также обнаруживает ошибки. В функции для символа <множитель> при обнаружении ошибки вызывается функция error. В реаль- ной программе синтаксического анализа в этом случае должно выдаваться диагностиче- ское сообщение. Более того, большинство компиляторов должны возобновлять свою ра- боту при появлении ошибки, чтобы процесс синтаксического анализа мог продолжаться. Подобные компиляторы не могут просто останавливаться при обнаружении в программе первой синтаксической ошибки. 142 Глава 3. Описание синтаксиса и семантики
void factor () { if (next_token == id_code) { lexical () ; return; } else if (next_token == left—paren_code) { lexical (); expr (); if (next—token == right—paren_code) { lexical(); return; } else error(); /* обнаружена закрывающая круглая скобка */ ) else error(); /* не обнаружено ни идентификатора, ни открывающей круглой скобки */ } Целью данного краткого обсуждения синтаксического анализа было убедить вас, что программу рекурсивного нисходящего синтаксического анализа легко написать, если для языка существует подходящая грамматика. В данном контексте слово “подходящая” оз- начает, что для того, чтобы грамматику можно было использовать для рекурсивного син- таксического анализа, она должна иметь особую форму. Обсуждение всех характеристик, позволяющих или запрещающих использование конкретной грамматики для рекурсивно- го нисходящего синтаксического анализа, выходит за рамки этой книги. Подробнее об этом вопросе вы можете узнать из работы (Fisher^and LeBlanc, 1988). Одной простой характеристикой, создающей рекурсивному нисходящему граммати- ческому разбору катастрофические проблемы, является левосторонняя рекурсия. Рас- смотрим следующее правило: <А> -> <А> + <В> Подпрограмма рекурсивного нисходящего синтаксического анализа для символа <А> немедленно вызовет себя же. Это приведет к вызову подпрограммы синтаксического анализа символа <А> и еще одному вызову ее же, и еще раз, и еще. Понятно, что это ни к чему не приведет. По этой причине левосторонняя рекурсия не допускается в граммати- ках, для которых должна создаваться программа нисходящего рекурсивного синтаксиче- ского анализа. Существенным фактором важности грамматик является их тесная связь с компиля- торами. 3.5. Атрибутивные грамматики Атрибутивная грамматика (attribute grammar) более подробно описывает структу- ру языка программирования, чем контекстно-свободная грамматика, и является ее рас- ширением. Это расширение позволяет описать такие правила языка, как совместимость типов. Прежде чем мы формально определим форму атрибутивной грамматики, разъяс- ним концепцию статической семантики. 3.5. Атрибутивные грамматики 143
3.5.1. Статическая семантика Существуют некоторые характеристики структуры языков программирования» опи- сать которые с помощью формы БНФ сложно, а иногда — просто невозможно. Приме- ром правил языка, определение которых трудно дать с помощью формы БНФ, являются правила совместимости типов. Например, в языке Java число с плавающей точкой не может быть присвоено переменной целого типа, хотя обратное присвоение возможно. Несмотря на то что подобное ограничение может быть точно описано в форме БНФ, оно потребует дополнительных нетерминальных символов и правил. Если все подобные пра- вила типов языка Java точно определить в рамках формы БНФ, то грамматика станет слишком громоздкой, поскольку ее размер определяет и размер программы синтаксиче- ского анализа. Обычное правило обязательного объявления переменных не может быть определено в форме БНФ. Другой пример: если за оператором end подпрограммы, написанной на языке Лоа. следует некоторое имя. то это имя должно совпадать с именем подпрограммы. Эти две проблемы служат примером правил статической семантики. Статическая семантика (static semantics) языка в основном связана с формами программ, а не с их содержанием (т.е. синтаксисом, а не семантикой). Во многих случаях правила статиче- ской семантики языка устанавливают ограничения, наложенные на типы. Статическая семантика называется так потому, что анализ, требуемый для проверки этих условий, должен быть выполнен во время компиляции. Из-за существования проблемы описания статической семантики с помощью формы БНФ для решения этой задачи было разработано множество более мощных механизмов. Разработка одного из таких механизмов, описывающего синтаксис и статическую семан- тику программ и названного атрибутивными грамматиками, была выполнена Кнутом (Knuth, 1968а). Обсуждение динамической семантики будет проведено в разделе 3.6. 3.5.2. Основные понятия Атрибутивными грамматиками называются грамматики, к которым были добавлены атрибуты, атрибутивные вычислительные функции и предикативные функции. Атрибу- 1ы (attributes), связанные с символами грамматики, подобны переменным в том смысле, что им,могут присваиваться значения. Атрибутивные вычислительные функции (attribute computation functions), иногда называемые семантическими функциями, связаны с грамматическими правилами вычисления значений атрибутов. Предикативные функ- ции (predicate functions), устанавливающие некоторые синтаксические правила и правила статической семантики языка, связаны с грамматическими правилами. 3.5.3. Определение атрибутивных грамматик Атрибутивными называются грамматики, имеющие следующие дополнительные свойства. С каждой грамматикой связывается символ X, обозначающий набор атрибутов А(Х). Набор А(Х) состоит из двух непересекающихся множеств S(X) и 1(Х), назы- ваемых синтезированными и унаследованными атрибутами, соответственно. Син- тезированные атрибуты (synthesized attributes) используются для передачи се- мантической информации вверх по дереву синтаксического анализа, тогда как 144 Глава 3. Описание синтаксиса и семантики
унаследованные атрибуты (inherited attributes) используются для передачи се- мантической информации вниз по дереву. Правило, связанное с каждой грамматикой, является набором семантических функций и, возможно, пустого множества предикативных функций, определенных на атрибутах символов в грамматическом правиле. Для правила Хо -> Х| ...Хп син- тезированные атрибуты правила Хо вычисляются семантической функцией вида S(X0) = f(А(ХО,..., А(ХП)). Таким образом, величина синтезированного атрибута уз- ла дерева синтаксического анализа зависит исключительно от значений атрибутов дочерних узлов этого узла. Унаследованные атрибуты символов Хр 1< j <п (в пра- виле, приведенном выше) вычисляются семантической функцией вида I(Xj) = f(А(Х।),..., А(ХП)). Таким образом, значение унаследованного атрибута узла дерева синтаксического анализа зависит от значения атрибута родительского узла и значений атрибутов узлов того же уровня. Отметим, что во избежание порочно- го круга унаследованные атрибуты часто ограничиваются функцией вида I(Xj) = f(A(Xo),..., A(Xj.|)). Такая форма предотвращает зависимость унаследован- ных атрибутов от самих же себя или от атрибутов, находящихся правее на дереве синтаксического анализа. Предикативная функция имеет вид булевского выражения, заданного на множест- ве атрибутов {А(Х0),..., А(ХП)}. Единственными выводами, разрешенными в рам- ках атрибутивной грамматики, являются те, в которых все предикаты, связанные с каждым нетерминальным символом, истинны. Ложное значение предикативной функции указывает на нарушение синтаксических правил или правил статической семантики языка. Дерево синтаксического анализа атрибутивной грамматики базируется на грамматике формы БНФ, лежащей в основе данной атрибутивной грамматики, с. возможно, пустым множеством значений атрибутов, связанных с каждым узлом. Если вычислены значения всех атрибутов дерева синтаксического анализа, то оно называется полностью опреде- ленным (fully attributed). Хотя на практике подобным образом поступают далеко не все- гда, удобно полагать, что значения атрибутов вычисляются после создания дерева син- таксического анализа. 3.5.4. Внутренние атрибуты Внутренние атрибуты (intrinsic atributes)— это синтезированные атрибуты узлов, значения которых определяются вне дерева синтаксического анализа. Например, тип эк- земпляра переменной в программе может поставляться из таблицы идентификаторов, используемой для хранения имен переменных и их типов. Содержимое таблицы иденти- фикаторов определяется выполненными ранее операторами объявления. Предположим, что изначально было создано дерево с неопределенными значениями атрибутов, и нам нужно их определить. При этом единственными атрибутами, имеющими значения, явля- ются внутренние атрибуты узлов. Поместив значения внутренних атрибутов на дерево синтаксического анализа, мы сможем использовать семантические функции для вычис- ления значений остальных атрибутов. 3.5. Атрибутивные грамматики 145
3.5.5. Примеры атрибутивных грамматик Рассмотрим следующий фрагмент атрибутивной грамматики, описывающий правило, заключающееся в том, что имя, следующее за оператором end в конце процедуры языка Ada, должно совпадать с именем самой процедуры. Атрибут строки <имя_продедуры>, обозначенный как <имя_процедуры>.строка, является реальной строкой символов, кото- рая может обнаруживаться лексическим анализатором. Отметим, что при наличии в син- таксическом правиле атрибутивной грамматики нескольких нетерминальных символов, последние заключаются в скобки лишь с целью их различения. Ни индексы, ни скобки частью описываемого языка не являются. Синтаксическое правило: <определение_процедуры> —> procedure <имя_процедуры>[1] <тело_процедуры> end <имя__процедуры> [2 ] ; Семантическое правило: <имя_процедуры>[1].строка = <имя_процедуры>[2].строка Ниже показано, как с помощью атрибутивной грамматики проверить правила совмес- тимости типов простого оператора присваивания. Синтаксис и семантика этого операто- ра следующие: единственными именами переменных являются А, В и С. Правая сторона присваивания может быть переменной или выражением в форме сложения двух пере- менных. Переменные могут быть двух типов: целые и действительные. Если в правой части присваивания находятся две переменные, они не обязаны иметь один и тот же тип. Значение выражения, в котором фигурируют операнды различных типов, всегда имеют действительный тип. Тип значения выражения с операндами одного типа совпадает с ти- пом операндов. Тип левой части присваивания должен совпадать с типом правой части. Таким образом, типы операндов правой части могут смешиваться, но присваивание кор- ректно только в том случае, если его левая часть и значение, полученное при вычислении правой части, имеют один и тот же тип. Атрибутивная грамматика определяет описан- ные семантические правила. Синтаксическая часть атрибутивной грамматики, описывающей данный пример, име- ет следующий вид: <присваивание> —> <переменная> := <выражение> <выражение> —> <переменная> + <переменная> I <переменная> <переменная> —> А|В|С В следующих абзацах описываются атрибуты для нетерминальных символов данной атрибутивной грамматики. Фактический тип Синтезированный атрибут, связанный с нетерминальными симво- лами <переменная> и <выражение>. Он используется для запоминания факти- ческого типа (в нашем примере целого или действительного) переменной или выражения. Для переменной фактический тип является внутренним. Для выражения он определяется на основе типов дочернего узла или узлов нетерминального символа <выражение>. Ожидаемыйтип Внутренний атрибут, связанный с нетерминальным символом <выражение>. Используется для запоминания типа (целого или действительного), 146 Глава 3. Описание синтаксиса и семантики
ожидаемого от выражения, и определяемого типом переменной, стоящей в левой части оператора присваивания. Полностью атрибутивная грамматика показана в листинге 3.6. 1. Синтаксическое правило: <присвоить> —> <переменная> := <выражение> Семантическое правило: <выражение>.ожидаемый_тип <— <переменная>.фактический_тип 2 . Синтаксическое правило: <выражение> —> <переменная>[2] + <переменная>[3] Семантическое правило: <выражение>.фактический_тип <— if (<переменная>[2].фактический_тип = int) and(<переменная>[3].фактический_тип = int) then int else real end if Предикат: <выражение>.фактический_тип = <выражение>.ожидаемый_тип 3. Синтаксическое правило: <выражение> —> <переменная> Семантическое правило: <выражение>.фактический_тип <— <переменная>.фактический_тип Предикат: <выражение>.фактический_тип = <выражение>.ожидаемый_тип 4. Синтаксическое правило: <переменная> —> А|В|С 5. Семантическое правило: <переменная>.фактический_тип <— поиск(<переменная>.строка) Функция поиска ищет имя данной переменной в таблице идентификаторов и возвра- щает тип переменной. Пример дерева синтаксического анализа предложения А : = А + В, порождаемого грамматикой, описанной в листинге 3.6, показан на рис. 3.7. Как и в самой грамматике, после повторяющихся меток узлов дерева добавлены числа в скобках, так что ни них можно ссылаться однозначно. 3.5. Атрибутивные грамматики 147
<присвоить> Рис. 3.7. Дерево синтаксического анализа пред- ложения А А + В 3.5.6. Вычисление значений атрибутов Рассмотрим теперь процесс заполнения дерева синтаксического анализа атрибутами. Если все атрибуты унаследованные, то этот процесс может полностью происходить в нисходящем порядке, от корня к листьям. Если же все атрибуты синтезированные, то процесс полностью протекает в восходящем порядке, от листьев к корню. Поскольку рассматриваемая грамматика содержит и синтезированные, и унаследованные атрибуты, вычисление значений не может происходить в каком-то одном направлении. Ниже опи- сано вычисление атрибутов в том порядке, в котором они могут вычисляться. В общем случае определение такого порядка вычисления значений атрибутов в общем случае яв- ляется сложной задачей, требующей создания графа зависимостей для прослеживания всех связей между атрибутами. 1. <п.еременная>. фактический_тип поиск (А) (Правило 4) 2. <выражение>. ожидаемьп4__тип <переменная>.фактический__тип 'Правило 1) 3. <переменная> [2] .фактический__тип поиск(А) (Правило 4) <леременная>[3].фактический_тип <— поиск(В) (Правило 4) 4. <вь:ражение>. фактический_тип <— целый или действительный (Правило 2) 5. <выражение>.ожидаемый_тип <— <выражение>.фактический_тип или ИСТИНА или ЛОЖЬ (Правило 2) Де рево, приведенное на рис. 3.8. показывает процесс заполнения значениями атрибутов дерева, приведенного на рис. 3.7. Сплошные линии использованы для изображения дерева синтаксического анализа, пунктирные — процесса вычисления значений атрибутов. Де рево, изображенное на рис. 3.9, показывает окончательные значения атрибутов всех узлов дерева. В данном примере тип переменной А определен действительным, а переменной В — целым. 148 Глава 3. Описание синтаксиса и семантики
Рис. 3.8. Процесс заполнения дерева атрибутами Рис. 3.9. Полностью описанное дерево синтаксического анализа 3.5.7. Оценка Атрибутивные грамматики использовались: для полного описания синтаксиса и ста- тической семантики языков программирования (Watt. 1979); в качестве формального оп- ределения языка для создания систем генерации компиляторов (Farrow, 1982); как основа для некоторых синтаксически управляемых систем редактирования (Teitelbaum and Reps, 1981; Fisher et al, 1984). Помимо этого, атрибутивные грамматики применяются в систе- мах обработки текстов на естественных языках. Одним из основных недостатков атрибутивной грамматики для описания всего син- таксиса и статической семантики реального современного языка программирования яв- ляется ее сложность и громоздкость. Большое число атрибутов и грамматических пра- вил, требуемое для полного описания языка программирования, значительно затрудняет написание и чтение подобной грамматики. Более того, вычисление значений афибутов большого дерева синтаксического анализа дорого обходится. С другой стороны, менее 3.5. Атрибутивные грамматики 149
формальные атрибутивные грамматики представляют собой мощный и широко исполь- зхемый инструмент для разработчиков компиляторов, которых больше интересует соз- дание компилятора, чем формализм грамматики. 3.6. Описание смысла программ: динамическая семантика Перейдем к сложной задаче описания динамической семантики (dynamic semantics), или смысла, выражений, операторов и программных единиц. Благодаря мощи и естест- венности формы записи описание синтаксиса является относительно простым делом. С другой стороны, для динамической семантики еще не изобрели общепринятой формы записи. В данном разделе кратко описаны несколько разработанных методов. Термин семантика будет означать динамическую семантику; статическую семантику мы будем называть полностью. Существует несколько причин, по которым следует заниматься описанием семантики. Во-первых, программисты должны точно знать, что именно делают операторы языка. Однако зачастую они узнают это, читая неточные и неполные англоязычные объяснения в руководствах по языку. Разработчики компиляторов также обычно определяют семан- тику языков, для которых они создают компиляторы, руководствуясь описаниями на английском языке. Все эти неформальные описания заменяют сложные формальные описания семантики. Цель исследований очевидна — найти семантический формализм, который мог бы использоваться программистами и разработчиками компиляторов. Было проделано несколько экспериментальных работ по автоматическому порождению ком- пиляторов языков программирования непосредственно из их семантических описаний. В заключение отметим, что доказательство корректности программ опирается на некото- рое формальное описание семантики языка. 3.6.1. Операционная семантика Операционная семантика (operational semantics), сводится к описанию смысла про- граммы посредством выполнения ее операторов на машине, реальной или виртуальной. Смысл оператора определяется изменениями, произошедшими в состоянии машины по- сле выполнения данного оператора. Для того чтобы разобраться в этой концепции, рас- смотрим команду на машинном языке. Пусть состояние компьютера— это значения всех его регистров и ячеек памяти, в том числе коды условий и регистры состояний. Ес- ли просто записать состояние компьютера, выполнить команду, смысл которой нужно определить, а затем изучить новое состояние машины, то семантика этой команды станет понятной: она представляется изменением в состоянии компьютера, вызванным выпол- нением этой команды. 3.6.1.1. Основной процесс Описание операционной семантики операторов языков программирования высокого уровня требует создания реального или виртуального компьютера. В главе 1 говорилось, что аппаратное обеспечение компьютера является чистым интерпретатором его машин- ного языка. Чистый интерпретатор любого языка программирования может быть создан с помощью программных средств, которые становятся виртуальным компьютером для данного языка. Семантику языка высокого уровня можно описать, используя чистый ин- 150 Глава 3. Описание синтаксиса и семантики
терпретатор данного языка. При таком подходе, правда, существуют две проблемы. Во- первых, сложность и индивидуальные особенности аппаратного обеспечения компьюте- ра и операционной системы, используемых для запуска чистого интерпретатора, затруд- няют понимание происходящих действий. Во-вторых, выполненное таким образом се- мантическое определение будет доступно только для людей с абсолютно идентичной конфигурацией компьютера. Этих проблем можно избежать, заменив реальный компьютер виртуальным компью- тером низкого уровня, реализованным с помощью программного моделирования. Реги- стры, память, информация о состоянии и процесс выполнения операторов— все это можно моделировать. Набор команд можно создать так, чтобы семантику каждой от- дельной команды было легко понять и описать. Таким образом, машина была бы идеали- зирована и значительно упрощена, что облегчило бы понимание изменений ее состояния. Использование операционного метода для полного описания семантики языка про- граммирования L требует создания двух компонентов. Во-первых, для преобразования операторов языка L в операторы выбранного языка низкого уровня нужен транслятор. Во-вторых, для этого языка низкого уровня необходима виртуальная машина, состояние которой изменяется с помощью команд, полученных при трансляции данного оператора языка высокого уровня. Именно изменения состояния этой виртуальной машины опре- деляют смысл данного оператора. Основной процесс операционной семантики не является чем-то необычным. Факти- чески эта концепция часто используется в учебниках по программированию и справоч- никах по языкам программирования. Семантику конструкции for языка С. например, можно описать в терминах следующих простых команд. Оператор языка С Операционная семантика for (exprl; ехрг2; ехргЗ){ exrpl ... loop: if expr2 = 0 goto out } ехргЗ; goto loop out: ... Человек, читающий подобное описание, является “виртуальным компьютером” и предполагается способным правильно “выполнить” команды описания и распознать эф- фект такого “выполнения”. В качестве примера низкоуровневого языка, который можно применять для операци- онной семантики, рассмотрим следующий список операторов, соответствующих про- стым управляющим операторам типичного языка программирования: ident := var isent := ident + 1 ident := ident -1 goto label if var relop var goto label Здесь relop— одни из операторов отношений из набора { = , <>f >, <, >=, <=}, ident — идентификатор, a var — идентификатор или константа. Все эти опера- торы просты и легки для понимания и реализации. 3.6. Описание смысла программ: динамическая семантика 151
Незначительное обобщение приведенных выше трех операторов присваивания позво- ляет описывать более обшие арифметические выражения и операторы присваивания. Новые операторы выглядят следующим образом: Loen’s := var bin_op var ident := un_op var Здесь bin_op— бинарный арифметический оператор, a un_op— унарный опера- тор. Многочисленные арифметические типы данных и автоматическое преобразование 1 инов, конечно, несколько усложняют это обобщение. Введение небольшого количества новых относительно простых команд позволит описывать семантику массивов, записей, указателей и подпрограмм. Семантика различных управляющих операторов с использованием операционной се- мантики описана в главе 7. 3.6.1.2. Оценка Первым и самым значительным использованием формальной операционной семанти- ки было описание семантики языка PL/I (Wegner. 1972). Эта абстрактная машина и пра- вила трансляции языка PL/I были названы общим именем Vienna Definition Language (VDL) в честь города, в котором они были созданы корпорацией IBM. Операционная семантика является эффективной до тех пор. пока описание языка ос- тается простым и неформальным. К сожалению, описание VDL языка PLZI настолько сложно, что практическим целям оно фактически не служит Операционная семантика зависит от алгоритмов, а не от математики. Операторы од- ного языка программирования описываются в терминах операторов другого языка про- граммирования. имеющего более низкий уровень. Этот подход может привести к пороч- ном} круг}', когда концепции неявно выражаются через самих себя. Методы, описывае- мые в следующих двух разделах, значительно более формальны в том смысле, что они опираются на логику и математику, а не на машины. 3.6.2. Аксиоматическая семантика Аксиоматическая семантика (axiomatic semantics) была создана в процессе разра- ботки метода доказательства правильности программ. Такие доказательства показывают, что программа выполняет вычисления, описанные ее спецификацией. В доказательстве каждый оператор программы сопровождается предшествуюши.м и последующим логиче- скими выражениями, устанавливающими ограничения на переменные в программе. Эти выражения используются для определения смысла оператора вместо полного описания состояния абстрактной машины (как в операционной семантике). В качестве формы за- писи используется исчисление предикатов, являющееся фактическим языком аксиомати- ческой семантики. Простых булевских выражений не всегда бывает достаточно для вы- ражения ограничений. 3.6.2.1. Утверждения Аксиоматическая семантика основана на математической логике. Логические выра- жения называются предикатами, или утверждениями (assertions). Утверждение, непо- средственно предшествующее оператору программы, описывает ограничения, наложен- ные на переменные в данном месте программы. Утверждение, следующее непосредст- венно за оператором программы, описывает новые ограничения на те же (а возможно, и 152 Глава 3. Описание синтаксиса и семантики
другие) переменные после выполнения оператора. Эти утверждения называются соот- ветственно предусловием (precondition) и постусловием (postcondition) оператора. Раз- работка аксиоматического описания или доказательства данной программы требует на- личия у каждого оператора программы предусловий и постусловий. В следующих разделах при изучении утверждений мы будем предполагать, что пре- дусловия операторов вычисляются на основе постусловий, хотя этот процесс можно рас- сматривать и с противоположной точки зрения. Предположим, что все переменные яв- ляются целочисленными Рассмотрим в качестве примера оператор присваивания и по- стусловие: sum := £. 4 х -г 1 sum > 1} Предусловия и постусловия помещаются в фигурные скобки, для того чтобы отличать их от операторов программы. Одним из возможных предусловий данного оператора мо- жет быть {х > 10). 3.6.2.2. Слабейшие предусловия Слабейшими предусловиями (weakest preconditions) называются наименьшие пре- дусловия, обеспечивающие выполнение соответствующего постусловия. Например, для приведенного выше оператора и его постусловия предусловия «х > 10}. . х > 5С> и {х > 1000 > являются правильными. Слабейшим из всех предусловий в данном случае будет {х > 0 }. Если для каждого оператора языка по заданным постусловиям можно вычислить сла- бейшее предусловие, то для программ на данном языке может быть построено коррект- ное доказательство. Доказательство начинается с использования результатов, которые надо получить при выполнении программы, в качестве постусловия последнего операто- ра программы, и выполняется с помощью отслеживания программы от конца к началу с последовательным вычислением слабейших предусловий для каждого оператора. При достижении начала программы первое ее предусловие отражает условия, при которых программа вычислит требуемые результаты. Для некоторых операторов программы вычисление слабейшего предусловия на осно- ве оператора и его постусловия является достаточно простым и может быть задано с по- мощью аксиомы. Однако в большинстве случаев слабейшее предусловие вычисляется только с помощью правила логического вывода. Аксиомой (axiom) называется логиче- ский оператор, предполагаемый истинным; правилом логического вывода (inference rule) является метод выведения истинности одного утверждения на основе значений ос- тальных утверждений. Чтобы использовать аксиоматическую семантику с данным языко.м программирова- ния для доказательства правильности программ или для описания формальной семанти- ки. для каждого вида операторов языка должны быть определены аксиомы или правила логического вывода. В следующих подразделах мы приводим аксиому для операторов присваивания и правила логического вывода для последовательности операторов, опера- торов ветвления и логически управляемых циклов с предусловиями. 3.6.2.3. Операторы присваивания Пусть х = Е — обычный оператор присваивания, a Q— его постусловие. Тогда пре- дусловие Р того же оператора определяется аксиомой Р ~ Qx~*E 3.6. Описание смысла программ: динамическая семантика 153
Эта аксиома означает, что предусловие Р вычисляется как условие Q, в котором все переменные х заменены переменными Е. Пусть, например, есть оператор присваивания и постусловие а = Ь / 2 -1 {а < 10) Тогда слабейшее предусловие вычисляется подстановкой b / 2 - 1 в утверждение «.а < 10): b / 2 -1 < 10 Ь < 22 Таким образом, слабейшим предусловием для данных оператора и его постусловия является {Ь < 22). Отметим, что истинность аксиомы присваивания гарантируется только при отсутствии побочных эффектов. Побочный эффект оператора присваивания может проявляется, если оператор изменяет значения переменных, не входящих в его ле- вую часть. Ниже представлена обычная запись для задания аксиоматической семантики данного оператора: {P}S{Q}, где Р — предусловие, Q — постусловие, aS — операторная форма. Для оператора при- сваивания форма записи такова: {Qx_>e} х = Е {Q} Рассмотрим другой пример вычисления предусловия для оператора присваивания: X = 2 * у - 3 {х > 25} В данном случае предусловие вычисляется следующим образом: 2 * у - 3 > 25 у > 14 Следовательно, {у > 14 } — слабейшее предусловие при заданных операторе при- сваивания и его постусловии. Отметим, что появление левой части оператора присваивания в его правой части не влияет на процесс вычисления слабейшего предусловия. Например, для оператора х = х + у - 3 {х> 10} слабейшим предусловием является х + у - 3 > 10 у > 13 - х В начале нашего обсуждения мы утверждали, что аксиоматическая семантика была разработана для доказательства правильности программ. В свете этого утверждения ес- тественным будет поинтересоваться, как вообще аксиома для оператора присваивания может использоваться для доказательства чего-либо. А вот как: данный оператор при- сваивания с предусловием и постусловием может рассматриваться как теорема; теорема считается доказанной, если аксиома присваивания, применяемая к постусловию и опера- тору присваивания, дает заданное предусловие. Рассмотрим, логическое утверждение {х > 3} х « х - 3 {х > 0} 154 Глава 3. Описание синтаксиса и семантики
Применение аксиомы присваивания к постусловию и оператору х = х - 3 {х > 0} дает {х > 3}, что и является заданным предусловием. Следовательно, предыдущее ло- гическое утверждение доказано. Далее, рассмотрим логическое выражение {х > 5} х = х - 3 {х > 0} В этом случае заданное предусловие {х > 5} не соответствует утверждению, порож- даемому применением аксиомы. Несмотря на это, очевидно, что {х > 5 } => {х > 3}. Для использования этого факта в доказательстве нам необходимо правило логического вы- вода, называемое правилом логического следствия (rule of consequence). Общая форма правила логического вывода следующая: S1,S2..Sn S В ней утверждается, что из истинности выражений SI, S2, ... и Sn может быть выве- дена истинность утверждения S. Правило логического следствия имеет форму {PISlQbP^P.Q^Q' {P'}S{Q} Символ “=>” означает “следует”, a S может быть любым оператором программы. Правило формируется следующим образом: если истинно логическое утверждение {P}S{Q}, то из утверждения Р' следует утверждение Р, а из утверждения Q' следует ут- верждение Q, тогда можно доказать, что {P'}S{Q'}. Проще говоря, правило логического следствия утверждает, что постусловие всегда может быть ослаблено, а предусловие — усилено. В доказательстве программы этот факт довольно полезен, поскольку он позво- ляет, например, завершить доказательство последнего из приведенных выше логических выражений. Если мы будем считать, что в нашем случае Р— это {х >3}, Q и Q'— {х> 0}, а Р' — {х > 5}, то мы получим (х > 3}х = х - 3{х >0},(х >5) => (х > 3),(х > 0) => (х > 0) {х >5}х = х -3{х > 0} Доказательство завершено. 3.6.2Л. Последовательности Слабейшее предусловие последовательности операторов невозможно описать с по- мощью аксиомы, поскольку предусловие зависит от конкретного вида операторов в по- следовательности. В этом случае единственной возможностью описания предусловий является использование правила логического вывода. Пусть S1 и S2— соседние опера- торы программы. При следующих предусловиях и постусловиях этих операторов {Pl} SI {Р2} {Р2} S2 {РЗ} правило логического вывода подобной двухоператорной последовательности будет таким: 3.6. Описание смысла программ: динамическая семантика 155
{P1}S1{P2}.(P2}S2{P3} {P1}S1;S2{P3} Итак, для приведенного выше примера выражение {PI}S1;S2{P3} описывает аксиома- шческую семантику последовательности операторов S1;S2. Если операторы S1 и S2 яв- ляются операторами присваивания х! =Е1 и х2 = Е2, г*> имеем {РЗч2_>Е2}х2 = Е2{РЗ} {(РЗх2-»Е2)ч1-»Г1 }xl=El {РЗх2^:}. Следовательно, слабейшим предусловием последовательности xl = Е1; х2 =Е2 с по- c. условием РЗ является {(Р3х2—»E2)xl —»Е 1}. Рассмотрим последовательность операторов и постусловие: у 3 * х + 1; х - у + 3; (х < 10} Предусловием последнего оператора присваивания является У < / Оно же становится постусловием для первого оператора. Теперь мы можем вычис- ли '! ь предусловие первого оператора присваивания: 3 - х + 1 < 7 х < 2 3.6.2.5. Выбор Далее мы рассмотрим правило логического вывода для операторов ветвления. Мы б>дем изучать только операторы ветвления, содержащие оператор else. Правило логи- ческою вывода для таких выражений записывается следующим образом: {В and Р} SI {Q},{(not В) and Р} S2 {Q) {Р} if В then SI else S2 {Q} Это правило указывает, что данные операторы ветвления должны быть доказаны для обоих случаев. Первое из написанных над чертой логических выражений относится к оператору then, второе — к оператору else. Рассмотрим пример вычисления с использованием правила логического вывода для операторов ветвления. Возьмем следующий оператор ветвления: if (х >0) v у -1 else у - у + 1 Предположим, что постусловием для этого оператора выбора является {у > 0}. В случае выбора оператора then мы можем использовать аксиому присваивания: у у - 1 {у > 0} 156 Глава 3. Описание синтаксиса и семантики
Это дает условие {у - 1 > 0} или {у > 1}. Применим ту же аксиому к варианту else: у = у + 1 {у > 0} Это порождает предусловие (у * 1 > 0} или {у > -1}. Поскольку {у > - 1} => {у > -1}, то правило логического следствия разрешает нам использовать в качестве предусловия оператора выбора условие {у > 1}. 3.6.2.6. Логически управляемый цикл с предварительной проверкой условия Другой необходимой конструкцией императивных языков программирования являет- ся цикл с предварительной проверкой условия, или цикл while. Вычисление слабейше- го предусловия для такого цикла изначально предполагается более сложным, чем для по- следовательности операторов, поскольку в общем случае число повторений цикла опре- делить заранее невозможно. Если число повторений цикла известно, то такой цикл можно трактовать как последовательность операторов. Проблема вычисления слабейшего предусловия для циклов подобна проблеме дока- зательства теоремы обо всех натуральных числах. В последнем случае обычно использу- ется индукция, тот же метод мы используем и для циклов. Основным шагом индукции является определение индуктивного предположения. Соответствующим шагом в аксио- матической семантике для цикла while является поиск утверждения, называемого ин- вариантом цикла (loop invariant), что является ключевым этапом обнаружения слабей- шего предусловия. Правило логического вывода для вычисления предусловия цикла while выглядит следующим образом: __________(I and В) S{I}______ {1} while В do S end {I and (not B)} где I — инвариант цикла. Аксиоматическое описание цикла while записывается следующим образом: {Р} while В do S end {Q} Инвариант цикла должен удовлетворять большому количеству условий. Во-первых, из слабейшего предусловия цикла while должна следовать истинность инварианта цик- ла. Во-вторых, из истинности инварианта цикла должна следовать истинность постусло- вия после завершения цикла. Эти ограничения приводят нас от правила логического вы- вода к аксиоматическому описанию. Во время выполнения цикла результаты вычисления булевского выражения, управляющего циклом, и операторы тела цикла не должны вли- ять на истинность инварианта цикла. Поэтому он и называется инвариантом. Еще одним усложняющим фактором для циклов while является вопрос о заверше- нии цикла. Если Q— постусловие, выполняющееся непосредственно после выхода из цикла, то предусловие цикла Р обеспечивает истинность условия Q на момент выхода из цикла, а также собственно завершение цикла. Полное аксиоматическое описание цикла while требует истинности всех следующих условий (через 1 обозначен инвариант цикла): 3.6. Описание смысла программ: динамическая семантика 157
р=>1 {1} В {1} {land В} S {1} (I and (not В)) => Q завершение цикла Чтобы найти инвариант цикла, используем метод, подобный методу определения ин- д\ктивного предположения математической индукции. Для нескольких случаев вычис- ляются зависимости в надежде найти общую закономерность. При таком подходе удобно рассматривать процесс создания слабейшего предусловия как функцию wp. В общем случае: wp (оператор, постусловие) = предусловие Для того чтобы обнаружить инвариант цикла I, используем постусловие цикла Q для вычисления предусловий нескольких повторений тела цикла, начав с нуля повторений. Если тело цикла содержит один оператор присваивания, то для операторов присваивания используется аксиома. Рассмотрим пример цикла while у <> xdoy = y+lend {у = х} Будьте осторожны и помните, что знак равенства в данном случае имеет два различ- ных смысла. В утверждениях он означает математическое равенство; вне их он является оператором присваивания. Для нулевого количества повторений цикла значение слабейшего предусловия оче- видно: {у = х} Для одного повторения: wp(у = у + 1, {у=х})={у+1=х}, или {у = х - 1) Для двух wp(у = у + 1, {у=х-1})=(у+1=х-1}, или {у - х - 2} Для трех повторений wp(y = у + 1, {у=х-2))={у+1=х-2}, или {у = х - 3) Уже понятно, что в случае одного или нескольких повторений условия {у < х) бу- дет достаточно. Объединив это условие с условием {у = х), полученным для нулевого числа повторений цикла, окончательно получим условие {у <= х), которое и будем использовать в качестве инварианта цикла. Предусловие для оператора while можно определить из инварианта цикла. В нашем примере можно использовать условие Р = I. Мы должны убедиться, что наш выбор соответствует пяти критериям, упомянутым выше. Проверяем первый: поскольку Р = I, то Р => 1. Второе требование состоит в том, чтобы на инвариант цикла I не оказывало влияния вычисление значения булевского вы- ражения, которым в нашем случае является выражение у о х. В этом выражении ни- какие значения не изменяются, так что на инвариант цикла I оно также не влияет. Далее, должно быть справедливым следующее выражение: {I and В} S {1} 158 Глава 3. Описание синтаксиса и семантики
В нашем примере имеем: {у <= х and у <> х} у = у + 1 {у <= х} Применим к последнему выражению аксиому присваивания у = у + 1 {у <= х} Тогда мы получим условие {у + 1 <= х}, что эквивалентно условию {у < х}. следующему из условий {у <= х} и {у о х}. Таким образом, приведенное выше утверждение доказано. Далее, должно быть справедливым условие (I and (not В)) => Q В нашем случае имеем {(у <« х) and not (у о х)) => {у = х} {(у <= х) and not (у = х)) => {у = х} {у = х} => {у = х} Эти утверждения, очевидно, истинны. Далее должно быть рассмотрено завершение цикла. В нашем примере, это вопрос о том, завершается ли цикл {у <= х} while y<>xdoy=y+l end {у = х} Поскольку х и у предполагаются целыми числами, ясно, что число повторений цикла не бесконечно. Предусловие гарантирует, что изначально значение переменной у не пре- вышает значения переменной х. Каждое повторение тела цикла увеличивает значение переменной у до тех пор, пока оно не станет равным значению переменной х. Не важно, насколько изначально значение переменной у было меньше значения переменной х, в конечном счете оба значения станут равными, т.е. выполнение цикла завершится. По- скольку наш выбор условия 1 удовлетворяет всем пяти поставленным критериям, то оно подходит на роль инварианта цикла и предусловия цикла. Описанный выше процесс вычисления инварианта цикла не всегда порождает утвер- ждение, являющееся слабейшим предусловием (хотя для приведенного выше примера это было именно так). В качестве другого примера нахождения инварианта цикла рассмотрим следующий оператор цикла: while s>ldos=s/2 end {s = 1} Как и в описанном выше случае, мы используем аксиому присваивания для определе- ния инварианта и предусловия цикла. Для нулевого числа повторов цикла слабейшим предусловием будет {s = 1}. Для одного повтора: wp(s = s / 2, {s = 1}) = {s / 2 = 1}, или {s = 2} Для двух повторов цикла: wp(s = s / 2, {s = 2}) = {s / 2 = 2}, или {s = 4} Для трех: 3.6. Описание смысла программ: динамическая семантика 159
wp(s = s / 2, {s = 4}) = {s / 2 = 4}, или {s = 8} Таким образом, инвариант равен: {s — неотрицательная степень числа 2} Напомним, что вычисленный инвариант цикла 1 может служить предусловием цикла Р и удовлетворяет пяти поставленным требованиям. Однако в отличие от предыдущего примера поиска предусловия цикла найденное условие отнюдь не является слабейшим Рассмотрим использование предусловия {s > 1}. Можно легко доказать логическое выражение {s > 1} while s>ldos=s/2 end {s = 1) Это предусловие значительно шире предусловия, найденного выше. Цикл и преду- словие удовлетворяются любым положительным значением переменной^, а не только степенью двойки, как было показано ранее. В силу правила логического следствия ис- пользование предусловия, более сильного, чем слабейшее предусловие, не опровергает доказательства. Найти инварианты цикла не всегда легко. Для этого следует понять их природу. Во- первых, инвариант цикла является ослабленным вариантом постусловия и одновременно предусловием цикла. Таким образом, условие I должно быть достаточно слабым для удовлетворения его в начале выполнения цикла, но в то же время в сочетании с условием выхода из цикла оно должно быть достаточно сильным, чтобы гарантировать истинность постусловия. Вследствие трудности доказательства завершения цикла это условие часто просто иг- норируется. Если завершение цикла может быть показано, то аксиоматическое описание цикла называется доказательством полной корректности (total correctness). Если ос- тальные условия удовлетворены, но завершение цикла не гарантировано, то такое описа- ние называется доказательством частичной корректности (partial correctness). Поиск подходящего инварианта в более сложных циклах даже для доказательства его частичной корректности требует известной доли изобретательности. Поскольку поиск предусловия для цикла while зависит от обнаружения инварианта цикла, то доказатель- ство с использованием аксиоматической семантики правильности программ, содержа- щих подобные циклы, может оказаться сложным. Ниже следует пример доказательства правильности программы, написанной на псев- докоде и вычисляющей факториал. {п >= 0} счетчик = п; факториал = 1; while счетчик о 0 do факториал = факториал * счетчик; счетчик = счетчик - 1; end {факториал = п’} Описанный выше метод нахождения инварианта цикла не подходит для цикла в дан- ном примере. Здесь требуется определенная находчивость, проявлению которой поможет краткое изучение программы. Данный цикл вычисляет факториал, начиная с последнего множителя; т.е. первым выполняется умножение (п - 1) * п (предполагается, что п больше единицы). Таким образом, частью инварианта может быть 160 Глава 3. Описание синтаксиса и семантики
факториал = (счетчик + 1) * (счетчик + 2)* ... * (п - 1; * п Однако мы также должны гарантировать, чтобы переменная счетчик всегда была неотрицательной. Для этого следует добавить требуемое условие к полученной выше части: I = (факториал = (счетчик + 1) * ... * n) AND (счетчик >= 0) Затем надо проверить, чтобы условие I удовлетворяло требованиям, предъявляемым инвариантам. Снова используем условие I в качестве условия Р, таким образом, из усло- вия Р будет явно следовать условие 1. Вычисление булевского выражения оператора while (в данном случае счетчик о 0), очевидно, никак не повлияет на условие I. Следующий вопрос, требующий доказательства: {I и В} S {1} Условие (I and В) означает: ((факториал = (счетчик + 1) * ... * n) AND (счетчик >= 0)) AND (счетчик о 0) В упрощенном виде можно записать: (факториал = (счетчик +1) * ... * n) AND (счетчик > 0) В нашем случае мы должны вычислить предусловие тела цикла, используя значение инварианта для постусловия. Для условия {Р} счетчик = счетчик - 1 {I) мы находим, что условие Р имеет вид { (факториал « счетчик * (счетчик +1) * ... * n) AND (счетчик >= 1)) Используем это в качестве постусловия для первого присваивания в теле цикла {Р} факториал = факториал * счетчик {(факториал = счетчик * (счетчик + 1) * ... * n) AND (счетчик >= 1)} В этом случае условием Р будет следующее {(факториал « (счетчик + 1) * ... * n) AND (счетчик >= 1)) Очевидно, что из условий I и В следует условие Р, т.е. по правилу логического след- ствия утверждение {I AND В} S {1} истинно. Последняя проверка условия Г. I AND (NOT В) => Q Для нашего примера это означает ((факториал = (счетчик + 1) * ... * n) AND (счетчик >= 0)) AND (счетчик =0) => факториал = п! 3.6. Описание смысла программ: динамическая семантика 161
Это, очевидно, истинно при значении переменной счетчик = 0, таким образом, первая часть является в точности определением факториала. Итак, наш выбор условия I удовле- творяет всем требованиям, предъявляемым инвариантам цикла. Теперь мы можем ис- пользовать условие Р (аналогичное условию I) для цикла с условием while в качестве постусловия второго присваивания программы: {Р) факториал = 1 {(факториал = (счетчик + 1) * ... * n) AND (счетчик >= 0)} Из него вытекает следующее значение условия Р: {(1 = (счетчик + 1) * ...* п) AND (счетчик >= 0)} Используем это в качестве постусловия первого присваивания программы {Р} счетчик = п { (1 = (счетчик + 1) * ... * n) AND (счетчик >= 0)) Тогда мы получим следующее значение условия Р: {((п + 1) * ... * п = 1) AND (п >= 0)) Левый операнд оператора AND истинный (поскольку 1 = 1), а правый операнд в точности равен предусловию всего фрагмента программы {п >« 0}. Следовательно, правильность программы доказана. 3.6.2.7. Оценка При определении семантики полного языка программирования с использованием ак- сиоматического метода для каждого вида операторов языка должны быть сформулиро- ваны аксиома или правило логического вывода. Доказано, что определение аксиом и правил логического вывода для некоторых операторов языков программирования — очень сложная задача. Напрашивающимся решением такой проблемы является разработ- ка языка, подразумевающего использование аксиоматического метода, т.е. содержащего только те операторы, для которых могут быть написаны аксиомы или правила логиче- ского вывода. К сожалению, подобный язык оказался бы слишком маленьким и простым, что отражает нынешнее состояние аксиоматической семантики как науки. Аксиоматическая семантика является мощным инструментом для исследований в об- ласти доказательств правильности программ, она также создает великолепную основу для анализа программ, как во время их создания, так и позднее. Однако ее полезность при описании содержания языков программирования весьма ограничена как для пользо- вателей языка, так и для разработчиков компиляторов. 3.6.3. Денотационная семантика Денотационная семантика (denotational semantics) — самый строгий широко извест- ный метод описания значения программ. Она прочно опирается на теорию рекурсивных функций. Всестороннее рассмотрение денотационной семантики — длительное и сложное дело. Мы намереваемся ознакомить читателя лишь с ее основными принципами. Основной концепцией денотационной семантики является определение для каждой сущности языка некоего математического объекта и некоей функции, отображающей эк- земпляры этой сущности в экземпляры этого математического объекта. Поскольку объ- екты определены строго, то они представляют собой точный смысл соответствующих им 162 Глава 3. Описание синтаксиса и семантики
сущностей. Сама идея основана на факте существования строгих методов оперирования математическими объектами, а не конструкциями языков программирования. Сложность использования этого метода заключается в создании объектов и функций отображения. Название метода “денотационная семантика” происходит от английского слова denote (обозначать), поскольку математический объект обозначает смысл соответствующей синтаксической сущности. 3.6.3.1. Два простых примера Для введения в денотационный метод мы используем очень простую языковую кон- струкцию — двоичные числа. Синтаксис этих чисел можно описать следующими грам- матическими правилами: <двоичное_число> —> О I 1 • I <двоичное_число> О I <двоичное_число> 1 На рис. 3.10 показано дерево синтаксического анализа для выбранного в качестве примера двоичного числа 110. 1 Рис. 3.10. Дерево синтаксического анализа двоичного числа ПО Для описания двоичных чисел с использованием денотационной семантики и грамма- тических правил, указанных выше, их фактическое значение связывается с каждым пра- вилом, имеющим в своей правой части один терминальный символ. Объектами в данном случае являются десятичные числа. В нашем примере значащие объекты должны связываться с первыми двумя грамма- тическими правилами. Остальные два правила являются, в известном смысле, правилами вычислений, поскольку они объединяют терминальный символ, с которым может ассо- циироваться объект, с нетерминальным, который может представлять собой некоторую конструкцию. Если предположить, что процесс вычисления проходит вверх по дереву синтаксического анализа, то нетерминальный символ, находящийся в правой части, уже имел бы приписанное ему значение. В таком случае синтаксическое правило потребова- ло бы наличия функции, вычислившей значение левой части правила, которое, в свою очередь, представляет собой значение всей правой части. 3.6. Описание смысла программ: динамическая семантика 163
Пусть область определения семантических значений объектов представляет собой множество неотрицательных десятичных целых чисел N. Это именно те объекты, кото- рые мы хотим связать с двоичными числами. Семантическая функция Mbin отображает синтаксические объекты в объекты множества N согласно указанным выше грамматиче- ским правилам. Сама функция МЫп определяется следующим образом: МЫп(‘0’) = 0 МЫп(‘1’)=1 Мьт(<Двоичное_число> ‘0’) = 2 ♦ МЫп(<двоичное_число>) Мьш(<Двоичное_число> ‘ Г) = +2 * МЬ1П(<двоичное_число>) + I Отметим, что мы заключили синтаксические цифры в апострофы, чтобы отличать их от математических цифр. Отношение между этими категориями подобно отношениям между цифрами в кодировке ASCII и математическими цифрами. Когда программа счи- тывает число как строку, то прежде, чем это число сможет использоваться в программе, оно должно быть преобразовано в математическое число. Значения, или обозначенные объекты (которыми в данном случае являются десятич- ные числа), могут быть приписаны к узлам указанного выше дерева синтаксического анализа, образуя дерево, показанное на рис. 3.11. Это— синтаксически управляемая се- мантика. Синтаксические сущности отображаются в математические объекты, имеющие конкретное значение. 6 <двоичное_число> <двоичное_число> О <двоичное число> 1 Г 1 Рис. 3.11. Дерево синтаксического анали- за числа 110 с обозначенными объектами Приведем подобный пример для описания значения десятичных синтаксических ли- теральных констант, который понадобится нам в дальнейшем. <десятичное_число> —> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <десятичное_число> (0|1|2|3|4|5|6|7|8|9) Денотационные отображения для этих синтаксических правил имеют следующий вид: Mdcc(‘O') = 0, Mdec(‘r), Mdcc(‘2\ ..., MU‘9’) = 9 Маес(<десятичное_число> ‘0’) = 10 ♦ Масс(<двоичное_число>) М<1ес(<десятичное_число> ‘Г) = 10 * Масс(<десятичное_число>) + 1 Маес(<десятичное_число> ‘9’) = 10 * Mdcc(<десятичное_число>) + 9 164 Глава 3. Описание синтаксиса и семантики
В следующих разделах мы представим денотационные семантики для нескольких простых конструкций. Важнейшим сделанным здесь упрощением является предположе- ние о том, что и синтаксис, и семантика конструкций корректны. Помимо этого, предпо- лагается, что существует только два скалярных типа — целый и булевский. 3.6.3.2. Состояние программы Денотационную семантику программы можно определить в терминах изменений со- стояния идеального компьютера. Подобным образом определялись операционные се- мантики. приблизительно так же определяются и денотационные. Правда, для простоты они определяются только в терминах значений всех переменных, объявленных в про- грамме. Операционная и денотационная семантики различаются тем. что изменения со- стояний в операционной семантике определяются запрограммированными алгоритмами, а в денотационной семантике они определяются строгими математическими функциями. Пусть состояние s программы определяется следующим набором упорядоченных пар: {<ii, vj>, <i2, v2>, ...,<in, vn>} Каждый параметр i является именем переменной, а соответствующие параметры v являются текущими значениями данных переменных. Любой из параметров v может иметь специальное значение undef, указывающее, что связанная с ним величина в дан- ный момент не определена. Пусть VARMAP — функция двух параметров, имени пере- менной и состояния программы. Значение функции VARMAP(ij, s) равно Vj (значение, со- ответствующее параметру ij в состоянии s). Большинство семантических функций ото- бражения для программ и программных конструкций отображают состояния в состояния. Эти изменения состояний используются для определения смысла программ и программных конструкций. Отметим, что такие языковые конструкции, как выражения, отображаются не в состояния, а в величины. 3.6.3.3. Выражения Выражения являются основой большинства языков программирования и не имеют побочных эффектов. Более того, мы имеем дело только с очень простыми выражениями. Единственными операторами являются операторы + и *; выражения могут содержать не более одного оператора; единственными операндами являются скалярные переменные и целочисленные литеральные константы; круглые скобки не используются; значение вы- ражения является целым числом. Ниже следует описание этих выражений в форме БНФ: <выражение> —> <десятичное_число> | <переменная> I<двоичное_выражение> <двоичное_выражение> —> <выражение_слева> <оператор> <выражение_справа> <оператор> —> + | * Единственной рассматриваемой нами ошибкой в выражениях является неопределен- ное значение переменной. Разумеется, могут появляться и другие ошибки, но большин- ство из них зависят от машины. Пусть Z— набор целых чисел, a error— ошибочное значение. Тогда множество Z u {error} является множеством значений, для которых вы- ражение может быть вычислено. Ниже приведена искомая функция отображения для данного выражения Ей состоя- ния s. Чтобы отличать определения математической функции от операторов присваива- 3.6. Описание смысла программ: динамическая семантика 165
ния языков программирования, для определения математических функций мы использо- вали символ Д= Ме(< выражение >,s) Д= case < выражение > of < десятичное число > => Mdcc(< десятичное_число >, s) < переменная > => if VARMAP (< переменная >, s) = undef then error else VARMAP(< переменная >, s) < двоичноевыражение > => iflMe(< двоичное_выражение >.< выражениеслева >, s) = undef OR Me(< двоичное выражение >.< выражениесправа >, s) = undef) then error else if (< двоичное_выражение >.< оператор > = *+’ then М€ (< двоичное выражение >.< выражение слева >, s) + Ме(< двоим ноевы раже ние >.< выражениесправа >,s) else Ме(< двоичное выражение >.< выражение_слева >,s) * Ме(< двоичное выражение >.< выражение справа >, s) 3.6.3.4. Операторы присваивания Оператор присваивания — это вычисление выражения плюс присваивание его значе- ния переменной, находящейся в левой части. Сказанное можно описать следующей функцией: Ма(х = Е, s) Д= if Ме(Е, s) = error then error else s' = {< i/, v/ >, < i2', v2' < in', vn' >} where forj = 1, 2,n, v/ - VARMAP(ij«, s) if ij <>x; Me(E, s) if ij = x Отметим, что два сравнения, выполняющиеся в двух последних строках (ij<> х и ii = х), относятся к именам, а не значениям. 3.6.3.5. Логически управляемые циклы с предварительной проверкой условия Денотационная семантика простого логически управляемого цикла обманчиво про- ста. Для простоты дальнейшего обсуждения предположим, что существуют две другие функции MS| и Мь, отображающие списки операторов в состояния, а булевские выраже- ния — в булевские значения (или значение error), соответственно. Эта функция имеет следующий вид: M|(while В do L, s) Д = if Mb(B, s) = undef then error else ifMb(B, s) = false then s else if Msj(L, s) = error then error else M|(while b do L, M$t(L, s)) 166 Глава 3. Описание синтаксиса и семантики
Значение цикла является просто значением переменных, объявленных в программе, после заданного количества выполнений операторов цикла (предполагается, что ошибок при этом не было). В сущности, цикл был превращен из итерации в рекурсию, причем контроль над последней математически определялся другими рекурсивными функциями отображений состояний. Математически строго описать рекурсию легче, чем итерацию. Это определение, подобно реальным циклам программ, может ничего не вычислять вследствие своей бесконечности. 3.6.3.6. Оценка Объекты и функции, подобные использованным в приведенных выше выражениях, могут определяться и для других синтаксических сущностей языков программирования. После определения полной системы для заданного языка ее можно использовать для оп- ределения смысла полных программ этого языка. Это создает основу для очень строгого способа мышления в программировании. Денотационная семантика может использоваться для разработки языка. Например, операторы, описать которые с помощью денотационной семантики трудно, могут ока- заться сложными и для понимания пользователями языка, и тогда разработчику* следует подумать об альтернативной конструкции. Значительное количество работ было посвящено возможности использования денота- ционных описаний языка для автоматического порождения компиляторов (Jones, 1980; Milos et al., 1984; Bodwin et al., 1982). Эти работы показали, что этот метод вполне осу- ществим, но он ни разу не использовался для порождения пригодного к использованию компилятора. С одной стороны, денотационные описания очень сложны, с другой — они дают ве- ликолепный метод краткого описания языка. Несмотря на то что использование денотационной семантики обычно приписывается Скотту и Стречи (Scott and Strachey, 1971), происхождение общего денотационного под- хода к описанию языка можно проследить до девятнадцатого века (Frege, 1892). Р е з ю м е Форма Бэкуса-Наура и контекстно-свободные грамматики эквивалентны метаязыкам, которые практически идеально подходят для описания синтаксиса языков программиро- вания. Точными методами описания являются не только они, но и деревья синтаксиче- ского анализа, которые вместе с порождающими действиями, образуют графическое представление основных синтаксических структур. Более того, они естественным обра- зом связаны с устройствами распознавания порождаемых ими языков, что приводит к относительно легкому построению синтаксических анализаторов, входящих в состав компиляторов этих языков. Графическим представлением грамматик являются синтаксические графы. Атрибутивная грамматика— описательный формализм, который может описывать как синтаксис, так и статическую семантику языка. Существуют три основных метода описания семантики языка: операционный, аксио- матический и денотационный. Операционная семантика является методом описания смысла языковых конструкций в терминах их воздействия на идеальную машину. Ак- сиоматическая семантика, основанная на формальной логике, была придумана как сред- Резюме 167
ство доказательства правильности программ. В денотационной семантике для выражения смысла языковых конструкций используются математические объекты. Преобразование языковых сущностей в математические объекты выполняется с помощью рекурсивных функций. Дополнигельнсй литература Описание синтаксиса с использованием контекстно-свободных грамматик и формы БНФ всесторонне рассмотрено в книге Кливленда и Узгалиса (Cleaveland and Uzgalis. 1976). Синтаксические графы были разработаны в компании Burroughs с целью ис- пользования их в качестве компактного описания синтаксиса языка ALGOL 60 в рам- ках проекта по разработке компилятора (Taylor et. al.. 1961). Позже они были моди- фицированы А.Шаи (A.Schai), руководителем вычислительного центра в Швейцар- ском федеральном технологическом институте (ЕТН) в Цюрихе. В печати эта модифицированная версия впервые появилась в виде книги Рутишаузера о языке ALGOL 60 (Rutishauser, 1967). Исследование аксиоматической семантики было начато Флойдом (Floyd, 1967) и позже развито Хоаром (Ноаге, 1969). С помощью этого метода Хоаром и Виртом (Hoare and Wirth, 1973) была описана семантика большей части языка Pascal. В число неописан- ных моментов входили побочные эффекты функций и операторы goto, поскольку эти объекты считались сложными для описания. Техника использования предусловий и постусловий во время разработки программ опи- сывалась (и пропагандировалась) Дийкстрой (Dijkstra, 1976), также они подробно рассмотрены в работе Гриса (Gries, 1981). Хорошими вводными курсами в денотационную семантику могут служить работы Гор- дона (Gordon, 1979) и Стоя (Stoy, 1977). Введение во все три метода семантического описания, представленные в данной главе, можно найти в книге Маркотти (Marcotty et al., 1976). Еще одним полезным справочником по большей части описанного мате- риала может служить книга Пагана (Pagan, 1981). Форма использованных в главе функций денотационной семантики подобна форме, использованной в книге Мейера (Meyer, 1990). В О П р О С bf 1. Дайте определение понятий “синтаксис" и “семантика". 2. Для кого создается описание языка? 3. Опишите работу обычного генератора языка. 4. Опишите работ)' обычного устройства распознавания языка. 5. Чем отличается “предложение" от “сентенциальной формы"? 6. Дайте определение леворекурсивного грамматического правила. 7. Какие три дополнения обычно делаются в формах РБНФ? 8. Опишите статическую и динамическую семантики. 9. Для чего в атрибутивной грамматике служат предикаты? 168 Глава 3. Описание синтаксиса и семантики
10. Чем различаются синтезированный и унаследованный атрибут? 11. Как определяется порядок вычисления атрибутов для деревьев с заданной атрибу- тивной грамматикой? 12. Назовите основное применение атрибутивной грамматики. 13. В чем заключается проблема операционной семантики при использовании про- граммно реализованного чистого интерпретатора? 14. Объясните смысл в аксиоматической семантике предусловий и постусловий данно- го оператора. 15. Опишите подход к использованию аксиоматической семантики для доказательства правильности данной программы. 16. Опишите основную концепцию денотационной семантики. 17. Чем отличается операционная семантика от денотационной? Упражнения 1. Двумя математическими моделями описания языка являются порождение и распо- знавание. Опишите, каким образом каждая из них может определять синтаксис языка программирования. 2. Создайте описания в форме РБНФ и синтаксического графа следующих объектов. 2.1. Оператор заголовка procedure языка Pascal. 2.2. Оператор вызова procedure языка Pascal. 2.3. Оператор switch языка С. 2.4. Определение union языка С. 2.5. Литеральные константы float языка С. 3. Используя грамматику, приведенную в листинге 3.2, продемонстрируйте дерево синтаксического анализа и левосторонний вывод для следующих выражений. 3.1. А := А * (В + (С * А) ) 3.2. В := С * (А * С + В) 3.3. А := А * (В + (С) ) 4. Используя грамматику, приведенную в листинге 3.4. продемонстрируйте дерево синтаксического анализа и левосторонний вывод для следующих выражений. 4.1. А : = (А + В) * С 4.2. А := В + С + А 4.3. А := А * (В + С) 4.4. А := В * (С * (А + В) ) 5. Докажите неоднозначность следующей грамматики: <S> -4 <А> Упражнения 169
<А> -» <А> + <А> | <идентификатор> <идентификатор> -4 а|Ь|с 6. Модифицируйте грамматику, приведенную в листинге 3.4, для введения унарного оператора вычитания, имеющего более высокий приоритет, чем операторы + и ♦. 7. Опишите на русском языке язык, определяемый следующей грамматикой: <S> -4 <А> <ВхС> <А> —> а <А>|а <В> —> b <В> I ь <C>-4c<C>jc 8. Рассмотрите следующую грамматику: <S> <А> а <В> b <А> -> <А> b I Ь <В> -> а <В> | а Какие из следующих предложений принадлежат к языку, генерируемому этой грамматикой? 8.1. baab. 8.2. bbbab. 8.3. bbaaaaa. 8.4. bbaab. 9. Рассмотрите следующую грамматику: <S> -» a <S> с <В> | <А> | b <А> -» с <А> | с <В> —>d|<А> Какие из следующих предложений принадлежат к языку, генерируемому этой грамматикой? 9.1. abed. 9.2. acccbd. 9.3. acccbcc. 9.4. acd. 9.5. accc. 10. Напишите грамматику для языка, состоящего из строк, имеющих и (п > 0) экземп- ляров буквы а, за которыми следует такое же число экземпляров буквы Ь. К этому языку, например, принадлежат строки ab, aaaaabbbb и aaaaaaaabbbbbbbb, но не при- надлежат строки a, abb. ba и aaabb. 11. Создайте деревья синтаксического анализа для предложений aabcc и aaabbbc, полу- ченных при использовании грамматики, описанной в упражнении 10. 170 Глава 3. Описание синтаксиса и семантики
12. Используя команды виртуальной машины, представленные в разделе 3.6.1.1. дайте в рамках операционной семантики определение следующих объектов. 12.1. Оператор repeat языка Pascal. 12.2. Оператор for-downto языка Pascal. 12.3. Оператор DO языка FORTRAN в форме DO N К = start, end, step. 12.4. Структура if-then-else языка Pascal. 12.5. Оператор for языка С. 12.6. Оператор switch языка С. 13. Найдите слабейшее предусловие для каждого из следующих присваиваний. 13.1. а:=2*(Ь-1)-1{а>0) 13.2. Ь := (с + 10) / 3 {Ь > 6} 13.3. а:=а + 2*Ь-1{а>1) 13.4. х:=2*у + х-1(х>11) 14. Найдите слабейшее предусловие для каждой последовательности присваиваний и относящихся к ним постусловий. 14.1. а := 2 * b + 1; b := а - {Ь < 0} 3 14.2. а : = 3 * (2 * b + а) ; Ь : = 2 * а - 1 {Ь > 5) 15. Напишите функцию отображения денотационной семантики для следующих опе- раторов. 15.1. Оператор for языка Pascal. 15.2. Оператор repeat языка Pascal. 15.3. Булевское выражения языка Pascal. 15.4. Оператор for языка С. 15.5. Оператор switch языка С. 16. В чем различие между внутренними и остальными синтезированными атрибутами? 17. Напишите атрибутивную грамматику, основанную на форме БНФ, приведенной в листинге 3.6 раздела 3.5.5, но со следующими правилами языка: типы данных в выражениях не могут смешиваться, но операторы присваивания не обязательно должны иметь один и тот же тип по обе стороны оператора присваивания. 18. Напишите атрибутивную грамматику, основанную на форме БНФ, аналогичной форме, приведенной в листинге 3.2, но с правилами типов, описанными в разде- ле 3.5.5. Упражнения 171
19. Докажите правильность следующей программы: {X = Vx and у = Vy} temp = х; х = у; у = temp; {X = Vy and у = Vx} 20. Докажите правильность следующей программы: {п > 0} count = п; sum = 0; while count о 0 do sum = sum + count; count = count - 1; end {sum = 1 + 2 + ... + n} 172 Глава 3. Описание синтаксиса и семантики
Имена, связывание, проверка типов и области видимости ВЙэ т о й главе'. Бьярн Страуструп (Bjame Stroustrup) Бьярн Страуструп, с 1979 года работавший в компании AT&T Labs, является создателем языка C++. Этот язык сделал объектно- ориентированное программиро- вание понятным и доступным разработчикам программного обеспечения, моделирующего объекты реального мира. В зна- чительной степени благодаря популярности языка С** в нача- ле 1990-х годов объектно-ориен- тированное программирование стало широко распространенной парадигмой. Страуструп являет- ся автором книги “The C++ Programming Language and The 4.1. Введение 4.2. Имена 4.3. Переменные 4.4. Концепция связывания 4.5. Проверка типов 4.6. Строгая типизация 4.7. Совместимость типов 4.8. Область видимости 4.9. Область видимости переменных и время их жизни 4.10. Среды ссылок 4.11. Именованные константы 4.12. Инициализация переменных Design and Evolution of C++". Имена, связывание, проверка типов и области видимости 173
В этой главе затрагиваются фундаментальные семантические проблемы, связан- ные с использованием переменных. Сначала освещается самая главная тема: природа имен и специальных слов в языках программирования. Затем рассматриваются атрибуты переменных, в том числе их тип, адрес и значение. Обсуждаются также альтер- нативные имена. Далее вводятся важные понятия связывания и времени связывания. Раз- личные варианты времени связывания атрибутов с переменными определяют четыре различные категории переменных. За их описанием следует основательный разбор про- верки типов, строгой типизации и правил совместимости типов. Затем описываются два способа обзора данных: динамический и статический, одновременно рассматривается концепция среды ссылок оператора. В заключение описываются именованные константы и способы инициализации переменных. 4.1. Введение Императивные языки программирования— это (в различной степени) абстракция компьютерной архитектуры фон Неймана, лежащей в их основе. Двумя основными ком- понентами этой архитектуры являются память, в которой хранятся данные и команды, и процессор, позволяющий изменять содержимое памяти. В языке программирования аб- стракциями ячеек памяти машины являются переменные. В некоторых случаях характе- ристики абстракций и ячеек очень близки; примером этому служит переменная целого типа, которая обычно точно представляется в виде отдельного слова аппаратной памятц. В других случаях абстракции довольно далеки от ячеек памяти. Так, для поддержания трехмерного массива требуется программная функция отображения. Охарактеризовать переменные можно с помощью набора свойств, или атрибутов, важнейшим из которых является тип — основное понятие в языках программирования. Изучение структуры типов данных в языке требует рассмотрения разнообразных вопро- сов. К важнейшим из них относятся область видимости и время жизни переменных. С этими вопросами связаны также проверка типов и инициализация. Чтобы разобраться в императивных языках программирования, необходимо изучить эти концепции. Еще одной важной частью структуры типов данных языка является совместимость типов. Далее в книге мы будем часто ссылаться на семейства языков как на один язык. На- пример, ссылаясь на язык FORTRAN, мы подразумеваем все его версии. Сказанное от- носится и к семействам языков Pascal и Ada. Упоминания о языке С включают исходную версию этого языка и язык ANSI С. Конкретную версию языка мы будем рассматривать, только если она отличается от остальных представителей семейства. 4.2. Имена Прежде чем начать обсуждение переменных, рассмотрим такой фундаментальный ат- рибут переменных, как имена, которые используются не только для именования пере- менных. Имена связаны с понятием меток, подпрограмм, формальных параметров и дру- гими программными конструкциями. Синонимом термина имя часто является термин идентификатор. 174 Глава 4. Имена, связывание, проверка типов и области видимости
4.2.1. Вопросы структуры Ниже приводятся основные вопросы, связанные с именами. Какова максимально допустимая длина имени? Может ли в именах использоваться соединительный символ? Зависят ли имена от регистра, в котором набраны буквы? Являются специальные слова зарезервированными или ключевыми? Эти вопросы обсуждаются в следующих двух разделах, в которые также включены примеры некоторых проектных решений. 4.2.2. Виды имен Имя (name) — это строка символов, используемая для идентификации некоторой сущ- ности в программе. В первых языках программирования использовались имена, состоящие только из одного символа. Это было естественно, поскольку ранние языки программирова- ния были в основном математическими, а математики давно использовали имена, состоя- щие из одного символа, для формального обозначения неизвестных параметров. Эта традиция была нарушена с появлением языка FORTRAN I, в котором в именах раз- решалось использовать до шести символов. Это ограничение длины имени шестью симво- лами сохранилось и в языке FORTRAN 77, но в языках FORTRAN 90 и С разрешенное ко- личество символов в именах увеличилось до 31; в языке Ada вообще нет ограничения на длину имени, и все имена являются значащими. В некоторых языках, например C++, также нет ограничения на длину имени, хотя иногда такое ограничение вводится создателями конкретных систем реализаций этих языков, чтобы таблица имен, в которой во время ком- пиляции хранятся идентификаторы, не была слишком большой и сложной. Общепринятым видом имени является строка с разумным ограничением длины (или без него), содержащая такие соединительные символы, как символ подчеркивания (_). Символ подчеркивания используется с той же целью, что и пробел в русских текстах, но при этом он не разрывает строку имени, содержащую его. Большинство современных языков программирования позволяют использовать в именах соединительные символы. В некоторых языках, особенно в языках С, C++ и Java, различаются прописные и строчные буквы; т.е. имена в этих языках зависят от регистра (case sensitive). Напри- мер, в языке C++ три следующих имени различны: rose, ROSE и Rose. В определенном смысле это значительно ухудшает читабельность, поскольку имена, внешне выглядящие очень похоже, на самом деле означают различные объекты. В этом отношении зависи- мость от регистра нарушает принцип проектирования, гласящий, что языковые конст- рукции, имеющие одинаковый вид, должны иметь одинаковый смысл. Разумеется, не все согласятся с утверждением, что зависимость от регистра — это не- удачное свойство имен. В языке С, например, зависимости от регистра можно избежать, используя исключительно имена, состоящие из строчных букв. Однако в языке Java по- добным образом проблемы избежать не удается, поскольку многие предопределенные имена содержат и прописные, и строчные буквы. Например, используемый в языке Java метод преобразования строки в целочисленное значение называется parselnt, а если написать его в виде Parselnt или parseint, то команда не будет распознана. Это проблема связана с легкостью создания программы, а не с ее читабельностью, поскольку необходимость запоминать необычные правила написания слов затрудняет создание 4.2. Имена 175
корректных программ. Она представляет собой разновидность нетерпимости части раз- работчиков языка, навязываемой компилятором. В версиях языка FORTRAN вплоть до версии FORTRAN 90 в именах могли исполь- зоваться только прописные буквы, что было излишним ограничением. Многие реализа- ции языка FORTRAN 77 (подобно языку FORTRAN 90) позволяют использовать строч- ные буквы — просто они переводятся в прописные для внутреннего использования во время компиляции. 4.2.3. Специальные слова В языках программирования специальные слова улучшают читабельность программ, называя выполняемые действия. Они также используются для обособления синтаксических сущностей программ. В большинстве языков программирования эти слова классифициру- ются как зарезервированные, но в некоторых они являются всего лишь ключевыми. Ключевое слово (keyword) имеет особое значение только в определенном контексте. Примером языка, в котором специальные слова являются ключевыми, служит язык FORTRAN. Если слово REAL языка FORTRAN встречается в начале предложения, а за ним следует имя, то это слово рассматривается как ключевое, указывающее на то, что данное выражение является оператором объявления переменной. В, то же время, если за словом REAL следует оператор присваивания, то это слово рассматривается как имя пе- ременной. Ниже проиллюстрированы эти варианты использования слова REAL: REAL APPLE REAL =3.4 Компиляторы языка FORTRAN и читатели его программ должны различать имена и специальные слова по контексту. Зарезервированное слово (reserved word) не может использоваться в качестве име- ни. При разработке языка лучше использовать зарезервированные слова, поскольку воз- можность переопределения ключевых слов может ухудшить читабельность. Например, в языке FORTRAN могут встретиться следующие операторы: INTEGER REAL REAL INTEGER Эти операторы объявляют, что переменная программы REAL имеет тип INTEGER, а пе- ременная INTEGER— тип REAL. Помимо странного вида этих операторов объявления переменных, появление слов REAL и INTEGER в качестве имен переменных может вве- сти читателя такой программы в заблуждение. В данной книге в примерах фрагментов программ зарезервированные слова выделя- ются жирным шрифтом. Многие языки содержат предопределенные имена, которые в некотором смысле явля- ются чем-то средним между зарезервированными словами и именами, определяемыми пользователем. Они имеют предопределенный смысл, но могут переопределяться пользо- вателем. Например, такие встроенные в язык Ada имена типов данных, как INTEGER и FLOAT, предопределены. Эти имена не зарезервированы: они могут переопределяться лю- бой программой на языке Ada. В языке Pascal предопределенные имена иногда называются стандартными идентификаторами. В этом языке обычные имена подпрограмм ввода- вывода, в том числе и названия подпрограмм readin и writein, предопределены. 176 Глава 4. Имена, связывание, проверка типов и области видимости
Определения предопределенных имен в языках Pascal и Ada должны быть видимы компиляторами этих языков, поскольку проверка типов в таких языках производится во время компиляции. В обоих языках приведенные выше примеры предопределенных имен видимы компилятором неявно. Другие предопределенные имена языка Ada, на- пример, стандартные подпрограммы ввода и вывода GET и PUT, сделаны видимыми явно с помощью оператора with, указанного пользователем. В языках С и C++ многие имена предопределены в библиотеках, используемых пользо- вательскими программами. Например, имена функций ввода и вывода языка С printf и scanf определяются в библиотеке stdio. Доступ компилятора к именам, предопределен- ным в библиотеках, возможен через соответствующие заголовочные файлы. 4.3. Переменные Переменная в программе представляет собой абстракцию ячейки памяти компьютера или совокупности таких ячеек. Программисты часто думают о переменных как об име- нах ячеек памяти, но переменная — это не только имя. Переход с машинных языков на языки ассемблера происходил в основном путем замены абсолютных числовых адресов ячеек памяти именами, при этом значительно повысилась читабельность программ, а значит, они стали более легкими для создания и эксплуатации. Этот шаг также позволил избежать возникновения проблем, связанных с абсолютной адресацией, поскольку транслятор преобразовывает имена в фактические адреса, которые сам выбирает. Переменные можно охарактеризовать шестеркой атрибутов (имя, адрес, значение, тип, время жизни, область видимости). Несмотря на то что такое описание может пока- заться слишком сложным для внешне простой концепции, оно позволяет ясно объяснить различные свойства переменных. Обсуждение атрибутов переменных приводит к исследованию важных концепций, свя- занных с этим вопросом: альтернативных имен, связывания, времени связывания, объявле- ний, проверки типов, строгой проверки типов, правил обзора данных и сред ссылок. Имя, адрес, тип и значение переменных рассматриваются в следующих подразделах. Вре- мя жизни и область видимости атрибутов описаны в разделах 4.4.3 и 4.8, соответственно. 4'ЗЛ. Имя Имена переменных — самые распространенные имена в программах. Они детально рассматривались в разделе 4.2 в общем контексте имен сущностей, из которых состоят программы. Большинство переменных имеют имена. Переменные, у которых нет имен, рассматриваются в разделе 4.4.3.3. Часто вместо слова “имя” используется слово “идентификатор”. 4.3.2. Адрес Адрес (address) переменной — это ячейка памяти, с которой связана данная перемен- ная. Эта связь не так проста, как может показаться на первый взгляд. Во многих языках одно и то же имя можно связать с разными адресами в разных местах программы и в разное время. Например, программа может иметь две подпрограммы subl и sub2, в каждой из которых определяется переменная с одним и тем же именем, скажем, пере- менная sum. Поскольку эти переменные не зависят друг от друга, то обращение к пере- 4.3. Переменные 177
менной sum в подпрограмме subl не связано с обращением к переменной sum подпро- граммы sub2. Подобным образом большинство языков позволяют связать одно имя с разными адресами в разные моменты времени выполнения программы. Например, ре- курсивно вызываемая подпрограмма содержит многочисленные версии каждой локально объявленной переменной, по одной на каждую активацию подпрограммы. Связывание переменных с адресами будет обсуждаться в разделе 4.4.3. Модель реализации подпро- грамм и их активаций для языков, подобных языку ALGOL, рассматривается в главе 9. Адрес переменной иногда называется ее левым значением (l-value), поскольку именно он нужен, если в левой части оператора присваивания указана переменная. 4.3.2.1. Альтернативные имена Иногда несколько идентификаторов одновременно ссылаются на один и тот же адрес. Если для доступа к отдельной ячейке памяти можно использовать несколько имен пере- менных, то эти имена называются альтернативными (aliases). Совмещение имен ухуд- шает читабельность программы, поскольку оно позволяет переменным изменять свои значения при присвоении их другим переменным. Например, если имена переменных А и В являются альтернативными, то любое изменение переменной А приводит к изменению переменной В, и наоборот. Человек, читающий программу, всегда должен помнить, что А и В — разные названия одной и той же ячейки памяти. Поскольку в программе может быть любое количество альтернативных имен, то на практике это создает серьезные трудности. Кроме того, совмещение имен затрудняет верификацию программы. Альтернативные имена могут создаваться в программе несколькими различными способами. В языке FORTRAN для этого используется оператор EQUIVALENCE. Они также могут создаваться с помощью вариантных записей в таких языках, как Pascal и Ada, или с помощью объединений в языках С и C++. Альтернативные имена, создавае- мые этими типами данных, экономят память, позволяя переменным разного типа исполь- зовать в разное время одни и те же ячейки памяти. Они также могут применяться для на- рушения правил работы с типами в некоторых языках, допускающих эту возможность. Подробно вариантные записи и объединения рассмотрены в главе 5. Две переменные указателя являются альтернативными, если они указывают на одну и ту же ячейку памяти. Это справедливо и для ссылок. Такая разновидность совмещения имен не экономит место, а просто является побочным эффектом природы указателей и ссылок. Если указатель в языке C++ адресует именованную переменную, то при разыме- новании указатель и имя переменной являются альтернативными именами. Эти и другие свойства указателей и ссылок рассмотрены в главе 5. Во многих языках программирования имена можно совмещать, используя параметры подпрограмм. Эта разновидность альтернативных имен рассмотрена в главе 8. Некоторых причин, оправдывавших использование альтернативных имен, уже не су- ществует. Когда языковая конструкция создает альтернативные имена для повторного использования памяти, их можно заменить схемой управления динамической памятью, позволяющей повторное использование памяти, но не обязательно создающей при этом альтернативные имена. Более того, современная память компьютера значительно боль- ше, чем была в те времена, когда разрабатывались языки, подобные языку FORTRAN, так что сейчас память — уже не такой дефицитный товар. Момент времени, когда переменная связывается с адресом, очень важен для понима- ния языков программирования. Подробнее этот вопрос рассмотрен в разделе 4.4.3. 178 Глава 4. Имена, связывание, проверка типов и области видимости
4.3.3. Тип Тип (type) переменной определяет диапазон значений, которые может иметь пере- менная, и набор операций, предусмотренных для переменных этого типа. Например, для типа INTEGER в некоторых реализациях языка FORTRAN устанавлен диапазон значений от -32 768 до 32 767 и определены арифметические операции сложения, вычитания, ум- ножения, деления и возведения в степень, а также некоторые библиотечные функции для выполнения других операций, например, вычисления абсолютной величины числа. 4.3.4. Значение Значение переменной — это содержимое ячейки или ячеек памяти, связанных с дан- ной переменной. Память компьютера удобно представлять себе в терминах абстракт- ных ячеек, а не физических. Ячейки, или отдельно адресуемые единицы, большинства современных компьютеров имеют размер, равный байту, как правило, содержащему во- семь битов. Этот размер слишком мал для большинства программных переменных. Мы будем считать, что абстрактная ячейка памяти имеет размер, достаточный для хранения связанной с ней переменной. Несмотря на то что числа с плавающей точкой в отдельной реализации конкретного языка могут занимать четыре физических байта, мы считаем, что число с плавающей точкой занимает одну абстрактную ячейку памяти. Мы полагаем, что величина любого элементарного неструктурированного типа занимает отдельную аб- страктную ячейку. С этого момента под ячейкой памяти мы будем подразумевать абст- рактную ячейку памяти. Значение переменной иногда называется ее правым значением (r-value), поскольку именно оно необходимо при использовании переменной, указанной в правой части опе- ратора присваивания. Для того чтобы получить доступ к правому значению переменной, вначале следует определить ее левое значение. Значительно усложнить этот процесс мо- гут, например, правила обзора данных, как это показано в разделе 4.8. 4.4. Концепция связывания В общем смысле, связывание (binding) представляет собой процесс установления связи, аналогичной существующей между атрибутом и объектом или между операцией и символом. Момент времени, когда эта связь устанавливается, называется временем свя- зывания (binding time). Связывание и время связывания — важные понятия семантики языков программирования. Связывание может происходить во время разработки или реализации языка; при компиляции, загрузке или выполнении программы. Звездочка (*), например, обычно связывается с операцией умножения во время разработки языка. Тип данных, например тип INTEGER в языке FORTRAN, связывается с диапазоном возмож- ных значений во время реализации языка. В языках С и Pascal переменная связывается с конкретным типом данных во время компиляции программы. Вызов библиотечной под- программы связывается с командами подпрограммы при компиляции. Переменная мо- жет связываться с ячейкой памяти при загрузке программы в память. Аналогичное свя- зывание в некоторых случаях не происходит вплоть до времени выполнения программы, например, для переменных, объявленных в подпрограммах языка Pascal, или функций языка С (если объявления не содержат спецификатор static). 4.4. Концепция связывания 17»
Рассмотрим следующий оператор присваивания языка С с указанным определением переменной count: int count; count = count + 5; Ниже приведены некоторые виды связывания и времен связывания для частей опера- тора присваивания. Множество возможных типов переменной count: связывание во время разработ- ки языка. Тип переменной count: связывание во время компиляции. Множество возможных значений переменной count: связывание при разработке компилятора. Значение переменной count: связывание во время выполнения указанного оператора. Множество возможных значений функционального символа +: связывание во время разработки языка. Смысл функционального символа + в данном операторе: связывание во время компиляции. Внутреннее представление константы 5: связывание во время разработки компи- лятора. Дтя того чтобы разобраться в семантике языка программирования, необходимо понять, что такое время связывания арибутов с сущностями программы. Например, необходимо знать, как при вызове подпрограммы фактические параметры связываются с формальными параметрами в ее определении. Для того чтобы определить текущее значение переменной, нужно узнать, когда данная переменная была связана с ячейкой памяти. 4.4.1. Связывание атрибутов с переменными Связывание называется статическим (static), если оно выполняется до выполнения программы и не меняется во время ее выполнения. Если связывание происходит во вре- мя выполнения программы или может меняться в ходе ее выполнения, то оно называется динамическим (dynamic). Физическое связывание переменной с ячейкой в среде вирту- альной памяти — сложный процесс, поскольку страница или сегмент адресного про- странства, в котором находится ячейка, во время выполнения программы может много- кратно загружаться и выгружаться из памяти. В некотором смысле такие переменные многократно связываются и открепляются. Такие связи, однако, поддерживаются аппа- ратным обеспечением компьютера, а программе и пользователю эти изменения не вид- ны. Поскольку это не представляет интереса для обсуждения, мы не фокусируем внима- ние на аппаратном связывании. Главным моментом для нас являются различия между статическим и динамическим связываниями. 4.4.2. Связывание типов Прежде чем к переменной программы можно будет обращаться, она должна быть свя- зана с типом данных. При этом необходимо рассмотреть два важных аспекта этого связы- вания: каким образом указывается этот тип и когда происходит связывание. Типы могут определяться статически с помощью некоторой формы явного или неявного объявления. 180 Глава 4. Имена, связывание, проверка типов и области видимости
4.4.2.1. Объявление переменных Явное объявление (explicit declaration)— это оператор программы, перечисляющий имена переменных и устанавливающий, что они имеют определенный тип. Неявное объявление (impicit declaration) — это средство связывания переменных с типами по- средством принятых по умолчанию соглашений, а не операторов объявления. В этом случае первое появление имени переменной в программе является ее неявным объявле- нием. И явное, и неявное объявления создают статические связи с типами. Большинство языков программирования, созданных с середины 1960-х годов, требу- ют явного*объявления всех переменных (двумя исключениями являются языки Perl и ML). Некоторые широко используемые языки, первоначальные работы по созданию ко- торых были проведены в конце 1960-х годов, допускают неявные объявления перемен- ных, особенно это касается языков FORTRAN. PL/I и BASIC. Например, в программах на языке FORTRAN идентификатор, для которого не было выполнено явного объявле- ния, объявляется неявно в соответствии со следующим соглашением: если идентифика- тор начинается с одной из букв I, J. К, L. М или N, то неявно объявлено, что он имеет тип INTEGER; в противном случае неявно объявлено, что он имеет тип REAL. Неявные объявления могут нанести серьезный ущерб надежности программы, по- скольку они препятствуют выявлению на этапе компиляции различных опечаток или программистских ошибок. Переменные, которые программистом были случайно остав- лены необъявленными, получат типы по умолчанию и будут иметь неожиданные атрибу- ты, что может вызвать неявные ошибки, которые трудно обнаружить. Некоторых проблем, связанных с неявными объявлениями, можно избежать, требуя, чтобы имена отдельных типов начинались с конкретных специальных символов. Напри- мер, в языке Perl все имена, начинающиеся с символа $. являются скалярными величи- нами, которые могут быть строкой или числом. Если имя начинается с символа 0. то именуемая им сущность является массивом; если имя начинается с символа %, то оно обозначает хешированную структуру (hash structure). Подобный подход создает различ- ные пространства имен для различных типов переменных. При таком сценарии имена @apple и Я apple не связаны между собой, поскольку принадлежат различным про- странствам имен. Более того, тип переменной определяется ее именем. В языках С и C++ необходимо различать объявления и определения. Объявления ус- танавливают типы и другие атрибуты, но не приводят к распределению памяти. Опреде- ления устанавливают атрибуты и вызывают распределение памяти. Для конкретного имени в программе на языке С может содержаться любое количество согласованных объявлений, но только одно определение. В языке С можно объявлять переменные, внешние по отношению к функции. Объявление указывает компилятору тип переменной и то, что она где-то была определена. Эта идея переносится на функции языков С и C++, в которых прототипы объявляют имена и интерфейсы, но не команды функций. С другой стороны, определения функций являются полными. 4.4.2.2. Динамическое связывание типов При динамическом связывании в операторе объявления тип не указывается. Вместо этого переменная связывается с типом при присвоении ей значения оператором присваи- вания. При выполнении оператора присваивания переменная, которой присваивается значение, связывается с типом переменной, выражения или значения, находящегося в правой части оператора присваивания. 4.4. Концепция связывания 181
Языки, в которых типы связываются динамически, значительно отличаются от языков со статическим связыванием типов. Основным преимуществом динамического связыва- ния переменных с типом является то, что оно обеспечивает значительную гибкость про- граммирования. Например, программу обработки таблицы данных в языке, использую- щем динамическое связывание типов, можно написать в виде настраиваемой программы. Это означает, что программа сможет работать с данными любого типа. Любой тип вход- ных данных будет приемлемым, поскольку переменные, предназначенные для их хране- ния, после ввода этих данных будут связываться с соответствующим типом во время присваивания. В отличие от динамического статическое связывание типов не позволяет написать на языках С или Pascal программу обработки таблицы данных без уточнения типа данных. В языках APL и SNOBOL4 связывание переменных с типом происходит динамиче- ски. Например, в программе на языке APL может содержаться следующий оператор: LIST <- 10.2 5.1 0.0 Независимо от предыдущего типа переменной LIST в результате этого присваивания она станет обозначать одномерный массив длины 3, содержащий числа с плавающей точкой. Если оператор LIST <- 47 будет выполнен после написанного выше присваивания, то переменная LIST станет це- лочисленной скалярной переменной. У динамического связывания типов есть два недостатка. Во-первых, поскольку по обе стороны оператора присваивания могут находиться величины двух любых типов, то воз- можность обнаружения компилятором ошибок снижается по сравнению с языками со статическим связыванием типов. Неверные типы в правой стороне оператора присваива- ния не будут расценены как ошибки; вместо этого просто произойдет изменение типа ле- вой стороны оператора присваивания на этот неверный тип. Предположим, что в кон- кретной программе i и х — целочисленные переменные, а у — массив, содержащий числа с плавающей точкой. Предположим также, что в программе необходим оператор присваивания i := х Однако при наборе оператор был записан в виде i := у В языке с динамическим связыванием типов ни система компиляции, ни система под- держки выполнения программ не обнаружат ошибку. Тип переменной i просто будет изменен на тип массива, содержащего числа с плавающей точкой. Поскольку вместо правильной перемененной х была использована переменная у, результаты программы будут ошибочными. В языке со статическим связыванием типов компилятор обнаружит ошибку, и программа не будет выполнена. Отметим, что этот недостаток до некоторой степени присутствует и в таких языках, использующих статическое связывание типов, как FORTRAN, С и C++, которые во мно- гих случаях автоматически преобразовывают тип правой части оператора присваивания в тип его левой части. 182 Глава 4. Имена, связывание, проверка типов и области видимости
Другим недостатком динамического связывания типов является его цена, которая весьма значительна, особенно во время выполнения. Именно в это время должна произ- водиться проверка типов. Более того, каждая переменная должна содержать дескриптор, связанный с нею, для запоминания текущего типа. Память, используемая для хранения переменной, должна быть переменного размера, поскольку значения различных типов требуют различных объемов памяти. Языки, имеющие динамическое связывание типов переменных, часто реализовыва- ются с помощью интерпретаторов, а не компиляторов. Это происходит отчасти из-за сложности динамического изменения типов переменных в машинных кодах. Более того, время, необходимое для динамического связывания типов, перекрывается общим време- нем интерпретации, так что в этой среде динамическое связывание кажется более деше- вым. С другой стороны, языки со статическим связыванием типов редко реализуются с помощью интерпретаторов, поскольку программы, написанные на этих языках, легко могут транслироваться в эффективные версии в машинных кодах. 4.4.2.3. Логический вывод типа Относительно недавно был разработан язык ML, поддерживающий как функциональ- ное, так и императивное программирование (Miller et al., 1990). Этот язык использует интересный механизм логического вывода типа, в котором типы большинства выраже- ний могут определяться без участия программиста. Например, объявление функции fun circumf(r) = 3.14159 * г * г; определяет функцию, аргумент и результат которой имеют действительный тип. Их тип логически выводится из типа константы, входящей в выражение. Подобным образом в функции fun timeslO(x) = 10 * х; логически выводится целый тип аргумента и значения функции. Система языка ML отвергнет функцию fun square(х) = х * х; Это происходит потому, что тип оператора * определить невозможно. В подобных слу- чаях программист может дать системе подсказку, подобную указанной ниже, в которой функция устанавливается как имеющая тип int. fun square(х) : int = х * х; Факта, что значение функции указано целочисленным, достаточно для логического вывода, что аргумент также является целым. Вполне дозволены также следующие опре- деления: fun square(х : int) = х * х; fun square(х) = (х : int) * х; fun square(х) = х * (х : int); Логический вывод типа также используется в чисто функциональных языках про- граммирования Miranda и Haskell. 4.4. Концепция связывания 183
4.4.3. Связывание переменных с ячейками памяти и время их жизни Основные свойства языка программирования в значительной степени определяются разработкой способов связывания ячеек памяти с переменными, которые в них хранятся Из этого следует важность четкого понимания этих связей. Ячейки памяти, с которыми связываются переменные, каким-то образом должны из- влекаться из пула доступной памяти. Этот процесс называется размещением в памяти (allocation). Удаление из памяти (deallocation)— это процесс помещения ячейки памя- ти. открепленной от переменной, обратно в пул доступной памяти. (Следует обратить внимание на то. что с ячейкой и переменной, хранящейся в этой ячейке, производятся прямо противоположные действия. Для связывания с некоей переменной свободная ячейка извлекается из пула свободной памяти, а переменная помещается в эту ячейку, 1 е. размещается в памяти. При разрыве связи между переменной и ячейкой переменная удаляется из памяти, а ячейка возвращается обратно в пул свободной памяти. По этой причине обычно термины размещение и удаление относятся к переменной, а не к ячей- ке. — Прим, ред.) Время жизни переменной — это время, в течение которого переменная связана с оп- ределенной ячейкой памяти. Таким образом, время жизни переменной начинается при ее связывании с определенной ячейкой памяти и заканчивается при ее откреплении от этой ячейки. Для изучения связывания памяти с переменными удобно разделить скалярные (неструктурированные) переменные на четыре категории согласно их временам жизни. Мы назовем эти категории статическими (static), автоматическими (stack-dynamic), яв- ными динамическими (explicit heap-dynamic) и неявными динамическими переменными (implicit heap-dynamic). В следующих разделах рассматриваются эти четыре категории, в том числе их цели, достоинства и недостатки. 4.4.3.1. Статические переменные Статическими называются переменные, которые связываются с ячейкой памяти до начала выполнения программы и остаются связанными с той же самой ячейкой памяти вплоть до прекращения выполнения программы. Переменные, которые статически свя- зываются с памятью, имеют несколько полезных применений в программировании. Оче- видно. что глобальные переменные часто используются на всем протяжении программы, чго делает необходимым их привязку к одному месту памяти в течение всего времени выполнения программы. Иногда бывает удобно, чтобы переменные, объявляемые в под- программах. зависели от предыстории (history sensitive), т.е. сохраняли свое значение между отдельными выполнениями подпрограммы. Это как раз и является характеристи- кой переменной, статически связанной с памятью. Другим плюсом статических переменных является их эффективность. Вся адресация статических переменных может быть прямой, тогда как другие типы переменных часто требуют более медленной косвенной адресации. Более того, на размещение статических переменных в памяти и удаление их из памяти в процессе выполнения программы не за- трачивается дополнительное время. Одним из недостатков статического связывания с памятью является уменьшение гиб- кости; в частности, в языках, имеющих только статические переменные, не поддержива- ются рекурсивные подпрограммы. Еще одним недостатком является невозможность со- вместного использования памяти несколькими переменными. Предположим, что в про- 184 Глава 4. Имена, связывание, проверка типов и области видимости
грамме есть две подпрограммы, причем обеим нужны большие, не связанные между со* бой массивы. Если они являются статическими, то память, в которой они хранятся, нель- зя использовать совместно. В языках FORTRAN I. II и IV все переменные были статическими. Языки С. С*+ и Java позволяют программисту включать в определение локальных переменных специфи- катор static, делая их статическими. В языке Pascal статические переменные не пре- дусмотрены. 4.4.3.2. Автоматические переменные Автоматическими называются переменные, связывание памяти с которыми выпол- няется при обработке их операторов объявления, но типы которых связываются статиче- ски. Обработка (elaboration) такого объявления означает распределение памяти и вы- полнение процессов связывания, указанных в объявлении. Эти действия происходят при достижении фрагмента кода, с которым связано объявление, в процессе выполнения про- граммы. Следовательно, обработка происходит во время выполнения программы. На- пример. процедура языка Pascal состоит из раздела объявлений и раздела операторов. Раздел объявлений обрабатывается непосредственно перед началом выполнения раздела операторов, происходящего при вызове процедуры. Память для переменных, находя- щихся в разделе объявления, выделяется во время обработки объявлений и освобождает- ся после возврата процедурой управления вызывающему оператору. Как показывает их название, память автоматическим переменным выделяется из стека выполняемой про- граммы (run-time stack). Структура языка ALGOL 60 и последующих языков позволяет использовать рекур- сивные подпрограммы. Для того чтобы быть полезными, по крайней мере в большинстве случаев, рекурсивным подпрограммам требуется некоторая динамическая локальная па- мять для того, чтобы каждая активная копия рекурсивной подпрограммы имела свою собственную версию локальных переменных. Для удовлетворения этих требований ис- пользуются автоматические переменные. Даже при отсутствии рекурсии наличие дос- тупной для подпрограмм локальной памяти с динамическим стеком имеет свои положи- тельные стороны, поскольку для хранения локальных переменных все подпрограммы со- вместно используют одну область памяти. Недостатками автоматических переменных являются затраты времени на размещение в памяти и удаление из нее, а также то. что ло- кальные переменные не могут зависеть от предыстории. Языки FORTRAN 77 и FORTRAN 90 позволяют разработчикам систем их реализации ис- пользовать для локальных вычислений автоматические переменные, но содержат оператор SAVE list Этот оператор позволяет программисту указать, что некоторые или все перечисленные в списке list переменные в подпрограмме, содержащей данный оператор SAVE, будут статическими. В языках С и C++ локальные переменные по умолчанию являются автоматическими. В языках Pascal и Ada все определенные в подпрограммах нединамические переменные являются автоматическими. Все атрибуты, кроме памяти, статически связываются с автоматическими переменными. Исключения (для некоторых структурированных типов) рассматриваются в главе 5. Разме- щение в памяти и удаление из нее автоматических переменных описаны в главе 9. 4.4. Концепция связывания 185
4.4.3.3. Явные динамические переменные Явные динамические переменные— это безымянные (абстрактные) ячейки памяти, размещаемые и удаляемые с помощью явных команд периода выполнения, определяемых программистом. Обращаться к этим переменным можно только с помощью указателей и ссылок. Куча (heap) представляет собой набор ячеек памяти с крайне неорганизованной структурой, вызванной непредсказуемостью их использования. Явные динамические пере- менные создаются либо оператором (например в языках Ada и C++), либо вызовом преду- смотренной для этого системной подпрограммы (например в языке С). Существующий в языке C++ оператор распределения памяти new в качестве операн- да использует имя типа. При выполнении этого оператора создается явная динамическая переменная, имеющая тип операнда, и возвращается указатель на нее. Поскольку явная динамическая переменная связывается с типом во время компиляции, то это связывание является статическим. Тем не менее, подобные переменные связываются с некоторой ячейкой памяти во время их создания, т.е. при выполнении программы. Помимо подпрограмм или операторов для создания явных динамических перемен- ных, в некоторых языках есть средства их уничтожения. В качестве примера явных динамических переменных рассмотрим следующий фраг- мент программы на языке C++: int *intnode; intnode = new int /* связывает ячейку int */ delete intnode; /* освобождает ячейкуt на которую указывает указатель intnode */ В этом примере явная динамическая переменная, имеющая тип int, создается опера- тором new. Обращаться к этой переменной можно с помощью указателя intnode. За- тем переменная удаляется из памяти оператором delete. В объектно-ориентированном языке Java все данные, за исключением основных ска- лярных величин, являются объектами. Объекты языка Java представляют собой явные динамические объекты, и доступ к ним открывают ссылки. В языке Java нет способа яв- ного уничтожения динамических переменных; вместо этого используется неявная “сборка мусора”. Явные динамические переменные часто используются в таких динамических структу- рах, как связные списки и деревья, которым необходимо расти и/или сокращаться во время выполнения программы. Подобные структуры удобнее создавать с помощью ука- зателей или ссылок, а также явных динамических переменных. Недостатком явных динамических переменных является сложность корректного ис- пользования указателей и ссылок, а также стоимость ссылок на переменные, размещения в памяти и удаления из нее. Эти соображения, указатели и ссылки, а также методы реа- лизации явных динамических переменных подробно рассмотрены в главе 5. 4.4.3.4. Неявные динамические переменные Неявные динамические переменные связываются с ячейкой динамической памяти только при присваивании им значений. Все их атрибуты фактически связываются каж- дый раз при присвоении переменным некоторого значения. Эти переменные, в некото- ром смысле, — просто имена, приспособленные для любого использования, которое от 186 Глава 4. Имена, связывание, проверка типов и области видимости
них потребуется. Плюсом таких переменных является их высокая степень гибкости, по* зволяюшая писать крайне общие программы. Минусом являются затраты на поддержку во время выполнения программы всех динамических атрибутов, в число которых, поми* мо прочих, могут входить типы и диапазоны индексов массивов. Другим недостатком является потеря возможности обнаружения компилятором некоторых ошибок (см. раз- дел Д.4.2.2). В том же разделе приводятся примеры неявных динамических переменных в языке APL. Примеры этих переменных в языке ALGOL 68 даны в главе 2, где они назы- ваются массивами flex. 4.5. Проверка типов Для обсуждения проверки типов обобщим понятия операндов и операторов, чтобы включить в эти понятия подпрограммы и операторы присваивания. Мы будем считать подпрограммы операторами, операндами которых являются их параметры. Символ при- сваивания мы будем рассматривать как бинарный оператор, а операндами будут его це- левая переменная и выражение. Проверка типов (type checking) обеспечивает совместимость типов операндов опера- тора. Совместимым (compatible) типом называется тип, который либо изначально допус- кается для данного оператора, либо правила языка позволяют с помощью команд компиля- тора неявно преобразовать его в тип, допускаемый для данного оператора. Это автоматиче- ское преобразование называется приведением (coercion). Применение оператора к операнду неприемлемого типа называется ошибкой определения типа (type error). Если в языке все связывания переменных с типами являются статическими, то про- верка типов практически всегда может выполняться статически. Динамическое связыва- ние типов требует проверки типов во время выполнения программы, называющейся ди- намической проверкой типов. Некоторые языки, например APL и SNOBOL4, имеющие динамическое связывание типов, допускают только динамическую проверку типов. Намного лучше обнаружить ошибки во время компиляции, чем во время выполнения, поскольку исправление на ран- них стадиях, как правило, обходится дешевле. Платой за статическую проверку типов является снижение гибкости программирования. При этом разрешается использовать значительно меньше сокращений и уловок. Правда, подобные технические приемы це- нятся сейчас не очень высоко. Проверка типов осложняется, если язык позволяет хранить в ячейке памяти в разное время выполнения программы величины разных типов. Это можно сделать, например, с помощью вариантных записей языков Ada и Pascal, оператора EQUIVALENCE языка FORTRAN и объединений языков С и C++. В этих случаях проверка типов, если она производится, должна быть динамической, кроме того, она требует, чтобы система под- держки выполнения программ хранила типы текущих значений, записанных в таких ячейках памяти. Таким образом, даже если в языках, подобных С и Pascal, все перемен- ные статически связаны с типами, не все ошибки определения типа будут выявлены при статической проверке типов. 4.5. Проверка типов 187
4.6. Строгая типизация Одной из новых идей в области структуры языков, проявившейся во время так назы- ваемой структурной революции в программировании 1970-х годов, является строгая типизация (strong typing). Строгая типизация общепризнанна как крайне полезная кон- цепция. К сожалению, довольно часто она определяется неточно, а иногда в компьютер- ной литературе используется вообще без определения. Ниже следует простое, но неполное определение строго типизированного языка. В программах, написанных на таком языке, каждое имя имеет отдельный связанный с ним тип, причем этот тип известен во время компиляции. Сутью этого определения является то. что все типы связываются статически. Слабостью этого определения является игно- рирование в нем следующей возможности: хотя тип переменной и может быть известен, ячейка памяти, с которой связана эта переменная, в разное время может содержать вели- чины разных типов. Для того чтобы учесть эту возможность, мы определим строго ти- пизированный язык как такой, в котором всегда обнаруживаются ошибки типов. Для этого требуется, чтобы типы всех операндов могли определяться либо во время компи- ляции, либо во время выполнения. Важность строгой типизации заключается в возмож- ности выявления всех неправильных употреблений переменных, приводящих к ошибкам определения типов. Строго типизированные языки позволяют также обнаружить (в про- цессе выполнения) использование величин некорректных типов в переменных, которые могут содержать величины нескольких типов. Язык FORTRAN не принадлежит к строго типизированным языкам, поскольку отноше- ния между фактическими и формальными параметрами не подвергаются проверке соответ- ствия типов. Кроме того, использование оператора EQUIVALENCE между переменными различных типов позволяет переменной одного типа ссылаться на переменную другого ти- па. причем, если на одну из переменных, входящих в оператор EQUIVALENCE, ссылаются или присваивают ей значение, то система не может проверить тип этой величины. Провер- ка типов переменных, входящих в оператор EQUIVALENCE, фактически сводит на нет большинство достоинств этих переменных. Язык Pascal относится к почти строго типизированным языкам, причем этим “почти” он обязан своей структуре вариантных записей, поскольку последние позволяют опус- кать метку, содержащую текущий тип переменной и являющуюся средством проверки корректного типа величины. Вариантные записи и потенциальные проблемы, возникаю- щие при их использовании, рассматриваются в главе 5. Язык Ada также является почти строго типизированным языком. Ссылки на перемен- ные. входящие в вариантные записи, динамически проверяются на наличие величин кор- ректных типов. Это значительное усовершенствование по сравнению с языками Pascal и Modula-2, в которых проверка вообще невозможна и намного менее необходима. Однако язык Ada позволяет программистам нарушать правила проверки типов, вводя отдельное требование о временной отсрочке проверки для конкретного преобразования типов. Та- кая отстрочка возможна только при использовании библиотечной функции UNCHECKED_CONVERSION. Эта функция, версия которой может существовать для лю- бого типа данных, принимает переменную ее типа в качестве параметра и возвращает . року битов, представляющую собой текущее значение этой переменной. Действитель- го преобразования при этом не происходит; это просто средство извлечения значения переменной одного типа и использования его в качестве значения переменной другого 188 Глава 4. Имена, связывание, проверка типов и области видимости
типа. Это может оказаться полезным в определяемых пользователем операциях по раз- мещению переменных в памяти и освобождению памяти, при которых с адресами обра- щаются как с целыми числами, в то время как они должны использоваться как указатели. Поскольку в функции UNCHECKED-CONVERSION нет проверки типов, то за осмыслен- ное использование переменных, полученных из этой функции, ответственность несет программист. Те же функции, что и функция UNCHECKED-CONVERSION языка Ada. выполняет имеющаяся в языке Modula-З встроенная процедура LOOPHOLE. Языки С и C++ не относятся к строго типизированным языкам, поскольку в них до- пускается существование функций, тип параметров которых не проверяется. Более того, типы объединений этих языков также не проверяются. Строго типизированным языком является язык ML, правда с некоторыми отличиями от императивных языков. В языке ML есть переменные, все типы которых известны ста- тически или из объявлений, или из правил логического вывода типов, описанных в раз- деле 4.4.2.3. Язык Java, хотя он в значительной степени создан на основе языка C++, является строго типизированным в том же смысле, что и язык Ada. Типы могут приводиться явно, что может вызвать ошибку определения типа. Тем не менее, не существует неявных пу- тей, позволяющих ошибкам определения типа остаться незамеченными. Правила приведения типов языка значительно влияют на результат проверки типов. Например, выражения в языке Pascal являются строго типизированными. Несмотря на это, допускается использование арифметического оператора с одним операндом, являю- щимся числом с плавающей точкой (в языке Pascal называемым real) и одним целым операндом. Значение целого операнда приводится к виду числа с плавающей точкой, и в результате получается операция над числами с плавающей точкой. Обычно это — имен- но то, что задумывал программист. Тем не менее, это также приводит к потере одной из причин строгой типизации— обнаружения ошибок. Таким образом, приведение типов снижает результат строгой типизации. Языки, в которых широко используется приведе- ние типов, например FORTRAN, С и C++, значительно менее надежны, чем языки, в ко- торых приведение типов применяется нечасто, например Ada. В языке Java содержится в два раза меньше разновидностей приведений типов, чем в языке C++. Подробнее вопрос приведения типов освещается в главе 6. 4.7. Совместимость типов Понятие совместимости типов было определено при освещении вопроса проверки типов. В данном разделе мы рассмотрим различные правила совместимости типов. Структура существующих в языке правил совместимости типов важна, поскольку она влияет на структуру типов данных и операции, производимые над величинами этих ти- пов. Вероятно, важнейшим следствием того, что две переменные имеют совместимые типы, является то, что любой из них может быть присвоено значение другой. Существуют два различных вида совместимости типов: совместимость имен типов и совместимость структур типов. Совместимость имен типов (name type compatibility) означает, что две переменные имеют совместимые типы только в том случае, если они были объявлены в одном объявлении или в объявлении, использующем одно и то же имя типа. Совместимость структур типов (structure type compatibility) означает, что две пе- 4.7. Совместимость типов 189
ременные имеют совместимые типы в том случае, если у их типов одинаковые структу- ры. Существуют некоторые разновидности этих двух методов, и в большинстве языков используются комбинации различных способов. Совместимость имен типов легко реализуется, но крайне ограничивает программиста. При строгой интерпретации переменная, принадлежащая к ограниченному типу целых чисел, не будет совместимой с переменной, имеющей целый тип. Предположим, что в языке Pascal используется строгая совместимость типов имен, и рассмотрим следующий фрагмент программы: type indextype = 1..100; (ограниченный тип) var count : integer; index : indextype; Переменные count и index не будут совместимы; значение переменной count не сможет присваиваться переменной index, и наоборот. Другая проблема, связанная с совместимостью имен типов, возникает при передаче структурированного типа между подпрограммами через параметры. Такой тип должен определяться только один раз, глобально. Подпрограмма не может устанавливать тип подобных формальных параметров локально, как было в исходной версии языка Pascal. Совместимость структур типов значительно более гибкая, чем совместимость имен типов, но гораздо сложнее реализуется. При определении совместимости имен типов должны сравниваться только имена двух типов, а при использовании совместимости структур типов — целые структуры двух типов. Выполнить это сравнение не всегда лег- ко. (Рассмотрите, например, такую структуру данных, ссылающуюся на собственный тип, как связный список.) При этом может возникнуть еще один вопрос. Являются ли, например, два комбинированных или структурных типа совместимыми, если они имеют одинаковую структуру, но разные имена полей? Совместимы ли два одномерных масси- ва в программе на языке Pascal или Ada, если они содержат элементы одного типа, но различаются областью значений индекса: 0. . 10 и 1. . 11? Совместимы ли два перечис- лимых типа, если они содержат одинаковое число компонентов, но по-разному образо- вывают литеральные константы? Еще одной трудностью, связанной с совместимостью структур типов, является то, что она не признает различий между типами, имеющими одинаковую структуру. Рассмотрим следующее объявление, которое могло бы появиться в программе на языке Pascal: type Celsius = real; fahrenheit = real; Переменные указанных типов считаются совместимыми при проверке совместимости структур типов. Это позволяет им смешиваться в выражениях, что, очевидно, в данном случае нежелательно. Вообще, типы с различными именами, вероятнее всего, являются абстракциями различных категорий сущностей задачи, и не должны рассматриваться как эквивалентные. В исходном определении языка Pascal (Wirth, 1971) явно не устанавливается, когда должна использоваться совместимость структур типов, а когда— совместимость их имен. Это крайне вредно для мобильности программ, поскольку программа, корректная в одной системе реализации языка, не должна быть некорректной в другой. Стандарт язы- ка Pascal, созданный Международной организацией по стандартизации (ISO, 1982), явно 190 Глава 4. Имена, связывание, проверка типов и области видимости
устанавливает правила совместимости типов для данного языка, частично — по имени, частично — по структуре. В большинстве случаев используется структура, а имя приме- няется для формальных параметров и в некоторых других ситуациях. Рассмотрим, на- пример, следующие объявления: type typel = array [1..10] of integer; type2 = array [1..10] of integer; type3 = type2; В этом примере типы typel и type2 несовместимы, что свидетельствует об исполь- зовании совместимости структур типов. Более того, тип type2 совместим с типом type3, из чего можно заключить, что эквивалентность имен также не используется строго. Такая форма совместимости иногда называется эквивалентностью объявлений (declaration equivalence), поскольку при определении типа с помощью имени другого ти- па, оба они являются совместимыми, даже если они несовместимы по именам типов. В языке Ada используется совместимость имен типов, но при этом имеются две кон- струкции — подтипы и производные типы, — позволяющие устранить проблемы, возни- кающие при этом виде совместимости. Производным (derived) называется новый тип, основанный на некотором ранее определенном типе, с которым он несовместим, несмот- ря на то, что они имеют идентичную структуру. Производные типы наследуют все свой- ства родительских типов. Рассмотрим следующий пример: type Celsius is new FLOAT; type fahrenheit is new FLOAT; Переменные данных типов несовместимы, хотя и имеют идентичную структуру. Более того, переменные этих типов несовместимы ни с каким другим типом чисел с плавающей точкой. Исключением из правила являются только литеральные константы. Литеральная константа, например 3.0, имеет тип универсальных действительных чисел и совместима с любым типом чисел с плавающей точкой. Производные типы также могут содержать огра- ничения диапазона родительского типа, наследуя при этом все его операции. Подтип (subtype) в языке Ada — версия существующего типа с возможно ограничен- ным диапазоном. Подтип совместим с породившим его типом. Рассмотрим следующее объявление: subtype SMALL__TYPE is INTEGER range 0..99; Переменные, имещие тип SMALL_TYPE, совместимы с переменными, имеющими тип INTEGER. Правила совместимости типов в языке Ada более важны, чем соответствующие правила языков, в которых широко используется приведение типов. Например, два операнда, вхо- дящие в оператор сложения в языке С, могут иметь практически любую комбинацию чи- словых типов этого языка. Один из операндов при этом просто приводится к типу другого. Однако в языке Ada нет приведения типов операндов арифметического оператора. В языке С используется структурная эквивалентность для всех типов, за исключением структур (записи языка С) и объединений, для которых используется эквивалентность объявлений. Правда, если две структуры или объединения определяются в двух различ- ных файлах, используется эквивалентность типов структур. 4.7. Совместимость типов 191
В языке C++ используется эквивалентность имен. Отметим, что оператор typedef языков С и C++ не вводит новый тип. Он просто определяет новое имя для уже сущест- вующего типа. Во многих языках переменные могут объявляться без использования имен типа, соз- давая безымянные типы. Рассмотрим следующий пример из языка Ada: А : array (1..10) of INTEGER; В этом случае переменная А имеет безымянный, но неоднозначно определенный тип. В объявлении В : array (1..10) of INTEGER; переменные А и В будут принадлежать к безымянным, но различным и несовместимым типам, хотя они имеют идентичную структуру. Множественное объявление Q, D : array (1..10) of INTEGER; создаст два безымянных типа: один для переменной С, другой — для переменной D, не- совместимых между собой. Фактически эти объявления можно рассматривать как сле- дующие два объявления: С : array (1..10) of INTEGER; D : array (1..10) of INTEGER; Результатом этого будет несовместимость переменных С и D. Однако в объявлении type LIST_10 is array (1..10) of INTEGER; C, D ; LIST-10; переменные С и D будут совместимыми. Очевидно, что в языках, не позволяющих пользователям определять и называть типы, например FORTRAN и COBOL, эквивалентность имен использоваться не может. Возникновение таких объектно-ориентированных языков, как Java и C++, подняло вопрос о другом типе совместимости типов. Это — вопрос о совместимости объектов и его связи с иерархией наследования. Подробнее об этом рассказывается в главе 11. Совместимость типов в выражениях рассмотрена в главе 6; совместимость типов па- раметров подпрограмм описана в главе 8. 4.8. Область видимости Одной из важнейших характеристик переменных является область видимости. Об- ласть видимости (scope) переменных программы — это ряд операторов, в которых пе- ременная видима. Переменная является видимой (visible) в операторе, если к перемен- ной, входящей в оператор, можно обратиться. Правила обзора данных в языке определяют, как именно конкретное появление имени связано с переменной. В частности, правила обзора данных определяют, каким образом ссылки на переменные, объявленные вне выполняющейся в данный момент подпро- граммы или блока, связаны с их объявлениями и, вследствие этого, с их атрибутами (блоки рассматриваются в разделе 4.8.2). Следовательно, для написания или чтения про- грамм на данном языке необходимо полное знание этих правил. 192 Глава 4. Имена, связывание, проверка типов и области видимости
Как определялось в разделе 4.4.3.2. переменная является локальной в программной единице или блоке, если она там объявлена. (В данной главе мы считаем программными единицами главный программный модуль или подпрограммы. Единицы, подобные клас- сам языков C++ или Java, рассматриваются в главе 10.) Нелокальными переменными (nonlocal variables) программной единицы или блока называются переменные, которые видимы в этой программной единице или блоке, но не объявляются в них. 4.8.1. Статическая область видимости В языке ALGOL 60 был введен метод связывания имен с нелокальными переменными, названный статическим обзором данных (static scoping). Позже этот метод был позаимст- вован большинством императивных, а также многими неимперативными языками. Исполь- зование статического обзора данных получило свое название из-за возможности статиче- ского (т.е. до периода выполнения) определения области видимости любой переменной. Большинство отдельных статических областей видимости в императивных языках связаны с определениями программных единиц. Предположим, что все области видимо- сти связаны с программными единицами. В данной главе мы также будем полагать, что для обращения к нелокальным переменным в обсуждаемых языках используются только области видимости. Последнее не совсем справедливо даже для языков со статическим обзором данных, но такое предположение упрощает обсуждение. Дополнительные мето- ды обращения к нелокальным переменным рассматриваются в главе 8. Во многих языках подпрограммы создают собственные области видимости. Во всех распространенных языках со статическим обзором данных, за исключением языков С, C++, Java и FORTRAN, подпрограммы могут вкладываться в другие подпрограммы, что создает в программе иерархию областей видимости. Когда в языке, использующем статический обзор данных, компилятор обнаруживает переменную, ее атрибуты определяются путем поиска объявившего ее оператора. В язы- ках, использующих статический обзор данных, при наличии вложенных подпрограмм этот процесс протекает следующим образом. Предположим, что сделано обращение к переменной х подпрограммы subl. Соответствующее объявление вначале разыскивает- ся в объявлениях подпрограммы subl. Если для данной переменной объявления не най- дено, то поиск продолжается в объявлениях подпрограммы, объявившей подпрограмму subl. называемой статическим родителем (static parent) подпрограммы subl. Если объявление переменной х не найдено и в этой подпрограмме, то поиск продолжается в следующей внешней единице (в модуле, объявившем родителя подпрограммы subl) и так далее, пока не будет найдено объявление переменной х, или поиск в самом внешнем блоке не увенчается успехом. В последнем случае будет зафиксирована ошибка необъяв- ленной переменной. Статический родитель подпрограммы subl, его статический роди- тель и так далее вплоть до основной программы называются статическими предками (static ancestors) подпрограммы subl. Отметим, что методы реализации статического обзора данных, рассматриваемые в главе 9, значительно эффективнее, чем только что описанный процесс. Рассмотрим следующую процедуру языка Pascal: procedure big; var x : integer; procedure subl; begin { subl } 4.8. Область видимости 193
. . . X . . . end; { subl ) procedure sub2; var x : integer; begin { sub2 } end; { sub2 } begin { big } end; { big ) При использовании статического обзора данных ссылка на переменную х подпро- граммы subl относится к переменной х, объявленной в процедуре big. Это действи- тельно так, поскольку поиск объявления переменной х начался в той процедуре, в кото- рой встречается ссылка, но объявления этой переменной в ней найдено не было. Далее поиск продолжился в статическом родителе подпрограммы subl (подпрограмме big), в котором и было найдено объявление переменной х. Наличие предопределенных имен, рассмотренных в разделе 4.2.3, несколько затруд- няет описанный процесс. Иногда предопределенное имя подобно ключевому слову и может переопределяться пользователем. В таких случаях предопределенное имя исполь- зуется, только если пользовательская программа не содержит переопределения. В других случаях предопределенное имя может быть зарезервировано, что означает начало поиска значения данного имени в списке предопределенных имен, который выполняется даже до проверки объявлений локальной области видимости. В языках со статическим обзором данных объявления некоторых переменных могут быть скрыты от некоторых подпрограмм. Рассмотрим следующую скелетную программу на языке Pascal: program main; var x : integer; procedure subl; var x : integer; begin { subl } . . . x. . . end; { subl } begin {main} end. {main} Ссылка на переменную x в процедуре subl относится к объявившей переменную х процедуре subl. В этом случае переменная х программы main скрыта от команд про- цедуры subl. Вообще, объявление переменной эффективно скрывает любое объявление одноименной переменной, содержащейся во внешней области видимости. В языке Ada к переменным, скрытым от областей видимостей предков, можно полу- чить доступ с помощью селективных ссылок, содержащих имя области видимости пред- ка. В предыдущей программе, например, к переменной х процедуры subl можно обра- титься с помощью ссылки main. х. Несмотря на то что в языках С и C++ не разрешено использование подпрограмм, вложенных в определения других подпрограмм, глобальные переменные в этих языках есть. Эти переменные объявляются вне определения любой подпрограммы. Как и в язы- 194 Глава 4. Имена, связывание, проверка типов и области видимости
ке Pascal, локальные переменные могут скрывать эти глобальные переменные. В языке C++ к таким скрытым глобальным переменным можно обращаться с помощью операто- ра доступа (: :). Например, если переменная х является глобальной переменной, скры- той в подпрограмме локальной переменной х. то обратиться к глобальной переменной можно в форме : : х. 4.8.2. Блоки Многие языки позволяют создавать новые статические области видимости во время выполнения программы. Эта мощная концепция, впервые появившаяся в языке ALGOL 60, позволяет фрагменту7 программы иметь собственные локальные переменные с минимизированной областью видимости. Подобные переменные, как правило, являют- ся автоматическими, так что память выделяется им в начале выполнения фрагмента про- граммы, а освобождается по окончании его выполнения. Подобный фрагмент программ- ного кода получил название блока (block). Как показано ниже, в языке Ada блоки задаются оператором declare: declare TEMP : integer; begin TEMP := FIRST; FIRST := SECOND; SECOND := TEMP; end; От понятия “блок" произошло выражение язык с блочной структурой (block- structured language). Хотя языки Pascal и Modula-2 и называются языками с блочной структурой, они не имеют непроцедурных блоков. В языках С, С4 + и Java любой составной оператор (последовательности операторов, заключенной в фигурные скобки) может содержать объявления и таким образом опреде- лять новую область видимости. Такие составные операторы являются блоками. Напри- мер, если list — массив целых чисел, то можно написать следующий код: if (list [i] < list [ j]) { int temp; temp = list [i]; list[i] = list [ j]; 1i s t [ j] = t emp; } Области видимости, создаваемые блоками, трактуются точно так же, как области ви- димости, создаваемые подпрограммами. Обращения к переменным блока, не объявляе- мым в этом блоке, связываются с их объявлениями путем поиска по возрастающей во внешних областях. В языках C++ и Java определять переменные можно в любом месте функции. Если определение появляется не в начале функции, то область видимости данной переменной начинается с оператора определения и заканчивается концом функции. Оператор for языков C++ и Java позволяет определять переменные в выражениях, инициализирующих счетчики цикла. В ранних версиях языка C++ область видимости та- кой переменной начиналась с ее определения и заканчивалась в конце наименьшего бло- 4.8. Область видимости 195
ка, содержащего данную переменную. Впрочем, в предварительной стандартной версии область видимости таких переменных была ограничена телом цикла for, так же пос i \ - пили и в языке Java. Определения класса и метода в объектно-ориентированных языках программирова- ния также порождают вложенные статические области видимости. Этот вопрос рассмаг ривается в главе 11. 4.8.3. Оценка статического обзора данных Использование статических областей видимости представляет собой метод нелокаль- ного доступа, хорошо работающий во многих ситуациях. Впрочем, у этого метода есть и недостатки. Рассмотрим программу, скелетная структура которой показана на рис. 4.1. При этом будем считать, что все области видимости создаются определениями основной программы и процедур. Программа содержит общую область видимости для блока main, помимо этого су- ществуют две процедуры А и В, определяющие области видимости внутри блока main. Внутри процедуры А расположены области видимости процедур С и D. Внутри процеду- ры В находится область видимости процедуры Е. Мы предполагаем, что необходимое обращение к данным и процедурам определяется структурой рассматриваемой програм- мы. Требуемое обращение к процедуре имеет следующий вид: программа main может вызывать блоки А и В, блок А — блоки С и D, а блок В — блоки А и Е. Структуру программы удобно представить в виде дерева, каждый узел которого представляет процедуру и, следовательно, область видимости. Представление програм- мы, показанной на рис. 4.1, изображено на рис. 4.2. Структура этой программы может показаться весьма естественной организацией программы, отчетливо отражающей структурные требования. Впрочем, изображенный на рис. 4.3 граф возможных вызовов процедур этой программы показывает, что возможно значительно большее количество вызовов, чем необходимо. Рис. 4.1. Структура программы Рис. 4.2. Древовидная структура программы, показанной на рис. 4.1 196 Глава 4. Имена, связывание, проверка типов и области видимости
На рис. 4.4 показаны требуемые вызовы рассматриваемой программы. Различия меж- ду рис. 4.3 и 4.4 показывают число возможных, а не необходимых вызовов. Программист может по ошибке вызвать подпрограмму, вызов которой не должен до- пускаться, причем это действие не будет расценено компилятором как ошибка. В резуль- тате ошибка будет обнаружена только при выполнении программы, что может повысить стоимость ее исправления. Следовательно, доступ к процедурам должен быть ограничен только необходимыми процедурами. Рис, 4,3, Г&аф вызовов, возможных в Рис. 4.4. Граф вызовов, необходимых для программе, показанной на рис. 4.1 программы, изображенной на рис. 4.1 С этой проблемой связано слишком интенсивное обращение к данным. Например, все переменные, объявленные в основной программе, видимы для всех процедур, причем избежать этого невозможно. Следующий сценарий описывает другие проблемы, возникающие при использовании статического обзора данных. Предположим, что после разработки и тестирования про- граммы потребовалось изменить ее спецификацию. В частности, необходимо открыть процедуре Е доступ к некоторым переменным из области видимости процедуры D. Для того чтобы решить возникшую проблему, можно переместить процедуру Е внутрь облас- ти видимости процедуры D. Однако в этом случае процедура Е уже не сможет обращать- ся к области видимости процедуры В, что, по-видимому, требуется (иначе зачем она там находится?). Другим решением является перемещение переменных, определяемых в процедуре D и необходимых в процедуре Е, в блок main. Это позволит обращаться к этим переменным из любой процедуры, которых может оказаться больше, чем надо, что может спровоцировать некорректный доступ. Например, неверно написанный идентифи- катор процедуры может быть воспринят не как ошибка, а как ссылка на идентификатор в некоторой внешней области видимости. Предположим, что переменная, перемешенная в блок main, называется х и необходима в процедурах D и Е. Допустим, что переменная с таким же именем объявляется в процедуре А. Это приведет к сокрытию требуемой пере- менной х от ее изначального владельца — процедуры D. Последней проблемой, возникающей при перемещении объявления переменной х в блок main, является пагубное влияние этого действия на читабельность, выражающееся в том, что объявления переменных находятся слишком далеко от места их использования. 4.8. Область видимости 197
Проблемы, связанные с видимостью переменных при использовании статических об- ластей видимости, характерны и для обращений к подпрограммам. Предположим, что в программе, изображенной на рис. 4.2, вследствие некоторых изменений спецификации возникла потребность вызова процедурой Е процедуры D. Чтобы это стало возможным, следует вложить процедуру D непосредственно в блок main, предполагая, что эта проце- дура также требуется процедурам А или С. При этом перемещении процедура D теряет доступ к переменным, определенным в процедуре А. Этот способ решения проблемы, если использовать его часто, приведет к возникновению программ с длинными списками служебных процедур низкого уровня. Таким образом, нарушение правил статического обзора данных может привести к то- му, что структуры программ будут лишь отдаленно походить на оригинал, причем даже в тех областях программы, в которых изменения не проводились. Разработчиков поощря- ют использовать излишнее количество глобальных переменных. Все процедуры в конце концов окажутся вложенными в основную программу на одном и том же уровне. При этом будут использоваться не более глубокие уровни вложения, а глобальные перемен- ные. Кроме того, окончательная структура может быть неуклюжей, запутанной и не со- ответствовать основной структурной концепции. У статического обзора данных есть и другие недостатки, подробно рассмотренные в книге Clarke, Wileden and Wolf (1980). Для решения проблем, связанных со статическим обзором данных, во многих новейших языках программирования используется конструкция инкапсуляции, подробно рассмот- ренная в главе 8. 4.8.4. Динамические области видимости Область видимости переменных в таких языках, как APL, SNOBOL4 и ранние версии языка LISP, является динамической. Динамический обзор данных (dynamic scoping) опирается на последовательность вызова подпрограмм, а не на их пространственную взаимосвязь. Следовательно, область видимости можно определить только во время вы- полнения программы. Рассмотрим повторно процедуру big из раздела 4.8.1: procedure big; var х : integer; procedure subl; begin { subl ) ...x... end; { subl ) procedure sub2; var x : integer; begin { sub2 ) end; { sub2 } begin { big } end; { big } Предположим, что правила динамического обзора данных применимы к нелокальным ссылкам. Значение идентификатора х, к которому обращаются в подпрограмме subl, — динамическое, оно не может определяться во время компиляции. В зависимости от по- 198 Глава 4. Имена, связывание, проверка типов и области видимости
следовательности вызова это значение может относиться к переменной из предыдущего объявления переменной х. Для того чтобы определить корректное значение переменной х во время выполнения программы, можно начать его поиск среди локальных объявлений. Этим же способом начинается поиск и при статическом обзоре данных, правда, на этом их сходство закан- чивается. При неудачном завершении поиска среди локальных объявлений рассматрива- ются объявления динамического родителя, или вызывающей процедуры. Если объявле- ние переменной х не будет найдено и там, то поиск продолжается в динамическом роди- теле этой процедуры, и так далее, пока не будет найдено объявление переменной х. Если оно не будет найдено ни в одном из динамических предков, то это будет ошибкой перио- да выполнения программы (run-time error). Рассмотрим две различные последовательности вызовов процедуры subl в приве- денном выше примере. В первом случае процедура big обращается к процедуре sub2, которая вызывает процедуру subl. При этом поиск перейдет от локальной процедуры subl к вызывающей ее процедуре sub2, в которой находится объявление переменной х. Таким образом, в данном случае обращение к переменной х процедуры subl являет- ся обращением к переменной х, объявленной в процедуре sub2. Во втором случае про- цедура subl вызывается непосредственно из процедуры big. При этом динамическим родителем процедуры subl является процедура big, и обращение будет направлено к переменной х, объявленной в процедуре big. 4.8.5. Оценка динамического обзора данных Воздействие динамического обзора данных на программирование значительно. Кор- ректные атрибуты нелокальных переменных, видимые операторам программы, невоз- можно определить статически. Более того, такие переменные не всегда одинаковы. Опе- ратор подпрограммы, содержащей ссылки на нелокальные переменные, во время раз- личных вызовов этой подпрограммы может обращаться к различным нелокальным переменным. Некоторые проблемы программирования связаны непосредственно с дина- мическим обзором данных. Во-первых, в период между вызовом подпрограммы и ее завершением все локальные переменные этой подпрограммы видимы для всех выполняющихся подпрограмм, вне за- висимости от их буквальной близости. От такой общедоступности локальных перемен- ных нет защиты. Подпрограммы всегда выполняются в непосредственной среде вызы- вающей программы; следовательно, использование динамического обзора данных поро- ждает менее надежные программы, чем использование статического обзора. Второй проблемой, связанной с динамическим обзором данных, является невозмож- ность статической проверки типов при обращении к нелокальным переменным. Это про- исходит из-за невозможности статически определить объявление переменной, на кото- рую ссылаются как на нелокальную. Использование динамического обзора данных также затрудняет чтение программ, по- скольку для определения нелокальных переменных, на которые имеются ссылки, должна быть известна последовательность вызова подпрограмм. Если программу читает человек, то определить последнее практически невозможно. В заключение отметим, что обращение к нелокальным переменным в языках с дина- мическим обзором данных происходит значительно медленнее, чем то же обращение, но в языках со статическим обзором. Причины этого подробно описаны в главе 9. 4.8. Область видимости 199
С другой стороны, у динамического обзора данных есть и достоинства. В некоторых случаях параметры, передаваемые от одной подпрограммы к другой, являются простыми переменными, определяемыми в вызывающем модуле. В языках с динамическим обзо- ром данных не требуется передачи ни одной из этих переменных, поскольку все они не- явно видимы в вызываемой подпрограмме. Нетрудно понять причины более широкого распространения статического, а не динами- ческого обзора данных. Программы на языках со статическим обзором данных легче чи- тать, они надежнее и выполняются быстрее, чем аналогичные программы на языках, ис- пользующих динамический обзор. Именно по этим причинам в большинстве современных диалектов языка LISP динамический обзор данных был заменен статическим. Далее в гла- ве 9 рассматриваются методы реализации статического и динамического обзора данных. 4.9. Область видимости переменных и время их жизни Иногда область видимости и время жизни переменных оказываются связанными между собой. Рассмотрим, например, переменную, объявленную в процедуре языка Pascal, не со- держащей вызовов подпрограмм. Область видимости такой переменной — от ее объявления до зарезервированного слова end в процедуре. Время жизни такой переменной начинается при входе в процедуру и заканчивается при достижении команды end (в языке Pascal нет оператора возврата). Хотя и очевидно, что область видимости и время жизни переменной не совпадают, поскольку статическая область видимости представляет собой буквальную, или пространственную, концепцию, а время жизни — концепция временная, в данном случае эти две концепции связаны между собой, или, по крайней мере, кажутся такими. В других случаях связь между областью видимости и временем жизни переменной не настолько очевидна. В языках С и C++, например, переменная, объявленная в* функции с использованием спецификатора static, статически связана с областью видимости этой функции и с памятью. Таким образом, ее область видимости статична и локальна по от- ношению к функции, но ее время жизни распространяется на все выполнение програм- мы, частью которой она является. Область видимости и время жизни не связаны также при вызовах подпрограмм. Рас- смотрим следующую функцию языка C++: void printheader() { } /* конец функции printheader*/ void compute{) { int sum; printheader() ; ) /* конец функции compute */ Область видимости переменной sum полностью находится внутри функции compute. Она не распространяется на тело функции printheader, хотя эта функция выполняется в середине функции compute. Тем не менее, время жизни переменной sum распространяется на время выполнения функции printheader. Какая бы ячейка памя- ти не была выделена переменной sum до вызова функции printheader, эта связь со- храняется во время и после выполнения этой функции. 200 Глава 4. Имена, связывание, проверка типов и области видимости
4.10. Среды ссылок Средой ссылок (referencing environment) оператора называется совокупность всех имен, видимых в данном операторе. Среда ссылок оператора в языках со статическим обзором данных состоит из переменных, объявленных в его локальной области видимо- сти, и совокупности всех видимых переменных из областей видимости его предков. В таком языке среда ссылок оператора нужна во время компиляции оператора, поэтому можно создавать команды и структуры данных, позволяющие доступ к переменным из других областей видимости во время выполнения программы. Методы реализации сред ссылок на нелокальные переменные в языках, использующих статический и динамиче- ский обзор данных, рассматриваются в главе 9. В языке Pascal, в котором области видимости создаются исключительно определе- ниями процедур, среда ссылок оператора содержит локальные переменные, все перемен- ные, объявленные в процедурах, содержащих данный оператор, а также переменные, объявленные в основной программе (за исключением переменных в нелокальных облас- тях видимости, скрытых объявлениями ближних процедур). Каждое определение проце- дуры создает новую область видимости, а следовательно, и новую среду. Рассмотрим следующую скелетную программу на языке Pascal: program example; var a, b : integer; procedure subl; var x, у : integer; begin { subl } . . . <----------------1 end; { subl } procedure sub2; var x : integer; procedure sub3; var x : integer; begin { sub3 } . . . <----------------2 end; { sub3 } begin { sub2 } . , . <---------------3 end; { sub2 } begin { example } . . . <---------------4 end. { example } В указанных точках данная программа имеет следующие среды ссылок: Точка Среда ссылок 1 переменные х и у процедуры subl, переменные а и b программы example 2 переменная х процедуры sub3, (переменная а процедуры sub2 скрыта), переменные а и b программы example 4.10. Среды ссылок 201
3 переменная х процедуры sub2, переменные а и b программы example 4 переменные а и b программы example Рассмотрим теперь объявления переменных в скелетной программе. Отметим, во- первых, что, хотя область видимости процедуры subl и выше уровнем (она менее глубоко вложена), чем область видимости процедуры sub3, область видимости процедуры subl не является статическим предком процедуры sub3, поэтому процедура sub3 не имеет доступа к переменным, объявленным в процедуре subl. Для этого существует важная причина. Переменные, объявленные в процедуре subl, являются автоматическими, поэто- му они не связаны с памятью в то время, когда процедура subl не выполняется. Посколь- ку процедура sub3 может выполняться в то время, когда процедура subl не выполняет- ся, она не должна иметь доступа к переменным процедуры subl, которые не обязательно должны связываться с ячейками памяти во время выполнения процедуры sub3. Подпрограмма называется активной, если ее выполнение началось, но еще не за- вершилось. Среда ссылок оператора в языке с динамическим обзором данных состоит из локально объявленных переменных и переменных всех других активных на данный мо- мент подпрограмм. Повторимся, некоторые переменные активных подпрограмм могут быть скрыты от среды ссылок. Новые активации подпрограммы могут содержать объяв- ления переменных, скрывающих переменные с теми же именами в предыдущих актива- циях подпрограммы. Рассмотрим следующую программу. Предположим, что единственными возможными вызовами функций являются следующие: функция main вызывает функцию sub2, кото- рая вызывает функцию subl. void subl() { int a , b; . . . <-------------1 ) /* конец функции subl */ void sub2() { int b, c; • . . <-------------2 subl; } /* конец функции sub2 */ void main() { int c, d; . . . <-------------3 sub2(); ) /* конец функции main */ В указанных точках данная программа имеет следующие среды ссылок: Точка Среда ссылок 1 переменные а и b процедуры subl, переменная с процедуры sub2, переменная d функции main (переменная с функции main и переменная b процедуры sub2 скрыты) 2 переменные b и с процедуры sub2, переменная d блока main (переменная с функции main скрыта) 3 переменные с и d функции main 202 Глава 4. Имена, связывание, проверка типов и области видимости
4.11. Именованные константы Именованной константой (named constant) называется переменная, связываемая со своим значением только во время связывания ее с ячейкой памяти: значение именован* ной константы невозможно изменить оператором присваивания или ввода. Данный объ* ект помогает улучшить читабельность программы: значительно удобнее, например, ис- пользовать имя pi, а не константу 3.14159. Другим полезным применением именованных констант являются программы, обра- батывающее заданное количество данных, например, 100. В подобных программах во многих местах, как правило, используется константа 100 для объявления диапазонов значений индексов массивов, пределов изменения счетчиков цикла и т.п. Рассмотрим следующую скелетную программу на языке Pascal: program example; type intarray = array [1..100] of integer; realarray = array [1..100] of real; begin { example ) for index := 1 to 100 do begin end; for count := 1 to 100 do begin end; average := sum div 100; end. {example} Для того чтобы эту программу модифицировать для работы с другим количеством значений, следует обнаружить все места, в которых написана цифра 100. и все числа за- менить новыми. В большой программе это может оказаться трудоемким и подвержен- ным ошибкам процессом. Более легким и надежным методом является использование именованной константы: program example; count listlen = 100; type intarray = array [1..listlen] of integer; realarray = array [1..listlen] of real; begin { example } for index := 1 to listlen do begin 4.11. Именованные константы 203
end; for count := 1 to listlen do begin end; average := sum div listlen; end. {example} Если в такой программе потребуется изменить количество данных, то изменять при- дется только одну строку, вне зависимости от количества упоминаний константы 100 в программе. Вот еще один пример преимущества абстракции: имя listlen является аб- стракцией для количества элементов некоторых массивов и количества повторений не- которых циклов. Приведенный пример иллюстрирует, как именованные константы могут облегчить модификацию программы. Объявление именованных констант в языке Pascal требует просто наличия некоторого значения справа от оператора =. Вместе с тем, языки Modula-2 и FORTRAN 90 позволя- ют использовать константные выражения, которые могут содержать предварительно объявленные именованные константы, постоянные величины и операторы. Язык Pascal ограничивается только константами, а язык Modula-2 — константными выражениями, поскольку в этих языках используется статическое связывание величин с именованными константами. Именованные константы в языках со статическим связыванием величин иногда еще называются манифестными константами (manifest constants). Языки Ada, C++ и Java допускают динамическое связывание величин с именованны- ми константами. Это позволяет в объявлениях присваивать константам выражения, со- держащие переменные. Например, оператор МАХ : constant integer := 2 * WIDTH + 1 языка Ada объявляет переменную МАХ именованной константой целого типа, значение которой соответствует значению выражения 2 * WIDTH + 1, причем значение пере- менной WIDTH должно быть видимым при выделении памяти константе МАХ и связыва- нии этой константы со своим значением. Помимо этого, язык Ada допускает существо- вание именованных констант перечислимого и структурированного типов, рассматри- ваемых в главе 5. 4.12. Инициализация переменных Обсуждение связывания значений с именованными константами естественным обра- зом приводит к теме инициализации переменных, поскольку связывание величины с именованной константой является таким же процессом, только постоянным. В большинстве случаев удобно, чтобы переменные имели значения до начала выпол- нения программы или подпрограммы, в которой они объявляются. Связывание перемен- ной со значением во время связывания ее с ячейкой памяти называется инициализацией (initialization). Если переменная статически связана с ячейкой памяти, то связывание и инициализация происходят до выполнения программы. При динамическом же связыва- нии с памятью инициализация также является динамической. 204 Глава 4. Имена, связывание, проверка типов и области видимости
В языке FORTRAN начальные значения переменных могут устанавливаться в опера- торе DATA: REAL PI INTEGER SUM DATA SUM /0/, PI /3.14159/ В этом операторе переменная SUM инициализируется значением С. а переменная PI — значением 3.14159. В данном случае фактическая инициализация происходит во время компиляции. Как только начнется выполнение программы, переменные SUM и PI не бу- дут ничем отличаться от других переменных. Во многих языках исходные значения переменных могут задаваться в операторах объявления, как в объявлении языка Ada: SUM : INTEGER := С; Ни язык Pascal, ни язык Modula-2 не содержат иного способа инициализации переменных, кроме инициализации во время выполнения с помощью операторов при- сваивания. Вообще, инициализация статических переменных происходит только один раз, но для таких динамических переменных, как локальные переменные в процедуре языка Ada, она происходит каждый раз при распределении памяти. Р е з ю м е Форма имен в языке может воздействовать как на читабельность этого языка, так и на удобство его использования. Другим значительным конструкторским решением является взаимосвязь имен и специальных слов, которыми могут быть зарезервированные или ключевые слова. Охарактеризовать переменные можно шестеркой атрибутов: именем, адресом, значе- нием, типом, временем жизни и областью видимости. Альтернативными именами называются имена, связанные с одним адресом памяти. С точки зрения надежности они расцениваются как вредные, но полностью исключить их из языка трудно. Связывание — это установление связи между программными объектами и их атрибу- тами. Знание момента времени, когда происходит связывание атрибутов с объектами, необходимо для понимания семантики языков программирования. Связывание может быть статическим или динамическим. Объявления, явные или неявные, обеспечивают средства статического связывания переменной с типом. Динамическое связывание, в общем случае, придает языку большую гибкость за счет ухудшения читабельности, эф- фективности и надежности программ, написанных на нем. Скалярные переменные можно разделить на четыре категории по времени их жизни: статические, автоматические, явные динамические и неявные динамические переменные. Строгая типизация означает необходимость обнаружения всех ошибок определения типа. Результатом строгой типизации является повышенная надежность. Правила совместимости типов в языке оказывают значительное влияние на выпол- няемые операции над величинами. Совместимость типов определяется, как правило, в терминах совместимости имен или структур типов. Резюме 205
Статический обзор данных является основным свойством языка ALGOL 60 и боль- шинства его потомков. Он предлагает эффективный метод разрешения видимости нело- кальных переменных подпрограмм. Динамический обзор данных обеспечивает большую по сравнению со статическим обзором гибкость, но, опять-таки, за счет ухудшения чита- бельности, эффективности и надежности программ. Среда ссылок оператора является совокупностью всех его переменных, видимых дан- ным оператором. Именованные константы — это простые переменные, связываемые со своим значени- ем только в момент их связывания с ячейкой памяти. Инициализация — это связывание переменной со своим значением во время ее связывания с ячейкой памяти. 1. Какие вопросы разработки языков программирования связаны с именами? 2. В чем состоит потенциальная опасность имен, зависящих от регистра? 3. Чем зарезервированные слова лучше ключевых? 4. Что такое альтернативное имя? 5. Какие категории ссылок в языке C++ всегда имеют альтернативные имена? 6. Что такое левое значение переменной? Что такое правое значение переменной? 7. Дайте определение связывания и времени связывания, 8. Какие четыре варианта времени связывания есть в программе после разработки языка и его реализации? 9. Дайте определение статического и динамического связывания, 10. Назовите достоинства и недостатки неявных объявлений. 11. Назовите достоинства и недостатки динамического связывания типов. 12. Дайте определение статических переменных, автоматических переменных, явных и неявных динамических переменных, 13. Дайте определение приведения типов, ошибки определения типов, проверки типов и строгой типизации. 14. Дайте определение совместимости имен типов и совместимости структур типов. Какое полезное свойство объединяет эти два понятия? 15. Чем различаются производные типы языка Ada и подтипы того же языка? 16. Дайте определение времени жизни, области видимости, статического и дина- мического обзора данных. 17. Каким образом обращение к нелокальной переменной в программе со статическим обзором данных связано с ее определением? 18. Назовите основную проблему, возникающую при статическом обзоре данных. 19. Что такое “среда ссылок оператора”? 20. Что является статическим предком подпрограммы? Что является динамическим предком подпрограммы? 21. Что такое “блок”? 206 Глава 4. Имена, связывание, проверка типов и области видимости
22. Назовите достоинства и недостатки динамического обзора данных. 23. В чем заключаются достоинства именованных констант? V п р а ж н с н и я 1. Определите, какая из следующих форм идентификаторов является самой чита- бельной. Аргументируйте ваше решение. SumOfSales sum_of_sales SUMOFSALES 2. В некоторых языках определение типов не предусмотрено. Назовите очевидные достоинства и недостатки таких языков. 3. Одним из распространенных использований оператора EQUIVALENCE языка FORTRAN является следующее. Большой массив числовых величин доступен под- программе как параметр. Массив содержит множество различных не связанных между собой переменных, а не совокупность повторений одной и той же перемен- ной. Данные представляются в виде массива для уменьшения числа имен, которые требуется передать как параметры. Вне подпрограммы длинный оператор EQUIVALENCE используется для создания дополнительных имен в качестве аль- тернативных имен различных элементов массива, что повышает читабельность ко- манд подпрограммы. Хороша ли эта идея? Какие существуют альтернативы ис- пользованию совмещения имен? 4. Напишите простой оператор присваивания с одним арифметическим оператором из какого-нибудь известного вам языка. Для каждого компонента оператора пере- числите различные связывания, требуемые для определения его семантики при вы- полнении. Для каждого связывания укажите используемые в языке времена связы- вания. 5. Объясните взаимосвязь динамического связывания типов с неявными динамиче- скими переменными. 6. Опишите ситуацию, в которой могут оказаться полезными переменные, зависящие от предыстории. 7. Рассмотрите следующую скелетную программу на языке Pascal: program main; var x : integer; procedure sub3; forward; procedure subl; var x: integer; procedure sub2; begin { sub2 ) end; { sub2 } begin { subl ) end; { subl } Упражнения 207
procedure s ub 3; begin { sub3 } end; { sub3 } begin { main } end. { main } Предположим, что выполнение программы осуществляется следующим образом: процедура main вызывает процедуру subl; процедура subl вызывает процедуру sub2 ; процедура sub2 вызывает процедуру sub3; 7.1. Если предположить, что используется статический обзор данных, какое из объ- явлений переменной х будет справедливо для следующих обращений к перемен- ной х? 7.1.1. В процедуре subl. 7.1.2. В процедуре sub2. 7.1.3. В процедуре sub3. 7.2. Повторите задание пункта 7.1, предполагая использование динамического обзора данных. 8. Предположим, что следующая программа была откомпилирована и выполнена с использованием правил статического обзора данных. Какое значение переменной х выводится на экран в процедуре subl? Каким бы было это значение при исполь- зовании правил динамического обзора? program main; var х : integer; procedure subl; begin { subl } writein('x =', x) end; { subl } procedure sub2; var x : integer; begin { sub2 } x := 10; subl end; { sub2 ) begin { main } x : = 5 ; sub2 end. {main} 9. Рассмотрите следующую программу: program main; var x, y, z : integer; procedure subl; var a, y, z : integer; 208 Глава 4. Имена, связывание, проверка типов и области видимости
procedure sub2; var a, b, z : integer; begin { sub2 } end; { sub2 ) begin { subl } end; { subl } procedure sub3; var a, x, w : integer; begin { sub3 } end; { sub3 ) begin { main } end. {main} Предполагая использование статического обзора данных, перечислите все пере- менные, видимые в телах процедур subl, sub2 и sub3, вместе с программными единицами, в которых они объявляются. 10. Рассмотрите следующую программу: program main; var х, у, z : integer; procedure subl; var a, y, z : integer; begin { subl } end; { subl } procedure sub2; var a, x, w : integer; procedure sub3; var a, b, z : integer; begin { sub3 } end; { sub3 } begin { sub2 } end; { sub2 } begin { main } end. {main} Предполагая использование статического обзора, перечислите все переменные, ви- димые в телах процедур subl. sub2 и sub3, вместе с программными единицами, в которых они объявляются. 11. Рассмотрите следующую программу на языке С: void fun(void) { int a, b, с; /* определение 1 */ Упражнения 209
while (...) { int b, c, d; /* определение 2 */ . . . <---------------------1 while (...) { int c, d, e; /* определение 3 */ . . . <-----------------2 } . . . ------------------3 } . . . <----------------------4 } Для каждой из четырех отмеченных точек приведенной функции перечислите все видимые переменные и укажите количество операторов, определяющих эти пере- менные. 12. Рассмотрите следующую скелетную программу на языке С: void funl(void); /*прототип*/ void funl(void); /*прототип*/ void funl(void); /*прототип*/ void main(); { int a, b, c; } void funl(void) { int b, c, d; } void fun2(void) { int c, d, e; } void fun3(void) { int d, e, f; } Предполагая использование динамического обзорй данных, рассмотрите следую- щие последовательности вызовов и укажите, какие переменные видимы во время выполнения последней вызванной функции. Для каждой видимой переменной укажите имя функции, определяющей данную переменную. 12.1. Функция main вызывает функцию funl; функция funl вызывает функцию fun2; функция f un2 вызывает функцию f ипЗ. 12.2. Функция main вызывает функцию funl; функция funl вызывает функцию fun3. 12.3. Функция main вызывает функцию fun2; функция fun2 вызывает функцию fипЗ; функция fипЗ вызывает функцию funl. 12.4. Функция main вызывает функцию fun3; функция fun3 вызывает функцию funl. 210 Глава 4. Имена, связывание, проверка типов и области видимости
12.5. Функция main вызывает функцию funl; функция funl вызывает функцию fun3; функция fun3 вызывает функцию fun2. 12.6. Функция main вызывает функцию fun3; функция fun3 вызывает фчнкшио fun2; функция fun2 вызывает функцию funl. 13. Рассмотрите следующую программу: program main; var х, у, z : integer; procedure subl; var a, y, z : integer; begin { subl } end; { subl } procedure sub2; var a, b, z : integer; begin { sub2 } end; { sub2 } procedure sub3; var a, x, w : integer; begin { sub3 } end; { sub3 ) begin { main } end. {main} Предполагая использование динамического обзора данных, рассмотрите следую- щие последовательности вызовов и укажите, какие переменные видимы во время активации последней подпрограммы. Для каждой видимой переменной укажите имя блока, в котором объявляется данная переменная. 13.1. Программа main вызывает процедуру subl; процедура subl вызывает про- цедуру sub2; процедура sub2 вызывает процедуру sub3. 13,.2 . Программа main вызывает процедуру subl; процедура subl вызывает про- \ цедуру sub3. 13.3. Программа main вызывает процедуру sub2; процедура sub2 вызывает про- цедуру sub3; процедура sub3 вызывает процедуру subl. 13.4. Программа main вызывает процедуру sub3; процедура sub3 вызывает про- цедуру subl. 13.5. Программа main вызывает процедуру subl; процедура subl вызывает про- цедуру sub3; процедура sub3 вызывает процедуру sub2. 13.6. Программа main вызывает процедуру sub3; процедура sub3 вызывает про- цедуру sub2; процедура sub2 вызывает процедуру subl. Упражнения 211
5.1. 5.2. 5.3. 5.4. Джеймс Гослинг (James Gosling) 5.5. 5.6. 5.7. 5.8. 5.9. 5.10. Введение Элементарные типы данных Символьные строки Порядковые типы, определяемые пользователем Массивы Ассоциативные массивы Записи Объединения Множества Указатели Джеймс Гослинг, вице-президент и член Совета директоров ком- пании Sun Microsystems, разра- ботал изначальную структуру языка Java и реализовал первый компилятор и виртуальную ма- шину для этого языка. Гослинг также являлся ведущим разра- ботчиком системы окон NeWS и создал текстовый редактор EMACS. Типы данных 213
Вначале этой главы вводится концепция типа данных и свойства основных типов данных. Затем рассматривается структура перечислимых и ограниченных типов. После этого исследуются структурные типы данных, особое внимание уделяется масси- вам, записям и объединениям. В заключение рассматриваются множественные и ссы- лочные типы. Для каждой категории типов данных формулируются вопросы разработки и объясня- ются конструкторские решения, принятые разработчиками важнейших языков. Затем оцениваются структуры этих языков. На структуру типов значительное влияние оказывают методы их реализации. По этой причине в главу включена еще одна важная часть, касающаяся вопросов реализации ти- пов данных, в частности, массивов. 5.1. Введение Компьютерные программы получают результаты, обрабатывая данные. Легкость, с которой выполняется этот процесс, зависит от того, насколько точно типы данных соот- ветствуют реальной задаче. Следовательно, очень важно, чтобы в языке была преду- смотрена поддержка соответствующего разнообразия типов и структур данных. Современные концепции типов данных развиваются на протяжении последних 40 лет. В ранних языках программирования все структуры данных, соответствующие конкрет- ным задачам, моделировались небольшим количеством основных структур данных, под- держиваемых этими языками. Например, в версиях языка FORTRAN, разработанных до языка FORTRAN 90, связные списки и двоичные деревья обычно моделировались с по- мощью массивов. Первый шаг в сторону от модели, использованной в языке FORTRAN I, был сделан разработчиками структур данных в языке COBOL, позволившими программистам уста- навливать точность десятичных чисел и предложившими использовать структурные ти- пы данных для представления записей, содержащих информацию. В языке PL/I возмож- ность установить точность целочисленных величин и чисел с плавающей точкой была еще более расширена. Позднее средства, обеспечивающие эту возможность, были вклю- чены в языки Ada и FORTRAN 90. Пытаясь расширить спектр приложений, разработчи- ки языка PL/I включили в него много различных типов данных. С нашей точки зрения, лучше было бы предоставить в распоряжение программиста небольшое количество ос- новных типов данных и операторов, обеспечивающих гибкое определение структур, что позволило бы создавать определяемые пользователем типы данных, приспосабливая их структуру к поставленной задаче, как это сделано в языке ALGOL 68. Это, очевидно, бы- ло одним из самых значительных достижений в области разработки типов данных. Назо- вем некоторые преимущества типов данных, определяемых пользователем. Эти типы улучшают читабельность программ, поскольку для них можно использовать осмыслен- ные имена. Определяемые пользователем типы допускают проверку типов переменных, относящихся к особой категории. Без типов, определяемых пользователем, это было бы невозможно. Кроме того, эти типы облегчают модификацию программ: программист может изменять тип некоторой категории переменных в программе, изменив оператор объявления типов. Концепции разработки типов данных, появившиеся в конце 1970-х годов в результате естественного обобщения идеи типов, определяемых пользователем, были воплощены в языке Ada 83. Методология, лежащая в основе определяемых пользователем типов данных, 214 Глава 5. Типы данных
состоит в том, что программисту следует позволить создавать отдельный тип для каждого отдельного класса переменных, определяемых предметной областью задачи. Более того, язык должен обеспечивать уникальность типов, являющихся, фактически, абстракциями переменных из предметной области задачи. Это довольно мощная концепция, оказываю* щая значительное влияние на общий процесс разработки программного обеспечения. Сде- лав еще один шаг, мы приходим к концепции абстрактных типов данных, которые могли моделироваться в языке Ada 83. Идея, лежащая в основе абстрактного типа данных, заклю- чается в отделении использования типа от способа представления переменных этого типа и совокупности действий над ними. Все типы данных, предусмотренные в высокоуровневых языках программирования, являются абстрактными. Подробно абстрактные типы данных, определяемые пользователем, рассматриваются в главе 10. Двумя самыми распространенными структурными (нескалярными) типами данных являются массивы и записи. Они, а также несколько других типов данных, задаются опе- раторами типов, или конструкторами, используемыми для создания переменных данного типа. В качестве примера операторов типа можно назвать существующие в языке С круглые и квадратные скобки, а также звездочки, используемые для задания массивов, функций и указателей. Удобно, как абстрактно, так и конкретно, думать о переменных в терминах дескрип- торов. Дескриптором (descriptor) называется совокупность атрибутов переменной, кото- рая реализуется в виде набора ячеек памяти, содержащих эти атрибуты. Если все пере- менные являются статическими, то дескрипторы нужны только во время компиляции. Статические дескрипторы (compile-time descriptors) обычно создаются компилятором в виде части таблицы идентификаторов и используются во время компиляции. Динамиче- ские атрибуты, в свою очередь, нуждаются в динамическом дескрипторе (run-time descriptor) или его части во время выполнения программы. В этом случае дескриптор ис- пользуется системой поддержки выполнения программ. И статические, и динамические дескрипторы используются для проверки типов, а также в операциях размещения пере- менных в памяти и удаления из нее. Со значением переменной и занимаемой ею памятью часто ассоциируется слово “объект”. В данной книге под словом “объект” подразумеваются исключительно экземп- ляры абстрактных типов данных, определяемых пользователем, и мы не использовали его при описании значений переменных встроенных типов. В объектно-ориентирован- ных языках программирования объектом называется любой экземпляр любого встроен- ного или определенного пользователем класса. Подробнее объекты рассматриваются в главах 10 и 11. В следующих разделах рассмотрены все широко распространенные типы данных. Для большинства из них формулируются связанные с ними вопросы разработки. Для всех приводится не менее одного примера. Для всех типов данных основным является сле- дующий вопрос: какие операции предусмотрены с переменными данного типа и как они задаются? 5.2. Элементарные типы данных Типы данных, не определяемые в терминах других типов, называются элементарными типами данных (primitive data types). Практически все языки программирования преду- сматривают определенный набор элементарных типов данных. Некоторые из этих типов 5.2. Элементарные типы данных 215
являются просто отражениями особенностей аппаратного обеспечения — например целые числа. Реализация других тицов требует незначительной программной поддержки. Для создания структурных типов данных в языке используются основные типы дан- ных вместе с одним или несколькими конструкторами. 5.2.1. Числовые типы Во многих ранних языках программирования существовали только числовые элемен- тарные типы. В современных языках эти типы по-прежнему играют центральную роль. 5.2.1.1. Целые числа Наиболее распространенным элементарным числовым типом является целое число (integer). В наше время многие компьютеры поддерживают несколько размеров целых чисел, и эти возможности нашли отражение в некоторых языках программирования. В реализациях языка Ada. например, может содержаться до трех размеров целых чисел: SHORT INTEGER, INTEGER и LONG INTEGER. Некоторые языки, например С, содержат беззнаковые типы целых чисел, представляющие собой целые числа без знаков. Все целые числа представляются в компьютере в виде строки битов, причем один из битов (как правило, крайний слева) представляет знак. Целые типы данных поддержива- ются непосредственно аппаратным обеспечением. Отрицательные целые числа могут храниться в памяти в виде записи числа со знаком, в которой знаковый бит указывает на отрицательность числа, а остальные биты строки представляют абсолютное значение числа. Впрочем, запись числа со знаком сама по себе в компьютерной арифметике не применяется. В большинстве современных компьютеров для хранения отрицательных чисел используется дополнительный код числа в двоичной системе, удобный для выполнения операций сложения и вычитания. Дополнительный код отрицательного целого числа в двоичной системе образуется путем логического до- полнения положительного числа и прибавления к нему единицы. В некоторых компью- терах все еще используется другое представление, а именно: обратный код числа в дво- ичной системе. При такой записи отрицательное значение целого числа хранится как ло- гическое дополнение к его абсолютному значению. Недостатком представления в виде обратного кода является наличие двух форм записи числа 0. Если вас заинтересовал во- прос о представлениях целых чисел, то подробнее о нем вы можете прочесть в любой книге по программированию на языках ассемблера. 5.2.1.2. Типы чисел с плавающей точкой Типы чисел с плавающей точкой (floating-point) моделируют действительные чис- ла. хотя представления большинства этих чисел являются только аппроксимацией. На- пример. ни одно из фундаментальных чисел л и е (основание натурального логарифма) не может точно представляться в виде числа с плавающей точкой. Впрочем, ни одно из этих чисел не может точно представляться вообще никакой конечной формой записи. В большинстве компьютеров числа с плавающей точкой хранятся в двоичных кодах, что только усугубляет проблем} их записи. Например, даже десятичную величину 0.1 нельзя представить конечным набором двоичных чисел. Другой проблемой использования чи- сел с плавающей точкой является потеря точности при арифметических операциях. Под- робнее о проблемах представления чисел с плавающей точкой можно прочесть в книге (Knuth. 1981). 216 Глава 5. Типы данных
Числа с плавающей точкой представляются в виде мантисс и показателей степени в форме, перенятой из научной записи. Ранее компьютеры использовали различные пред- ставления величин с плавающей точкой, но в наше время большинство машин исполь- зуют формат, описанный стандартом IEEE Floating-Point Standard 754. Разработчики средств реализации языков программирования используют любое представление, под- держиваемое аппаратным обеспечением. Большинство языков программирования со- держит два типа чисел с плавающей точкой, часто называемых float и double. Пере- менные типа float имеют стандартный размер, равный, как правило, четырем байтам памяти. Тип double используется в ситуациях, требхюших большей по размеру мантис- сы. Относящиеся к этому типу переменные с удвоенной точностью записи обычно зани- мают вдвое больше памяти, чем обычные переменные с плавающей точкой, и имеют как минимум вдвое больше битов в мантиссе. Множество величин, которые можно представить с помощью чисел с плавающей точкой, определяется их точностью и диапазоном. Точность числа— это точность его мантиссы, измеряемая числом битов, а в понятие диапазона входит диапазон изменения мантиссы и, что более важно, диапазон изменения показателя степени. На рис. 5.1 показан формат представления чисел обычной и удвоенной точности, описанный стандартом IEEE Floating-Point Standard 754, (IEEE. 1985). Подробнее о фор- матах IEEE вы можете прочитать в книге Таненбаума (Tanenbaum. 1990). 8 бит 23 бит показатель степени мантисса знаковый бит а) 11 бит 52 бит показатель степени мантисса знаковый бит б) Рис. 5.1. Форматы чисел с плавающей точкой, описанные стандар- том института IEEE: а) обычная точность, б) удвоенная точность Аппаратное обеспечение некоторых небольших компьютеров не поддерживает опе- рации с плавающей точкой. На таких машинах эти операции моделируются с помощью программного обеспечения, что может замедлять их выполнение в 10-100 раз по сравне- нию с аналогичными аппаратными операциями. 5.2.1.3. Десятичные числа Большинство крупных компьютеров, разработанных для коммерческих приложений, содержат аппаратное обеспечение, поддерживающее типы десятичных чисел (decimal). К этим типам данных принадлежат числа, содержащие фиксированное количество деся- тичных знаков и десятичную точку, находящуюся в установленном месте и отделяющую 5.2. Элементарные типы данных 217
целую часть числа от дробной. Такие типы данных в коммерческих приложениях явля- ются основными, и поэтому они образуют существенную часть языка COBOL. Достоинством десятичных чисел является их способность (в отличие от типов с пла- вающей точкой) содержать точные значения десятичных величин, по крайней мере, из ограниченного диапазона. Недостатками десятичных типов является ограниченный диа- пазон изменения переменных вследствие отсутствия показателей степени и неэкономно- сти их представления в памяти. Десятичные числа, как и строки символов, записываются в памяти с помощью двоич- ных кодов десятичных цифр. Такие представления называются двоично-кодированными десятичными числами (BCD— binary-coded decimal). В некоторых случаях десятичные величины запоминаются в виде одной цифры на байт, а в других байт содержит две циф- ры. В любом случае памяти для запоминания числа требуется больше, чем при двоичных представлениях. Поясним это на примере. Кодировка десятичной цифры требует не ме- нее 4 бит. Следовательно, для запоминания закодированного шестиразрядного десятич- ного числа потребуется 24 бит памяти. В то же время запоминание этого же числа в дво- ичном представлении требует всего 20 бит. Операции над десятичными величинами про- изводятся аппаратным обеспечением машин, имеющих такие возможности, либо модели- руются программным обеспечением. 5.2.2. Булевские типы Булевские (boolean) типы являются, вероятно, простейшими из всех типов. Диапазон их значений содержит всего лишь два элемента, один для обозначения истинности, дру- гой— ложности. Впервые эти типы появились в языке ALGOL 60 и с 1960 года были включены в большинство универсальных языков программирования. Исключением яв- ляется распространенный язык С, в котором в качестве условных выражений могут ис- пользоваться числовые выражения. В таких выражениях все операнды с ненулевыми значениями считаются истинными, тогда как нуль считается ложным значением. Не- смотря на то что в языке C++ булевский тип предусмотрен, в этом языке также можно использовать числовые выражения вместо булевских. Булевские типы часто применяются для представления переключателей или призна- ков. Хотя для этих целей могут использоваться и другие типы, например, целые числа, булевские типы повышают читабельность программы. Булевские значения могут представляться единственным битом, но поскольку на многих машинах к отдельному биту памяти эффективно обратиться сложно, то эти зна- чения часто содержатся в минимальной ячейке памяти, имеющей эффективно доступный адрес, которой обычно является байт. 5.2.3. Символьные типы Символьные данные запоминаются в компьютерах с помощью цифрового кодирования. Наиболее распространенной системой кодировки является система ASCII (American Standard Code for Information Interchange— Американский стандартный код обмена ин- формацией), в которой для кодировки 128 различных символов используется диапазон зна- чений 0-127. В качестве средства, обеспечивающего обработку кодов отдельных символов, многие языки программирования предусматривают для них отдельный основной тип. Вследствие глобализации коммерции и необходимости связи между компьютерами во всем мире набор символов ASCII быстро становится недостаточным. Недавно был 218 Глава 5. Типы данных
разработан альтернативный 16-битовый набор символов, получивший название Unicode. В этом наборе содержатся символы большинства естественных языков мира. Например, в нем есть символы кириллицы и тайские цифры. Первым широко распространенным языком, использующим набор символов Unicode, является язык Java, но этот набор, не- сомненно, вскоре найдет применение и в других популярных языках. 5.3. Символьные строки Символьные строки (character strings) представляют собой последовательности сим- волов. Константные строки символов сопровождают вывод результатов, а ввод и вывод всех типов данных часто производится с помощью строк. Разумеется, строки символов играют существенную роль во всех программах, работающих с символьными данными. 5.3.1. Вопросы разработки Ниже сформулированы два важнейших вопроса разработки, связанные с символьны- ми строками. Должны ли строки быть просто особой разновидностью массива, состоящего из символов, или элементарным типом (без индексации, характерной для массивов)? Должны ли строки иметь статическую или динамическую длину? 5.3.2. Строки и действия над ними Если строки не определены как элементарный тип, то строковые данные обычно хра- нятся в массивах, состоящих из отдельных символов, и ссылаться на них можно лишь как на элементы массивов. Такой подход принят в языках Pascal, С, C++ и Ada. В языке Pascal, в котором строки не являются основным типом, массивам char, имеющим атри- бут packed, могут присваиваться значения, и они могут сравниваться между собой с помощью операторов отношений. В языке Ada тип STRING является встроенным и определяет одномерные массивы элементов, принадлежащих к типу CHARACTER. Для типов STRING предусмотрены опе- рации обращения к подстрокам, конкатенация, операторы отношений и присваивания. Обращение к подстрокам позволяет интерпретировать любую подстроку данной строки как величину, на которую можно ссылаться, или переменную, которая может входить в оператор присваивания. Обращение к подстроке обозначается целочисленным диапазо- ном, взятым в круглые скобки и указывающим расположение требуемых символов. На- пример, команда NAME1(2:4) задает подстроку, состоящую из второго, третьего и четвертого символа строки, являю- щейся значением переменной NAME1. Конкатенация строк символов в языке Ada является операцией, задаваемой символом &. Следующий оператор выполняет присоединение переменной NAME2 к правому концу строки, являющейся значением переменной NAME1: NAME1 := NAME1 & NAME2; 5.3. Символьные строки 219
Например, если переменная NAME1 содержит строку “PEACE”, а переменная NAME2 — строку “FUL”, то после операции присваивания переменная NAME1 будет содержать строку “PEACEFUL”. В языках С и C-I-+ для хранения строк символов используются массивы char, а набор операций со строками предусмотрен в стандартной библиотеке с заголовочным файлом string. h. В большинстве операций со строками и в большинстве библиотечных функ- ций подразумевается, что строки символов завершаются специальным символом: нуле- вым байтом, представляемым числом 0. Это соглашение является альтернативой хране- нию длины строковых переменных. Библиотечные функции просто выполняют свои операции, пока не дойдут в обрабатываемой строке до нулевого байта. Библиотечные функции, создающие строки, сами вносят в них нулевой байт. Литералы символьных строк, создаваемые компилятором, также содержат нулевой байт. Рассмотрим следую- щее объявление: char *str = "apples”; В этом примере str — указатель типа char, адресующий строку символов applesO, где 0— символ нуля. Инициализация указателя str разрешена, поскольку литералы символьных строк представляются указателями типа char, а не собственно строкой. Назовем некоторые широко распространенные библиотечные функции языков С и C++, выполняющие операции со строками символов. Это функция strcpy, перемещающая строки; strcat, конкатенирующая одну данную строку с другой; strcmp, выполняющая лексикографическое сравнение двух заданных строк (по порядку их ASCII-кодов); функция strlen, возвращающая число непустых символов данной строки. Параметры и возвра- щаемые значения большинства функций обработки строки являются либо указателями типа char, адресующими массивы символов, либо строковыми литералами. В языках FORTRAN 77, FORTRAN 90 и BASIC строки интерпретируются как основ- ные типы, и для них предусмотрены операции присваивания, конкатенации, ссылки на подстроки, а также операторы отношения. В языке Java строки поддерживаются классом String как элементарные типы, зна- чения которых являются константными строками, и классом StringBuf fer, значения которого могут изменяться, и более всего похожи на массивы отдельных символов. Ис- пользование подстрок разрешено для переменных класса StringBuf fer. Вообще, операции присваивания и сравнения символьных строк усложнены возмож- ностью присваивания и сравнения операндов различных длин. Что произойдет, напри- мер, при присвоении более длинной строки более короткой строке или наоборот? Обыч- но в таких ситуациях заранее принимаются простые и целесообразные решения, хотя пользователи часто сталкиваются с проблемами при их запоминании. Еще одной базовой операцией со строками символов является сопоставление с образ- цом. Часто она обеспечивается библиотечной функцией, а не операцией языка. Существует два важных исключения, одним из которых является язык SNOBOL4, содержащий деталь- но разработанную встроенную в язык операцию сопоставления с образцом. Этот язык, ве- роятно, предоставляет максимально полные возможности для работы со строками. Образцами строк в языке SNOBOL4 являются выражения, которые могут присваи- ваться переменным. Рассмотрим, например, следующий фрагмент программы: LETTER = * abcdefghij klmnopqrstuvwxyz’ WORDPAT = BREAK(LETTER) SPAN(LETTER) . WORD 220 Глава 5. Типы данных
LETTER— это переменная, значением которой является строка из всех строчных букв. WORDPAT — это образец, следующим образом описывающий слова: вначале пропуска- ются все символы, пока не будет найдена буква, затем эти буквы перебираются, пока не будет найден символ, не являющийся буквой. Образец также содержит оператор указывающий на то. что строка, совпавшая с эталоном, присваивается переменной WORD. Этот образец может использоваться и в операторе TEXT WORDPAT Такой оператор попытается найти строку букв в строковом значении переменной TEXT. Вторым важным языком, содержащим встроенные операции сопоставления с образ- цом, является язык Perl. В этом случае выражения сопоставления с образцом отдаленно напоминают регулярные математические выражения. (В действительности они часто и называются регулярными выражениями.) Они произошли от раннего строчного редакто- ра ed операционной системы UNIX и стали частью языков оболочек системы UNIX. В конечном итоге самой сложной формой этих языков стал язык Perl. Объяснение этих выражений довольно громоздко и занимает целую главу в книге по языку Perl, впрочем, стоит отметить, что уже существует книга, посвященная выражениям сопоставления с образцом (Friedl, 1997). В данном разделе мы всего лишь кратко познакомимся со сти- лем выражений сопоставления с образцом, использовав для этой цели два относительно простых примера. Рассмотрим следующее выражение-образец: /[A-Za-z][A-Za-z\dj*/ Этот образец соответствует типичной форме имени в языках программирования (или описывает ее). В квадратных скобках заключены классы символов. Первый класс задает все буквы; второй — все буквы и цифры (цифра задается сокращением \d). Если вклю- чить только второй класс, то мы не сможем запретить имена, начинающиеся с цифр. Знак следующий за второй категорией, устанавливает, что в имени должно содер- жаться не менее одного символа, входящего в данную категорию. Таким образом, весь эталон целиком соответствует строке, начинающейся с буквы, за которой следует не ме- нее одной буквы или цифры. Рассмотрим следующее выражение-образец: /\d+\.?\d*I\.\d+/ Этот образец соответствует числовым константам. Символы \ . задают точку в десятич- ной записи числа. Вопросительный знак определяет число (0 или 1) появлений того, что следует за ним. Вертикальная черта (|) разделяет два альтернативных варианта всего об- разца. Первый вариант соответствует строкам с несколькими цифрами, за которыми, возможно, следует десятичная точка, и цифры; вторая альтернатива соответствует стро- кам, начинающимся с десятичной точки, за которой следует не менее одной цифры. 5.3.3. Варианты длины строк Существует несколько проектных решений, касающихся длины строковых величин. Во-первых, длина может быть статической и задаваться в объявлении. Такая строка на- зывается строкой со статической длиной (static length string). Подобные строки суще- ствуют в языках FORTRAN 77, FORTRAN 90, COBOL, Pascal и Ada. Например, сле- дующий оператор языка FORTRAN 90 объявляет переменные NAME1 и ХАМЕ2 символь- ными строками длины 15: 5.3. Символьные строки 221
CHARACTER(LEN = 15) NAME1, NAME2 Строки co статической длиной всегда полные; если строковой переменной присваи- вается строка меньшей длины, то свободные места заполняются символами нуля. Второй альтернативой является наличие у строк переменной длины, ограничиваемой заданным при объявлении размером, как это сделано в языках С и C++. Подобные строки называ- ются строками с ограниченной динамической длиной (limited dynamic length strings) и могут содержать любое количество символов, от нуля до максимального значения. На- помним. что в строках языков С и C++ вместо хранения длины строки используется спе- циальный символ для указания конца символьной строки. Третьей возможностью является разрешение строк с переменной неограниченной длиной, как это сделано в языках SNOBOL4 и Perl. Такие строки называются строками с динамической длиной (dynamic length string). Подобное проектное решение требует расходов на динамическое размещение строк в памяти и удаление их из памяти, но обес- печивает максимальную гибкость. 5.3.4. Оценка Строковые типы значительно влияют на легкость написания программ. Обращение со строками как с массивами может быть более громоздким, чем обращение с элементарным строковым типом. Использование символьных строк в качестве элементарного типа не ус- ложняет ни сам язык, ни его компилятор, поэтому трудно объяснить их отсутствие в неко- торых современных языках программирования. Разумеется, отсутствие таких типов может компенсироваться наличием стандартных библиотек подпрограмм для работы со строками. Операции со строками, например простое сопоставление с образцом и конкатенация, необходимы и должны существовать для величин, принадлежащих к строковым типам. Очевидно также, что хотя строки с динамической длиной и более гибкие, но эта допол- нительная гибкость должна сопоставляться с затратами на их реализацию. 5.3.5. Реализация символьных строк Символьные строки иногда поддерживаются с помощью аппаратного обеспечения, но в большинстве случаев для хранения строк в памяти, поиска в строке и обработки строк все же использхется программное обеспечение. Если символьные строки представляются в виде массивов символов, то язык часто довольствуется небольшим числом операций. Дескриптор статического строкового типа (единственное, что требуется во время компиляции) содержит три поля. Для статических символьных строк второе поле содер- жит длину типа (в символах). Третье поле является адресом первого символа. Такой де- скриптор изображен на рис. 5.2. Как показано на рис. 5.3, строки с ограниченной дина- мической длиной требуют наличия динамического дескриптора, содержащего текущую и максимально возможную длины строки. Строкам с динамической длиной нужен более простой динамический дескриптор, поскольку он должен содержать только текущую длину строки. Строки с ограниченной динамической длиной в языках С и C++ не требуют наличия динамических дескрипторов, поскольку конец строки помечен символом нуля. Им не нужна максимальная длина, поскольку в этих языках значения индексов при обращении к массиву не проверяются на предмет выхода за пределы интервала. 222 Глава 5. Типы данных
Строки со статической и ограниченной динамической длиной не требуют особого ди- намического распределения памяти. Для строк с ограниченной динамической длиной память, достаточная для содержания строки максимальной длины, выделяется при свя- зывании переменной с памятью, таким образом, выполняется только один процесс раз- мещения в памяти. Максимальная длина строки фиксируется во время компиляции. скриптор строк со стати- ческой длиной Строка с ограниченной динамической длиной Максимальная длина Текущая длина Адрес Рис. 5.3. Динамический деск- риптор строк с ограниченной динамической длиной Строки с динамической длиной требуют более сложного управления памятью. Длина строки (а следовательно и память, с которой она связывается) должна расти и умень- шаться динамически. Существует два возможных подхода к решению проблемы динамического распреде- ления памяти. Во-первых, строки могут храниться в связных списках, таким образом, при росте строки новые ячейки будут выделяться где-либо в динамической памяти. Не- достатком этого метода является большое количество памяти, занимаемое связями при представлении строк в виде списков. Альтернативным вариантом является хранение полных строк в смежных ячейках памяти. Осложнения появляются при росте строк: как может ячейка памяти, соседствующая с уже существующей ячейкой, содержать строко- вую переменную? Довольно часто такие ячейки недоступны. Вместо этого производится поиск новой области памяти, способной вместить новую строку, при этом старая часть строки перемещается в эту область памяти, после чего ячейки памяти, содержавшие ста- рую строку, освобождаются. Несмотря на то что использование метода связных списков требует большего объема памяти, происходящие при этом процессы размещения в памяти и удаления из нее про- сты. Впрочем, некоторые операции со строками замедляет отслеживание указателей. С другой стороны, хранение полных строк в смежных ячейках памяти приводит к ускоре- нию выполнения операций над строками и требует значительно меньшего объема памя- ти. Несмотря на это, процесс размещения в памяти выполняется медленнее. Метод смежных ячеек памяти порождает общую проблему управления распределением и осво- бождением ячеек памяти переменного размера. Подробнее эта проблема рассматривает- ся в разделе 5.10.10.3. 5.3. Символьные строки 223
5.4. Порядковые типы, определяемые пользователем Порядковым (ordinal) называется тип, в котором область возможных значений пере- менных может быть легко связана с последовательностью натуральных чисел. В языках Pascal и Ada, например, основными порядковыми типами являются целый, символьный и булевский типы. Во многих языках пользователи сами могут определять две разновидно- сти порядковых типов: перечислимые типы и ограниченные типы. 5.4.1. Перечислимые типы Перечислимым (enumeration) называется тип, в описании которого перечислены все возможные значения, являющиеся символьными константами (в языке Ada они также могут быть символьными литералами). Обычный перечислимый тип показан в следую- щем примере из языка Ada: type DAYS is (Mon, Tue, Wed, Thu, Fri, Sat, Sun); Основным вопросом разработки, характерным для перечислимых типов, является следующий: может ли литеральная константа появляться в нескольких описаниях типа, и если да, то как проверяется в программе тип конкретного литерала? 5.4.1.1. Структуры В языке Pascal литеральную константу в данной среде ссылок нельзя использовать в не- скольких описаниях перечислимого типа. Переменные, принадлежащие к перечислимым типам, могут использоваться в качестве индексов массивов, переменных цикла for, выра- жений оператора ветвления case, но не могут вводиться или выводиться. Два перечисли- мых типа переменных и/или литералов, принадлежащих к одному типу, могут сравниваться с помощью операторов отношений, при этом результат сравнения определяется их относи- тельными положениями в объявлении. Например, во фрагменте программы type colortype = (red, blue, green, yellow); var color : colortype; color := blue; if color > red ... булевское выражение в операторе if будет вычислено как истинное. В языках ANSI С и C++, как и в языке Pascal, одна литеральная константа не может появляться в нескольких описаниях перечислимого типа в данной среде ссылок. Пере- числимые величины языков ANSI С и C++ неявно преобразуются в целые, так что они подчиняются только правилам использования целых чисел. Перечислимые типы языка Ada подобны соответствующим типам языка Pascal, за ис- ключением того, что литералы могут появляться в нескольких объявлениях в одной и той же среде ссылок. Такие литералы называются перегруженными литералами (overloaded literals). Правило разрешения перегрузки (т.е. определения типа данного эк- земпляра литерала) состоит в том, что тип должен определяться контекстом, в котором появляется литерал. Например, если сравниваются перегруженный литерал и переменная перечислимого типа, то тип литерала считается совпадающим с типом переменной. 224 Глава 5. Типы данных
В некоторых случаях при появлении перегруженного литерала программист должен указать спецификации типа. Предположим, что в программе есть два следующих пере- числимых типа: type LETTERS is ('A', 'В', 'C, 'D', 1Е', 'г', 'G', 'Н ' J' , 'К' , ' L', ' М' , ' N', 'О', * ?' , ’Q 'S', 'T', 'U', ’V', 'W', 'X', 'Y', 'Z type VOWELS is ('A', 'E', 'I', 'O', ’ C ’ ) ; • т » 'R* , Предположим далее, что в программе использован цикл for. переменные которого сле- дующим образом принимают значения типа VOWELS: for LETTER in 'A'..'O' loop Проблема заключается в том. что компилятор не может корректно определить тип пере- менной LETTER, поскольку дискретный диапазон (в данном случае ' А' . . ' U') опреде- лен неоднозначно. (В языке Ada тип переменной цикла for неявно определяется компи- лятором. Она получает тип дискретного диапазона, задающегося в операторе.) Решением проблемы является следующее использование спецификатора типа для литералов дис- кретного диапазона: for LETTER in VOWELS' ('A') ..VOWELS' ( 'U') loop Переменные перечислимых типов могут выводиться, а перечислимые литералы могут вводиться при наличии пакета ТЕХТ_1О языка Ada. Эти операции требуют настраивае- мых реализаций встроенных пакетов для специфических перечислимых типов. Настраи- ваемые реализации пакетов рассматриваются в главе 10. Типы BOOLEAN и CHARACTER языка Ada в действительности являются встроенными -'еречислимыми типами. Обычными операциями с перечислимыми типами являются оп- ределение предшествующего и последующего элементов, позиции в списке величин и чачения данной позиции. В языке Pascal все эти операции предоставляются встроенны- •и функциями. Например, значение функции pred (blue) равно red. В языке Ada эти перации являются атрибутами. Например, значение LETTER' PRED ( ' В' ) равно ' А'. 5.4.1.2. Оценка Перечислимые типы способствуют повышению и читабельности, и надежности про- -амм. Читабельность улучшается непосредственно: именованные значения легко разли- з-отся, тогда как закодированные— нет. Закодированные элементы бессмысленны для .vx. за исключением автора программы. Предположим, что программе, написанной на -.ке FORTRAN, потребовалась переменная, принимающая значения десяти различных - лов. Вероятнее всего, названия цветов будут закодированы в виде целых чисел, с ис- ьзованием значений 1.2, .... 10. Целочисленные значения, использованные для коди- зки. мало осмысленны. Если, например, константа 4 обозначает голубой цвет, и это -чение присваивается переменной, то этот факт будет совсем не очевидным для чита- . программы. С точки зрения надежности перечислимые типы предоставляют два преимущества. -первых, если целая переменная используется программистом для кодирования, тре- - щего очень небольшого поддиапазона целых значений, то может появиться ошибка -пазона. которая не будет обнаружена системой поддержки исполнения программ, на- мер, при использовании в программе команды ‘’color 17". Во-вторых, допускается ~ Порядковые типы, определяемые пользователем 225
применение любого арифметического оператора к закодированным дням и любым це- лым числам, поскольку типы этих величин будут совпадать. Это исключает обнаружение компилятором многих логических ошибок и опечаток, связанных с закодированными данными. (Поскольку в языках ANSI С и C++ перечислимые переменные рассматрива- ются как целые переменные, то ни одного из этих преимуществ данные языки не имеют.) Таким образом, использование вместо целого типа перечислимых типов, подобных определенному выше типу LETTERS языка Ada, ограничивающих присваиваемые значе- ния небольшим диапазоном, улучшает читабельность и обеспечивает проверку типов. Типы enum (перечислимые типы) языков С и C++ в язык Java не включены. 5.4.2. Ограниченные типы Ограниченным типом (subrange type) называется непрерывная подпоследователь- ность порядкового типа. Например, тип 12. .14 является ограниченным целым типом. Впервые ограниченные типы появились в языке Pascal, также они имеются в языках Modula-2 и Ada. Особых вопросов, связанных с разработкой ограниченных типов, не су- ществует. 5.4.2.1. Структуры В языке Pascal объявление ограниченного типа выглядит следующим образом: type uppercase = ’A’..’Z*; index = 1. .100; Связь ограниченного типа с его родительским типом устанавливается путем сопоставле- ния величин в определении ограниченного типа с соответствующими величинами в ра- нее объявленном или встроенном порядковом типе. Тип uppercase в приведенном выше примере определен как ограниченный встроенный тип отдельных символов. Тип index определен как ограниченный тип целых чисел. В языке Ada ограниченные типы относятся к классу подтипов. Как указывалось в гла- ве 4, подтипы не являются новыми типами, они лишь дают новые имена ограниченным версиям существующих типов. Предположив, что тип DAYS определен так же, как и в предыдущем разделе, запишем следующее: subtype WEEKDAYS is DAYS range Mon..Fri; subtype INDEX is INTEGER range 1..100; В этих примерах сужение существующих типов касается диапазона возможных значе- ний. Все операции, определенные для породившего типа, определены и для подтипа, за исключением операции присваивания значений, не входящих в заданный диапазон. В следующих командах DAY1 : DAYS; DAY2 : WEEKDAYS; DAY := DAY1; присваивание разрешено только до тех пор, пока значение переменной DAY1 равно Sat или Sun. В языках Pascal и Modula-2, как и в языке Ada, подтипы наследуют все опера- ции, разрешенные родительскому типу. 226 Глава 5. Типы данных
Определяемые пользователем порядковые типы чаще всего используются в качестве индексов массивов, как это будет показано в разделе 5.5. Они также могут использовать- ся в качестве переменных цикла. В языке Ada, например, подтипы порядковых типов яв- ляются единственным способом указания переменных цикла for. Отметим, что в языке Ada ограниченные типы значительно отличаются от производ- ных типов, рассмотренных в главе 4. Рассмотрим следующие объявления типов: type DERIVED_SMALL_INT is new INTEGER range 1..100; subtype SUBRANGE_SMALL_INT is INTEGER range 1..10C; Переменные обоих типов, DERIVED_SMALL_INT (производный тип) и SUBRANGE_SMALL_INT (ограниченный тип), наследуют область значений и операции над переменными типа INTEGER. Тем не менее, переменные типа DERIVED_SMALL_INT не совместимы ни с каким типом INTEGER, тогда как перемен- ные типа SUBRANGE_SMALL_INT совместимы с переменными и константами типа INTEGER и любого подтипа этого типа. 5.4.2.2. Оценка Ограниченные типы улучшают читабельность программ, показывая, что переменные подтипа могут содержать только определенный диапазон значений. Надежность при ис- пользовании ограниченных типов увеличивается, поскольку присвоение переменной такого типа некоторого значения, лежащего вне заданного диапазона, диагностируется компиля- тором (если присваиваемое значение является литеральной величиной) или системой под- держки выполнения программ (если это переменная или выражение) как ошибка. 5.4.3. Реализация порядковых типов, определяемых пользователем Для того чтобы реализовать перечислимые типы, следует установить соответствие между неотрицательными целым числами и символьными константами. Обычно первое из перечисляемых значений представляется числом 0, второе — числом 1 и так далее. Понятно, что операции с переменными перечислимых типов значительно отличаются от операций с целыми числами, исключением являются только операторы отношений, идентичные для обоих случаев. Как указывалось ранее, перечислимые типы языков ANSI С и C++ часто рассматриваются как целые типы. Ограниченные типы реализуются так же, как и породившие их типы, за исключением того, что в каждом операторе присваивания и в каждом выражении, которые содержат переменную ограниченного типа, компилятор должен выполнять неявную проверку вы- хода значения переменной за пределы допустимого диапазона. Это увеличивает размер программы и время ее выполнения, но обычно с этим смиряются. Кроме того, хорошо оптимизированный компилятор может минимизировать количество проверок. 5.5. Массивы Массивом (array) называется однородное множество данных, в котором каждый от- дельный элемент идентифицируется его положением по отношению к первому элементу. Обращение к элементу массива в программе часто содержит один или несколько пере- менных индекса. Такие ссылки следует вычислять во время выполнения, чтобы опреде- 5.5. Массивы 227
лить область памяти, к которой производится обращение. Отдельные элементы массива принадлежат к одному из ранее определенных типов, элементарному или определенному пользователем. Всеобщая потребность в массивах очевидна, поскольку большинство компьютерных программ должны моделировать множества, состоящие из величин, зна- чения которых принадлежат одному типу и могут одинаково обрабатываться. 5.5.1. Вопросы разработки Ниже перечислены основные вопросы разработки, характерные для массивов. Какие типы могут иметь индексы? Проверяется ли выход индекса за пределы допустимого диапазона в индексиро- ванных обращениях к элементам? Ограничены ли области значений индексов? Когда массив размещается в памяти? Какое количество индексов разрешено? Могут ли массивы инициализироваться после размещения их в памяти? Какие разновидности сечений массивов допускаются и допускаются ли они вообще? В следующих разделах обсуждаются примеры проектных решений, касающихся мас- сивов, принятые в самых распространенных языках программирования. 5.5.2. Массивы и индексы Обращение к определенным элементам массива выполняется с помощью двухуровнево- го синтаксического механизма, первой частью которого является имя масссива, а второй — динамический селектор, состоящий из одного или нескольких индексов (subscripts, indexes). Если все индексы являются константами, то селектор является статическим, в противном случае — динамическим. Выборку можно представить как отображение имени массива и множества значений индексов в элемент массива. Массивы иногда называют ко- нечными отображениями. Условно это отображение можно записать в следующем виде: имя_массива(список_значений_индекссв) —> элемент Синтаксис обращений к массиву является универсальным: за именем массива следует список индексов, заключенный в круглые или квадратные скобки. Проблема, возникаю- щая при использовании круглых скобок, заключается в том, что они часто применяются для выделения параметров в вызовах подпрограмм. От этого ссылки на элементы масси- ва становятся похожими на вызовы подпрограмм. Рассмотрим следующий оператор при- сваивания в языке FORTRAN: SUM = SUM т В(I) Поскольку в языке FORTRAN круглые скобки используются и для параметров подпро- грамм, и для индексов массивов, то и люди, читающие программу, и компиляторы вынуж- дены пользоваться другой информацией, позволяющей определить, является ли выражение В ; I) в этом операторе присваивания вызовом функции или элементом массива. Если про- грамму читает человек, то для него это может оказаться просто невозможным. Разработчики версий языка FORTRAN, предшествовавших языку FORTRAN 90, а также разработчики языка РЫ решили использовать круглые скобки для обозначения 228 Глава 5. Типы данных
индексов массивов, поскольку в то время других удобных символов не было. Если в про- граммах на этих языках встречается идентификатор, за которым следует выражение или список выражений в круглых скобках, компилятор определяет, является это обращением к массиву или вызовом функции, сопоставляя данное имя с именами всех массивов, объ- явленных в среде ссылок. Если соответствия не обнаружено, то обращение считается вы- зовом функции. Если имя не соответствует локально определенной подпрограмме, то оно считается соответствующим подпрограмме, определенной во внешних модулях. Ес- ли произошло обращение к массиву с проп\щенным объявлением, то компилятор не сможет обнаружить этот факт, поскольку в указанных языках используется раздельная компиляция, и подпрограммы, используемые в программе, но определенные в другом модуле, не обязательно объявлять как внешние. В языке Ada, также использующем круглые скобки для выделения параметров под- программ и индексов массивов, компилятор всегда способен определить, к какой сущно- сти выполняется обращение: к массиву или к функции, поскольку он имеет доступ к ин- формации (из ранее откомпилированных программ) обо всех именах блока компилируе- мой программы, к которым можно обращаться. Этим язык Ada отличается от версий языка FORTRAN, предшествовавших версии FORTRAN 90. и от языка PL/I. в которых компилятор не имеет доступа к информации о ранее скомпилированных программах. Разработчики языка Ada преднамеренно выбрали круглые скобки для выделения ин- дексов массивов — они хотели добиться единообразия в выражениях, содержащих ссыл- ки на массив и вызовы функций, несмотря на проблемы, возникающие при чтении про- граммы. Выбор был сделан, исходя из того факта, что и обращения к элементам массива, и вызовы функций являются отображениями. Обращения к элементам массива отобра- жают индексы в отдельные элементы массива. Вызовы функций отображают фактиче- ские параметры в описание функции и, в конечном итоге, в значение функции. В языках Pascal. С. C++, Modula-2 и Java для выделения индексов массивов исполь- зуются квадратные скобки. Тип массива содержит два различных типа: тип элемента и тип индексов. Тип индек- сов часто является подтипом целых чисел, хотя в языках Pascal, Modula-2 и Ada в качест- ве индексов могут использоваться переменные таких типов, как булевский, символьный и перечислимый. В ранних языках программирования неявной проверки выхода индекса за пределы допустимого диапазона не требовалось. Поскольку такие ошибки в программах всгреча- ются часто, то соответствующая проверка повышает надежность языка. В современных языках программирования С. C++ и FORTRAN нет проверки выхода индекса за пределы допустимого диапазона, а в языках Pascal. Ada и Java она есть. 5.5.3. Связывания индексов и категории массивов Связывание типа индекса массива с переменной массива обычно является статиче- ским, но диапазон значений индексов иногда связывается динамически. В некоторых языках неявно задается нижняя граница диапазона значений индексов. Например, в языках С, C++ и Java нижняя граница всех диапазонов значений индексов равна нулю; в языках FORTRAN I, II и IV она была зафиксирована на единице; в языках FORTRAN 77 и FORTRAN 90 она по умолчанию равна 1. В большинстве других языков диапазоны значений индексов должны полностью задаваться программистом. 5.5. Массивы 229
Как и скалярные переменные, массивы делятся на четыре категории. В этом случае распределение по категориям основано на связывании с диапазонами значений индексов и с памятью. Повторимся, имена категорий указывают, когда и где выделяется память. Статическим массивом (static array) называется массив, в котором диапазоны зна- чений индексов связываются статически и размещение в памяти также является статиче- ским (происходит до выполнения программы). Достоинствами статических массивов яв- ляется их эффективность: динамического размещения в памяти и удаления из нее не тре- буется. Фиксированным автоматическим массивом (fixed stack-dynamic array) называется массив, в котором диапазоны значений индексов связываются статически, но размеще- ние в памяти происходит во время обработки объявлений при выполнении программы Достоинствами массивов этого типа по сравнению со статическими является эффектив- ность использования памяти: большой массив одной процедуры может использовать т\ же область памяти, что и большой массив другой процедуры, причем так может продол- жаться до тех пор, пока обе процедуры не окажутся активными одновременно. Автоматическим массивом (stack-dynamic array) называется массив, в котором диа- пазоны значений индексов связываются статически, а.размещение в памяти— динами- ческое (происходит во время выполнения программы). Однако после связывания диапа- зонов значений индексов и размещения массива в памяти и диапазоны, и адрес массива в памяти не изменяются в течение всей жизни переменной. Достоинством массивов этого типа по сравнению с двумя предыдущими является гибкость: не нужно заранее знать размер массива. Динамическим массивом (heap-dynamic array) называется массив, в котором связы- вание диапазонов значений индексов и размещение в памяти являются динамическими, причем ячейки памяти, содержащие массив, за время жизни массива могут меняться сколько угодно. Достоинством этого типа массива по сравнению с вышеописанными яв- ляется гибкость: размеры массива по мере необходимости могут увеличиваться и уменьшаться во время выполнения программы. Примеры всех четырех категорий масси- вов приведены в следующих абзацах. В языке FORTRAN 77 тип индексов связан с массивом во время разработки структх- ры языка: все индексы принадлежат к целому типу. Диапазоны значений индексов свя- зываются статически; размещение в памяти также происходит статически; следователь- но, массивы языка FORTRAN относятся к статическим. Массивы, объявляемые в процедурах языка Pascal и функциях языка С (в отсутствие спецификатора static), являются примерами фиксированных автоматических массивов. Как показано ниже, массивы языка Ada могут быть автоматическими: GET(LIST_LEN); declare LIST : array (1..LIST_LEN) of INTEGER; begin end; В этом примере пользователь вводит в список массивов количество требуемых элемен- тов, которые затем динамически размещаются в памяти при переходе к блоку declare Когда выполнение доходит до конца блока, массив LIST из памяти удаляется. 230 Глава 5. Типы данных
В языке FORTRAN 90 предусмотрены динамические массивы. Размешаться в памяти и открепляться от нее они могут по требованию. Диапазоны значений индексов этих массивов могут изменяться при сохранении, удалении или повторном размещении мас- сива в памяти. В языке FORTRAN 90, например, можно следующим образом объявить динамиче- ский массив: INTEGER, ALLOCATABLE, ARRAY :: MAT В этом выражении выполняется объявление матрицы МАТ с элементами типа INTEGER, которые могут динамически размещаться в памяти. Размещение в памяти задается опе- ратором ALLOCATE, например: ALLOCATE (МАТ(10, NUMBER_OF_COLS)) Диапазоны значений индексов могут задаваться переменными программы или литерала- ми. Нижние границы диапазонов значений индексов по умолчанию равны 1. Удалить динамические массивы из памяти можно с помощью оператора DEALLOCATE: DEALLOCATE (MAT) Для увеличения или уменьшения существующего динамического массива его элемен- ты следует временно записать в другом массиве, после чего старый массив должен быть удален из памяти и повторно размещен в ней. но уже с новым размером. В языках С и C++ также предусмотрены динамические массивы. В массивах языка С могут использоваться стандартные библиотечные функции malloc и free, выполняю- щие, соответственно, общие операции выделения и освобождения динамической памяти. В языке C++ для управления динамической памятью используются операторы new и delete. Поскольку в языках С и C++ нет проверки выхода индекса за пределы диапазо- на, то длина массива не интересует систему поддержки выполнения программ, поэтому уменьшение и увеличение массивов выполняется легко. Как описывается в разде- ле 5.10.6, массивы интерпретируются как указатели на некоторый набор ячеек памяти, причем эти указатели могут быть пронумерованы. Другой тип динамических массивов существует в языке Perl. Эти массивы растут при присваивании значений элементам, находящимся за текущим последним элементом. Уменьшение же массива происходит при присвоении ему пустого множества, задаваемо- го символами ( ). В исходной версии языка Pascal диапазон или диапазоны значений индексов массива являлись частью его типа. Это, наряду с использованием эквивалентности имен, обеспе- чивающим совместимость, запрещало существование подпрограмм, обрабатывающих массивы различной длины. Например, процедуру, выполнявшую сортировку целочис- ленных массивов, можно было написать только для одного зафиксированного диапазона значений индексов. В стандарте языка Pascal (ISO, 1982) предусматривалась лазейка для решения этой проблемы — совместимые массивы (conformant arrays). Эти массивы яв- ляются формальными параметрами, содержащими определения типа массива. Рассмот- рим следующий пример: procedure sumlist(var sum : integer; list : array [lower .. upper : integer] of integer); 5.5. Массивы 231
var index : integer; begin sum := 0; for index := lower to upper do sum := sum + list[index] end Вызов этой процедуры выглядит следующим образом: var scores : array [1..100] of integer; .•'umlist (sum, scores) 5.5.4. Количество индексов массива В языке FORTRAN I количество индексов массивов не превышало трех, поскольку во время разработки языка основное значение придавалось эффективности. Конструкторы языка FORTRAN 1 нашли очень быстрый метод получения доступа к элементам масси- вов. размерность которых не превышала трех. Начиная с языка FORTRAN IV. число возможных измерений массива было увеличено до семи, но большинство современных языков не содержат и такого ограничения. Причин для введения ограничений, сущест- вующих в языке FORTRAN, нет. Программист, который хочет использовать переменную с десятью измерениями и готов платить за обращение к элементам такого массива сни- жением эффективности программы, должен иметь такую возможность. Массивы языка С могут иметь только один индекс, но при этом элементами массивов могут быть другие массивы, что позволяет создавать многомерные массивы. Такая воз- можность является примером использования ортогональной структуры языка. Рассмот- рим следующее объявление языка С: int mat[5][4]; Это объявление создает целочисленную переменную mat, представляющую собой мас- сив из пяти элементов, каждый из которых является массивом из четырех элементов. Различие между этой переменной и матрицей другого языка, например, языка FORTRAN, ничтожно. Пользователь практически всегда может игнорировать тот факт, что в действительности переменная mat не является матрицей, лишь синтаксис обраще- ния к отдельному элементу матрицы требует использования пар квадратных скобок, вы- деляющих каждый индекс. 5.5.5. Инициализация массива Некоторые языки предусматривают возможность инициализации массивов во время их размещения в памяти. В языке FORTRAN 77 вся память размещается динамически, таким образом, возможна инициализация во время загрузки с использованием оператора DATA. В этом языке мы можем, например, получить следующее: INTEGER LIST(3) DATA LIST /0, 5, 5/ Массив LIST инициализируется значениями из списка, заключенного между символами ?32 Глава 5. Типы данных
Языки ANSI С и C++ также позволяют инициализировать их массивы, но с одной но- вой особенностью. Например, в объявлении int list [; - {4, 5, 1, 83}; компилятор сам устанавливает длину массива. Это удобно, но не проходит даром: по- добная инициализация не дает системе обнаруживать такие ошибки программирования, как случайное использование значения, выходящего за пределы списка. Символьные строки в языках С и О+ реализованы как массивы char. Эти массивы могут быть инициализированы строковыми константами: char name [1 == "freddie" Массив name будет содержать восемь элементов, поскольку все строки завершаются символом нуля, неявно добавляемого системой к строковым константам. Массивы строк в языках С и C++ также могут инициализироваться строковыми лите- ралами. В этом случае массив представляет собой массив указателей на символы. На- пример, char *names [] = {"Bob", "Jake", "Darcie"}; Этот пример иллюстрирует природу символьных литералов языков С и C++. В преды- лшем примере для инициализации символьного массива с именем name использовался строковый литерал, представляющий собой массив типа char. Однако в следующем за ним примере (names) литералы представляют собой указатели на символы, поэтому и ^ам массив является массивом указателей на символы. Например, names [С] является сказателем на букву 'В' в массиве литеральных символов, содержащем символы 'В'. ’ О’, ’ b' и символ нуля. В языках Pascal и Modula-2 инициализация массивов в разделе объявления програм- мы невозможна. В языке Ada предусмотрены два механизма инициализации массивов в операторе объявления: перечисление их в том порядке, в котором они должны храниться в памяти, л непосредственное присваивание значений соответствующим индексным позициям с помощью оператора => (в языке Ada называемого стрелкой). Рассмотрим следующие команды: LIST : array (1..5) of INTEGER := (1, 3, 5, 7, 9); BUNCH : array (1..5) of INTEGER := (1 => 3, 3 => 4, others => 0) ; В первом операторе все элементы массива LIST содержат инициализированные значе- ния. присвоенные ячейкам массива в порядке их появления. Во втором операторе первый и третий элементы массива инициализируются с помощью прямого присваивания, а опе- ратор others использован для инициализации остальных элементов. Такие наборы зна- чений. заключенных в круглые скобки, называются составными регулярными значе- ниями (aggregate values). 5.5.6. Операции над массивами Операцией над массивом называется действие, при выполнении которого массив счи- тается единым целым. В некоторых языках, например. FORTRAN 77, операции над мас- сивами не предусмотрены. 5.5. Массивы 233
В языке Ada возможны присваивания массивов, в том числе и те, в которых правая часть является множеством, а не массивом. В этом языке допустима также конкатенация, задаваемая знаком &. Конкатенация определена между двумя одномерными массивами и между одномерным массивом и скалярной величиной. Помимо этих операций практически все типы языка Ada содержат встроенные операторы отношений равенства и неравенства. В язык FORTRAN 90 включено значительное число операций над массивами, назван- ных элементными (elemental), поскольку они представляют собой операции над парами элементов массива. Например, оператор сложения (+), помещенный между двумя масси- вами, приводит к созданию массива, состоящего из сумм соответствующих пар элемен- тов двух массивов. Операторы присваивания, отношений, арифметические и логические операторы— все они перегружены для массивов любых размеров и видов. В языке FORTRAN 90 также есть встроенные, или библиотечные, функции, выполняющие опе- рации умножения, транспонирования матриц и векторного произведения. Массивы и операции над ними являются основным компонентом языка APL; этот язык — самый мощный из когда-либо разработанных языков обработки массивов. Тем не менее, вследствие его относительной неизвестности и отсутствия влияния на после- дующие языки мы ограничимся беглым ознакомлением с его операциями над массивами. В языке APL для векторов (одномерных массивов) и матриц определены четыре ос- новные арифметические операции, так же, как и для скалярных операндов. Например, выражение А + В разрешено как для скалярных величин А и В, так и для векторов или матриц. Язык APL также содержит набор унарных операторов для векторов и матриц, некото- рые из которых приведены ниже (V обозначает вектор, а М — матрицу). 0V обращает элементы вектора V фМ обращает столбцы матрицы И 0М обращает строки матрицы М 0М транспонирует матрицу М (строки становятся столбцами и наоборот) Г-*-~1м инвертирует матрицу М В языке APL есть несколько особых операторов, принимающих в качестве операндов другие операторы. Одним из них является оператор скалярного произведения, указывае- мый точкой (.). Он принимает два операнда, являющихся бинарными операторами. На- пример, оператор + .х является новым оператором, принимающим два аргумента, векторы или матрицы. Вна- чале этот оператор выполняет умножение соответствующих элементов обоих аргумен- тов, а затем суммирует результаты. Допустим, что А и В — векторы, тогда результатом действия А X В будет вектор, содержащий попарные произведения элементов векторов А и В. Результа- том оператора А +.Х В 234 Глава 5. Типы данных
является скалярное произведение объектов А и В. Если А и В являются матрицами, то это выражение задает матричное умножение матриц А и В. Особые операторы языка APL фактически являются функциональными формами, рассматриваемыми в главе 14. 5.5.7. Сечения Сечением (slice) массива называется некоторая подструктура массива. Например, ес- ли А — матрица, то одним из возможных сечений будет первая строка матрицы А, то же относится и к последней строке или первому столбцу. Важно понимать, что сечение не является новым типом данных. Это скорее механизм обращения к части массива как к единому целому. Если с массивами языка нельзя обращаться как с единым целым, то в таком языке нет и сечений. Одним из вопросов разработки, касающихся сечений, является синтаксис задания ссылки на отдельное сечение. Обращение к отдельному элементу целого массива пред- ставляет собой имя массива и выражение для каждого индекса. Поскольку сечение явля- ется подструктурой массива, то обращение к нему требует меньшего числа выражений с индексами, чем обращение ко всему массиву. Каким-то образом пропущенные выраже- ния с индексами должны быть обозначены так. чтобы существующие выражения были связаны с верными индексами. Пропущенный индекс или индексы, входящие в обраще- ние к сечению, иногда указываются звездочками. Рассмотрим следующие объявления в языке FORTRAN 90. INTEGER VECTOR(1:10) , МАТ(1:3, 1:3), CUBE(1:3, 1:3, 1:4) Объект VECTOR (3:6) является четырехэлементным массивом, содержащим с третьего по шестой элементы вектора VECTOR, объект МАТ (1:3, 2) является обращением ко второму столбцу матрицы МАТ; объект МАТ(3, 1:3) является ссылкой на третью строку матрицы МАТ. Все эти обращения могут использоваться как одномерные масси- вы. Ссылки на все сечения массива интерпретируются как массивы оставшейся размер- ности. Таким образом, ссылка на сечение CUBE (1:3, 1:3, 2) вполне может быть присвоена матрице МАТ. Сечения могут также использоваться как целевые объекты опе- раторов присваивания. Например, одномерный массив может присваиваться сечению матрицы. Примеры сечений массивов МАТ и CUBE приведены на рис. 5.4. В языке FORTRAN 90 могут задаваться и более сложные сечения. Сечение VECTOR (2 :10: 2), например, является пятиэлементным массивом, состоящим из вто- рого, четвертого, шестого, восьмого и десятого элементов матрицы VECTOR. Сечения могут также содержать нерегулярные расположения элементов существующего массива. Например, сечение VECTOR ((/3, 2, 1, 8/)) является массивом, содержащим тре- тий, второй, первый и восьмой элементы массива VECTOR. В языке Ada возможны только крайне ограниченные сечения, состоящие из последо- вательных элементов одномерного массива. Например, если LIST — массив с диапазо- ном значений (1. . 100). то LIST (5 . .10) является сечением массива LIST, состоя- щим из шести элементов, имеющих индексы от 5 до 10. Как указывалось в разделе 5.3.2, сечение переменной типа STRING называется ссылкой на подстроку. 5.5. Массивы 235
MAT (1:3,2) MAT (2 3,13) CUBE(1.3,1.3,2 3) Puc. 5.4. Примеры сечений в языке FORTRAN 90 5.5.8. Оценка Массивы есть практически во всех языках программирования. Они просты и хорошо разработаны. Единственными существенными усовершенствованиями, сделанными со времени их появления в языке FORTRAN I, было включение всех порядковых типов в число возможных типов индексов и, разумеется, введение динамических массивов. Не- смотря на необходимость и фундаментальность массивов, их структура все же вызывает некоторые разногласия. Иногда бывает удобно разрешать программисту задавать отдельные подструктхры многомерных массивов, но платой за это удобство может оказаться повышенная слож- ность реализации и ухудшение читабельности. 5.5.9. Реализация типов массивов Реализация массивов требует больших затрат во время компиляции, чем реализация та- ких простых типов, как типы целых чисел. Команды, позволяющие доступ к элементам массива, должны формироваться во время компиляции. Во время выполнения программы эти команды должны вычислять адреса элементов массива. Если команды доступа разрабо- таны плохо, то доступ к элементам массива, особенно в массивах с несколькими индекса- ми, становится неэффективным. Это справедливо независимо от того, каким именно обра- 236 Глава 5. Типы данных
jom массив связывается с памятью: статически или динамически. Нельзя предварительно вычислить адрес, доступ к которому осуществляется с помощью обращения список[к] Одномерный массив представляет собой список последовательных йчеек памяти. Предположим, что нижняя граница диапазона индексов массива list равна 1. Функция доступа для массива list часто имеет вид: адрес (список [к] ) = адрес (список ' 1 : ) * (к-1) * размес_эле:/.е:--:та ' 1ослсднее упрощается до вида адрес(список[к]) = (адрес(список[1]) - размер_элемента) * (к * размер_элемента) }десь первый операнд операции сложения является постоянной частью функции доступа, второй — переменной. Если тип элемента связывается статически, а массив, в свою очередь, статически свя- лвается с памятью, то значение постоянной части можно вычислить до начала выпол- .♦ния программы. Во время выполнения останется произвести операции сложения и ум- жения. Если базовый, или начальный, адрес массива не известен до начала выполне- «я программы, то при размещении массива в памяти должна выполняться еще и ерация вычитания. Обобщением этой функции доступа на случай произвольной нижней границы будет . едующая функция: -:дрес (список [ к] ) = адрес (список [нижняя_гранина] ) + (к -нижняЯ—Граница) * размер_элемента Динамический дескриптор одномерного массива всегда можно вставить в виде, показанном на рис. 5.5. Дескриптор содержит Формацию, необходимую для создания функции доступа. Если во ?мя выполнения программы проверка выхода индекса за пределы ^пазона не проводится и все атрибуты являются статическими, то время выполнения сам дескриптор не нужен, требуется только чкция доступа. При проверке выхода индекса за пределы диапа- •а во время выполнения программы может потребоваться хранить амяти эти диапазоны значений индексов в динамическом деск- торе. Если диапазоны значений индексов отдельного типа мае* -а являются статическими, то диапазоны могут включаться в ко- выполняюшие проверку, что устраняет потребность в динами- ком дескрипторе. Если какая-либо из позиций дескриптора • ывается динамически, то эти части дескриптора должны сохра- <я во время выполнения программы. Многомерные массивы реализовать значительно труднее, чем одномерные, хотя рас- тение на большее число измерений выполняется достаточно просто. Аппаратная па- линейна — обычно это последовательности байтов. Таким образом, значения типов •ых. имеющих несколько измерений, должны отображаться в одномерную память, ествует два способа отображения многомерных массивов в одно измерение: запись . рокам и запись по столбцам. При записи по строкам (row major order) вначале за- даются элементы массива, имеющие в качестве первого индекса значение нижней Массив Тип элемента Тип индекса Нижняя граница индекса Верхняя граница индекса Адрес Рис. 5.5. Динамичес- кий дескриптор од- номерных массивов : Массивы 237
границы этого индекса, за ними следуют элементы второго значения первого индекса и так далее. Если массив является матрицей, он запоминается построчно. Например, пусть матрица имеет значения: 3 4 7 ‘625 13 8 Тогда при использовании записи по строкам она будет храниться в памяти следующим образом: 3, 4, 7, 6, 2, 5, 1, 3, 8 При записи по столбцам (column major order) вначале запоминаются элементы масси- ва, имеющие в качестве последнего индекса значение нижней границы этого индекса, за ними следуют элементы второго значения последнего индекса и так далее. Если массив яв- ляется двумерной матрицей, то он запоминается по столбцам. Если приведенную выше матрицу записать по столбцам, то в память она будет занесена в виде следующей строки: 3, 6, 1, 4, 2, 3, 1, 5, 8 Запись по столбцам используется в языке FORTRAN, а в остальных языках используется запись по строкам. Иногда следует знать порядок запоминания многомерных массивов; например, когда такие массивы обрабатываются с помощью указателей в программах на языке С, или ко- гда массивы в программах на языке FORTRAN ставятся в соответствие массивам друго- го вида оператором EQUIVALENCE. Вообще такая информация важна во всех языках, разработчики которых серьезно заинтересованы в скорости выполнения программ, и в компьютерах, использующих виртуальную память (практически все машины, не являю- щиеся персональными компьютерами, используют виртуальную память). Последова- тельный доступ к элементам матриц будет осуществляться быстрее, если производить его в том порядке, в котором хранятся эти элементы, поскольку такой доступ минимизи- рует замещение страниц. (Замещение страниц — процесс перемещения блоков инфор- мации между диском и оперативной памятью.) Функция доступа к элементам многомерного массива представляет собой отображение его базового адреса и множества значений индексов в адреса элементов, заданных значе- ниями индексов. Ниже приводится изображение функции доступа для двумерного массива, записанного в память по строкам. Вообще, адрес элемента — это базовый адрес структуры плюс размер элемента, умноженный на число элементов структуры, предшествующих дан- ному. Для матрицы, записанной в память по строкам, число предшествующих элементов равно количеству строк, находящихся выше, умноженному на размер строки в элементах, плюс число элементов, находящихся слева от данного. Сказанное иллюстрирует рис. 5.6, в котором для простоты все нижние границы считались равными 1. Для того чтобы получить фактическое значение адреса, число элементов массива, предшествующих данному, следует умножить на размер элемента. Теперь функцию дос- тупа можно записать так: адрес(a[i, j]) = адрес(а[1, 1]) + ((((число строк, находящихся над /-той строкой) * (размер строки)) + (число элементов, находящихся слева от у-того столбца)) * размер элемента) 238 Глава 5. Типы данных
Поскольку число строк, находящихся над i-той строкой, равно (i-1) и число элемен- тов слева от j -того столбца равно (j -1), то получаем: адрес(a[i, j]) = адрес(а[1, 1]) + ((((i-1) * n) + (j - 1)) * размер—Элемента) Здесь п — число элементов в строке. Последнее выражение можно преобразовать в вид: адрес(a[i, j]) = адрес (а[1, 1]) - ( (n + 1) * размер_элемента) + (i * n + j) * размер_элемента) Здесь первые два члена — постоянная часть, а последний — переменная. Обобщим функцию доступа на случай произвольных нижних границ: адрес (a[i, j]) = адрес (а[гр_стр, гр_стлб]) + ((((i - гр_стр) * п) + (j - гр_стлб)) * размер_элемента) Здесь гр_стр— нижняя граница строк, гр_стлб— нижняя граница столбцов. По- следнее выражение можно преобразовать так: адрес(a[i, j]) = адрес (а[гр_стр, гр_стлб]) - (((гр_стр + п) + гр_стлб) * размер_элемента)+ (((i * n + j) * размер_элемента) Здесь первые два члена — постоянная часть, а последний — переменная. Последнее вы- ражение относительно просто обобщается на произвольное число измерений. Для каждого измерения массива функции доступа требуется выполнить одну команду умножения и одну сложения. Следовательно, обращения к элементам массива, имеюще- го несколько индексов, являются неэффективными. Вид статического дескриптора мно- гомерного массива показан на рис. 5.7. 5.5. Массивы 239
Многомерный массив Тип элемента Тип индекса Размерность Диапазон значений первого индекса Диапазон значений n-го индекса Адрес Рис. 5.7. Статический дескриптор много- мерного массива Еше один уровень сложности функции отображения памяти создается сечениями. Дня того чтобы пояснить это, рассмотрим программу, в которой есть матрица и массив, при- чем столбец матрицы присваивается массиву: INTEGER МАТ (1:10, 1:5), LIST (1:10) LIST = MAT (1:3, 3) Функция отображения памяти для матрицы МАТ выглядит следующим образом (предполагается использование записи в память по строкам и единичный размер элемента)’ адрес (МАТЦ, jj) = адрес (МАТЦ, 1] ) + ( (i - 1) * 5 + (j -1)) * 1 = (адрес (МАТЦ, 1] ) - 6) ) + ((5 * i) + j) Функция отображения памяти для обращения к сечению МАТ [1:3, 3] выглядит сле- дующим образом: адрес (МАТЦ, 3]) == адрес (МАТ[1, 1]) + ((1 - 1) * 5 + (3 -1)) * 1 = (адрес (МАТЦ, 1]) - 3) +(5 * i) О।метим, что это отображение имеет в точности ту же форму, что и любая другая функ- ция доступа к одномерному массиву, хотя форма постоянной части несколько отличается из-за того, что массив, составляющий основу сечения, является двумерным. Элементы матрицы МАТ, которые требуется присвоить массиву LIST, находятся п\- тем присваивания индексу i значений из диапазона изменения индексов первого измере- ния матрицы МАТ. 240 Глава 5. Типы данных
5.6. Ассоциативные массивы .Ассоциативным массивом называется неупорядоченное множество элементов дан- -ых. индексированных таким же количеством величин, называемых ключами. В неассо- .нативных массивах индексы никогда не надо запоминать (поскольку они идут по по- ядку). В ассоциативном же массиве определяемые пользователем ключи должны со- держаться в самой структуре массива. Таким образом, каждый элемент ассоциативного месива фактически является парой элементов: ключом и величиной. Для пояснения этой .-руктуры данных мы используем структуру языка Perl. Другим языком, имеющим ассо- циативные массивы, является язык Java, в котором подобная структура поддерживаются .тандартной библиотекой классов. Для ассоциативных массивов характерны следующие вопросы разработки. Какова форма обращений к элементам? Каким является размер ассоциативного массива: статическим или динамическим? 5.6.1. Структура и операции Ассоциативные массивы языка Perl часто называются хешами, поскольку их элемен- ы записываются в память и извлекаются из нее с помощью функций хеширования. Про- .транство имен хешей в языке Perl четко очерчено: каждая хешированная переменная юлжна начинаться со знака процента (%). Хешам могут присваиваться литеральные ве- тчины, например: ^salaries = ("Cedric" => 75000, "Perry" => 57000, "Mary" => 55750, "Gary" => 47850); Обращение к отдельным значениям элементов производится с помощью записи, ха- гактерной только для языка Perl. Значение ключа помешается в фигурные скобки, а имя хеша заменяется тем же самым именем скалярной переменной с присоединенным к ней :наком доллара ($) в позиции первого символа. Например: Ssalaries{"Perry"} = 58850; Новый элемент присоединяется к хешу с помощью той же формы оператора. Удалить элемент из хеша можно оператором delete: delete Ssalaries{"Gary"); Весь хеш может быть обнулен путем присвоения ему пустого литерала. ^salaries = () ; Размер хеша в языке Perl является динамическим: он увеличивается при добавлении нового элемента и уменьшается при удалении элемента или при присвоении ему пустого чперала. Оператор exists возвращает значение true или false в зависимости оз то- I о. является ли его операнд (ключ массива) элементом массива. Например: if (exists Ssalaries { "Shelly" } ) ... Оператор keys при применении к хешу' возвращает массив ключей хеша. Оператор blues выполняет то же для значений хеша. Оператор each выполняется для каждой пары элементов хеша. 5.6. Ассоциативные массивы 241
Для поиска элемента хеш значительно лучше массива, поскольку неявные операции хеширования, используемые для доступа к элементу хеша, очень эффективны. С другой стороны, для обработки каждого элемента списка лучше использовать массивы. 5.6.2. Реализация ассоциативных массивов Для реализации ассоциативных массивов в языке Perl сначала фиксируется некото- рый объем памяти. Когда структура достигает заранее определенного уровня заполне- ния. этот объем увеличивается. Такой процесс расширения памяти неэффективен, по- скольку для его выполнения приходится использовать новую функцию хеширования и повторно хешировать все существующие в структуре элементы. 5.7. Записи Записью (record) называется возможно неоднородная совокупность данных, в кото- рой отдельные элементы идентифицируются именами. В программах часто требуется моделировать совокупность неоднородных данных. Например, информация о студенте колледжа должна содержать его имя, номер, средний балл и так далее. Переменная, моделирующая такую совокупность, может имя хранить как строку символов, номер — как целое число, средний балл — как число с плавающей точкой, и так далее. Для удовлетворения потребности в таком общем типе и были разра- ботаны записи. Впервые появившись в языке COBOL в начале 1960-х годов записи вошли во все наиболее популярные языки программирования, за исключением версий языка FORTRAN, предшествовавших языку FORTRAN 90. В объектно-ориентированных языках программирования записи обеспечиваются кон- сгр>кцией классов. Язык C++ по-прежнему содержит оператор struct из языка С для структур, представляющих собой записи, хотя он является излишним. В язык Java дан- ный оператор уже не включался. В следующих разделах показано, как можно объявить или определить записи, как осуществить ссылку на поле записи, а также описываются общие операции над записями. Для записей характерны следующие вопросы разработки. Какова синтаксическая форма ссылок на поля записи? Возможны ли эллиптические ссылки? 5.7.1. Описания записей Фундаментальное различие между записью и массивом заключается в однородности элементов массивов по сравнению с возможной неоднородностью элементов записей. Од- ним из следствий этого различия является то, что к элементам записи, или полям, не всегда можно обратиться с помощью индексов. Взамен них для указания полей используются идентификаторы, а ссылки на поля реализуются с помощью этих идентификаторов. Еще одним существенным различием между массивами и записями является то, что в некото- рых языках записи могут содержать объединения, рассматриваемые в разделе 5.8. Ниже показан пример объявления записи в языке COBOL, являющегося частью раз- дела данных программы на этом языке. 242 Глава 5. Типы данных
О1 EMPLOYEE-RECORD. 02 EMPLOYEE-NAME 05 FIRST 05 MIDDLE 05 LAST 02 HOURLY-RATE PICTURE IS PICTURE IS PICTURE IS PICTURE IS X(20) . X (10) . X (20) . 99V99. Запись EMPLOYEE-RECORD состоит из записи EMPLOYEE-NAME и поля HOURLY-PATE. Числа 01, 02 и 05, с которых начинаются строки объявления записи, являются номера* ми уровня (level numbers), которые определяют иерархическую структуру записи. Лю- бая строка, за которой следует строка с более высоким номером уровня, также является записью. Оператор PICTURE показывает формат ячеек памяти, содержащих данное по- ле: X (20) указывает на 20 буквенно-цифровых символов, a 99V99 — на четыре деся- тичные цифры с десятичной точкой посередине. Языки Pascal, Modula-2 и Ada в записях используют собственные синтаксические правила, а не принятые в языке COBOL номера уровней: в этих языках для создания структуры записей используется ортогональная структура, позволяющая просто вклады- вать одни объявления записей в другие. Рассмотрим следующее объявления языка Ada: EMPLOYEE_RECORD : record EMPLOYEE_NAME : record FIRST : STRING (1..20); MIDDLE : STRING (1..10); LAST : STRING (1..20); end record; HOURLY_RATE : FLOAT; end record; В языке С записи также предусмотрены и называются структурами. Они во многом подобны записям языка Pascal, за исключением того, что структуры не содержат вари- антных записей, или объединений, рассматриваемых в разделе 5.8. Объявления записей в языке FORTRAN 90 требуют предварительного описания лю- бых вложенных записей в качестве типов. Таким образом, в приведенной выше записи информации о сотруднике, запись имени сотрудника требует предварительного описа- ния, после чего в первом поле записи сотрудника она просто называется как тип. 5.7.2. Ссылки на поля записи Ссылки на отдельные поля записи синтаксически задаются несколькими различными методами, в двух из которых называются нужное поле и запись, которая их содержит. Ссылки к полям языка COBOL имеют следующий вид: ИМЯ—поля OF ИМЯ—записи—1 OF ... OF имя_записи_п Здесь первая запись называет наименьшую или наиболее глубоко вложенную запись, со- держащую нужное поле. Следующее имя записи в этой последовательности относится к записи, содержащей предыдущую, и так далее. Таким образом, к полю MIDDLE из при- веденного выше примера записи языка COBOL можно обратиться следующим образом: MIDDLE OF EMPLOYEE-NAME OF EMPLOYEE-RECORD 5.7. Записи 243
Большинство других языков использует для обращений к полям точечную запись, в которой компоненты обращения соединяются точками. Имена в точечной записи идут в противоположном языку COBOL порядке: вначале пишется имя наибольшей внешней записи, а последним— наименьшей. Ниже показан пример обращения к полю MIDDLE из приведенной выше записи языка Ada. EMPLOYEE—RECORD.EMPLOYEE-NAME.MIDDLE В языке FORTRAN 90 используется та же форма, за исключением того, что вместо точки > потребляется знак процента ( О- Полностью определенной ссылкой (fully qualified reference) на поле записи называ- ется ссылка, в которой названы все имена промежуточных записей, начиная с наиболь- шей внешней записи и заканчивая требуемым полем. Обе приведенные выше ссылки (для языка COBOL и для языка Ada) являются полностью определенными. Помимо та- ких ссылок, языки COBOL и PL/I допускают эллиптические ссылки на поля записи. В эллиптической ссылке поле называется, но некоторые или все имена внешних записей могут пропускаться, пока получающееся в результате обращение остается однозначным в данной среде ссылок. Например, ссылки FIRST, FIRST OF EMPLOYEE-NAME и FIRST OF EMPLOYEE-RECORD являются эллиптическими ссылками к имени сотрудни- ка в объявленной выше записи языка COBOL. Несмотря на то что эллиптические ссылки удобны для программиста, они вынуждают компилятор обрабатывать структуры данных и процедуры, чтобы правильно определить требуемое поле. Стоит также отметить, что такой вид ссылок наносит некоторый ущерб читабельности программы. В языке Pascal определенные эллиптические ссылки допускаются только в специаль- ных структурах. Фрагмент программы может помещаться в оператор with, где часть квалификаторов указывается неявно. Рассмотрим, например, следующие два фрагмента программ: первый написан без использования оператора with, а во втором этот опера- тор используется. emloyee.name := 'Bob’; emloyee.age := 42; employee.sex := 'M'; employee.salary := 23750.0; with employee do begin name := 'Bob 1; age := 42; sex := ’M *; salary := 23750.0 end; { конец блока with } Если в оператор with помещается относительно небольшой блок команд, то это повы- шает читабельность, если же в этом операторе содержатся несколько страниц команд, то это читабельности может только навредить. 244 Глава 5. Типы данных
5.7.3. Операции над записями Обычной операцией над записями является присваивание. В большинстве случаев типы обеих частей оператора присваивания должны совпадать. Язык Ada позволяет сравнивать записи для выяснения их равенства или неравенства. Помимо этого записи языка Ada могут инициализироваться набором литералов. В языке COBOL для перемещения записей предусмотрен оператор MOVE CORRESPONDING. Этот оператор выполняет копирование поля заданной записи- источника в целевую запись, при этом необходимым условием является наличие в целе- вой записи поля с тем же именем. Этот оператор часто полезен в приложениях обработ- ки данных, в которых входные записи после некоторых изменений перемещаются в вы- ходные файлы. Поскольку входные записи часто содержат большое количество полей с теми же именами и задачами, что и выходные записи (причем порядок полей не обяза- тельно совпадает), то операция MOVE CORRESPONDING помогает сэкономить большое количество операторов. Рассмотрим следующие структуры языка COBOL: 01 INPUT-RECORD. 02 NAME. 05 LAST PICTURE IS X(20) . 05 MIDDLE PICTURE IS X(15) . 05 FIRST PICTURE IS X(20) . 02 EMPLOYEE-NUMBER PICTURE IS 9(10) . 02 HOURS-WORKED PICTURE IS 99. 01 OUTPUT-RECORD. 02 NAME. 05 FIRST PICTURE IS X(20). 05 MIDDLE PICTURE IS X(15) . 05 LAST PICTURE IS X(20). 02 EMPLOYEE-NUMBER PICTURE IS 9(10) . 02 GROSS-PAY PICTURE IS 999V99 02 NET-PAY PICTURE IS 999V99 Оператор MOVE CORRESPONDING INPUT-RECORD ТО OUTPUT-RECORD скопирует поля FIRST, MIDDLE, LAST и EMPLOYEE-NUMBER из входной записи (INPUT-RECORD) В выходную (OUTPUT-RECORD). 5.7.4. Оценка В языках программирования записи часто являются полезными типами данных. Структура типов записей проста, а их использование надежно. Единственным не совсем читабельным аспектом записей является возможность использования в языках COBOL и PL/I эллиптических ссылок. Записи и массивы тесно связаны между собой структурными формами, поэтому ин- тересно их сравнить. Массивы используются в том случае, если все величины данных принадлежат к одному типу и обрабатываются одинаково. Эта обработка выполняется легко, если в структуре существует систематический способ упорядочения элементов массивов. Подобная обработка хорошо поддерживается использованием динамической индексации в качестве метода адресации. А 7 245
Записи используются в том случае, если совокупность данных неоднородна, и раз- личные поля не обрабатываются однотипно. Часто также требуется обрабатывать поля записи в определенной последовательности. Имена полей подобны литеральным, или постоянным, индексам, и поскольку они статичны, то обеспечивают крайне эффектив- ный доступ к самим полям. Динамические индексы также могут использоваться для дос- тупа к полям записи, но такой подход сделает невозможным проверку типов, и, кроме того, он выполняется медленнее. Записи и массивы представляют собой продуманные и эффективные методы удовле- творения двух различных, но родственных требований к структурам данных. 5.7.5. Реализация записей Поля записей содержатся в смежных ячейках памяти. Однако, поскольку размеры по- лей не обязательно совпадают, метод доступа, используемый для массивов, для записей не подходит. Вместо этого, с каждым полем связывается относительный адрес, указы- вающий величину смещения от начала записи. На рис. 5.8 показана общая форма стати- ческого дескриптора записи. Динамические дескрипторы записей не обязательны. Рис. 5.8. Статический дескриптор записи 5.8. Объединения Объединением (union) называется переменная, которая может содержать в различ- ные периоды выполнения программы значения различных типов. Для того чтобы понять, для чего нужны объединения, рассмотрим таблицу констант компилятора, используемую для хранения констант, обнаруживаемых в компилируемой программе. Отдельное поле каждой позиции таблицы предназначено для хранения значения константы. Предполо- жим. что в конкретном компилируемом языке константы могут быть целочисленными, булевскими и числами с плавающей точкой. Если рассматривать компиляцию с точки зрения управления таблицей, было бы удобно, чтобы одна ячейка, или поле таблицы, со- держала значение любого из указанных трех типов. В таком случае к значениям всех коне । ан * можно было бы обращаться одинаково. Такой тип ячейки памяти является в некотором смысле объединением трех типов величин, которые могут в ней содержаться. 246 Глава 5. Типы данных
5.8.1. Вопросы разработки Один из основных вопросов разработки, порождаемый проблемой проверки объеди- -:-ных типов, уже рассматривался в главе 4. Другим фундаментальным вопросом явля- : синтаксическое представление объединения. В некоторых случаях объединения яв- • <тся всего лишь частью структур записей, а в некоторых— нет. Итак, с объединен- '-‘и типами связаны следующие основные вопросы разработки. Следует ли требовать выполнения проверки типов? Отметим, что любая подобная проверка типов должна быть динамической. Следует ли включать объединения в записи? 5.8.2. Свободные объединения В языках FORTRAN, С и C++ предусмотрены конструкции объединений, в которых -е- языковой поддержки проверки типов. В языке FORTRAN для задания объединений пользуется оператор EQUIVALENCE, а в языках С и C++ — конструкция union. Объ- : мнения в этих языках носят названия свободных (free unions), поскольку при их ис- * льзовании программисты совершенно освобождаются от выполнения проверки типов. 5.8.3. Размеченные объединения языка ALGOL 68 Для выполнения проверки типов объединений требуется, чтобы каждое объединение одержало указатель типа. Такой указатель называется меткой (tag), а объединение, со- держащее метку, — размеченным объединением (discriminated union). Первым языком, *?именившим размеченные объединения, был язык ALGOL 68. Рассмотрим следующий тимер: union (int, real) irl, ir2 5 этом примере указывается, что переменные irl и ir2 принадлежат к типу union. • гторый может быть типом int или real. Несмотря на то что таким переменным мож- - э присваивать значения обоих указанных типов, ссылаться на эти переменные совсем ~е так просто. Рассмотрим следующий фрагмент программы: union (int, real) irl; xnt count; irl := 33; count := irl; Здесь выполнение первого оператора присваивания разрешено, а второго— нет. по- скольку система не может статически проверить тип переменной irl. Компилятор не “ожет гарантировать, что переменная irl действительно будет содержать целое значе- -ие. Для решения проблемы обращения к таким переменным в языке ALGOL 68 преду- смотрены операторы согласования (conformity clauses). Рассмотрим, например, сле- дующий фрагмент программы: union (int, real) irl; int count; real sum; 247 5.8. Объединения
case irl in (int intval): count := intval, (real realval): sun := realval esac Оператор case выполняет присваивание, допустимое в данный момент, т.е. именно то присваивание, в котором тип значения переменной irl совпадает с типом целевой пе- ременной (count или sum). Следовательно, разные типы обрабатываются по-разному. Правильный выбор осуществляется путем проверки метки типа, сохраняемого системой поддержки выполнения программ для данной переменной. Взятые в скобки операторы, предваряющие операторы присваивания, указывают текущий тип переменной irl, а следующий за ними идентификатор представляет собой средство для обращения к пере- менной irl. Например, оператор (int intval) указывает, что если в данный момент значение переменной irl принадлежит к типу int, то выполняется заданный оператор: таким образом переменная intval ссылается на текущее значение переменной irl. Итак, переменные intval и realval представляют собой согласованные с типом объ- единения средства обращения к его значениям. Указанный метод является безопасным способом реализации размеченных объединений, поскольку он позволяет выполнять ста- тическую проверку типов в программе пользователя и динамическую проверку меток системы, для того чтобы избежать ошибочного использования значений. Фиктивные пе- ременные intval и realval можно рассматривать как неявно объявленные перемен- ные. областями видимости которых является оператор, следующий за их определениями. 5.8.4. Типы объединения в языке Pascal Впервые концепция интеграции размеченных объединений и структур записей в еди- ное целое была реализована в языке Pascal. Затем эта интеграция была использована в языках Modula-2 и Ada. Во всех этих языках размеченное объединение называется вари- антом записи (record variant), или вариантной частью записи. Метка— это доступная пользователю переменная, принадлежащая записи и хранящая тип варианта. Пример за- писи в языке Pascal, содержащей вариантную часть, приводится ниже. type shape = (circle, triangle, rectangle); colors = (red, green, blue); figure = record filled : boolean; color : colors; case form : shape of circle: (diameter : real); triangle: (leftside : integer; rightside : integer; angle : real); rectangle: (sidel : integer; side2 : integer) end; var myfigure : figure; 2да Глава 5. Типы данных
Структура вариантных записей показана на рис. 5.9 (предполагается, что для хранения целых и вещественных значений требуется одинаковый объем памяти). Переменная : . т_1ге состоит из метки form и памяти, достаточной для хранения наибольшего вари- анта переменной figure. В нашем случае наибольшим вариантом является переменная - rtangle, состоящая из двух целых чисел и одного вещественного. Во время выполне- ния программы метка должна указывать, какой из вариантов в данный момент содержит- ся в переменной figure. Если значение варианта нужно вывести на экран, используется следующая последовательность команд: case myfigure.form of circle: writein(’Это окружность; ее диаметр равен:’, myfigure.diameter); triangle: begin writein(’Это треугольник'); writein(’его стороны равны:’, myfigure.leftside, myfigure.rights ide); writein(’угол между сторонами равен:’, myfigure.angle); end; rectangle: begin writein (’Это прямоугольник') ; writein ('его стороны равны:', myfigure.sidel, myfigure.side2) end end rectangle, sidel, side2 t л \ circle:diameter /-------K\ triangle: leftside, rightside, angle Puc. 5.9. Размеченное объединение трех параметров формы (размер всех переменных предполагается равным) Несмотря на то что при разработке языка Pascal была предпринята попытка рассмот- реть, по крайней мере, возможность проверки типов вариантных записей, при разработке этого языка возникают две особые проблемы, делающие проверку типов практически не- возможной. Первая проблема связана с тем, что пользовательская программа может изме- нять метку без изменения соответствующего варианта. Следовательно, если проверка типа варианта производится системой поддержки выполнения программ путем изучения метки перед использованием варианта, то все ошибки система обнаружить не сможет, поскольку 5.8. Объединения 249
пользовательская программа уже могла так изменить метку, что ее значение теперь не со- ответствует типу текущего варианта. Вот почему разработчики средств реализации этих языков обычно игнорируют проверку типов ссылок на вариантные записи. Вторая проблема заключается в том, что программист может просто не указывать метку в структуре вариантной записи, делая ее свободным объединением. Рассмотрим следующую последовательность команд: type figure = record case shape of circle : (diameter : real); triangle : (leftside : integer); end При такой структуре записи ни пользователь, ни система не могут определить текущий тип записи. Предположим, что значение переменной myfigure. diameter равно 2.73. Не существует способа защиты от неверных ссылок, например: side := myfigure.leftside; Такие ссылки редко бывают полезными, поскольку на данный момент в ячейке, отведенной переменной leftside, находится значение с плавающей точкой. Вообще, мы можем в любое время сослаться на переменные myfigure.diameter и myfigure.leftside и присвоить им значения. Иногда вариантные записи языка Pascal используются для того, чтобы обойти неко- торые ограничения, введенные в этом языке. Они предоставляют удобную лазейку в пра- вилах проверки типов. Например, в языке Pascal не разрешена арифметика указателей, но некоторые приложения требуют манипулирования значениями указателей. Так, со- путствующая система динамического управления памятью использует арифметику ука- зателей для вычисления адресов ячеек памяти, в которых будет производиться размеще- ние объектов. Для того чтобы обойти запреты арифметики указателей, программист мо- жет помещать указатели в вариант с целыми числами, а уже с этими числами обращаться так. как того требует поставленная задача. В языках С, Modula-2 и Ada описанное ис- пользование вариантных записей необязательно, поскольку в этих языках существуют другие методы выполнения арифметических операций над указателями, или адресами. 5.8.5. Объединения в языке Ada В языке Ada форма вариантных записей языка Pascal была расширена для повышения их безопасности. Были решены обе проблемы, связанные с использованием вариантных записей в языках Pascal и Modula-2: в языке Ada изменить метку без изменения варианта невозможно и метка нужна во всех вариантных записях. Более того, в языке Ada следует проверять метку при любых ссылках на вариант. В языке Ada пользователь может задавать переменные, принадлежащие к типу вари- антных записей и содержащие только один из возможных типов варианта. Подобным способом пользователь может указать системе, когда возможна статическая проверка типов. Сами переменные, определенные таким образом, называются ограниченными вариантными переменными (constrained variant variable).* 250 Глава 5. Типы данных
Метка ограниченной вариантной переменной интерпретируется как именованная кон* станта. Неограниченные вариантные записи в языке Ada подобны их аналогам в языке Pascal, поскольку во время выполнения программы значения вариантов могут изменять свои типы. Однако тип варианта может меняться только при присвоении ему целой записи, в том числе и метки. Подобный подход исключает возможность появления несовместимых записей, поскольку если вновь присвоенная запись является множеством констант, то зна- чение метки и типа варианта можно статически проверить на совместимость. Если при- сваиваемой величиной является переменная, то ее совместимость обеспечивается в процес- се присваивания, так что новое значение переменной наверняка будет совместимым. Выше приводился пример вариантной записи на языке Pascal. Затем эта же запись описывается на языке Ada: type SHAPE is (CIRCLE, TRIANGLE, RECTANGLE); type COLORS is (RED, GREEN, BLUE); type FIGURE (FORM : SHAPE) is record FILLED : BOOLEAN; COLOR : COLORS; case FORM is when CIRCLE => DIAMETER : FLOAT; when TRIANGLE => LEFT_SIDE : INTEGER; RIGTH_SIDE : INTEGER; ANGLE : FLOAT; when RECTANGLE => SIDE-1 : INTEGER; SIDE_2 : INTEGER; end case; end record; Следующие два оператора объявляют переменные типа FIGURE: FIGURE_1 : FIGURE; FIGURE-2 : FIGURE(FORM => TRIANGLE); Переменная FIGURE—1 объявляется как неограниченная вариантная запись без начального значения. Тип этой переменной может меняться при присваивании ей полной < с меткой) записи: FIGURE—1 := (FILLED => true, COLOR => BLUE, FORM => RECTANGLE, SIDE-1 => 12, SIDE_2 => 3); Правая часть оператора присваивания представляет собой множество данных. Множество возможных типов объявленной выше переменной FIGURE—2 ограничи- вается типом TRIANGLE, кроме того, значение данной переменной не может измениться на другой вариант. Форма размеченного объединения совершенно безопасна, поскольку она всегда по- зволяет выполнять проверку типов, хотя ссылки на поля неограниченных вариантов должны проверяться динамически. 5.8. Объединения 251
5.8.6. Оценка Во многих языках объединения — потенциально небезопасные конструкции. Это од- на из причин того, что в языках FORTRAN, Pascal, С, О+ и Modula-2 отсутствует стро- гая проверка типов: в этих языках невозможна проверка типов ссылок на объединения. С другой стороны, объединения предоставляют некоторую гибкость программирова- ния; в языке Pascal, например, объединения позволяют использовать арифметику указате- лей. Более того, как показал опыт языка Ada. структуру объединения можно сделать безо- пасной. В большинстве других языков объединения приходится использовать осторожно. Существуют недавно разработанные языки, не содержащие объединении. В их число входят языки Oberon, Modula-З и Java. Одним из объяснений этого факта может служить растущая забота о безопасности языков программирования. 5.8.7. Реализация объединений Размеченные объединения реализуются простым использованием одинаковых адре- сов для всех возможных вариантов. Наибольшему варианту выделяется достаточное ко- личество памяти. Для ограниченных вариантов в языке Ada, в которых изменения не происходят, можно использовать точное количество памяти. Метка размеченного объе- динения хранится в структуре, подобной записи, вместе с соответствующим вариантом. Во время компиляции в памяти должно содержаться полное описание каждого вариан- та. Эго можно осуществить, связывая таблицы выбора с меткой в дескрипторе. Таблица выбора содержит элементы, соответствующие каждому варианту и указывающие на его де- скриптор. Для иллюстрации этой схемы рассмотрим следующий пример из языка Ada: type NODS (TAG : BOOLEAN) is record case TAG is when true -> COUNT : INTEGER; when false => sum : FLOAT; end case; end record; Возможная форма дескриптора этого типа показана на рис. 5.10. имя тип имя тип Рис. 5.10. Динамический дескриптор размеченного объединения 252 Глава 5. Типы данных
5.9. Множества Переменные множественного типа (set type) могут содержать неупорядоченную со- - ->пность отдельных величин, имеющих некоторый порядковый тип. называемый ба- . вым типом (base type). Множественные типы данных часто используются для моде- гования множеств в математическом смысле этого термина. Примером может служить .-х:из текста, для выполнения которого нужно обеспечить хранение и удобное исполь- = ание таких небольших наборов символов, как знаки пунктуации или гласные буквы. При разработке множественных типов возникает один специфический вопрос: каким 2 :жно быть максимальное число элементов базового типа в множестве? 5.9.1. Множества в языках Pascal и Modula-2 Среди распространенных императивных языков программирования множества как - ’1 данных существуют только в языках Pascal и Modula-2. Опишем кратко множествен- е типы данных в языке Pascal. Максимальный размер множеств в языке Pascal зависит от реализации. Многие реа- • >ации жестко их ограничивают, зачастую числом, значительно меньше 100. Причина "2'?го ограничения заключается в том, что множества и операции над ними наиболее < дективно реализуются, если переменная множественного типа представляется в виде .токи битов, соответствующей одному машинному слову. Одна из проблем, связанных с тем, что машинные слова могут определять макси- тьный размер базового множества, состоит в том. что пользователи ограничены моде- гованием только небольших множеств, и это сказывается на легкости создания про- тдмм. Другая проблема заключается в том, что в различных машинах используются .• ва различных размеров, поэтому программы, созданные на машинах с большими -^мерами слов (а следовательно и использующих большие множества), часто нельзя пе- -•-ести на машины с меньшими размерами слов. Существование этих проблем является . едствием того, что максимальный размер базового множества не включается в струк- - языка, а выбирается разработчиками средств реализации языка. В языке Pascal есть некоторое количество операций над множествами, в число кото- - = \ входит объединение, пересечение и проверка равенства множеств. Ниже приводится пример определения множественного типа и нескольких перемен- ? \ этого типа. type colors = (red, blue, green, yellow, orange, white, black); colorset = set of colors; var setl, set2 : colorset; Переменным множественного типа setl и set2 можно следующим образом при- своить постоянные значения: setl := [red, blue, yellow, white]; set2 := [black, blue]; Множественные типы в языках Modula-2 и Modula-З практически совпадают с соот- ветствующими типами в языке Pascal за исключением небольших синтаксических изме- нений и некоторых дополнительных операций. Константные множества заключаются в 5.9. Множества 253
фигурные скобки ({ }). Скобкам может предшествовать имя типа этих констант. Рас- смотрим следующее объявление множественного типа данных: TYPE setypel = SET OF [red, blue, green, yellow]; setype2 = SET OF [blue, yellow]; VAR setvarl : setypel; В программе, содержащей эти объявления, константа {blue} может определяться неоднозначно. Однако в операторе setvarl := setypel {blue}; тип константы {blue} очевиден. В языке Pascal существует одна форма записи этой константы, [ blue ], что не позволяет указать ее тип. В языке Modula-2, подобно языку Pascal, не указано минимально возможное количе- ство элементов в множестве, так что эта величина также определяется в зависимости от реализации. В языках Pascal и Modula-2 переменные множественных типов, как и пере- менные перечислимых типов, не могут ни вводиться, ни выводиться. Множества широко используются для упрощения и сокращения составных булевских выражений, содержащих оператор OR. Например, оператор if (ch = 'a’) or (ch = *е*) or (ch = ’i’) or (ch = ’o’) or (ch = 'u') ... можно заменить оператором if ch in [’a’, 'e', *i', ’o’, *u*] ... 5.9.2. Оценка Несмотря на то что язык Ada создавался на основе языка Pascal, множественные типы данных в языке Ada отсутствуют. Вместо этого разработчики языка Ada добавили опера- тор принадлежности переменной к множеству переменных перечислимого типа. Это по- зволило выполнять одну из наиболее нужных операций над множествами. Отметим, что множества, в которых можно выполнять проверку принадлежности, очевидно, представ- ляют собой просто перечислимые значения, являющиеся константами. В других языках, не содержащих множественные типы данных, операции над множе- ствами должны выполняться с массивами, причем команды, предусматривающие эти операции, должны создаваться пользователем. Это не трудно, хотя, конечно, более гро- моздко. и. весьма вероятно, значительно менее эффективно. Например, если множество гласных букв в языке Pascal представлено как массив char, то проверка наличия глас- ной буквы в данной символьной переменной будет требовать цикла, просматривающего массив с гласными. В то же время, если гласные буквы были представлены в виде мно- жества. то этой же цели можно достичь одним применением оператора in. Это не только удобно для программистов, но и способствует эффективному использованию компьюте- ра. В обоих случаях гораздо удобнее обращаться с множеством как с единым целым, в то время как массив должен просматриваться поэлементно. Массивы, разумеется, намного гибче множеств; они допускают выполнение значитель- но большего количества операций, более сложные формы и большую свободу выбора типа элементов. Фактически, если массивы ограничить максимальной длиной 32 (как это сдела- но для множеств во многих реализациях языка Pascal), пользователи не сочли бы их прием- 254 Глава 5. Типы данных
лемыми. Множества предлагают альтернативный вариант, приносящий гибкость в жертву - гфективности и предназначенный для определенного класса приложений. 5.9.3. Реализация множественных типов данных Множества, как правило, содержатся в памяти в виде строки битов. Например, пусть множество имеет в качестве базового следующий порядковый тип: :'а*..*р'] В этом случае переменные этого типа могут использовать первые 16 бит машинного ;’ова, причем бит 1 будет представлять присутствующий элемент, а бит 0— отсутст- вующий. При использовании такой схемы значение ;'а*, 'с', •h’, 'о* ] можно представить в виде 1010000100000010 Выигрыш от такого подхода заключается в возможности определить результат типичной перации объединения множеств с помощью одной машинной команды — логического /.ЛИ. Если количество элементов базового множества не превышает размера машинного :лова, то операцию принадлежности к множеству также можно произвести с помощью дной команды. Например, для переменной множественного типа setchars проверка -а принадлежность элемента к множеству выглядит следующим образом: 'g* in setchars Лри этом процесс может выполняться с помощью операции логического И между двумя верандами, представленными в виде строк битов. 5.10. Указатели Указателем (pointer) называется переменная, диапазон значений которой состоит из адресов ячеек памяти и специального значения — нулевого адреса. Значение нулевого ад- реса не является реальным адресом и используется только для обозначения того, что указа- ель в данный момент не может использоваться для обращения ни к какой ячейке памяти. Указатели разработаны для применения в двух различных сферах. Во-первых, они позво- ляют использовать некоторые выгоды косвенной адресации, широко применяемой в про- граммировании на языках ассемблера. Во-вторых, указатели предлагают метод динамическо- го управления памятью: их можно использовать для доступа к области с динамическим раз- мещением памяти, обычно называемой кучей (heap), или динамической памятью. Переменные, динамически размещаемые в куче, называются динамическими (heap- ch namic variables). Часто они не содержат связанных с ними идентификаторов, и ссы- латься на них можно только с помощью указателей и ссылок (references). Эти не имею- щие имен переменные называются безымянными (anonymous variables). Именно в 'пой недавно появившейся области использования указателей и возникают важнейшие вопро- сы разработки. Указатели, в отличие от массивов и записей, не относятся к структурным типам дан- ных, хотя они и описываются с помощью оператора типа (оператора * в языках С и С • оператора access в языке Ada и оператора А в языке Pascal). Более того, они отличаю i- 5.10. Указатели 255
ся и от скалярных переменных, поскольку они чаще используются для обращения к дру- гим переменным, чем для хранения каких-либо данных. Использование указателей в указанных случаях облегчает создание программ на язы- ке. Предположим, что нам нужно реализовать динамическую структуру наподобие дво- ичного дерева в языке со структурой, схожей со структурой языка FORTRAN 77, не имеющего указателей. Такая задача потребует от программиста создания и поддержания пула доступных узлов дерева, что, вероятно, придется реализовывать в форме парал- лельных массивов. Кроме того, поскольку в языке FORTRAN 77 отсутствует динамиче- ское управление памятью, от программиста потребуется приблизительная оценка макси- мального числа требуемых узлов. Очевидно, что такой способ работы с двоичными де- ревьями неуклюж и громоздок. 5.10.1. Вопросы разработки Основными вопросами разработки, характерными для указателей, являются следующие Каковы область видимости и время жизни переменных указателей? Каково время жизни динамических переменных? Ограничены ли указатели типом той величины, которую они могут адресовать? Используются ли указатели для динамического управления памятью, для косвен- ной адресации или для обеих целей? Должен ли язык поддерживать указатели, ссылки или оба вида переменных? 5.10.2. Операции над указателями Языки, в которых предусмотрен тип указателей, содержат, как правило, две основные операции над ними: присваивание и разыменование. Первая из этих операций присваи- вает указателю некоторый адрес. Если эти переменные используются только для управ- ления динамической памятью, то для их инициализации используется механизм разме- щения в памяти (реализуемый оператором или встроенной подпрограммой). Если же указатели используются для обеспечения косвенной адресации переменных, не относя- щихся к динамическим, то для выборки адреса переменной должен использоваться яв- ный оператор или встроенная подпрограмма, после чего данный адрес может присваи- ваться указателю. Появление в выражении переменной, являющейся указателем, можно интерпретировать двумя различными способами. Во-первых, указатель можно рассматривать как ссылку на содержимое ячейки памяти, с которой связана переменная, т.е. в данном случае указа- тель — это адрес. Таким же образом можно интерпретировать входящую в выражение пе- ременную, не являющуюся указателем, хотя в этом случае переменная может не быть адре- сом памяти. Впрочем, указатель также можно рассматривать как ссылку на значение той ячейки памяти, адрес которой находится в ячейке памяти, с которой связан данный указа- тель. В этом случае указатель интерпретируется как косвенная ссылка. Первый из упомяну- тых случаев является обычной ссылкой с использованием указателя, тогда как во втором случае в выражение входит результат разыменования (dereferencing) указателя. Разымено- вание, принимающее ссылку через один уровень косвенной адресации, является второй ос- новной операцией с указателями. Для того чтобы пояснить понятие разыменования, рас- смотрим переменную-указатель pt г, связанную с ячейкой памяти, имеющей значение 256 Глава 5. Типы данных
“' SO Предположим, что ячейка памяти с адресом 7080 содержит значение 206. Обычная .сыпка на переменную pt г даст значение 7080, а разыменованная — 206. Разыменование может быть явным и неявным. Языки ALGOL 68 и FORTRAN 90 от- носятся к языкам с неявным разыменованием, тогда как в большинстве современных языков программирования разыменование происходит только при явном указании. В языке Pascal например, разыменование явно задается знаком А как постфиксная унар- ная операция. Допустим, что переменная pt г является указателем со значением 7080, •ак это было в приведенном выше примере, а ячейка с адресом 7080 содержит величину 2.6. Тогда оператор присваивания : :=-• ptrA тис войт переменной j значение 206. Графически описанный процесс представлен на гис. 5.11. 7080 206 Безымянная динамическая переменная 7080 Рис. 5.11. Операция присваивания jptr* Если указатель адресует записи, то синтаксическая форма ссылки на поля таких запи- сей изменяется в зависимости от языка. В языках С и C++, например, существует два способа использования указателя на запись в качестве ссылки на поле этой записи. Если переменная-указатель р указывает на запись, имеющую поле аде, то для ссылки на это поле можно использовать оператор (*р) . аде. Если между указателем на запись и по- ~ем этой записи помещается оператор ->, то происходит объединение разыменования и ссылки на поле. Следовательно, выражение р -> аде эквивалентно приведенному вы- ше выражению (*р) . аде. В языке Pascal та же ссылка записывается в виде рА . аде, а в языке Ada может использоваться запись р. аде, поскольку в таком случае указатели разыменовываются неявно. Языки, предусматривающие использование указателей для управления динамической памятью, должны содержать оператор явного размещения переменных в памяти. В неко- торых языках помимо этого оператора предусмотрен еще и оператор явного удаления переменных из памяти. Обе эти операции часто принимают форму встроенных подпро- грамм, хотя в языках Ada, C++ и Java для размещения в памяти используется отдельный оператор. 5.10. Указатели 257
5.10.3. Проблемы, возникающие при использовании указателей Первым высокоуровневым языком, содержащим указатели, был язык PL/1, в котором они могли использоваться для обращения не только к динамическим переменным, но и другим переменным программы. Указатели в языке PL/I крайне гибки, но их использо- вание может привести к определенным ошибкам при программировании. Некоторые и: проблем, связанных с использованием указателей в языке PL/I, сохранились и в боле, поздних языках программирования. Одним из решений таких проблем в современньл языках программирования является полная замена указателей ссылками, которые также позволяют производить неявное разыменование. Примером языка, использующего такс; подход, может служить язык Java. 5.10.3.1. Висячие указатели Висячим указателем (dangling pointer), или висячей ссылкой (dangling reference), на- зывается указатель, содержащий адрес динамической переменной, уже удаленной из пам=- ти. Висячие указатели опасны по нескольким причинам. Во-первых, ячейка памяти, на ко- торую указывает ссылка, может содержать какую-нибудь новую динамическую перемен- ную. Если тип этой новой переменной отличается от типа старой, то проверка типов п; использовании висячих указателей становится некорректной. Однако, даже если новая ге- ременная имеет тот же тип, что и старая, ее значение не имеет никакого отношения к зна- чению переменной, удаленной из памяти. Во-вторых, если висячий указатель использхете; для изменения динамической переменной, то значение этой новой динамической переме - ной будет уничтожено. И последнее, существует возможность того, что рассматриваема ячейка памяти в данный момент временно используется системой управления память - возможно, в качестве указателя в цепочке доступных блоков памяти. В таком случае изме- нения в ячейке вызовут сбой в работе программы управления памятью. Во многих языках висячие указатели могут создаваться следующей последователь-. • стью действий. 1. Целью указателя pl устанавливается новая динамическая переменная. 2. Указателю р2 присваивается значение указателя pl. 3. Динамическая переменная, являющаяся целью указателя pl, явно удаляется из л.- мяти (значение указателя pl устанавливается равным нулю), но при выполне-.- этой операции указатель р2 не меняется и с данного момента становится висячим 5.10.3.2. Потерянные динамические переменные Потерянной динамической переменной (lost heap-dynamic variable) называе-;*- размещенная в памяти динамическая переменная, переставшая быть доступной из п..ь- зовательской программы. Такие переменные часто называют мусором (garbage». - • скольку они уже бесполезны для первоначальных целей, и не могут повторно размена-= - ся в памяти для новых целей. Самым распространенным способом создания потеря г. — динамических переменных является следующая последовательность действий. 1. Целью указателя pl устанавливается вновь созданная динамическая переменна* 2. Целью указателя pl устанавливается другая вновь созданная динамическая пель- менная. 258 Глава 5. Типы дань»»
:сле выполнения этих действий первая динамическая переменная становится . : ~ иной, или потерянной. :блема потерянных динамических переменных, иногда называемая утечкой памяти '*: -• leakage), затрагивает языки, в которых требуется явное удаление из памяти дина- л\ переменных. В следующих разделах мы рассмотрим, как разработчики языков - is ’яьэтся с проблемами висячих указателей и потерянных динамических переменных. 5.10.4. Указатели в языке Pascal : 2=>1ке Pascal указатели используются исключительно для доступа к динамическим =гным переменным. Поскольку в этом языке есть явная операция удаления из па- •э в нем легко могут создаваться висячие указатели. Создание программистом ди- -еских переменных в языке Pascal выполняется оператором new, а уничтожение — -.::-:эом dispose. Для безопасного уничтожения динамических переменных функ- - • z _ scose должна найти все указатели на данную динамическую переменную и при- нм значения nil. К сожалению, это процесс сложный и неэффективный и, как - : = редко используется. Данная проблема является обшей проблемой языков про* •гзвания, касающейся всех процессов явного удаления из памяти. • -зтгчкторы языка Pascal предлагают следующие альтернативы явному удалению ’“норировать команду dispose, при этом удаление из памяти не происходит и - один из указателей, целью которых являлся искомый динамический объект, не ечяется. -е включать в язык оператор dispose, делая его неразрешенным. *• далять из памяти сомнительные динамические переменные и присваивать указа- -г-.ю. являющемуся параметром оператора dispose, значение nil, создавая, та- * -м образом, из всех указателей, указывающих на объект, висячие указатели. ~: :ностью и корректно реализовать оператор dispose, сделав существование : :’чих указателей невозможным. (Автору неизвестна ни одна реализация языка 125cal. в которой была бы выбрана эта альтернатива.) 5.10.5. Указатели в языке Ada • Ada содержит указатели, называемые типами access, подобные указателям г ... • 2 5сз1. Стоит отметить, что проблема висячих указателей в языке Ada частично :: счет структуры языка. Динамическая переменная может (по желанию разра- и - • • зредств реализации языка) неявно удаляться из памяти в конце области види- > .* .дгесчюших ее указателей, при этом значительно снижается потребность в явном : -з памяти. Поскольку единственным способом доступа к динамическим пере- - • -.’яются указатели этого типа, то при достижении конца области видимости ” • • : - • 'того типа уже не остается указателей, указывающих на данный объект. Это -гобле.му висячих указателей, основным источником которой является некор- е . - з. зованное явное удаление из памяти. К сожалению, язык Ada также содержит т:_- : :зного удаления из памяти UNCHECKED__DEALLOCATION. Его имя (букв. - мое \ даление из памяти”) должно отговорить пользователя от использования : 7 ора или, по крайней мере, предупредить о возможных проблемах. 1 •<дзотели 259
Проблема потерянных динамических переменных структурой указателей языка не устранена. Одним из небольших усовершенствований указателей языка Ada, по сравнению . языками Pascal и Modula-2, является требование неявной инициализации всех указателе/’ значением null (значение нуля в языке Ada). Такой подход предотвращает случайный доступ к произвольным ячейкам памяти, происходящий из-за того, что пользователь за- был инициализировать указатель до его использования. 5.10.6. Указатели в языках С и C++ В языках С и C++ указатели могут использоваться так же, как и адреса в языках ас- семблера. Это означает, что эти указатели крайне гибки, но должны использоватьс = очень осторожно. Такая структура не предлагает решений проблем висячих указателе? или потерянных динамических переменных. Тем не менее, тот факт, что в языках С ? C++ разрешены арифметические операции над указателями, делает эти указатели более интересными, чем в других языках программирования. В отличие от указателей в языках Pascal и Ada, которые могут указывать только Н2 ячейки динамической памяти, указатели в языках С и C++ могут ссылаться практически на любую переменную во всей доступной области памяти. Операция разыменования в языках С и C++ задается звездочкой (*), а знак & указы- вает на оператор выборки адреса переменной. Рассмотрим, например, фрагмент про- граммы int *ptr; int count, init; ptr = &init; count = *ptr; Приведенные в нем два оператора присваивания эквивалентны одному: count = init; Операция присваивания значения переменной ptr помещает в нее адрес переменной init. Первое присваивание переменной count разыменовывает указатель ptr, чтобы получить значение переменной init, которое затем присваивается переменной count Таким образом, результатом приведенных выше двух операций присваивания является присваивание значения переменной init переменной count. Отметим, что объявление указателя задает тип переменных, которые он может адресовать. Указателям могут присваиваться адреса любых объектов, имеющих соответствующий лип, либо нуль, используемый для обозначения нулевого адреса. До некоторой степени в описываемых языках возможна и арифметика указателей. Если, например, ptr— указатель, объявленный для адресации некоторого объекта, имеющего определенный тип, то выражение ptr + index допустимо. Семантика этого выражения следующая: вместо простого сложения величин переменных index и ptr, значение переменной index сперва увеличивается согласно размеру ячейки памяти (в единицах памяти), на которую указывает переменная ptr. Ес- ли, например, переменная ptr указывает на ячейку памяти, занимаемую типом, имею- 260 Глава 5. Типы данных
шим размер 4 единицы памяти, то переменная index умножается на 4, после чего полу- ченный результат складывается с переменной pt г. Основной целью таких арифметиче- ских операций над адресами является управление массивами. Ниже обсуждаются только одномерные массивы. В языках С и C++ все массивы используют в качестве нижней границы диапазона ин- дексов число 0, а имя массива без индексов всегда относится к адресу первого элемента. Имя массива без индексов интерпретируется как указатель, за исключением того, что оно представляет собой константу, и, следовательно, ему не могут присваиваться значения. Рассмотрим следующие объявления: int list [10]; int *ptr; Рассмотрим инициализирующее присваивание ptr = list; Здесь адрес элемента list [0] присваивается переменной ptr, поскольку, как уже го- ворилось, имя массива без индекса интерпретируется как базовый адрес массива. Рас- смотрев данное присваивание, мы можем прийти к выводу, что *(ptr + 1) эквивалентно list [1], *(ptr + index) эквивалентно list[index] и ptr[index]эквивалентно list[index] Из этих выражений понятно, что операции над указателями содержат то же масштабиро- вание, которое используется в операциях индексации. Более того, указатели на массивы могут индексироваться так. как будто они являются именами массивов. Указатели могут адресовать функции. Это свойство используется для передачи функ- дий в качестве параметров другим функциям. Указатели также используются для пере- дачи обычных параметров, описанной в главе 8. Еще одной интересной особенностью языков С и C++ является наличие указателей типа void *, принадлежность к которому означает возможность указания на величины любых типов. Указатели этого типа являются эффективными настраиваемыми указате- лями. Впрочем, проблемы проверки типов при использовании указателей типа void * не существует, поскольку разыменовывать данные указатели нельзя. Указатели типа void * используются, как правило, в качестве параметров функций, оперирующих с памятью. Предположим, что мы хотим, чтобы функция переместила последовательность байтов данных из одной области памяти в другую. Более общим случаем была бы воз- можность передачи двух указателей любого типа. Подобное действие будет допустимым, если соответствующие формальные параметры функции имеют тип void *. После это- го функция может преобразовать их в тип char * и выполнить требуемое действие не- зависимо от того, указатели какого типа передались как фактические параметры. 5.10.7. Указатели в языке FORTRAN 90 Указатели в языке FORTRAN 90 используются для указания на динамические и ста- тические переменные. Например, в объявлении INTEGER, POINTER :: INT_PTR INTEGER, POINTER, DIMENSION (:) :: INT_LIST_PTR 5.10. Указатели 261
указатель INT__?TR может ссылаться на любую величину типа INTEGER, а указатель INr__LIS?_?TR — на любой одномерный массив, состоящий из элементов типа INTEGER. Указатели в языке FORTRAN 90 обычно разыменовываются неявно. Если, например, указатель появляется в обычном выражении, то он всегда является неявно разыменован- ным. Если разыменование нежелательно, то используется специальная форма оператора присваивания уКа'ЗсТеЛЬ LlCTIb Такое присвоение используется как для задания конкретной переменной в качестве пе- ременной. на которую ссылается данный указатель, так и для присвоения указателям ад- ресов др\гих указателей. Любая переменная, которую планируется адресовать с помо- щью указателя, должна содержать атрибут TARGET, устанавливаемый при объявлении переменной. Допустим, объявление было выполнено следующим образом: INTEGER, TARGET :: APPLE INTEGER ORANGE При этом на переменную APPLE указатель INT_PTR ссылаться может, а на переменную ORANGE — нет. Указатели в языке FORTRAN 90 легко могут становиться висячими, поскольку опе- ратор DEALLOCATE, принимающий указатель как аргумент, и не попытается определить, указывают ли еще какие-либо указатели на удаляемую из памяти динамическую пере- менную. 5.10.8. Ссылки В языке С<-* существует один особый тип переменных, носящих название ссылок, которые используются исключительно для описания формальных параметров функций. Переменная, являющаяся ссылкой в языке C++, представляет собой константу, которая всегда разыменовывается неявно. Поскольку ссылка в языке С*+ является константой, то во время своего определения она должна инициализироваться адресом некоторой пере- менной, а после инициализации данная ссылка уже не сможет быть связанной ни с одной другой переменной. Последнее очевидно: неявное разыменование препятствует присвое- нию ссылке нового адреса. Узнать ссылки в описаниях можно по знаку &, с которого всегда начинаются их име- на. Рассмотрим следующий фрагмент программы: int result = 0; int &ref__result = result; ref_result = 100; В нем переменные result и ref__result имеют совмещенные имена. Если при описании функций в качестве формальных параметров используются ссыл- ки, то они обеспечивают двустороннюю связь между вызывающей и вызываемой функ- циями. Для параметров, которые не являются ни ссылками, ни указателями, это было бы совершенно невозможно, поскольку параметры в языке C++ передаются по значению. Передача указателя как параметра также обеспечивает двустороннюю связь, но при этом приходится выполнять явное разыменование формальных параметров, что делает про- 262 Глава 5. Типы данных
•амм\ менее читабельной и менее надежной. К ссылкам вызываемая функция обраща- .-;я точно так же, как и к другим параметрам. Вызывающая функция не должна как-то . пенно выделять тот факт, что формальные параметры, соответствующие фактическим -гаметрам, являются ссылками. Ссылкам компилятор передает адреса, а не значения. Возможности ссылок языка C++ в языке Java были расширены до такой степени, что ’Л смогли совершенно заменить собой указатели Поскольку перед разработчиками • ка Java стояла задача повысить надежность работы программ по сравнению с языком —, то они совершенно удалили указатели, используемые в языках С и С~+. Основным -личием указателей в языке C++ от ссылок в языке Java является тот факт, что указате- • • в языке C++ обращаются к адресам памяти, тогда как ссылки в языке Java обращают- .: к экземплярам класса, что сразу же делает бессмысленными арифметические опера- . и над ссылками. С другой стороны, при этом не запрещается операция присваивания. .•хим образом, в отличие от ссылок в языке С ^. с помощью ссылок в языке Java можно 'гащаться к экземплярам разных классов. Обращения ко всем экземплярам классов в = :ыке Java выполняются с помощью ссылок. Это. фактически, и является единственным .пользованием ссылок в языке Java. Подробнее все эти вопросы описаны в главе 11. осмотрим следующий пример, в котором String — стандартный класс языка Java: Spring strl; strl = "Это строковый литерал языка Java”; . >том фрагменте программы переменная strl определяется как ссылка на экземпляр /и объект класса String, но изначально ее значение устанавливается равным нулю. В "ллпьтате следующего присвоения переменная strl начинает ссылаться на строку >го строковый литерал языка Java", являющуюся объектом String. Поскольку экземпляры классов в языке Java удаляются из памяти неявно (явного опе- - л тора удаления не существует), висячая ссылка в этом языке возникнуть не может. 5.10.9. Оценка Проблемы висячих указателей и мусора описана довольно подробно. Проблема правления динамической памятью еще будет рассматриваться в разделе 5.10.10.3. Указатели часто сравнивают с оператором дето. Этот оператор расширил область ператоров, которые могли выполняться на следующем этапе программы. Указатели - ас ширили область ячеек памяти, на которые могли ссылаться переменные. Самым не- . добрительным образом, вероятнее всего, к указателям отнесся Хоар (Ноаге, 1973), ска- звший: “Их введение в высокоуровневые языки было шагом назад, от которого мы ни- • vaa не оправимся". Ссылки в языке Java обеспечивают некоторую гибкость и возможности указателей, не мея при этом недостатков, присущих указателям. Создается впечатление, что програм- мисты согласны обменять всю мощь указателей языков С и C++ на значительно более высокую надежность ссылок языка Java. 5.10.10. Реализация ссылок и указателей В большинстве языков указатели используются для управления динамической памя- тью. То же самое справедливо и для ссылок языка Java. По этой причине мы не можем рассматривать эти свойства раздельно, поэтому вначале кратко опишем принципы Пред- S. 10. Указатели 263
ставления указателей и ссылок, после чего обсудим возможные решения проблемы вися- чих указателен, а в заключение опишем основные проблемы, связанные с методами управления кучей. 5.10.10.1. Представления указателей и ссылок В большинстве больших компьютеров указатели и ссылки являются просто значе- ниями. содержащимися в двух- или четырехбайтовых ячейках памяти, в зависимости от размера адресного пространства машины. Впрочем, существуют еще и микрокомпьюте- ры. большинство из которых основано на микропроцессорах Intel, использующих адреса, состоящие из двух частей: сегмента и смешения. Таким образом, указатели и ссылки в этих системах представляются в виде пары 16-битовых слов, в каждом из которых со- держимся одна из двух частей адреса. 5.10.10.2. Решение проблемы висячих указателей К висячим указателям относят те. которые указывают на уже освобожденную память. Зачастую они создаются в результате явного удаления динамических переменных из па- мяти: оператор удаления из памяти называет только один указатель на динамическую переменную, подлежат}ю удалению. Поскольку существование других указателей на удаляемую динамическую переменную системой поддержки выполнения программ не проверяется, то все остальные указатели на эту’ переменную этим процессом переводятся в категорию висячих. Существует два отдельных, но связанных между собой решения проблемы висячих указателей, которые либо предлагаются, либо фактически реализовываются. Первым из них является предложение использовать особые ячейки динамической памяти, называе- мые надгробиями. Использование надгробий было предложено Лометом (Lomet, 1975). Идея состоит в выделении для каждой динамической переменной особой ячейки памяти, или надгробия, являющейся указателем на эту' динамическую переменную. Переменная, действительно яв- ляющаяся указателем, всегда ссылается не на саму динамическую переменную, а только на надгробие. После удаления динамической переменной из памяти ее надгробие остается, но ему присваивается нулевой адрес, показывая, что динамической переменной уже нет. Та- кой подход препятствует обращению указателя к переменной, уже удаленной из памяти. Теперь все ссылки на указатель, целью которого является надгробие, содержащее нулевой адрес, могут диагностироваться как ошибки. На рис. 5.12 показано различие реализации динамических переменных с использованием метода над|робий и без него. Надгробия являются неэффективным методом с точки зрения затрат времени и памя- ти. Поскольку надгробия никогда не удаляются из памяти, то занимаемую ими память никогда нельзя использовать повторно. Каждый доступ к динамической переменной че- рез надгробие требует дополнительного уровня косвенной адресации, что в большинстве компьютеров требует дополнительного машинного цикла. По-видимому, разработчики всех распространенных языков программирования решили, что повышение безопасности не стоит дополнительных затрат, вследствие чего ни в одном широко известном языке надгробия не используются. Впрочем, метод надгробий был оценен вне области языков программирования. Этот метод широко используется системой программного обеспечения компьютеров Macintosh для обнаружения разыменований висячих указателей и для содействия по- вторному размещению в памяти динамически распределяемых ячеек памяти. 264 Глава 5. Типы данных
Рис. 5.12. Реализация динамических переменных с использованием надгробий и без них Альтернативой для решения проблемы висячих указателей является метод замков и <;ючей, использованный в реализации компилятора UW-Pascal (Fisher and LeBlanc. 1977, ^80). В этом компиляторе значения указателей представляются упорядоченными пара- *.*и (ключ, адрес), в которых ключом является целое число. Объем памяти, выделяемой динамическую переменную, предусматривает хранение собственно переменной и --ейки заголовка, вмещающей целочисленное запирающее значение (“замок”)- При раз- решении динамической переменной в памяти создается запирающее значение, которое помещается как в ячейку замка динамической переменной, так и в ячейку ключа указате- ля. которому при вызове устанавливается атрибут new. При каждом обращении к разы- менованному указателю ключевое значение указателя сравнивается с запирающим зна- •ением динамической переменной. Если они совпадают, то доступ к переменной разре- шен, в противном случае обращение интерпретируется как ошибка периода выполнения. /1юбое копирование значения указателя должно копировать и значение ключа. Следова- тельно, на данную динамическую переменную может ссылаться любое число указателей Как только оператор dispose удаляет динамическую переменную из памяти, ее запи- рающее значение очищается и устанавливается равным значению, принятому для недо- пустимой операции. У всех остальных указателей, не перечисленных при выполнении оператора dispose, значение адресов не изменяется, но при разыменовании такого ука- зателя его ключевое значение уже не будет соответствовать замку, вследствие чего дос- туп к удаленной переменной разрешен не будет. Как указывалось ранее, язык Java не содержит явной операции удаления из динами- ческой памяти, поэтому ссылки этого языка никогда не могут становиться висячими. 5.10. Указатели 265
5.10.10.3. Управление динамической памятью Управление диамической памятью может оказаться очень сложным процессом, про- изводимым при выполнении программы. Мы рассмотрим этот процесс в двух ситуациях: когда при размещении и удалении переменных из динамической памяти используются ячейки постоянного размера, и когда используются ячейки переменного размера. По- скольку основательный анализ этих процессов и связанных с ними проблем относится скорее к вопросам реализации, чем к вопросам разработки языка, то наше обсуждение будет кратким и далеко не всеобъемлющим. Ячейки постоянного размера. Простейшая ситуация: при размещении и удалении пере- менных из динамической памяти используются ячейки постоянного размера. Для про- стоты будем считать, что каждая ячейка содержит указатель. Описанная ситуация соот- ветствует многим реализациям языка LISP, разработчики которого первыми столкнулись с серьезными проблемами, связанными с динамическим размещением переменных в па- мяти. Все программы и большинство данных языка LISP состоят из ячеек, соединенных в связные списки. Мы не будем рассматривать некоторые связанные с динамической па- мятью процессы управления строками, а сконцентрируем наше внимание собственно на динамической памяти. В динамической памяти, имеющей ячейки постоянного размера, все доступные ячей- ки связаны вместе с помощью указателей ячеек, образуя список доступной памяти. Раз- мещение в памяти заключается в простом получении по мере необходимости требуемого числа ячеек из этого списка. Удаление же из памяти несколько сложнее. Основная про- блема. возникающая при удалении из памяти, рассматривалась в разделе 5.10.4 в связи с рассмотрением процедуры dispose языка Pascal. На динамическую переменною могут ссылаться несколько указателей, поэтому можно определить момент, когда переменная перестает использоваться в программе. Тот факт, что один из указателей открепляется от ячейки, еще не значит, что ячейка становится мусором: могут существовать еще не- сколько указателей, ссылающихся на данную ячейку. В языке LISP несколько часто употребляемых операций создают набор ячеек, доступ к которым из программы невозможен, и которые, как следствие, не могут быть освобождены (возвращены в список доступной памяти). Одной из основных целей, стоявших перед раз- работчиками языка LISP, было обеспечить освобождение неиспользуемой памяти системой поддержки выполнения программ, а не программистом. Эта цель породила следующий ос- новной вопрос разработки: когда должно производиться удаление из памяти? Су шествует два различных, и в некотором смысле противоположных, процесса осво- бождения памяти, занятой мусором: подсчет ссылок, при котором освобождение неис- пользуемых ячеек происходит при их возникновении, и сборка мусора, при которой ос- вобождение памяти происходит только, когда доступная память будет, исчерпана. Дру- гими названиями этих методов являются энергичный подход (eager approach) и ленивый подход (lazy approach), соответственно. При использовании метода подсчета ссылок (reference counter method) в каждую ячейку памяти помещается счетчик, содержащий число указателей, ссылающихся в дан- ный момент на эту ячейку. При откреплении указателя от ячейки значение счетчика уменьшается и одновременно проверяется, ни равен ли он нулю. Если счетчик достигает нулево! и значения, то в программе уже не существует указателей, ссылающихся на дан- ную ячейку, следовательно, она стала мусором и может возвращаться в список доступ- ной памяти. 266 Глава 5. Типы данных
.шествуют три различные проблемы, связанные с методом подсчета ссылок. Во- если ячейки памяти имеют относительно небольшой размер, то на все счетчики тебуется значительный объем памяти. Во-вторых, для эксплуатации значений счет- • • ?. очевидно, требуется затратить некоторое время выполнения программы. При ка- - -. м изменении значения указателя должны меняться значения двух счетчиков: счетчик, . ..гжашийся в ячейке, на которую ссылался указатель, должен уменьшить свое значе- . -.а единицу, а счетчик ячейки, на которую стал ссылаться указатель, должен увели- - свое значение на единицу. В языке, подобном LISP, в котором практически каждое . »’зие сопровождается изменением указателей, описанные процессы будут занимать ..дельную часть всего времени выполнения программы. Разумеется, если указатели .--ют свои значения нечасто, то такая ситуация не представляет собой проблемы. Тре- • проблема возникает, если набор ячеек связан циклично, и заключается в том, что . ение счетчика ссылок на каждую ячейку в кольцевом списке не меньше 1, и это пре- • ств\ет перемещению ячейки в список доступной памяти. Решение последней пробле- - можно найти в книге Фридмана и Вайса (Friedman and Wise, 1979). Основной альтернативой методу подсчета ссылок является метод сборки мусора -irbage collection), при котором система поддержки исполнения программ распределяет • „йки памяти и открепляет от них указатели по требованию, не заботясь при этом об - = эбождении памяти (позволяя мусору накапливаться). Как только все доступное про- . танство исчерпается, включается процесс сборки мусора, очищающий всю кучу от му- . ?а Для облегчения этого процесса в каждой ячейке памяти есть особый индикаторный ' *т или поле, используемые алгоритмом сборки. Сам процесс сборки проходит в три этапа. Вначале все ячейки памяти с помощью ин- 7 гаторов помечаются, как содержащие мусор. Разумеется, на самом деле это справед- ~.‘зо только для некоторых из них. Затем прослеживаются все указатели программы, и •-ейки, на которые они ссылаются, помечаются как используемые. После этого наступа- .? очередь третьего этапа: все ячейки динамической памяти, не помеченные на втором * ~апе как используемые, возвращаются в список доступной памяти. Чтобы пояснить особенности алгоритмов маркировки ячеек, используемых в данный •юмент, ниже приводится простой пример. Мы предполагали, что все динамические пере- менные (или ячейки динамической памяти) состоят из информационной части, метки tag г двух указателей llink и rlink. Эти ячейки используются для создания ориентирован- -?го графа, имеющего не более двух ребер, выходящих из каждого узла. Алгоритм марки- говки проходит по остовным деревьям графа, помечая все найденные ячейки. Отметим, чю -аш алгоритм, как и другие алгоритмы перемещения по дереву, использует рекурсию. for каждого указателя г do mark(г) procedure mark(ptr) if ptr <> null then if ptrA.tag не отмечен then установить ptrA.tag mark(ptrA.llink) mark(ptrA.rlink) end if end if 5.10. Указатели 267
Пример выполнения данной процедуры для конкретного графа приведен на рис. 5.13. Отметим, что недостатком этого простого алгоритма маркировки является использова- ние значительного объема памяти (для стека, поддерживающего рекурсию). Шорром и Уейтом (Schorr and Waite, 1967) был разработан процесс маркировки, не требующий до* полнительной стековой памяти. Их метод заключается в перестановке указателей в об- ратном порядке при прохождении связных структур. В таком случае при достижении конца всего списка процесс может проследовать за указателями к выходу из структуры. Штрихованными линиями показан порядок маркировки узлов Рис. 5.13. Пример выполнения алгоритма маркировки Самую серьезную проблему, связанную со сборкой мусора, можно сформулировать следующим образом: когда она более всего необходима, она работает хуже всего. Более всего этот процесс нужен, когда программе действительно требуется большинство ячеек динамической памяти. В этом случае сборка мусора занимает значительное время, по- скольку нужно просмотреть большинство ячеек и пометить их как полезные. Однако в этом же случае процессу доступно только небольшое число ячеек, которые можно по- местить в список доступной памяти. Мало того, существуют еще и затраты на дополни- тельную память для меток ячеек (для которых требуется всего по одному биту), при этом время сборки мусора включается во время выполнения программы. Впрочем, эти про- блемы не так уж серьезны. Во-первых, в большинстве современных компьютеров объем памяти значителен; во-вторых, все большие компьютеры используют виртуальную па- мять, что еще больше увеличивает объем доступной памяти. Алгоритм маркировки для сборки мусора и процесс подсчета ссылок могут реализо- вываться значительно эффективнее при использовании циклического сдвига указателей и операций скольжения, описанных Сузуки (Suzuki, 1982). 268 Глава 5. Типы данных
Ячейки переменного размера. Управление динамической памятью с ячейками переменно- * размера сопряжено с теми же трудностями, что и управление динамической памятью с : глками постоянного размера, и кроме этого возникают дополнительные проблемы, но, к . калению, ячейки переменного размера требуются в большинстве языков программиро- = 2-ия. Дополнительные проблемы, характерные для ячеек переменного размера, зависят от е~ода, используемого для управления динамической памятью. При использовании метода ;' ?рки мусора эти проблемы можно сформулировать следующим образом. Трудно установить индикаторы ячеек в положение, указывающее, что они содер- жат мусор. Поскольку ячейки имеют переменный размер, их просмотр становится проблемой. Одно из решений этой проблемы заключается в требовании наличия в каждой ячейке переменного размера первого поля, в котором указан ее размер. После этого может выполняться просмотр ячеек, правда, он занимает немного больше памяти и времени, чем его эквивалент для ячеек постоянных размеров. Процесс маркировки ячеек нетривиален. Как можно пройти цепочку, начавшуюся с указателя, если заранее нс определено положение указателя в целевой ячейке? Другой проблемой являются ячейки, не содержащие указателей. Конечно, можно ввести системный указатель для каждой ячейки, но такие указатели должны под- держиваться параллельно с пользовательскими указателями. Такой подход снижа- ет эффективность с точки зрения памяти и времени, затрачиваемых на выполне- ние программы. Поддерживается список доступной памяти. Вначале этот список может состоять из одной ячейки, содержащей всю доступную память. Запросы на сегменты памя- ти просто уменьшают размер этого блока. Освобожденные ячейки добавляются в список. Проблема же заключается в том, что через некоторое время список пре- вращается в перечень блоков, или сегментов, переменной длины. Это замедляет процесс размещения переменных в памяти, поскольку в ответ на запрос должен выполняться поиск блока достаточного размера. По прошествии некоторого вре- мени список может состоять из большого числа очень маленьких блоков, недоста- точных для удовлетворения большинства запросов. В этот момент может потре- боваться объединение смежных блоков в блоки больших размеров. Возможна аль- тернатива: использование первого достаточно большого блока в списке, что сократит поиск, но потребует упорядочения списка по размерам блока. В любом случае нужны дополнительные расходы на поддержание списка. Если в качестве метода управления динамической памятью используются счетчики ссылок, то из трех перечисленных проблем остается только последняя. Стиль и использование языка программирования во многом определяется его типами данных, которые вместе с управляющими структурами формируют ядро языка. В число элементарных типов данных многих языков входят числовые, символьные и булевские типы. Числовые типы данных довольно часто поддерживаются непосредст- венно аппаратным обеспечением. Определяемые пользователем перечислимые и ограниченные типы удобны, а их ис- пользование повышает читабельность и надежность программ. Резюме 269
Другой частью большинства языков программирования являются массивы. Связь ме- жду ссылкой на элемент массива и адресом этого элемента в памяти предоставляется функцией доступа, которая представляет собой реализацию отображения. Массивы мо- гут быть статическими (как в языке FORTRAN 77); фиксированными автоматическими (как в процедурах языка Pascal); автоматическими (как в блоках языка Ada) или динами- ческими (как в массивах ALLOCATABLE языка FORTRAN 90). В большинстве языков с\ шествует только ограниченное число операций с массивами, как с единым целым. В большинство современных языков вошел такой тип данных, как записи. Поля записей задаются разнообразными способами. В языках COBOL и PL/1, например, ссылаться на них можно, не указывая всех внешних блоков, хотя реализация этой возможности беспорядоч- на, а читабельность программ при этом ухудшается. В таких объектно-ориентированных языках программирования, как Java, записи поддерживаются конструкцией класса. Объединения — это ячейки памяти, которые могут содержать рахпичные типы значе- ний в различные периоды выполнения программы. Размеченные объединения содержат метку для записи значения текущего типа. Свободным объединением называется объе- динение без метки. Большинство объединений современных языков имеют небезопас- ную структуру, исключением является только язык Ada. Часто бывает удобно и относительно просто реализовать множества данных. Впро- чем. задачи, для выполнения которых обычно используются множества, могут практиче- ски с той же легкостью решаться и с помощью других типов. Целью использования указателей является достижение гибкой адресации и управление динамической памятью. Однако при использовании указателей возникает проблема вися- чих указателей, которую трудно обойти, и проблема мусора, который трудно собрать. Управление динамической памятью без опасностей, характерных для указателей, обеспечивают ссылки, подобные существующим в языке Java. На то, какие типы будут включены в язык, значительное влияние оказывает уровень сложности реализации различных типов данных. Перечислимые типы, ограниченные ти- пы и записи реализовать довольно просто. Реализация массивов также не вызывает за- труднений, хотя для массивов, имеющих несколько индексов, доступ к элементу может оказаться неэффективным. Это объясняется тем, что функция доступа к элементу масси- ва требует на каждый индекс по одной операции сложения и умножения. Другим неэффективным процессом является реализация указателей, если они предна- значены для управления динамической памятью и если принимаются какие-нибудь меры для решения проблемы висячих указателей. Если ячейки памяти имеют одинаковый раз- мер, то управление динамической памятью выполняется относительно легко, но тот же процесс значительно усложняется при использовании ячеек переменного размера. Дополнительная'литератур ст7 Существует множество литературы по компьютерным наукам, в которой освещаются вопросы структуры типов данных, их использования и реализации. Одно из первых семантических описаний структурных типов дает Хоар в книге (Dahl et al., 1972). Сравнение типов структур языков ALGOL 68 и Pascal проводится Тененбаумом (Tenenbaum, 1978), сравнение языков С и Pascal, в том числе и структур типов, про- водится Фоером и Гехани (Feuer and Gehani, 1982). Обсуждение ненадежных момен- тов в структуре типов данных языка Pascal рассмотрено в книге (Welsh et al., 1977). Общий обзор разнообразных типов данных дается в книге (Cleaveland, 1986). 270 Глава 5. Типы данных
С шером и Ле Бланком (Fisher and LeBlanc, 1980) рассмотрена проверка времени выпол- нения возможных ненадежных моментов в типах данных языка Pascal. В большинстве книг по разработке компиляторов, например (Fisher and LeBlanc, 1988) и (Aho et al.), a также прочих книгах по языкам программирования— (Pratt, 1984) и (Ghezzi and Jazayeri, 1987) — описываются методы реализации типов данных. Подробное обсужде- ние проблемы управления динамической памятью можно найти в книге (Tenenbaum et al.. 1990). Методы сборки мусора разработаны Шорром и Уайтом (Schorr and Waite, 1967), а также Дойчем и Бобровым (Deutsch and Bobrow, 1976). Всестороннее обсужде- ние алгоритмов сборки мусора можно найти в работе (Cohen, 1981). Вопросы 1. Что такое “дескриптор”? 2. Назовите достоинства и недостатки десятичных типов данных. 3. Назовите вопросы разработки, характерные для символьной строки. 4. Сформулируйте три варианта выбора длины строки. 5. Дайте определения порядковых типов, перечислимых типов и ограниченных типов. 6. В чем заключаются достоинства перечислимых типов, определяемых пользователем? ". Назовите вопросы разработки, характерные для массивов. 8. Дайте определения фиксированных автоматических, автоматических и дина- мических массивов. В чем достоинства каждого из них? 9. Какое свойство инициализации массива доступно в языке Ada и не доступно в дру- гих распространенных императивных языках программирования? 10. Что такое константное множество? 11. Какие операции с массивами заданы только дЛл одномерных массивов языка Ada? 12. В чем различие между сечениями языков FORTRAN 90 и Ada? 13. Дайте определения записи по строкам и записи по столбцам. 14. Что такое функция доступа к массиву? 15. Какие позиции требуются в дескрипторах массивов языка Pascal и когда они долж- ны записываться в память (во время компиляции или во время выполнения)? 16. Для чего в записях языка COBOL нужны номера уровней? 17. Дайте определение полностью определенной и эллиптической ссылки на поле за- писи. 18. Дайте определение объединения, свободного объединения и размеченного объединения. 19. Назовите вопросы разработки, характерные для объединений. 20. В чем заключаются две проблемы использования объединений языка Pascal? 21. Чем объединения языка Ada безопаснее объединений языка Pascal? Вопросы 271
22. Почему в реализациях языка Pascal значительно ограничивается размер множеств? 23. Назовите вопросы разработки, характерные для указателей. 24. Назовите две обшие проблемы, связанные с использованием указателей. 25. Назовите две причины большей надежности указателей Ada по сравнению с указа- телями языка Pascal 26. Почему цели указателей большинства языков ограничиваются объектами одного типа? 27. Что такое ссылки в языке C++ и как они обычно используются? 28. Почему в языке C++ в качестве формальных параметров лучше использовать ссылки, а не указатели? 29. Назовите преимущества ссылок в языке Java по сравнению с указателями в других языках. 30. Опишите ленивый и энергичный подходы к освобождению памяти, занятой мусором. 31. Чем различаются ссылки в языках C++ и Java? 32. Почему арифметические операции над ссылками в языке Java не имеют смысла? У л р а ж н е н и я 1. Назовите аргументы “за’’ и “против” представления булевских значений одним би- том памяти. 2. Почему десятичное значение неэкономно использует область памяти? 3. В языке COBOL используется несколько различных методов хранения десятичных чисел в памяти. Объясните формат и цели каждого из них. 4. Сравните методы надгробий, а также ключей и замков, используемых для предот- вращения возникновения висячих указателей. В качестве критериев при сравнении используйте надежность и стоимость их реализации. 5. Разработайте набор простых тестовых программ для определения правил совмес- тимости типов доступного вам компилятора языка Pascal или С. Напишите отчет о полученных вами данных. 6. Какие недостатки имеются у неявного разыменования указателей, но только в оп- ределенных контекстах? Рассмотрите, например, неявное разыменование указателя на запись языка Ada при использовании его для обращения к полю записи. 7. Обоснуйте существование оператора -> в языках С и C++. 8. Определите, какая из альтернатив реализации, описанных в разделе 5.10.4, исполь- зована в доступном вам компиляторе языка Pascal. 9. Объединения языков С и C++ отделены от записей этих языков, а в языках Pascal и Ada эти типы данных объединены. В чем заключаются достоинства и недостатки обоих проектных решений? 272 Глава 5. Типы данных
10. Определите, реализована ли в доступном вам компиляторе языка Pascal процедура dispose. 11. Определите, реализована ли в доступном вам компиляторе языка С функция free. 12. Предположим, что существует язык с определяемыми пользователем перечисли- мыми типами и возможностью перегрузки перечисляемых величин; т.е. язык, в ко- тором один литерал может включаться в два различных перечислимых типа: type colors = (red, blue, green); mood = (happy, angry, blue); Использование константы blue не может подвергаться проверке типов. Предло- жите метод, позволяющий проверку типов, но не запрещающий подобную пере- грузку. 13. Многомерные массивы могут записываться в память по строкам, как в языке Pascal, или по столбцам, как в языке FORTRAN. Напишите функцию доступа для обоих методов для трехмерного массива. 14. В языке Burroughs Extended ALGOL матрицы заносятся в память как одномерные массивы указателей на строки матриц, причем последние интерпретируются как одномерные массивы значений. В чем заключаются достоинства и недостатки та- кой схемы? 15. Напишите программу, выполняющую умножение матриц, на некотором языке, в котором существует проверка выхода индекса за пределы допустимого диапазона, для которой с помощью вашего компилятора можно получить версию вашей про- граммы toa машинном языке или языке ассемблера. Определите число команд, не- обходимых для проверки выхода индекса за пределы допустимого диапазона, и сравните его с числом команд в процессе умножения матриц. 16. На языке Pascal напишите программу, содержащую следующие объявления: var А, В : array [1..10] of integer; С : array [1..10] of integer; D : array [1..10] of integer; Включите в программу команды, определяющие для каждого из массивов совмес- тимые с ним массивы. 17. Если вы имеете доступ к компилятору, в котором пользователь может указывать, желательна ли проверка выхода индекса за пределы допустимого диапазона, на- пишите программу с многократным доступом к матрице и измерьте время выпол- нения программы с этой проверкой и без нее. 18. Проанализируйте и запишите результат сравнения функций malloc и free языка С с операторами new и delete языка C++. В качестве основного критерия срав- нения используйте безопасность. 19. Проанализируйте и запишите результат сравнения использования указателей языка C++ и ссылок в языке Java при обращении к динамическим переменным. В качест- ве основных критериев сравнения используйте безопасность и удобство.
20. Напишите краткое исследование потерь и приобретений, произошедших вследст- вие отказа разработчиков языка Java включить в язык указатели из языка C++. 21. Назовите аргументы “за” и “против” неявного освобождения кучи в языке Java по сравнению с явным освобождением динамической памяти, требуемым в языке C++. 274 Глава 5. Типы данных
иоператоры присваивания Фридрих (Фриц) Л. Бауэр (Friedrich (Fritz) L. Bauer) В э т о ?г п а ы 6.1. Введение 6.2. Арифметические выражения 6.3. Перегруженные операторы 6.4. Преобразования типов 6.5. Выражения отношений и булевские выражения 6.6. Сокращенное вычисление 6.7. Операторы присваивания 6.8. Смешанные присваивания Фриц Бауэр из Мюнхена вместе с Клаусом Сэмельсоном (Klaus Samelson) в первой половине 1950-х годов разработали алгеб- раический язык, который мог реализовываться непосредст- венно аппаратным обеспечени- ем Однако свой наиболее значи- тельный вклад в разработку языков программирования Фриц Бауэр сделал, руководя коман- дой разработчиков языка ALGOL. Выражения и операторы присваивания 275
Как можно понять из названия, основное внимание в этой главе уделяется выра- жениям и операторам присваивания. Вначале рассматриваются семантические правила, определяющие порядок вычисления операторов в выражениях. Затем описыва- ются потенциальные проблемы, связанные с порядком вычисления операндов в выраже- ниях и возникающие, когда выражения имеют побочные эффекты. Далее следует обсуж- дение встроенных и определяемых пользователем перегруженных операторов, а также их воздействие на выражения в программах. Затем рассматриваются и оцениваются смешанные выражения. Далее дается определение и оценка расширяющих и сужающих преобразований типов (как явных, так и неявных). После этого рассматриваются выра- жения отношений и булевские выражения, в том числе идея сокращенного вычисления. В заключение рассматривается оператор присваивания, начиная с простейших форм и заканчивая всеми его разновидностями, в том числе присваиваниями в качестве выраже- ний и смешанными присваиваниями. Материал данной главы ограничен традиционными нефункциональными и нелогиче- скими языками программирования, использующими в арифметических и логических вы- ражениях инфиксную запись. Функциональные и логические языки программирования подробно описаны и оценены в главах 14 и 15, соответственно. Еще одним моментом, не затронутым в данной главе, являются рассмотренные в гла- ве 5 выражения, сопоставляющие символьные строки с образцом. 6.1. Введение Выражения являются основными средствами вычислений в языке программирования. Для программиста важно понимать и синтаксис, и семантику выражений. Методы опи- сания синтаксиса уже приводились в главе 3, поэтому в данной главе основное внимание обращается на семантику выражений — т.е. на их смысл, определяемый тем, как именно они вычисляются. Для того чтобы понять, как вычисляются выражения, необходимо ознакомиться с по- рядком вычисления операторов и операндов. Порядок вычисления операторов в выраже- нии определяется правилами ассоциативности и приоритетами. Порядок вычисления операндов в выражениях часто не упоминается разработчиками языка, хотя он и может влиять на значение выражений. В этом случае возникает ситуация, позволяющая пр - граммисту получать в различных реализациях языка различные результаты. Кроме т ' с семантикой выражений связаны несоответствие типов, приведение типов и сокращен- ное вычисление. Сущность императивных языков программирования заключается в господствуй-., роли операторов присваивания. Цель оператора присваивания— изменить значение ременной. Таким образом, неотъемлемой частью всех императивных языков являе-.- концепция переменных, значения которых изменяются во время выполнения програ\“ - (Неимперативные языки программирования иногда содержат такие переменные осоГн типа, как параметры функций в функциональных языках программирования.) Выполнение оператора присваивания можно просто свести к копированию знач» - из одной ячейки памяти в другую. Однако в большинстве случаев операторы приевз,-.- ния содержат выражения, в которые входят операторы, вызывающие копирование знл ний в процессор, где над ними выполняются некоторые операции, а результаты коп?г - ются обратно в память. 276 Глава 6. Выражения и операторы присваиваний
Простой оператор присваивания задает выражение, подлежащее вычислению, и целе- вую ячейку памяти, в которую будет помещен результат вычисления этого выражения. Как мы увидим в данной главе, эта основная форма имеет множество вариаций. 6.2. Арифметические выражения Автоматическое вычисление арифметических выражений, подобных математическим выражениям, было одной из основных задач первых высокоуровневых языков програм- мирования. Большинство свойств арифметических выражений в языках программирова- ния произошли от условностей, принятых в математике. В языках программирования арифметические выражения состоят из операторов, операндов, круглых скобок и вызо- вов функций. Оператор может быть унарным (unary), что означает наличие у него одно- го операнда, бинарным (binary), имеющим два операнда, и тернарным (ternary), у кото- рого есть три операнда. Примером языков, содержащих тернарные операторы, являются языки С, C++ и Java, рассматриваемые в разделе 6.2.1.4. В большинстве императивных языков программирования бинарные операторы являют- ся инфиксными, т.е. они появляются между операндами. Одним из исключений является язык Perl, в котором есть префиксные операторы, т.е. предшествующие своим операндам. Цель арифметических выражений — указать арифметическое вычисление. Реализа- ция такого вычисления должна выполнять два действия: выбирать операнды (обычно из памяти) и выполнять над ними арифметические действия. В следующих разделах мы разберем подробности общей структуры арифметических выражений в императивных языках программирования. Ниже перечислены основные вопросы разработки, характерные для арифметических выражений, причем все они рассматриваются в этом разделе. Каковы правила приоритетов операторов в языке? Каковы правила ассоциативности операторов в языке? Каков порядок вычисления операндов? Имеются ли у вычислений операндов побочные эффекты? Допускает ли язык перегрузку операторов, определяемую пользователем? Какое смешивание типов позволено в выражениях? 6.2.1. Порядок вычисления операторов Вначале мы рассмотрим правила языка, задающие порядок выполнения его операторов. 6.2.1.1. Приоритет оператора Значение выражения зависит, по крайней мере частично, от порядка вычисления вхо- дящих в него операторов. Рассмотрим следующее выражение: А + В * С Предположим, что значения переменных А, В и С равны, соответственно, 3, 4 и 5. Если вычисление производится слева направо (вначале сложение, а затем умножение), то ре- зультат равен 35. Если же вычислить значение выражения справа налево, то оно окажет- ся равным 23. 6.2. Арифметические выражения 277
Вместо того чтобы вычислять значение выражений слева направо или справа налево, математиками была разработана концепция, заключавшаяся в размещении операторов в определенной иерархической последовательности в соответствии с порядком их выпол- нения в выражениях и частичном использовании этой иерархии при выполнении вычис- лений. В математике, например, умножение имеет более высокий приоритет, чем сложе- ние. Если эту условность применить к предыдущему примеру, то первым будет выпол- няться умножение. Правила приоритетов операторов (operator precedence) при вычислении выражений определяют порядок, в котором выполняются операторы, имеющие разные приоритеты. Правила приоритетов операторов при вычислении выражений основаны на такой иерар- хии приоритетов операторов, какой она видится разработчику языка. Эти правила во всех распространенных языках программирования практически идентичны, поскольку все они основаны на соответствующих математических правилах. В этих языках наи- высший приоритет имеет операция возведения в степень (если она существует в языке), за ней следуют имеющие одинаковый уровень операции умножения и деления, а на по- следнем уровне находятся операции сложения и вычитания. Во многих языках, помимо бинарных, существуют унарные операции сложения и вы- читания. Унарное сложение называется тождественным оператором (identity operator), поскольку с ним обычно не связано никакое действие, и, следовательно, оно не влияет на операнд. Эллис и Страуструп, обсуждая язык C++, назвали этот оператор историческим несчастным случаем и справедливо указали на его ненужность (Ellis and Stroustrup, 1990). Существующий в языке Java унарный плюс фактически эффективен только тогда, когда его операнды принадлежат к типам char, short или byte— этот оператор вы- полняет преобразование значений этих типов в тип int. Унарный минус, разумеется, всегда оказывает влияние на операнд — он изменяет знак его значения. Во всех распространенных императивных языках программирования оператор унар- ного минуса может помещаться либо в начале выражения, либо где-либо внутри него. Единственным условием является то. чтобы он непосредственно не примыкал к другому оператору, для чего используются круглые скобки. Например, следующее выражение А + (- В) * С допускается, тогда как выражение А + - В * С (как правило) нет. Как будет показано в разделе 6.2.1.2, в большинстве языков программирования при- оритет унарных операторов мало существен. Ниже приведены приоритеты арифметических операторов в некоторых распростра- ненных языках программирования. FORTRAN Pascal С Ada Наивысший ★ ★ ★, /, div, mod постфиксные ++, -- **, abs *, / все +, - префиксные + + , — *, Л mod все + , - унарные +, - унарные + f — *, /, % бинарные Нижайший бинарные +, - 278 Глава 6. Выражения и операторы присваивания
<ч <м упомянутые операторы. Оператор ** означает возведение в степень. Операто- и div языка Pascal описаны в разделе 6.3. Оператор % языка С идентичен операто- - г ?d языков Pascal и Ada: оба они принимают два целых операнда и вычисляют вели- ?статка от деления первого операнда на второй. Операторы ++ и — языка С опи- . :- .1>тся в разделе 6.7.5. Правила приоритетов в языках С+4- и С аналогичны, за . учением того, что в языке С+^ все операторы + + и — имеют равный приоритет. -гла приоритетов языка Java полностью совпадают с правилами языка C++. Опера- • abs в языке Ada является унарным и вычисляет абсолютное значение операнда. Ч необычном языке APL. описываемом в следующем разделе, все операторы имеют - ыи приоритет. Че стоит думать, что порядок выполнения операторов определяется исключительно - титетом операторов: кроме приоритетов, на него влияют правила ассоциативности, осматриваемые ниже. 6.2.1.2. Ассоциативность Рассмотрим следующее выражение: * - В + С - D . .! операторы сложения и вычитания имеют равный приоритет, то правила приоритетов - е ничего не могут сообщить о порядке выполнения операторов данного выражения. На вопрос о том. какой из операторов, имеющих равный приоритет, выполняется . гзым. отвечают правила ассоциативности (associativity). Ассоциативность оператора - ст быть правосторонней или левосторонней, что означает выполнение операторов ?.;ва налево или слева направо, соответственно. В распространенных языках программирования правила ассоциативности операторов — езеляют, что операторы с равным приоритетом выполняются слева направо, исклю- г не составляет только оператор возведения в степень (если он существует в языке), сюший противоположную ассоциативность. Рассмотрим следующее выражение языка - дseal: А - В - С ервым в нем выполняется левый оператор. Однако поскольку операция возведения в . епень в языке FORTRAN имеет правую ассоциативность, то в выражении А ** В ** С Ч’эвым будет выполняться правый оператор. В языке Ada оператор возведения в степень неассоциативен, поэтому выражение А ★* в ** С = языке Ada недопустимо. Чтобы указать правильный порядок выполнения операторов в • дком выражении, следует использовать скобки: VA ** В) ** С !.ЗИ А ** (В ** С) Теперь мы уже можем объяснить, почему приоритет унарных операторов зачастую не важен. В языке FORTRAN унарные и бинарные операторы имеют равный приоритет, 6.2. Арифметические выражения 279
однако в языке Ada (и большинстве других распространенных языков) унарный минус имеет более высокий приоритет, чем бинарный. Рассмотрим следующее выражение: - А - В Поскольку в языке FORTRAN операторы унарного и бинарного минуса являются лево- ассоциативными, а в языке Ada приоритет унарного оператора выше бинарного, то в обоих языках это выражение эквивалентно следующему: (-А) - В Рассмотрим такие выражения: - А / В - А * В - А ** В В первых двух примерах несущественно, приоритет какого оператора выше: любой по- рядок выполнения приводит к одному и тому же результату, чего нельзя сказать о по- следнем выражении. Из распространенных языков программирования только языки FORTRAN и Ada содержат оператор возведения в степень. В обоих языках этот оператор имеет более высокий приоритет, чем унарный минус, поэтому запись - А ** В эквивалентна выражению - (А ** В) В этих языках унарные операторы, появляющиеся не в крайней слева позиции выра- жения, должны заключаться в скобки, поэтому в таких ситуациях им будет обеспечен наивысший приоритет (подробнее о скобках рассказывается в разделе 6.2.1.3). Ниже приведены правила ассоциативности в наиболее распространенных языках про- граммирования. Язык Ассоциативность FORTRAN Левосторонняя: ★, /, +. - Правосторонняя: ** Pascal Левосторонняя: все С Левосторонняя: постфиксный ++, постфиксный , /, %, бинарный + , бинарный - Правосторонняя: префиксный + *-, префиксный унарный + , унарный - C++ Левосторонняя: *, /, %, бинарный + , бинарный - Правосторонняя: ++, унарный унарный + Ada Левосторонняя: все, за исключением ** Неассоциативен: ** Как указывалось в разделе 6.2.1.1, в языке APL все операторы имеют равный приори- тет. Следовательно, порядок выполнения операторов этого языка определяется исключи- тельно правилом ассоциативности, которая в данном языке является правосторонней. Например, в выражении А х В + С вначале выполняется операция сложения, а затем — умножения (оператор умножения этого языка обозначается символом х). Если переменные А, В и С содержали значения 3, 280 Глава 6. Выражения и операторы присваивания
* :. соответственно, то результатом вычисления приведенного выше выражения языка -: 1 б\ дет 27. Многие компиляторы используют математическую ассоциативность арифметических .гаторов. Это означает, что правила ассоциативности не влияют на значение выраже- < содержащего только такие операторы. Например, сложение является математически .. диативной операцией, поэтому в математике значение выражения * - В + С . ависит от порядка выполнения операторов. Если операции над числами с плавающей кой. выполняемые математически ассоциативными операторами, также являются ас- . . гаишными, то компилятор может использовать этот факт для проведения некоторой стой оптимизации. В частности, поменяв порядок выполнения операторов, компиля- г может создать более быструю программу выполнения выражений. Фактически, ком- ляторы осуществляют именно такие типы оптимизации. К сожалению, представление данных в виде чисел с плавающей точкой и арифмети- .ские операции с величинами такого вида являются только приближением математиче- . • действий (вследствие ограничения размеров). То, что математический оператор ас- . ..нативен, еще не означает ассоциативности соответствующего оператора, выполняю- ..е:о действия над числами с плавающей точкой. В действительности, процесс будет . -^ого ассоциативен только в том случае, если все операнды и промежуточные результа- * могут быть точно представлены в виде чисел с плавающей точкой. Существуют, на- гимер, патологические ситуации, в которых целочисленное сложение в компьютере не ъсоциативно. Предположим, что программа должна вычислить следующее выражение: А + В + С + D ’ги этом переменные А и С являются очень большими положительными числами, а В и - отрицательными числами с очень большой абсолютной величиной. В этой ситуации .:?жение чисел А и В не вызовет переполнения памяти, а сложение А и С— вызовет, ‘.-'алогично, сложение чисел С и D переполнения не вызывает, чего нельзя сказать о вожении чисел В и D. Из-за ограниченности компьютерной арифметики сложение в • <?м случае катастрофически неассоциативно. Следовательно, переупорядочение компи- ятором операций сложения отразится на значении выражения. Разумеется, это пред- ъявляет проблему, обойти которую может программист (предполагается, что приблизи- •гльные значения переменных известны). Обеспечить безопасный порядок выполнения ператоров можно с помощью скобок (см. раздел 6.2.1.3). Впрочем, проблема может 'меть более скрытую форму, и в этом случае определить нужную последовательность пераций будет намного труднее. 6.2.1.3. Скобки Изменить приоритет и правила ассоциативности программист может, разместив в выражении скобки. Заключенная в скобки часть выражения имеет приоритет выше, чем свободная. Рассмотрим следующее выражение: ;а + в) * с Несмотря на то что умножение имеет более высокий приоритет, чем сложение, в данном выражении первым будет выполнено именно сложение. Математически это в высшей степени естественно: в этом выражении первый операнд оператора умножения не досту- пен, пока не будет выполнено сложение во взятом в скобки подвыражении. 6.2. Арифметические выражения 281
Языки, допускающие использование скобок в арифметических выражениях, могут обходиться вообще без правил приоритетов и просто размещать все операторы слева на- право или справа налево. Программист сам укажет требуемый порядок вычисления, ис- пользовав для этого скобки. Это было бы даже проще, поскольку ни автору программы, ни ее читателю не потребовалось бы помнить все правила приоритетов или ассоциатив- ности. Недостатком такой схемы является утомительность написания выражений и воз- можность серьезного ухудшения читабельности. Все же, Кеном Айверсоном, разработ- чиком языка APL. такой выбор был сделан. 6.2.1.4. Условные выражения На данный момент мы завершили обсуждение унарных и бинарных операторов. Рас- смотрим теперь тернарный оператор ? :, являющийся частью языков С, От и Java. Этот оператор используется для создания условных выражений. Иногда для выполнения условного присваивания используется операторная структура if-then-else. Рассмотрим следующее выражение: if (count = 0) then average := 0 else average := sum / count В языках С, C++ и Java это можно выразить с помощью следующего оператора присваи- вания, использующего условное выражение: выражение_1 ? выражение_2 : выражение_3 Здесь выражение ! интерпретируется как булевское выражение. Если это выражение будет вычислено как истинное, то всему выражению будет присвоен результат выражения_2; в противном случае— результат выражения_3. Используя условное выражение, мы можем следующим образом переписать приведенный выше блок команд if-then-else: average = (count == 0) ? 0 : sum / count; По сути, знак вопроса отмечает начало оператора then, а двоеточие — начало операто- ра else. Использование обоих операторов обязательно. Отметим также, что знак ? ис- пользован в условном выражении как тернарный оператор. Условные выражения могут использоваться в тех же местах программ на языках С, С+т и Java, что и другие выражения. 6.2.2. Порядок вычисления операндов Такая структурная характеристика, как порядок вычисления операндов, обычно ос- вещается не очень широко. Процесс вычисления значений переменных, входящих в вы- ражения, происходит путем выборки их из памяти. Константы также иногда вычисляют- ся тем же способом. В других случаях константа может являться частью команды на ма- шинном языке и не требовать выборки из памяти. Если операнд представляет собой выражение, заключенное в скобки, то прежде, чем он сможет использоваться, все содер- жащиеся в нем операнды должны быть вычислены. Если ни один из операндов в операторе не имеет побочных эффектов, то порядок вы- числения операндов несуществен. Следовательно, особый интерес представляет именно наличие у операндов побочных эффектов. 282 Глава 6. Выражения и операторы присваивания
6.2.2.1. Побочные эффекты Побочный эффект (side effect) функции, называемый функциональным побочным эффектом (functional side effect), возникает при изменении функцией одного из своих параметров или глобальной переменной. (Напомним, что глобальная переменная объяв- ляется вне функции, но доступна в ней.) Рассмотрим следующее выражение: Л * FUN(А) Если функция FUN не вызывает побочного эффекта, связанного с изменением перемен- ной А, то порядок вычисления двух операндов, переменной А и функции FUN (А) на зна- чение выражения не влияет. Если же функция FUN изменяет значение переменной А, то порядок вычисления становится существенным. Рассмотрим следующую ситуацию: 9>нкция FUN возвращает значение аргумента, деленное на 2, и присваивает своему па- раметру значение 20. Предположим далее, что у нас имеются следующие команды: А := 10; В := А + FUN(А) Если в процессе вычисления приведенного выше выражения переменная А выбирается из памяти первой, то ее значение равно 10, а значение самого выражения равно 15. Од- нако если первым вычисляется второй операнд выражения, то значение первого уже ста- новится равным 20, а значение всего выражения — 25. Ниже приводится пример программы на языке С, иллюстрирующей ту же проблему ыя случая изменения функцией глобальной переменной, входящей в выражение. int а = 5; int funl() { а = 17; return 3; } /* конец функции funl */ void fun2() { a = a + funl (); } /* конец функции fun2 */ void main() { fun2(); } /* конец функции main */ Значение, вычисляемое в функции f un2 для переменной а, зависит от порядка вычисле- ния операндов выражения а + funl (), поэтому возможны два значения переменной а: 8 и 20. Существуют два возможных решения проблемы, связанной с определением порядка вычисления операндов. Во-первых, разработчики языка могут запретить возможность воздействия функции на величину выражения, просто не допуская функционального по- бочного эффекта. Второй метод борьбы с этой проблемой — указать в определении язы- ка точный порядок вычисления операндов выражений и потребовать от разработчиков средств реализации языка придерживаться именно этого порядка. Запретить функциональный побочный эффект трудно, и такой подход уничтожает не- которую гибкость программирования. Рассмотрим языки С и C++, состоящие исключи- тельно из функций. Для того чтобы запретить побочные эффекты двусторонних пара- метров и при этом по-прежнему иметь возможность создавать подпрограммы, возвра- 6.2. Арифметические выражения 283
щаюшие несколько значений, потребуется новый тип подпрограмм, подобных процеду- рам в других императивных языках программирования. Доступ из функций к глобаль- ным переменным также можно запретить. Впрочем, когда важна эффективность, исполь- зование доступа к глобальным переменным для того, чтобы избежать передачи пара- метров, является важным методом увеличения скорости выполнения программ. В компиляторах, например, глобальный доступ к таким данным, как таблица идентифи- каторов, используется широко. Проблема, связанная с наличием строгого порядка вычислений, состоит в том, что некоторые используемые компиляторами методики оптимизации программ содержат вычисление переупорядоченных операндов. Если же строго задать порядок вычисления, то эти методы оптимизации, выполняемые при вызовах функций, будут не доступны. Следовательно, оптимального решения не существует, что и подтверждается сущест- вующими языками программирования. Третье решение проблемы представили разработчики языка FORTRAN 77. В описа- нии этого языка указывается, что выражения, содержащие вызовы функций, дозволены только тогда, когда функции не меняют значения операндов выражений. К сожалению, компилятору не так просто определить точно, какое влияние имеет функция на внешние переменные, особенно если присутствие глобальных переменных обеспечивается опера- тором COMMON, а совмещение имен — оператором EQUIVALENCE. Это как раз тот слу- чай, когда определение языка указывает условия, при которых конструкция дозволена, но действительное существование в программе такой конструкции должен обеспечивать программист. Языки Pascal и Ada позволяют операндам бинарных операций вычисляться в любом порядке, выбираемом разработчиком средств реализации языка. Более того, функции этих языков могут иметь побочные эффекты, что порождает описанные ранее проблемы. Обсуждение функциональных побочных эффектов мы продолжим в главе 8. Описание языка Java обеспечивает вычисление операндов в порядке слева направо, снимая, таким образом, проблему, рассмотренную в* данном разделе. 6.3. Перегруженные операторы Арифметические операции часто используются для достижения нескольких целей. Знак +, например, часто используется для сложения любых операндов числовых типов. Некоюрые языки, например Java, используют его еще и для конкатенации строк. Такое множественное использование операторов получило название перегрузки операторов (operator overloading), и считается приемлемым, если не вредит читабельности и/или на- дежности. Некоторые считают, например, что в языках APL и SNOBOL операторы слишком перегружены, поскольку они используются в бинарных и унарных операциях. В качестве примера перегрузки рассмотрим использование амперсанда (знака &) в языке С. Как бинарный оператор он задает операцию побитового логического И. Его значение как унарного оператора существенно отличается от предыдущего и представля- ет собой адрес переменной, используемой в качестве операнда. В этом случае амперсанд называется оператором вычисления адреса. Например, выполнение присваивания х = & у; приведет к помещению адреса переменной у в переменную х. При таком множествен- ном использовании амперсанда возникают две проблемы. Во-первых, использование од- 284 Глава 6. Выражения и операторы присваивания
. им вола для двух абсолютно несвязанных операций вредит читабельности. Во- * г . простая ошибка при наборе или потеря первого операнда операции побитового кого И могут пройти незамеченными компилятором, поскольку амперсанд будет .. г как оператор вычисления адреса. Обнаружить такую ошибку довольно сложно. -логически все языки программирования содержат менее серьезные, но сходные .мы. часто возникающие вследствие перегрузки оператора “минус”. Проблема со- ’<\1ько в том, что компилятор не может указать, интерпретируется ли оператор как - или как унарный. Поэтому повторимся: если вы забыли включить первый опе- * уразумевая использование оператора как бинарного, то компилятор не опреде- как ошибку. Впрочем, в этом случае значения унарной и бинарной операции хотя .." ? связаны, так что читабельность в этом случае не страдает. чествование отдельных операторных символов для указания операций не только -. увает читабельность, их использование иногда бывает удобным в общих операци- ’•'имером может служить оператор деления. Рассмотрим проблему поиска среднего - : * этического для набора целых чисел, причем среднее требуется найти в виде числа э-ающей точкой. Обычно сумма целых чисел вычисляется как целое число. Предпо- - м. что это было выполнено, и значение суммы всех чисел мы присвоили перемен- а количество этих чисел — переменной count. Если теперь нам требуется вы- . ‘-ь среднее в виде числа с плавающей точкой и присвоить его переменной avg. то в • е С— это может быть выполнено следующим образом: • у ~ sum / count; з-з-о в большинстве случаев такое присваивание приведет к неверному результату. . •: льку оба операнда оператора деления принадлежат к целому типу, то выполняется /гания целочисленного деления, результат которой усекается до целого числа. Поэто- -есмотря на принадлежность целевой переменной (avg) к типу чисел с плавающей • ли. ее значение не будет содержать дробной части. Целочисленный результат деле- = преобразовывается в тип чисел с плавающей точкой после отбрасывания дробной i.’ef. происходящего в процессе целочисленного деления. -лгуация упрощается при наличии операторного символа для деления чисел с пла- :- шей точкой. В языке Pascal, например, символ / обозначает деление чисел с пла- • • шей точкой, поэтому можно использовать следующее присваивание -.•’г = sum / count; ш/сь переменная avg принадлежит к типу с плавающей точкой, а переменные sum и — к целому. Оба операнда операции деления неявно преобразуются в тип чисел с зваюшей точкой, после чего будет выполнено деление чисел с плавающей точкой. К •\м\ типу неявного преобразования мы еще вернемся в следующем разделе, а сейчас метим, что целочисленное деление в языке Pascal задается оператором div, прини- зюшим целые операнды и выдающим целочисленный результат. Если же в языке нет -дельного оператора, выполняющего деление чисел с плавающей точкой, то должно :пользоваться явное преобразование типов, рассматриваемое в разделе 6.4.2. Некоторые языки, поддерживающие абстрактные типы данных (см. главу 10), напри- •ер языки Ada, C++ и FORTRAN 90, разрешают программисту дальнейшую перегрузку .ераторных символов. Предположим, что пользователь желает определить оператор * чжду целым скаляром и целочисленным массивом для умножения каждого элемента массива на скаляр. Это можно сделать, написав функцию-подпрограмму с именем *, вы- 6.3. Перегруженные операторы 285
полняюшую эту новую операцию. При употреблении этого оператора компилятор, как и при встроенных перегрузках операторов, выберет нужное его значение, основываясь на значениях типов операндов. Если, например, новое определение оператора * давалось в программе на языке Ada, то компилятор будет использовать это новое значение везде, где оператор появится с целым числом в качестве левого операнда и с целочисленным массивом в качестве правого. При разумном использовании определяемые пользователем перегрузки операторов могут облегчить читабельность программ. Если, например, операторы + и * были пере- гружены для выполнения действий с абстрактными матричными типами, а переменные А, В, С и D принадлежат к этому типу, то вместо выражения MatrixAdd(MatrixMult(А, В), MatrixMult(С, D)) может использоваться выражение А * В + С * D С другой стороны, определяемая пользователем перегрузка может навредить надеж- ности. Например, ничто не мешает пользователю определить оператор + для обозначе- ния операции умножения. Более того, обнаружив в программе оператор *, читатель, что- бы определить его значение, должен найти и тип операндов, и определение оператора, причем любое или даже все эти определения могут находиться в других файлах. Язык C++ содержит несколько операторов, перегрузить которые невозможно. Среди них: оператор принадлежности к классу или структуре (.) и оператор разрешения облас- ти видимости (: :). Интересно отметить, что перегрузка операторов является одним из свойств языка C++, не включенных в язык Java. 6.4. Преобразования типов Преобразования типов бывают сужающими и расширяющими. Сужающее преобра- зование (narrowing conversion) трансформирует величину в тип, который не может со- держать даже приближений всех значений исходного типа. Пример: преобразование типа double в тип float языка С (диапазон значений типа double значительно больше, чем у типа float). Расширяющее преобразование (widening conversion), наоборот, трансформирует величину в тип, который может содержать, как минимум, приближения всех значений исходного типа. В качестве примера можно привести преобразование типа int в тип float языка С. Расширяющее преобразование безопасно практически всегда, тогда как сужающее — нет. В качестве примера потенциальной проблемы, связанной с расширяющим преобразо- ванием. можно назвать следующую. Несмотря на то что преобразование целых чисел в числа с плавающей точкой относится к расширяющим, во многих реализациях языков точность представления может теряться. В некоторых реализациях, например, целые числа имеют размер 32 бит памяти, что позволяет хранить не менее девяти значащих де- сятичных цифр. Значения с плавающей точкой во многих случаях также имеют размер 32 бит, допускающий точность только в семь десятичных цифр. Таким образом, расши- ряющее преобразование целой величины в величину с плавающей точкой может привес- ти к потере точности в размере двух цифр. Преобразования типов могут быть явными и неявными. В следующих двух разделах рассматриваются оба. 286 Глава 6. Выражения и операторы присваивания
6.4.1. Приведение типов в выражениях Одно из конструкторских решений относительно арифметических выражений связано с наличием у оператора операндов различных типов. Поскольку компьютер обычно не содержит бинарных операций, принимающих операнды различных типов, то в языках, Л с кающих существование подобных выражений, называемых смешанными выра- жениями (mixed-mode expressions), должны определяться соглашения для неявного при- - едения типов операндов. Напомним, что в главе 4 мы определили инициируемое ком- пилятором преобразование типов как неявное. Для преобразования типов, явно задавае- мо программистом, мы будем использовать термин “явное преобразование типов", а не приведение типов”. Хотя некоторые операторные символы могут перегружаться, мы будем предполагать, -•о либо на аппаратном, либо на программном уровне компьютера существуют опреде- де-ные в языке операции для каждого типа оператора и операнда. Для перегруженных * * ераторов языка, использующего статическое связывание типов, компилятор выбирает -ужный тип операции на основе типов операндов. Если в оператор входят два оператора, -гинадлежащих к разным типам, и такая операция в языке допустима, то компилятор дел жен выбрать один из операндов и применить код, реализующий соответствующее тиведение типов. Ниже рассматривается несколько проектных решений, относящихся к разработке приведения типов в некоторых языках. Разработчики языка не пришли к единому мнению относительно вопроса о приведе- -ии типов арифметических выражений. Противники его широкого использования утвер- -дают. что приведения типов аннулируют выгоды от проверки типов, а следовательно, г.ияют на надежность. Сторонники же включения в язык всех возможных приведений -.‘.лов подходят к этому вопросу с точки зрения гибкости, к потере которой приводит от- • аз от использования приведений типов. Предмет спора заключается в том, должна ли -ветственность за обнаружение соответствующей категории ошибок возлагаться на ’ гограммистов, или же эти ошибки должен обнаруживать компилятор. В качестве простой иллюстрации возникающей проблемы рассмотрим следующую скелетную программу на языке С: void main() { int a, b, с; float d; a = b * d; Предположим, что в качестве второго операнда оператора умножения планировалось использовать переменную с, но из-за ошибки при наборе была введена буква d. По- скольку в языке С допустимы смешанные выражения, то компилятор не воспримет это как ошибку — он просто вставит команды, приводящие тип переменной b к типу float. Однако если бы смешанные выражения в языке С не допускались, то подобная ошибка была бы зарегистрирована компилятором как ошибка определения типа. В качестве примера более серьезной опасности и большей неэффективности, вызван- ной существованием слишком широкого приведения типов, рассмотрим усилия разра- ботчиков языка PL/I, направленные на достижение гибкости выражений. В этом языке строка символов может входить в одно выражение с переменной целого типа. Во время 6.4. Преобразования типов , 287
выполнения программы в строке проводится поиск целочисленной величины. Если в ка- кой-либо величине обнаруживается десятичная точка, то величина считается принадле- жащей к типу с плавающей точкой, после чего другой операнд приводится к этому типу', и результат считается величиной с плавающей точкой. Наблюдение за приведением — чрезвычайно затратный процесс, поскольку и проверки типов, и их преобразования должны производиться во время выполнения программы. Оно также мешает обнаружить в выражениях программистские ошибки, поскольку бинарный оператор может смеши- вать операнд любого типа с операндом практически любого другого типа. Поскольку смешанные выражения снижают вероятность обнаружения ошибок, два языка, Ada и Modula-2, допускают существование в выражениях весьма ограниченного числа смешанных операндов. Ни в одном из операторов этих языков не допускается смешивание операндов целого типа и типа чисел с плавающей точкой, за единственным исключением: оператор возведения в степень в языке Ada может принимать в качестве первого операнда оба типа, а в качестве второго — целый тип. В обоих языках существу- ет небольшое число допустимых видов смешивания типов, в основном относящихся к подтипам. В большинстве остальных распространенных языков ограничений на смешанные арифметические выражения нет. В языках С+4- и Java существуют типы целых чисел, меньше типа int. В языке C++ это типы char и short int, а в языке Java — byte, short и char. Операнды этих типов приводятся к типу int при применении к ним практически любого оператора. Та- ким образом, пока переменные этих типов могут содержать данные, нельзя оперировать с такими данными без приведения их к большему типу. Рассмотрим, например, следую- щие команды языка Java: byte а, b, с; а = b + с; Значение переменных b и с приводятся к типу int, после чего выполняется целочис- ленное сложение, результат которого преобразовывается в тип byte и присваивается переменной а. 6.4.2. Явное преобразование типов Большинство языков позволяют выполнять явное преобразование типов, как сужающее, так и расширяющее. В некоторых случаях выдаются сообщения, предупреждающие о зна- чительном изменении величины объекта вследствие сужающего преобразования типов. Языки Modula-2 и Ada предусматривают операции явного преобразования типов, имеющие синтаксис вызовов функций. В языке Ada, например, можно ввести команду AVG := FLOAT(SUM) / FLOAT(COUNT) Здесь переменная AVG имеет тип с плавающей точкой, a SUM и COUNT могут иметь л.ю- бой числовой тип. В языках, основанных на языке С, явное преобразование типа называется приведени- ем (cast). (На русский язык слова “cast” и “coercion” переводятся одинаково — “приведение”, хотя происхождение этих терминов разное: “cast” используется при опи- сании языка С, a “coercion” — при описании языка ALGOL 68. — Прим. ред.). Синтак- 288 Глава 6. Выражения и операторы присваивания
;ис приведения не совпадает с синтаксисом вызовов функций: требуемый тип помещает- ся в круглые скобки непосредственно перед преобразовываемым выражением: int) angle ’дчой из причин использования в языке С скобок при преобразованиях типов является - чествование в этом языке имен типов, состоящих из двух слов, например long int. 6.4.3. Ошибки в выражениях При вычислении выражения может возникнуть множество ошибок. Если язык требу- ш проверки типов, то может возникнуть ошибка определения типов операндов. Ранее *с> ждались ошибки, возникающие из-за приведения операндов в выражениях. Другие ошибок связаны с ограниченностью компьютерной арифметики и собственными -чничениями арифметики. Наиболее часто возникает ошибка, если невозможно пред- . чбить результат операции в предназначенной для него ячейке памяти. Такие ошибки ч юзаются переполнением памяти и потерей значимости, в зависимости от того, являет- .: гчзчльтат слишком большим или слишком маленьким. Одним из ограничений ариф- .’ики является запрет деления на нуль. Однако то, что это деление не разрешено в ма- •. зтике, не мешает программе попытаться его выполнить. Переполнение памяти и потеря значимости при работе с величинами с плавающей • -<ой. а также деление на нуль— все это примеры ошибок времени выполнения про- гзммы. иногда называемых исключительными ситуациями. Способы, с помощью кото- г: \ разработчики языка пытаются бороться с исключительными ситуациями, рассмат- - злются в главе 13. 6.5. Выражения отношений и булевские выражения Помимо арифметических выражений, в языках программирования существуют еще - z. ражения булевские, или логические. 6.5.1. Выражения отношения Оператор отношений (relational operator) сравнивает значения двух своих операн- z?b Выражение отношений (relational expression) состоит из двух операндов и одного -ератора отношений. Значение выражения отношений принадлежит к булевскому типу, 1 исключением случая, когда в языке нет булевского типа. Операторы отношений обыч- - ’ перегружаются для большого числа типов. Операция, определяющая истинность или сжность выражения отношений, зависит от типов операндов. Она может быть простой ч:я целых операндов) или сложной (для операндов, являющихся символьными строка- ми». Обычными типами операндов, которые могут использоваться в выражениях отно- шений. являются числовые типы, строки и порядковые типы. Ниже приводится синтаксис выражений отношения в некоторых распространенных языках программирования. Операция Pascal Ada С FORTRAN 77 Равно = = == .EQ. Не равно <> /= । = . NE. Больше > > > .GT. 6.5. Выражения отношений и булевские выражения 289
Меньше < < < . LT. Больше или равно >= >= >= . GE. Меньше или равно <= <= <= . LE. Поскольку на карточных перфораторах времен разработки языка FORTRAN I отсутст- вовали символы < и >, то разработчики этого языка использовали в выражениях отношения соответствующие английские аббревиатуры. В языке FORTRAN 90 допускается использо- вание исходных операторов отношений и операторов, аналогичных существующим в языке Pascal, за исключением того, что для отношения равенства используется символ ==. Приоритет операторов отношения всегда ниже приоритета арифметических опера- ций, поэтому в выражениях а + 1 > 2 * b первыми вычисляются арифметические выражения. 6.5.2. Булевские выражения Булевские выражения состоят из булевских переменных, булевских констант, выра- жений отношения и булевских операторов. В число последних обычно входят операции логических И, ИЛИ и НЕ, а иногда еще исключающего ИЛИ и эквивалентности. Булев- ские операторы обычно принимают только булевские операнды (булевские переменные, булевские литералы или выражения отношения) и порождают булевские значения. В большинстве языков у булевских операторов, как и у арифметических, существует иерархия приоритетов выполнения. В большинстве распространенных языков програм- мирования операция унарного логического отрицания имеет наивысший приоритет, за ней следует операция логического И, а наименьший приоритет имеет операция логиче- ского ИЛИ. Поскольку арифметические выражения могут быть операндами выражений отноше- ния, а выражения отношения могут быть операндами булевских выражений, то должны быть определены соотношения между приоритетами операторов трех этих категорий. Приоритеты операторов языка Ada располагаются следующим образом: Наивысший abs, not ★, /t mod, rem + , - (унарные) + , & (бинарные) -f /=t <t >r <=r >=f in, not in Наименьший and, or, xor, and then, or else Отметим, что булевские операторы языка Ada, кроме оператора not, имеют одинаковый приоритет. Все эти операторы неассоциативны, поэтому для указания порядка вычисле- ния булевских операторов должны использоваться скобки. Например, выражение: А > В and А < С or К = 0 в языке Ada недопустимо. Допустимым будет следующее: (А > В and А < С) or К = 0 или такое: А > В and (А < С or К = 0) 290 Глава 6. Выражения и операторы присваивания
•с пользование данного выражения. Операторы and then и or else языка Ada рас- сматриваются в следующем разделе. Среди императивных языков язык С отличается тем, что он не содержит булевского -ила данных и, следовательно, булевских величин. Для представления булевских значе- ний в языке С используются целые значения. Вместо булевских операндов используются числовые переменные и константы, нулевое значение которых соответствует ложности, а любое другое — истинности. Таким образом, результатом вычисления такого выражения являются целое число 0 в случае ложности и 1 в случае истинности. Необычным следствием структуры языка С является допустимость выражения а > b > с Поскольку операторы отношения языка С являются левоассоциативными, то первым вы- числяется крайний слева оператор отношения, порождая 0 или 1. Затем этот результат сравнивается с переменной с. Сравнения переменных b и с не происходит никогда. Иерархия приоритетов операторов в языках С и C++ с включенными в нее неарифме- тическими операторами насчитывает более 50 операторов и 17 уровней приоритетов. Это является явным доказательством изобилия наборов операторов и сложности выра- жений, возможных в этих языках. Как уже говорилось в главе 5, с точки зрения читабельности язык должен содержать булевский тип данных и не использовать в булевских выражениях числовые типы. По- скольку в языке С любое числовое выражение, независимо от того, задумывалось это или нет, может использоваться в качестве допустимого операнда булевского оператора, то в этом языке утеряны некоторые возможности обнаружения ошибок. В других же им- перативных языках любое небулевское выражение, использованное в качестве операнда булевского оператора, будет воспринято как ошибка. В языке Pascal булевские операторы имеют более высокий приоритет, чем операторы отношения, поэтому выражение а > 5 or а < 0 в языке Pascal недопустимо (поскольку 5 не является допустимым булевским операто- ром). Правильным вариантом этого выражения будет следующий: (а > 5) or (а < 0) 6.6. Сокращенное вычисление Результат сокращенного вычисления выражения (short-circuit evaluation) опреде- ляется без вычисления всех операндов и/или операторов. Например, если значение пере- менной А равно 0, то значение арифметического выражения (13 * А) * (В / 13 - 1) не зависит от значения выражения (В / 13 - 1), поскольку для любого х О ★ х = 0. Поэтому, если значение переменной А равно 0, то вычислять значение вы- ражения (В / 13 - 1) не требуется, как не требуется выполнять и второе умножение. Впрочем, во время выполнения программы такое сокращение вычислений обнаружить нелегко, поэтому оно никогда не используется. Если значение переменной А меньше нуля, то значение булевского выражения 6.6. Сокращенное вычисление 291
(A >= 0) and (В < 10) не зависит от второго оператора отношения, поскольку для всех х значение выражения (FALSE and х) равно FALSE. Поэтому, если А < 0, то нам не требуется вычислять значение переменной В, сравнивать ее с константой 10 и выполнять логический опера- тор and, причем (в отличие от арифметического выражения) это сокращение выражений легко обнаружить и использовать во время выполнения программы. Многие программисты в языке Pascal сталкивались с проблемой при попытке запи- сать цикл поиска в таблице с использованием оператора while. Если предположить, что list [1. .listlen] — это массив, в котором выполняется поиск, a key— искомая величина, можно создать такую программу поиска: index := 1; while (index <- listlen) and (list[index] <> key) do index := index + 1 Проблема состоит в том, что большинство стандартных реализаций языка Pascal не ис- пользуют сокращенные вычисления, поэтому в булевском выражении оператора while второе выражение отношения вычисляется независимо от результата первого. Поэтому если переменной key нет в массиве list, то программа завершится ошибкой выхода значения индекса за пределы допустимого диапазона. Программа также завершится со- общением об ошибке индексирования при превышении счетчиком index верхней гра- ницы индекса, равной listlen, т.е. при обращении к элементу list [listlen+1]. Если в языке предусмотрено и используется сокращенное вычисление булевских вы- ражений, то это не проблема. В предыдущем примере схема сокращенного вычисления определит значение первого операнда оператора AND, и если он окажется ложным, то вычисление второго операнда будет пропущено. Сокращенное вычисление выражений поднимает проблему разрешения в выражениях побочных эффектов. Предположим, что в выражении используется сокращенное вычис- ление, и не была вычислена часть выражения, вызывающая побочный эффект; побочный эффект проявится только позднее, при вычислении всего выражения. Если правильность работы программы зависит от этого побочного эффекта, то сокращенное вычисление может привести к серьезной ошибке. Рассмотрим следующее выражение на языке С: (а > b) II (Ь++ / 3) В этом выражении изменение переменной Ь во втором арифметическом выражении про- исходит только при условии а <= Ь. Если программист предполагал, что переменная Ь будет изменяться при каждом вычислении значения выражения, то программа будет ра- ботать неправильно. В языке Modula-2 каждое вычисление выражений, содержащих операторы AND и OR, является сокращенным. В описании языка FORTRAN 77 эта проблема признана, и принято, что разработчик средств реализации может не вычислять выражений больше, чем нужно для определения результата. При этом отмечается, что если невычисляемой частью выражения является вызов функции, выполнение которой присваивает значение любой переменной, объяв- ленной вне функции, то при сокращенном вычислении эта переменная должна устанав- ливаться в "неопределенное" состояние. Правда, при реализации этого правила возника- ют проблемы, основной из которых является сложность выявления любого существенно- го функционального побочного эффекта. 292 Глава 6. Выражения и операторы присваивания
Язык Ada позволяет программисту задавать сокращенное вычисление булевских опе- - .торов AND и OR, используя двусложные операторы and then и or else. Предпо- . * им. что LIST — это массив с диапазоном индексов 1. . LISTLEN, тогда программа -л языке Ada index := 1 while (INDEX <= LISTLEN) and then (LIST (INDEX) /= KEY) loop INDEX := INDEX + 1; end loop; -e приведет к ошибке в отсутствие переменной KEY в массиве LIST и при превышении .летчиком INDEX значения LISTLEN. В языках С. C++ и Java обычные операторы логических И и ИЛИ. && и | |, соответ- вечно, могут вычисляться сокращенно. Кроме того, в этих языках существуют побито- = ->ie операторы логических И и ИЛИ, & и I, соответственно, которые, не вычисляясь со- • гашенно, могут использоваться в булевских выражениях. 6.7. Операторы присваивания Как отмечалось ранее, присваивание является одной из центральных конструкций в оперативных языках программирования. Оно обеспечивает механизм, с помощью ко- торого пользователь может динамически изменять связи значений с переменными. В следующем разделе рассмотрена простейшая форма присваивания, а далее следует опи- сание различных альтернатив. 6.7.1. Простые присваивания Общий синтаксис простого присваивания выглядит следующим образом: <целевая_переменная> <оператор__присваивания> <выражение> В качестве оператора присваивания в языках FORTRAN, BASIC, PL/I. С. C++ и Java использован знак равенства. Если этот же знак параллельно используется в качестве опе- ратора отношения, что справедливо для языков PL/I и BASIC, то может возникнуть пу- таница. Например, в выражении языка PL/I А = В С переменной А присваивается булевское значение выражения отношения В = С, хотя, на первый взгляд, все три переменные приравниваются друг к другу. В других языках, ис- пользующих знак = в качестве оператора присваивания, для оператора отношения равен- ства используется другой оператор, что позволяет избежать перегрузки оператора при- сваивания. В языке ALGOL 60 впервые в качестве оператора присваивания был использован знак : =, и многие последующие языки эту форму переняли. Оператор присваивания в языках С. C++ и Java интерпретируется как бинарный опе- ратор, и в таком качестве он появляется в выражениях. Подробнее об этом операторе рассказывается в разделе 6.7.6. Существует множество разнообразных проектных решений относительно использо- вания присваивания в языке. В языках FORTRAN, Pascal и Ada оно может появляться 6.7. Операторы присваивания 293
только в качестве самостоятельного оператора, и его целевой объект ограничен одной переменной. Существует, правда, множество альтернатив. 6.7.2. Множественные целевые объекты Одной из альтернатив простого оператора присваивания является возможность при- сваивания значения выражения нескольким объектам. Например, в языке PL/I оператор SUM, TOTAL = О одновременно присваивает нулевое значение переменным SUM и TOTAL. Присвоение значений нескольким целевым объектам удобно, но не очень важно. Эффекта множественного присваивания можно добиться с помощью оператора при- сваивания в языках С, C++ и Java, как показано в разделе 6.7.6. 6.7.3. Условные целевые объекты Языки C++ и Java допускают использование в операторах присваивания условных це- левых объектов. Например, выражение flag ? countl : count2 = О эквивалентно следующему: if (flag) countl = 0 ; else count2 = 0 ; 6.7.4. Составные операторы присваивания Составной оператор присваивания позволяет сокращенно задавать часто используе- мую форму присваивания. С помощью этого метода можно сократить запись присваива- ния, при котором целевая переменная используется в качестве первого операнда в пра- вой части выражения, например: а = а + b Составные операторы присваивания впервые появились в языке AGLOL 68, а позже в несколько видоизмененной форме перешли в язык С. Синтаксис составного оператора присваивания языка С представляет собой объединение нужного бинарного оператора и оператора =. Например, выражение sum += value; эквивалентно выражению sum = sum + value; В языках С, C++ и Java версии составных операторов присваивания существуют для большинства бинарных операторов этих языков. 6.7.5. Унарные операторы присваивания В языках С, C++ и Java есть два специальных унарных арифметических оператора, представляющих собой действительно сокращенные присваивания. Эти операторы соче- тают операции увеличения и уменьшения с присваиванием. Операторы ++ для увеличе- ния и — для уменьшения могут использоваться как в выражениях, так и для выполнения 294 Глава 6. Выражения и операторы присваивания
присваивания. Они могут появляться как префиксные операторы (т.е. перед операндами) ши как постфиксные (т.е. после операндов). В операторе присваивания sum = ++ count; Значение переменной count увеличивается на 1 и присваивается переменной sum. То же самое действие можно задать следующими операторами: count = count + 1; sum = count; Постфиксный оператор sum = count ++; вначале присваивает значение переменной count переменной sum, после чего перемен- ная count увеличит свое значение на 1. То же действие можно задать следующими опе- раторами: sum = count; count = count + 1; Ниже приведен пример использования оператора инкрементации для формирования завершенного оператора присваивания count ++; значение переменной count в нем просто увеличивается на 1. Хотя это и не выглядит присваиванием, но оно таковым является. Результат выполнения приведенного выше оператора равнозначен результату выполнения присваивания count = count + 1; При применении двух унарных операторов к одному операнду ассоциативность счи- тается правосторонней. Например, в операторе - count + + вначале происходит увеличение переменной count на 1, а результат этого действия ум- ножается на -1. Следовательно, это выражение эквивалентно выражению - (count + +) В отличие от выражения count) ++ Операторы инкрементации и декрементации в языках С, C++ и Java часто использу- ются для формирования выражений, содержащих индексы массивов. Компьютер PDP-11, на котором впервые был реализован язык С, содержал автоин- крементный и автодекрементный режимы адресации, представляющие собой аппарат- ною версию операторов инкрементации и декрементации в языке С при использовании их в качестве индексов массивов. На основании этого можно предположить, что разра- ботка указанных операторов языка С опиралась на структуру архитектуры компьютера PDP-11. Однако такая догадка будет неправильной, поскольку операторы языка С при- шли из языка В, разработанного еще до возникновения первого компьютера PDP-11. 6.7. Операторы присваивания 295
6.7.6. Присваивание как выражение В языках С, C++ и Java результат оператора присваивания равен значению, присваи- ваемому целевому объекту. Следовательно, присваивание можно использовать как вы- ражение и как операнд в других выражениях. Структура указанных языков интерпрети- рует присваивание подобно любому другому бинарному оператору, за исключением по- бочного эффекта, связанного с изменением его левого операнда. Людям, никогда не пользовавшимся такими языками, это может показаться необычным, хотя и удобным. Например, в программах на языке С довольно часто встречаются подобные выражения: while ((ch = getchar())) != EOF) { ... } В этом выражении функция get char принимает следующий символ из стандартного файла ввода (как правило, клавиатуры) и присваивает его переменной ch. Затем резуль- тат, или присвоенное значение, сравнивается с константой EOF. Если значение перемен- ной ch не равно константе EOF, то выполняется составной оператор { ... }. Отме- тим, что присваивание должно заключаться в скобки, поскольку в рассматриваемых язы- ках оно имеет более низкий приоритет, чем операторы отношения. Без скобок новый символ вначале сравнивался бы с константой EOF, после чего результат сравнивания (О или 1) был бы присвоен переменной ch. Недостатком использования присваиваний в качестве операндов выражений является возникновение еше одного вида побочных эффектов в выражениях. Такой тип побочного эффекта может привести к выражениям, которые трудно читать и понимать. Этот недос- таток характерен для всех выражений с любыми побочными эффектами. Такие выраже- ния приходится читать не как математические выражения, представляющие собой запись величин, а только как перечень команд с непонятным порядком выполнения. Например, выражение а = Ь + (с = d / Ь++) - 1 означает следующие указания: Присвоить величину Ь переменной temp. Присвоить величину Ь +1 переменной Ь. Присвоить величину d / temp переменной с. Присвоить величину Ь + с переменной temp. Присвоить величину temp - 1 переменной а. Отметим, что способ использования оператора присваивания в языке С позволяет присваивать значения нескольким объектам одновременно. Например, в выражении sum = count = О переменной count присваивается нуль, после чего значение переменной count при- сваивается переменной sum. В структуре операции присваивания в языке С потеряна возможность обнаружения ошибок, что часто приводит к сбоям программы. Предположим, что мы вместо команды if (х == у) ... случайно ввели команду if (х = у) ... 296 Глава 6. Выражения и операторы присваивания
- •_ : шибка (допустить которую весьма просто) не будет обнаружена компилятором, проверки выражения отношений будет выполняться проверка величины, присво- — й переменной х (в данном случае это значение, содержавшееся в переменной у на **снт вычисления выражений). Такая ошибка является результатом трех проектных . ..:ний: использования присваивания как обычного бинарного оператора, использова- -• арифметических выражений как булевских операндов и использования двух очень ' ожих операторов, = и ==, имеющих абсолютно разные значения. Вот еше один при- недостатка безопасности в программах на языках С и С+4-. Отметим, что в языке а подобной проблемы не существует, поскольку в этом языке разрешены только бу- .-ские выражения и операторы if. 6.8. Смешанные присваивания В разделе 6.4.1 мы уже рассматривали смешанные выражения. Довольно часто при- вания также являются смешанными. Возникает вопрос: должен ли тип выражения ветствовать типу переменной, которой присваивается значение, или в некоторых аях несоответствия типов может использоваться приведение типов? В языках FORTRAN, С и C++ для смешанных присваиваний использованы правила - ведения типов, подобные существующим для смешанных выражений, т.е. при сво- дном применении приведения допустимы многие из возможных сочетаний типов. В языке Pascal существует несколько приведений типов при выполнении оператора ' осваивания. например, величина типа integer может присваиваться переменной типа real, но не наоборот. В языках Ada и Modula-2 смешанные присваивания недопустимы. В языке Java, в отличие от языков С и C++, смешанные присваивания позволены ~эко тогда, когда требуемое приведение типов является расширяющим. Таким обра- v. величина типа int может присваиваться переменной типа float, но не наоборот, всех языках, допускающих существование смешанных присваиваний, приведение ти- з выполняется только после вычисления правой части выражения. Альтернативой до бы быть приведение типов всех операндов правой части к типу операнда левой гм до вычисления. Рассмотрим следующий фрагмент программы: int а, Ь; float с; 2 = а / Ь; ”. ос кольку переменная с относится к типу float, то переменные а и Ь могли бы приво- . .ться к этому типу до выполнения деления. В этом случае значение переменной с отли- лось бы от значения, полученного при более позднем приведении (например, если зна- ения переменных а и Ь равны 2 и 3, соответственно). 6.8. Смешанные присваивания 297
Р е i Ю м е Выражения состоят из констант, переменных, скобок, вызовов функций и операторов. Операторы присваивания состоят из целевых переменных, символов операторов при- сваивания и выражений. Семантика выражения большей частью определяется порядком вычисления операто- ров. Правила ассоциативности и приоритетов операторов, входящих в выражения, опре- деляют порядок вычисления операторов в этих выражениях. Если возможен функцио- нальный побочный эффект, то важным является еще и порядок вычисления операндов. Преобразования типа могут быть сужающими или расширяющими. Некоторые сужаю- щие преобразования типов приводят к ошибочным значениям. В выражениях распро- странены неявные приведения типов, или приведения типов, хотя они исключают про- верку типов, что, в свою очередь, понижает надежность. Существуют также явные при- ведения типов, задаваемые программистом. Существуют различные формы присваиваний: условные целевые объекты, множест- венные целевые объекты и операторы присваивания. Вопрос ы 1. Дайте определение приоритета оператора и ассоциативности оператора. 2. Дайте определение побочного функционального эффекта. 3. Что такое “приведение”? 4. Что такое “условное выражение”? 5. Что такое “перегруженный оператор”? 6. Дайте определение сужающих и расширяющих преобразований типов. 7. Что такое “смешанное выражение”? 8. Как порядок вычисления операндов взаимодействует с побочными функциональ- ными эффектами? 9. Что такое ‘'сокращенное вычисление”? 10. Назовите язык, всегда использующий сокращенное вычисление булевских выра- жений. Назовите язык, никогда этого не делающий. Назовите язык, в котором этот выбор оставлен за программистом. 11. Как язык С поддерживает булевские выражения и выражения отношения? 12. Для чего нужен составной оператор присваивания? 13. Какая ассоциативность у унарных арифметических операторов языка С? 14. Назовите один из возможных недостатков интерпретации оператора присваивания как арифметического оператора. 15. Какие смешанные присваивания разрешены в языке Ada? 16. Какие смешанные присваивания разрешены в языке Java? 298 Глава 6. Выражения и операторы присваивания
1. Когда может потребоваться игнорирование компилятором различий типов, входя- щих в выражение? 2. Приведите собственные аргументы против смешанных арифметических выражений. 3. Как вы считаете, удаление перегруженных операторов из вашего любимого языка было бы полезным? Почему? 4. Как вы считаете, удалить все правила приоритетов операторов и использовать скобки для указания требуемого приоритета было бы хорошей идеей? Почему? 5. Должны ли операторы присваивания языка С (например, оператор +=) включаться в другие языки? Почему? ь. Должны ли однооперандные операторные формы языка С (например форма --count) включаться в другие языки? Почему? Опишите ситуацию в языке программирования, в которой оператор сложения не будет коммутативным. S. Опишите ситуацию в языке программирования, в которой оператор сложения не будет ассоциативным. 4. Напишите программный блок на языке Pascal, в котором конструкция while ис- пользовалась бы для поиска заданной величины в массиве целых чисел, который бы работал даже при отсутствии сокращенного вычисления. 10. Предположим, что в некотором языке существуют следующие правила приоритета и ассоциативности: Приоритет Наивысший ★, /, not + , &, mod - (унарный) = , /=t <f <=f >=f > and Наименьший or, xor Ассоциативность: слева направо В следующих выражениях расставьте скобки и используйте верхний индекс, чтобы указать порядок вычисления выражений. Например, порядок вычисления выражения а + Ь * с + d можно представить следующим образом: ((а (Ь * с)')-' Ч df 10.1. а * Ь - 1 + с 10.2. а * (b - 1) / с mod d 10.3. (а - Ь) / с & (d * е / а - 3) 10.4. -a or с = d and е 10.5. 10.6. а > b хог х -а + b or d <= 17 Упражнения 299
11. Укажите порядок вычисления выражений в упражнении 10, предполагая, что при* оритетов не существует, а ассоциативность — та же. 12. Создайте описание в виде формы BNF правил приоритета и ассоциативности, оп- ределенных для выражений в упражнении 10. Единственными возможными име- нами операндов предполагаются a, b, с, d и е. 13. Используйте грамматику из упражнения 12 для создания дерева синтаксического анализа для выражений из упражнения 10. 14. Допустим, что функция FUN была определена следующим образом: function FUN(var К : integer) : integer; begin К := К + 4; FUN := 3 * К - 1 end; Предположим, что функция FUN следующим образом используется в программе: I := 10; SUMI := (I / 2) + FUN(I); J := 10; SUM2 := FUN(J) + (J / 2); Чему равны значения переменных SUM1 и SUM2 в следующих случаях? 14.1. Операнды в выражениях вычисляются слева направо? 14.2. Операнды в выражениях вычисляются справа налево? 15. Допустим, что функция fun языка С была определена следующим образом: int fun (int *k) { Ч += 4; return 3 * (♦ k) - 1 ; } Предположим, что функция fun следующим образом используется в программе: void main() { int i + 10, j = 10, suml, sum2; suml = (i / 2) + fun(&i); sum2 = fun(&j) + (j /2); } Для определения значения переменных suml и sum2 запустите программу на компьютере. Объясните результаты. 16. Назовите свой главный аргумент против (или за) правил приоритетов операторов в языке APL. 17. Для некоторого языка, по вашему выбору, составьте список символов, которые можно использовать для устранения всех перегрузок операторов. 18. Определите в двух языках, по вашему выбору, порождают ли явные сужающие преобразования типов сообщение о потере значимости преобразовываемой вели- чины. 300 Глава 6. Выражения и операторы присваивания
19. Должны ли оптимизирующие компиляторы языков С и C++ иметь возможность изменить порядок вычисления подвыражений в булевских выражениях? Почему? 20. Ответьте на предыдущий вопрос по отношению к языку Ada. 21. Рассмотрим следующую программу на языке С: int fun (int *i) { *i += 5; return 4; } void main() { int x - 3; x = x + fun(&x); } Чему равно значение переменной х после выполнения присваивания в функции main в следующих случаях? 21.1. Операнды вычисляются слева направо. 21.2. Операнды вычисляются справа налево. Напишите на вашем любимом языке программу определения и вывода правил приоритета и ассоциативности его булевских и арифметических операторов. Упражнения 301
W Структуры управления на уровне операторов В > т о й г п а в е . 7.1. Введение 7.2. Составные операторы 7.3. Операторы ветвления 7.4. Операторы цикла 7.5. Безусловный переход 7.6. Защищенные команды 7.7. Выводы Питер Наур (Peter Naur) Питер Наур из Копенгагена вплотную занялся разработкой языков программирования после того, как в 1958 году был опубли- кован первый отчет о языке ALGOL. Он стал редактором из- дания ALGOL Bulletin — европей- ского средства обмена мнениями между людьми, вовлеченными в разработку языка ALGOL. Наур модифицировал систему обозна- чений, использованную Бэкусом в 1959 году, и применил ее для представления самой последней версии языка ALGOL на Париж- ском совещании в 1960 году. Структуры управления на уровне операторов 303
Поток управления, или последовательность выполнения, в программе может изу- чаться на нескольких уровнях. В главе 6 мы рассматривали поток управления внутри выражений, подчиняющийся правилам ассоциативности операторов и приорите- там операций. Наивысшим уровнем является поток управления среди программных мо- дулей, обсуждаемый в главах 8 и 12. Между этими двумя крайностями находится важ- ный вопрос о потоке управления между операторами, описанный в данной главе. Мы начнем с обзора эволюции управляющих операторов в императивных языках. За- тем тщательно исследуем конструкции ветвления, включая одновариантное, двухвари- антное и многовариантное ветвление. Далее мы обсудим множество циклических конст- рукций. разработанных и применяемых в языках программирования. Затем детально рас- смотрим оператор безусловного перехода, вызывающий много споров. В заключение, мы опишем управляющие конструкции с защищенными командами. 7.1. Введение Вычисления в программах, написанных на императивных языках программирования, выполняются путем вычисления выражений и присваивания результирующих значений некоторым переменным. Однако количество полезных программ, состоящих исключи- тельно из операторов присваивания, ограничено. Чтобы сделать вычисления, выполняе- мые в программах, гибкими и мощными, необходимо наличие, по крайней мере, еще двух лингвистических механизмов: некоторых средств выбора среди альтернативных пу- тей потока управления (выполнения операторов) и некоторых средств для организации повторного выполнения определенных наборов операторов. Операторы, обеспечиваю- щие такие возможности, называются управляющими операторами (control statements). Управляющие операторы первого успешного языка программирования— языка FORTRAN — были, по существу, разработаны создателями архитектуры компьютера IBM 704. Все эти операторы были непосредственно связаны с инструкциями на машин- ном языке, так что их возможности в большей степени определялись структурой машин- ных команд, чем особенностями самого языка. В то время лишь немногие знали о труд- ностях. связанных с программированием, и управляющие операторы языка FORTRAN в конце 1950-х годов считались вполне приемлемыми. В последующих разделах этой гла- вы мы обсудим управляющие операторы языка FORTRAN и причины, по которым они теперь считаются неприемлемыми при разработке программ. Управляющим операторам были посвящены многие исследования и дискуссии, про- ходившие в течение 10 лет с середины 1960-х до середины 1970-х годов. Один из основ- ных выводов, сделанных на основе этих исследований, состоял в следующем: несмотря на очевидную достаточность одного управляющего оператора (условного оператора goto), язык программирования, не содержащий оператора goto, нуждается лишь в не- большом количестве различных управляющих операторов. Действительно, было доказа- но, что все алгоритмы, которые можно выразить с помощью блок-схем, могут быть зако- дированы на языке программирования, имеющем только два управляющих оператора: один для выбора между двумя путями потока управления и один— для логически управляемых итераций (B6hm and Jacopini, 1966). Важный результат, вытекающий из этого утверждения, заключается в том, что операторы безусловного перехода являются излишними— возможно, удобными, но несущественными. Этот факт вместе с пробле- мами использования безусловного перехода, или операторов goto, привел к большим спорам об операторе goto, как будет показано в разделе 7.5.1. 304 Глава 7. Структуры управления на уровне операторов
Программисты думали о результатах теоретических исследований, касающихся управ- ляющих операторов, меньше, чем о читабельности программ и легкости их создания. Все языки, получившие широкое признание, имеют больше управляющих операторов, чем два минимально необходимых, поскольку, чем больше в языке программирования управляю- щих операторов, тем легче писать программы на нем. Например, легче писать программы, в которых можно использовать оператор for, естественным образом управляемый счетчи- ком. чем применять для создания циклов оператор while. Основным фактором, ограничи- вающим количество управляющих операторов в языке, является читабельность программ, поскольку' наличие большого количества видов операторов требует от читателя более глу- бокого знания языка. Напомним, что лишь немногие люди знают все о каком-либо крупном языке программирования; вместо этого они изучают подмножество этого языка, выбранное »*ми для использования и часто отличающееся от подмножества, на котором написана чи- таемая им11 программа. С другой стороны, недостаток управляющих операторов может привести к необходимости использования операторов низкого уровня, таких как операторы joto. что делает программу менее читабельной. Вопрос о том, какая совокупность управляющих операторов обеспечивает требуемые возможности и нужную легкость создания программ, широко обсуждался в течение гю- . ледней четверти столетия. По существу, это — вопрос о том, насколько следует расши- тить язык, чтобы увеличить легкость разработки программ за счет его простоты, размера ? читабельности. У правляющая структура (control structure) — это управляющий оператор и совокуп- ность операторов, выполнение которых он контролирует. Исследования в области разра- 'отки языков программирования, проведенные в 1960 году, показали, что управляющие :^р\ктуры должны иметь один вход и один выход. Наличие нескольких входов в итератив- ные структуры, в частности, делает программы менее читабельными и понятными. 7.2. Составные операторы Одно из вспомогательных свойств языка, облегчающее разработку управляющих . лераторов, заключается в способе образования наборов операторов. Основная причина неприемлемости управляющих операторов ранних версий языка FORTRAN заключалась z отсутствии такой конструкции. В языке ALGOL 60 впервые была введена структура для представления набора опера- торов, а именно: составной оператор (compound statement), имеющий следующий вид: begin зыражение_1 зыражение_п end Составной оператор позволяет создавать набор операторов, рассматриваемый как от- дельный оператор. Это — мощная концепция, которую с большой пользой можно при- менять при разработке управляющих операторов. В некоторых языках в начало состав- ного оператора можно добавлять объявления данных, превращая его в блок, как показа- но в главе 4. Язык Pascal унаследовал от языка ALGOL способ образования составных операторов, но не позволяет использовать блоки. Языки, основанные на синтаксисе языка С (С, C++, Java), 7.2. Составные операторы 305
для разграничения составных операторов и блоков используют фигурные скобки. Некоторые языки не нуждаются в специальном разграничении составных операторов, интегрируя их в свои управляющие структуры. Этот способ обсуждается в следующем разделе. Существует один вопрос, связанный с разработкой управляющих структур, который относится ко всем условным и циклическим управляющим операторам: может ли управ- ляющая структура иметь несколько входов. Все операторы ветвления и цикла управляют выполнением сегментов программы, и вопрос состоит в том, всегда ли выполнение этих сегментов начинается с их первого оператора. В настоящее время общепринятым счита- ется мнение, что наличие нескольких входов лишь ненамного увеличивает гибкость управляющих конструкций, ухудшая читабельность программы вследствие ее повышен- ной сложности. Заметим, что наличие нескольких входов возможно только в языках, ко- торые содержат операторы goto и метки операторов. В этом месте читатель может поинтересоваться, почему наличие нескольких выходов из управляющих структур не указано в списке проблем, связанных с разработкой управ- ляющих структур. Это сделано потому, что наличие нескольких выходов из управляю- щей структуры не создает опасности, они имеются во всех языках. Таким образом, они не вызывают никаких проблем. 7.3. Операторы ветвления Оператор ветвления (selection statement) предоставляет программисту средства вы- бора между двумя или несколькими путями выполнения программы. Такие операторы являются основными и существенными частями всех языков программирования, как бы- ло доказано Бемом (Bdhm) и Джакопини (Jacopini). Операторы ветвления разделяются на две основные категории: двухвариантные и и- вариантные, или многовариантные операторы ветвления. В категории двухвариантных операторов ветвления существует вырожденный вид оператора, называемый одновари- антным оператором ветвления. Существует также вырожденный многовариантный опе- ратор ветвления, арифметический оператор IF в языке FORTRAN, являющийся трехва- риантным оператором ветвления. 7. 3.1. Двухвариантные операторы ветвления Несмотря на то что двухвариантные операторы ветвления в современных императив- ных языках совершенно одинаковы, вариации в их оценках основываются на совокупно- ст и соображений, связанных с разработкой языка. 7.3.1.1. Вопросы разработки Возможно, наиболее простой проблемой, возникающей при разработке двухвариант- ных операторов ветвления, является тип выражения, которое управляет оператором ветвления. Самый интересный вопрос разработки— можно ли с помощью оператора ветвления выбрать отдельный оператор, составной оператор или последовательность операторов. Оператор ветвления, который может выбирать только отдельные операторы, является очень ограниченным и обычно приводит к сильной зависимости от операторов goto. Возможность выбора составных операторов была большим шагом вперед в разви- тии управляющих операторов. Возможность выбора последовательности операторов 306 Глава 7. Структуры управления на уровне операторов
’тебует, чтобы оператор ветвления включал в себя синтаксическую сущность для опре- деления такой последовательности. Другой интересный и связанный с предыдущим во- зрос: как определить смысл вложенных операторов ветвления— синтаксическими или статическими семантическими правилами? Эти вопросы разработки языка можно сформулировать следующим образом. Какой вид и тип имеет выражение, контролирующее ветвление? Можно ли выбрать отдельный оператор, последовательность операторов или со- ставной оператор? Как определить смысл вложенных операторов ветвления? 7.3.1.2. Примеры двухвариантных операторов ветвления Все императивные языки программирования имеют одновариантный оператор ветв- ления, в большинстве случаев представляющий собой подвид двухвариантного операто- ра ветвления. Единственным исключением является язык FORTRAN IV, который не •мел двухвариантного оператора ветвления. Одновариантный оператор ветвления в языке FORTRAN IV, называемый логическим "ератором IF, имеет вид IF (булевское выражение) оператор Семантика этого оператора заключается в том, что выбираемый оператор выполняется, - :лько если булевское выражение имеет значение “истина”. В языке FORTRAN IV в от- -?шении логического оператора IF было принято следующее проектное решение: ргавляющее выражение в операторе ветвления имеет булевский тип, и может быть вы- 'ган только один оператор. Вложенные логические операторы IF не допускаются. Одновариантный логический оператор IF очень прост, однако крайне негибок. Тот :зкт. что можно выбрать только один оператор, способствует использованию операто- - ?з goto, поскольку часто в зависимости от некоторого условия требуется выполнять не- .-олько операторов. Единственным разумным способом для выполнения группы опера- - ров в зависимости от некоторого условия является создание условного ветвления во- •г>г этой группы операторов. Предположим, что мы хотели инициализировать две едеменные I и J значениями 1 и 2, но только в том случае, если переменная FLAG рав- -2 1. Как сделать это с помощью языка FORTRAN IV, показано ниже: IF(FLAG . NE. 1) GO ТО 20 1 = 1 J = 2 :: continue негативная логика, предусмотренная таким видом оператора, может ухудшить читабель- -гсть программы. Эта структура имеет несколько входов, поскольку любой из операторов, входящих в .егмент, может быть отмечен меткой и являться, таким образом, целью операторов • _ 70 в любом месте программы. Составной оператор, введенный в практику языком ALGOL 60, обеспечивает *: нструкцию ветвления с простым механизмом условного выполнения групп операто- ров. Он позволяет выбрать как отдельный, так и составной оператор, как показано ниже: 7.3. Операторы ветвления 307
if (булевское выражение) then begin выражение 1 выражение_п end Большинство языков, разработанных после языка ALGOL 60, включая языки FORTRAN 77 и 90, имеют одновариантный оператор ветвления, который может выби- рать составной оператор или последовательность операторов. Язык ALGOL 60 ввел в практику первый двухвариантный оператор ветвления, имев- ший следующий общий вид: if (булевское_выражение) then оператор else оператор Здесь либо один из операторов, либо оба могут быть составными. Оператор, следующий за ключевым словом then, называется оператором then, а оператор, следующий за ключевым словом else, — оператором else. Семантика двухвариантного оператора ветвления заключается в том, что оператор then выполняется, только если значением булевского выражения является “истина”; в противном случае выполняется оператор else. Ни при каких условиях эти два операто- ра не выполняются одновременно. Все императивные языки, разработанные после середины 1960-х годов, имели встро- енные двухвариантные операторы ветвления, хотя их синтаксис варьировался. Двухвариантные операторы ветвления в языках ALGOL 60, С и C++ могут иметь не- сколько входов, но в большинстве других современных языков программирования эти операторы могут иметь только один вход. 7.3.1.3. Вложенные операторы ветвления Интересная проблема возникает, если допускается вложение двухвариантных конст- рукций ветвления. Рассмотрим следующий фрагмент кода, напоминающего программу на языке Pascal: if sum = 0 then if count = 0 then result := 0 else result := 1 Эту конструкцию можно интерпретировать двумя различными способами в зависи- мости от того, соответствует оператор else первому оператору then или второму. За- метим, что структурированное расположение текста указывает на то, что оператор else принадлежит первому оператору then. Однако такое расположение текста не влияет на семантику в большинстве современных языков и, следовательно, игнорируется их ком- пиляторами. Сложность этого примера заключается в том, что оператор else следует за двумя опе- раторами then без промежуточных операторов else, и нет никаких синтаксических ин- 308 Глава 7. Структуры управления на уровне операторов
•зторов, указывающих на соответствие оператора else одному из операторов then, чыке Pascal, как и во многих других императивных языках, статическая семантика языка -знает, что оператор else всегда образует пару с ближайшим предшествующим опера- г м then, не имеющим пары. Для предотвращения неоднозначности здесь применяется * ?>!ло. а не синтаксическая сущность. Таким образом, если бы приведенный выше при- был написан на языке Pascal, то оператор else представлял бы собой альтернативу тому оператору then. Недостаток использования правила вместо синтаксической сущ- ;ти заключается в том, что. хотя программист может считать оператор else альтернати- первому оператору then, и компилятор сочтет эту структуру синтаксически правиль- - ее семантика является прямо противоположной. Чтобы внести в язык Pascal альтерна- * в«\ю семантику, требуется наличие иной синтаксической формы, в которой внутренний к оператора if-then представляет собой составной оператор. Разработчики языка ALGOL 60 решили использовать синтаксис, а не правило, для то- -тобы связывать операторы else с операторами then. В частности, не допускалось *. -кение оператора if непосредственно в оператор then. Если возникала необходи- :ть вложить оператор if в оператор then, он должен был быть заключен в составной ~:?атор. Например, если конструкция ветвления, приведенная выше, должна была свя- .’п оператор else со вторым оператором then, в языке ALGOL 60 это следовало за- :лть следующим образом: if sum = 0 then begin if count = 0 then result := 0 else result := 1 end .-и оператор else следовало связать с первым оператором then, то это можно было писать так: if sum = 0 then begin if count = 0 then result := 0 end else result := 1 •енно это позволяет получить смысл, совпадающий со смыслом фрагмента кода на • г ке Pascal. Разница между двумя описанными проектными решениями заключается в - м. что вариант, реализованный в языке Pascal, позволяет использовать вложенные опе- тхторы ветвления, которые выглядят так, как будто они связывают оператор else с пер- оператором then, в то время как они этого на самом деле не делают, тогда как та - е самая форма в языке ALGOL 60 является синтаксически недопустимой и не позволяет . издавать хитроумные программы, характерные для языка Pascal. В языках С, C++ и Java существуют те же самые проблемы, связанные с вложением опера- "?ров ветвления, что и в языке Pascal. Язык Perl требует, чтобы все операторы then и else 7э!ли составными, предотвращая таким образом проблему их взаимного соответствия. 7.3. Операторы ветвления 309
Альтернатива проектному решению, реализованному в языке ALGOL 60, как будет показано в следующем разделе, — введение специальных замыкающих слов для опера* торов then и else. 7.3.1.4. Специальные слова и замыкание оператора ветвления Рассмотрим синтаксическую структуру оператора if в языке Pascal. Оператор then начинается с зарезервированного слова then, а оператор else— с зарезервированного слова else. Если оператор then представляет собой отдельный оператор и присутству- ет оператор else, то зарезервированное слово else фактически отмечает конец опера- тора then, несмотря на то, что в этом нет никакой необходимости. Если оператор then является составным, он завершается зарезервированным словом end. Однако, если по- следний оператор в операторе if (либо then, либо else) не является составным, то не существует синтаксического способа для того, чтобы отметить конец всей конструкции ветвления. Использование ключевого слова для этой цели могло бы разрешить вопрос, связанный с семантикой вложенных операторов ветвления, и увеличить надежность кон- струкции. Именно так был решен этот вопрос при разработке языков ALGOL 60, FORTRAN 77 и 90, Modula-2 и Ada. Рассмотрим следующую конструкцию языка Ada: if А > В then SUM := SUM + А; /-.COUNT := ACOUNT -г 1; else SUM := SUM + B; LCOUNT := BCOUNT + 1; end if; Такая конструкция имеет более четкую структуру, чем конструкции ветвления в языках Pascal и ALGOL 60. поскольку ее форма остается той же самой независимо от количества операторов, входящих в операторы then и else. Эти операторы состоят из последователь- ностей операторов, а не из составных операторов. Первую интерпретацию примера ветвления, приведенного в начале раздела 7.3.1.3, можно записать на языке Ada следующим образом: if SUM = 0 then if COUNT = 0 then RESULT := 0; else RESULT := 1; end if; end if; Если ключевые слова end if закрывают вложенный оператор if, значит оператор else соответствует внутреннему оператору then. Вторую интерпретацию примера ветвления, приведенного в начале раздела 7.3.1.3, можно записать на языке Ada следующим образом: if SUM = 0 then if COUNT = 0 then RESULT := 0; end if; RESULT := 1; end if; 310 Глава 7. Структуры управления на уровне операторов
В языке Modula-2 все управляющие конструкции закрываются тем же самым заре- егэированным словом END. Программа на языке Modula-2 достигает того же результа- 2 что и пример на языке Ada, приведенный выше, используя при этом меньше зарезер- = гованных слов. Несмотря на это, программы на языке Modula-2 менее читабельны, ем аналогичные программы на языке Ada, особенно если различные управляющие кон- ;~г\кции включаются одна в другую. Когда для закрытия управляющей констрчкцин IF пользуется зарезервированное слово END, оно несет лишь часть информации, которую > г держат зарезервированные слова END IF. 7. 3.2. Конструкции многовариантного ветвления Конструкция многовариантного ветвления (multiple selection) позволяет выбрать для = ыполнения один оператор или одну группу операторов из произвольного количества гераторов или групп операторов, соответственно. Следовательно, она представляет со- :ой обобщение оператора ветвления. Действительно, одновариантный и двухвариантный гператорьГ ветвления можно построить на основе многовариантного оператора ветвле- ния. Исходная форма многовариантного оператора ветвления впервые была предложена в языке FORTRAN. Необходимость выбирать путь передачи потока управления в программе— вполне :бычное явление. Несмотря на то что многовариантный оператор ветвления можно по- строить на основе двухвариантного оператора ветвления и операторов безусловного пе- рехода goto, структуры, полученные таким образом, являются громоздкими, трудными для создания и чтения программ, а также ненадежными. Следовательно, возникает по- требность в специальной структуре. 7.3.2.1. Вопросы разработки Некоторые из вопросов разработки многовариантных операторов ветвления похожи на аналогичные вопросы, возникающие при разработке двухвариантных операторов ветвления. Например: можно ли выбрать отдельный оператор, составной оператор или последовательность операторов. Если структура многовариантного выбора является ин- капсулированной, то все выбираемые сегменты кода должны располагаться вместе Это не позволяет потоку управления сбиться с пути и перейти во время выполнения про- граммы к операторам, не являющимся частью структуры многовариантного ветвления. Поскольку это влияет на их читабельность, инкапсуляция представляет собой проблему. Другой вопрос, связанный с двухвариантным оператором ветвления, — это вопрос о ти- пе выражения, на котором основывается выбор. В данном случае спектр возможностей широк, частично из-за количества вариантов выбора. Для двухвариантного оператора ветвления требуется выражение, которое может принимать всего два значения. Кроме того, существует вопрос, может ли при такой конструкции выполняться лишь отдельный выбираемый сегмент. Этот вопрос не относится к двухвариантному оператору ветвления, поскольку в этом случае во всех языках программирования передавать поток управления можно только одному оператору. Как мы вскоре увидим, решение этой проблемы ля многовариантного оператора ветвления представляет собой компромисс междх надеж- ностью и гибкостью. В заключение отметим еще один вопрос — что следует считать ре- зультатом условного выражения, вычисляющего значение, которое не соответст в\ ет ни одному из сегментов, подлежащих выбору. Здесь разработчики должны принять реше- ние — просто не допускать возникновения такой ситуации или сформулировать правило. 7.3. Операторы ветвления 311
описывающее, что именно происходит, когда такая ситуация возникает. Непредставлен- ные значения условных выражений будут обсуждаться в разделе 7.3.2.3. Вопросы разработки многовариантных операторов ветвления можно резюмировать следующим образом. Какова форма и тип выражения, управляющего ветвлением? Можно ли выбрать отдельный оператор, последовательность операторов или со- ставной оператор? Инкапсулирована ли вся конструкция в некую синтаксическую структуру? Ограничен ли поток выполнения, проходящий через структуру, выбором лишь од- ного выбираемого сегмента? Как обрабатывать непредставленные значения условных выражений и надо ли их обрабатывать вообще? 7.3.2.2. Ранние многовариантные операторы ветвления Многовариантные операторы ветвления, введенные в практику языком FORTRAN I, упоминаются здесь по историческим причинам, а также потому, что они остаются ча- стью последней версии языка FORTRAN — языка FORTRAN 90. Подобно другим управляющим операторам языка FORTRAN, его многовариантные операторы ветвления основаны непосредственно на машинных командах компьютера IBM 704. Как указывалось ранее, трехвариантный оператор ветвления языка FOTRAN, на- зываемый арифметическим оператором IF, представляет собой вырожденный случай многовариантного оператора ветвления. Поскольку с помощью этого оператора можно выбрать не более трех наборов операторов, строго говоря, он не является многовариант- ным оператором ветвления. Арифметический оператор IF производит выбор среди трех ветвей на основе значе- ния некоего выражения. Ветвление математически основано на трихотомии чисел. Это означает, что заданное числовое значение либо больше нуля, либо равно нулю, либо меньше нуля. Арифметический оператор IF имеет вид: IF (арифметическое выражение) Nl, N2, N3 Здесь Nl, N2 и N3 — метки операторов, которым передается управление, если значение выражения меньше нуля, равно нулю или больше нуля, соответственно. Например, при выборе среди трех последовательностей операторов арифметический оператор IF имеет следующий общий вид: 10 IF (выражение) 10, 20, 30 20 GO ТО 4 0 30 GO ТО 4 0 40 . . . В действительности этот тип оператора ветвления мог бы быть еще более пагубным для читабельности программы, чем в приведенном примере, поскольку последовательность 312 Глава 7. Структуры управления на уровне операторов
.гаторов. подлежащая выбору, может находиться буквально в любом месте про- емного модуля, содержащего оператор GO ТО. Не существует синтаксической ин- фляции оператора GO ТО и выбираемых последовательностей операторов, которым - епедает управление. Поскольку ответственность за размещение операторов GO ТО в • -де выбираемых сегментов несет пользователь, выполнение этой конструкции может вать передачу потока управления любому количеству выбираемых сегментов про- * .ммы. Проблема состоит в том, что ошибка, заключающаяся в пропуске одной из этих . вей программы, компилятором не обнаруживается. Даже когда выполнение многова- лгпного сегмента необходимо, увеличивается сложность структуры, нанося большой ..ерб читабельности программы. Это проектное решение является компромиссом меж- •итабельностью и небольшим увеличением гибкости программ. Войти в арифметический оператор IF можно через любой из его операторов в любом .сте программы. Первые два действительно многовариантных оператора ветвления появились в языке »RTRAN I. Подобно арифметическому оператору IF, они входят во все версии языка RTRAN. Вычисляемый оператор GO ТО в языке FORTRAN имеет следующий вид: S? ТО (метка 1, метка 2, ..., метка п), выражение : :есь выражение имеет целое значение, а метки представляют собой метки операторов в гограмме. Семантика этого оператора состоит в том. что значение выражения исполь- егся для выбора метки, которой следует передать управление. Первая метка связана со •ачением, равным 1, вторая метка— со значением, равным 2, и так далее. Если значе- выходит за пределы диапазона от 1 до >?, оператор не выполняет никаких действий, троенный механизм для обнаружения ошибок не предусмотрен. Другой ранний многовариантный оператор ветвления в языке FORTRAN — назначен- ий оператор GO ТО— по форме напоминает вычисляемый оператор GO ТО. Оба эти •’чоговариантные операторы ветвления имеют те же недостатки, что и арифметический "ератор IF, а именно: отсутствие инкапсуляции и возможность нескольких входов. Кроме - vo. ничто не ограничивает поток управления одним выбираемым сегментом. 7.3.2.3. Современные многовариантные операторы ветвления Усовершенствованный вид операторов многовариантного ветвления, названный опе- гдтором case, был предложен Хоаром и включен в язык ALGOL-W (Wirth and Hoare, . чбб). Эта структура инкапсулирована и имеет один вход. Для каждого выбираемого или составного оператора предусмотрены неявные переходы, ведущие в одну точку в конце всей конструкции, что ограничивает поток управления через структуру одним выбирае- мым сегментом. Общий вид хоаровского многовариантного оператора ветвления приведен ниже: case целое_выражение of begin оператор_1; оператор п end 7.3. Операторы ветвления 313
Здесь операторы могут быть либо отдельными, либо составными. Выполняемым являет- ся только один оператор, выбираемый по значению выражения. Значение, равное 1, со- ответствует первому оператору и так далее. Оператор case языка Pascal очень похож на соответствующий оператор языка ALGOL-W, за исключением того, что все выбираемые сегменты помечены метками. Он имеет следующий вид: case выражение of список_констант_1: оператор_1; список констант_п: оператор__п; end Здесь выражение имеет порядковый тип (целый, булевский, символьный или перечисли- мый). Как и в большинстве (но не во всех) управляющих операторах языка Pascal, выби- раемые операторы могут быть либо отдельными, либо составными. Семантика оператора case языка Pascal заключается в следующем: вычисляется вы- ражение, и его значение сравнивается с константами в списке констант. Если соответст- вие найдено, то управление передается оператору, приписанному к соответствующей константе. После выполнения оператора управление передается первому оператору, сле- дующему за всей конструкцией case. Список констант, конечно, должен иметь тот же тип, что и выражение. Они должны быть взаимоисключающими, но не обязаны быть исчерпывающими; т.е. константы не могут появляться в нескольких списках констант, но в списке констант не обязательно должны быть представлены все значения из диапазона, которому может принадлежать значение выражения. Заметим, что, хотя список констант, соответствующих выбираемым сегментам, по форме похож на список меток, их нельзя использовать в качестве целей операторов ветв- ления. С транно, что первое широко используемое описание языка Pascal (Jensen and Wirth, 1974) не рассматривало возможность наличия в программах непредставленных значений операторов ветвления (в этом случае выражение принимает значение, не входящее ни в один из списков констант). О таких выражениях говорилось, что они приводят к неопреде- ленным результатам. Такая неопределенность, однако, означает, что данная проблема была просто проигнорирована. Более позднее описание ANSI/IEEE Pascal Standard (Ledgard, 1984) было конкретнее; в нем указывалось, что такие выражения являются ошибочными, предположительно подлежащими распознаванию и должны вызывать сообщения об ошиб- ках во время выполнения кода, сгенерированного компилятором языка Pascal. В настоящее время многие диалекты языка Pascal включают в себя условный опера- тор. который подлежит выполнению, если значение выражения не принадлежит ни од- ному из списков констант в операторе case, как показано ниже: case index of 1, 3: begin odd := add + 1; sumodd := sumodd + index end; 2, 4: begin even := even +1; 314 Глава 7. Структуры управления на уровне операторов
sumeven := sumeven + index end else writein(*Ошибка в операторе case, index =', index) end Как только значение переменной index выйдет за пределы диапазона от 1 до 4 при Е^.полнении оператора case, будет выведено сообщение об ошибке. Отметим, что нет никакой необходимости использовать оператор else исключи- “г.^ьно для обработки ошибочных ситуаций. Иногда он подходит и для обработки нор- “пьных условий, а операторы case — наоборот, для обработки необычных условий. Операционная семантика представляет собой эффективный способ описания семан- ’ !ки некоторых управляющих конструкций. Для этой цели расширим операционную се- vантику, введенную в главе 3, включив в нее операторы присваивания с такими общими заражениями, как RHS. Будем также использовать английские обозначения для некото- г =>i\ операторов. Такие описания будут появляться в скобках. В заключение отметим, что позволяем включать в операционную семантику операторы вывода из того языка, ко- ?рый она описывает. Описание операционной семантики предыдущего оператора case дано ниже: if index = 1 goto one_three if index = 3 goto one_three if index = 2 goto two_four if index = 4 goto two_four writein(1 Ошибка в операторе case, index = index) goto out cr.e_three: odd: = odd + 1; summodd := summed = index goto out two_four: even := even + 1 sumeven := sumeven + index out: ... Структура конструкции switch многовариантного ветвления в языке С, входящей также и в языки C++ и Java, относительно проста. Ниже представлен ее общий вил: switch (выражение) { case выражение_константа_1: оператор_1; case выражение_константа_п: оператор__п; [default: оператор_п+1] Здесь управляющее выражение и выражения-константы имеют целый тип. Выбираемые операторы могут быть последовательностями операторов, составными операторами или блоками. Оператор switch инкапсулирует выбираемые сегменты кода, подобно оператору case языка Pascal, но он не запрещает наличия нескольких входов и не предусматривает неявных переходов в конец сегментов кода. Это позволяет пропускать поток управления 7.3. Операторы ветвления 315
через несколько выбираемых сегментов во время одного выполнения оператора switch. Рассмотрим следующий пример, похожий на приведенный выше пример ис- пользования оператора case в языке Pascal: switch (index) { case 1: case 3: odd += 1; sumodd += index; break; case 2: case 4: even += 1; sumeven += index; break; default: printf("Ошибка в операторе switch, index = %d\n", index); } Иногда удобно позволить переход потока управления от одного выбираемого сегмен- та кода к другому. Очевидно, по этой причине в конструкции switch нет неявных пере- ходов. И поэтому, если программист по ошибке пропустит в конструкции switch опе- ратор break, возникает проблема с надежностью, поскольку поток управления ошибоч- но передается следующему сегменту. Разработчики оператора switch в языке С. подобно разработчикам вычисляемого оператора GO ТО в языке FORTRAN, решили не- много ослабить надежность, выиграв в гибкости языка. Исследования, однако, показали, что возможность передавать поток управления от одного выбираемого сегмента другому используется редко. Оператор switch в языке С создан по образцу многовариантного оператора ветвления в языке ALGOL 68, который также не имеет неявных переходов из выполняемых сегментов. Оператор case языка Ada позволяет использовать такие ограниченные типы, как 10. .15, а также операторы ИЛИ. указываемые символом |, например, в выражениях 10 | 15 I 20 в списках констант. В случае применения непредставленных значений выполняется оператор others. Язык Ada вводит дополнительные ограничения, состоя- щие в том, что списки констант должны быть исчерпывающими, обеспечивая немного большую надежность, поскольку это предотвращает ошибки, вызванные неумышленным пропуском одной или нескольких констант. В выражениях оператора case допускаются только целые и перечислимые типы. Большинство операторов case в языке Ada содер- жит оператор others, позволяющий гарантировать, что список констант является ис- черпывающим. Оператор case языка FORTRAN 90 похож на оператор case языка Ada. Во многих случаях конструкция case неприемлема для многовариантного выбора. Например, если выбор должен быть сделан на основе булевского выражения, а не неко- торого порядкового типа, можно использовать вложенные двухвариантные операторы ветвления для моделирования многовариантного оператора ветвления. Чтобы избежать плохой читабельности программ, возникающей из-за глубоко вложенных двухвариант- ных операторов ветвления, такие языки, как FORTRAN 90 и Ada, были расширены. Рас- ширения позволяют пропускать некоторые ключевые слова. В частности, последова- тельности else-if заменяются одним ключевым словом, а замыкающее ключевое сло- во вложенного оператора if отбрасывается. В этом случае вложенный оператор 316 Глава 7. Структуры управления на уровне операторов
-ствления называется оператором elsif. Рассмотрим следующую конструкцию ветв- чния в языке Ada: if COUNT <10 then BAG1 := TRUE; elsif COUNT < 100 then BAG2 := TRUE; elsif COUNT < 1000 then BAG3 := TRUE; end if; -а эквивалентна следующей конструкции: if COUNT <10 then BAG1 := TRUE; else if COUNT < 100 then BAG2 := TRUE; else if COUNT < 1000 then BAG3 := TRUE; end if; end if; end if; Из этих двух вариантов версия elsif более читабельна. Отметим, что данный при- ер довольно непросто моделируется с помощью оператора case, поскольку выбор ка- -2ого оператора производится на основе булевского выражения. Следовательно, конст- - -дня elsif не является избыточной формой оператора case. В действительности, ни :,'н из операторов многовариантного ветвления в современных языках не распростра- гч так, как оператор if-then-elsif. Описание операционной семантики общего ератора ветвления на основе операторов elsif, в котором буквы Е с цифрами обо- - знают логические выражения, а буквы S с цифрами — операторы, приведено ниже: if El goto 1 if El goto 1 : : SI goto out . : S2 goto out -о описание позволяет увидеть разницу между структурами многовариантного ветвле- я и конструкциями elsif. Она заключается в том. что в конструкции многовариант- ветвления все выражения, обозначенные буквами Е, можно свести к сравнениям . ад\ значениями отдельных выражений и некоторыми другими значениями. Языки, не включающие в себя конструкцию elsif, могут использовать ту же управ- - ошую структуру, лишь с ненамного большим количеством типов. Конструкции elsif основываются на общей математической конструкции — услов- - м выражении. Функциональные языки программирования, которые будут обсуждаться - лаве 14, часто используют условные выражения в качестве одной из основных управ- - - юших конструкций. 7.3. Операторы ветвления 317
7.4. Операторы цикла Операторы цикла (iterative statements)— это операторы, вынуждающие оператор или набор операторов выполняться один и сколько угодно раз, или не выполняться ни разу. Каждый язык программирования, начиная с языка Plankalkiil, содержал некоторый метод хтя повторения выполнения сегмента кода. Если бы цикл был невозможен, про- граммист был бы вынужден указывать каждое действие в последовательности операто- ров: полезные программы стали бы огромными, а их написание заняло бы громадный объем времени. Повторяющееся выполнение операторов часто реализуется в функциональных языках с помощью рекурсии, а не итеративных структур. Рекурсия в функциональных языках 6} дет обе} ждаться в главе 14. Первые циклические конструкции в языках программирования были непосредственно связаны с массивами. Это было следствием того факта, что на заре компьютерной эры вычисления были преимущественно числовыми по своей природе и часто использовали циклы для обработки данных, хранящихся в массивах. Было разработано несколько видов операторов управления циклами. Основные виды этих операторов зависели от того, как разработчик решал две главные проблемы проек- тирования. Как осуществляется управление циклом? В каком месте цикла находится механизм управления? Основные возможности управления циклом — логические выражения, индексирова- ние или комбинация этих способов. Основные способы расположения механизма управ- ления - в начале цикла или в конце цикла. Начало и конец цикла — это логические, а не физические понятия. Вопрос заключается не в физическом размещении механизма управления, а в том, может ли этот механизм выполняться и осуществлять управление перст пли после выполнения тела цикла. Третья возможность, позволяющая пользовате- лю самому решать, где разместить механизм управления циклом, обсуждается в разде- ле 7.4 3. Тело цикла— это набор операторов, выполнение которых управляется опера- тором цикла. Мы используем термин предварительная проверка (pretest) для того, чтобы отметить тот факт, что проверка условия завершения цикла осуществляется после выполнения тела цикла, а термин последующая проверка (posttest) означает, что эта проверка производится после выполнения тела цикла. Оператор цикла и связанное с ним тело цикла вместе образуют итеративную конструкцию (iteration construct). Кроме основных операторов цикла, мы обсудим также альтернативную форму, кото- рая сама по себе принадлежит некоторому классу операторов — определенным пользо- вателем механизмам управления циклом. 7.4.1. Циклы со счетчиком Оператор цикла со счетчиком имеет переменную, называемую счетчиком цикла (loop variable), в которой хранится значение счетчика. Он также обладает некоторыми сре к (вами для указания начального и конечного значений счетчика цикла и разности меддч последовательными значениями счетчика цикла, которую часто называют вели- чиной шага. Начальное и конечное значения счетчика, а также величина шага цикла на- чыван»!~я параметрами цикла. 318 Глава 7. Структуры управления на уровне операторов
Несмотря на то что логически управляемые циклы имеют более обший вид, чем цик- «о счетчиком, это не значит, что они более широко используются. Поскольку циклы . «нетчиком сложнее, к их разработке предъявляются повышенные требования. Никлы со счетчиком часто поддерживаются машинными командами. К сожалению. •• тьютерная архитектура зачастую переживает методы программирования, бывшие сходствующими во время разработки этой архитектуры. Например, в компьютерах - X есть команда, очень удобная для реализации циклов со счетчиком и последующей ;. веркой, которая была в языке FORTRAN во время проектирования этих компьюте- - z Однако сейчас язык FORTRAN больше не содержит такого цикла, в то время как . -пьютеры VAX стали широко распространенными. Конечно, языковые конструкции намного переживают машинную архитект\р\. На- : 'мер. автору неизвестен ни один современный компьютер, который имел бы трехва- : «нтную команду ветвления для реализации арифметического оператора I? языка - : RTRAN. 7.4.1.1. Вопросы разработки Природа счетчика цикла и параметров цикла вызывает много вопросов. Типы счетчи- •- _икла и параметров цикла, очевидно, должны быть одинаковыми или. по крайней ме- -z совместимыми, но какие именно типы следует считать допустимыми? Одно из оче- - дных решений— целые числа, но как насчет перечислимых и символьных типов, а же чисел с плавающей точкой? Другой вопрос заключается в том, является ли счетчик . --та обычной переменной с точки зрения области видимости или у него должна быть .гя специальная область видимости. Связан с вопросом об области видимости и вопрос '?м. какое значение имеет счетчик цикла после его завершения. Разрешение пользова- •- Ю изменять счетчик или параметры цикла внутри цикла может привести к созданию • да. очень трудного для понимания, поэтому следующий вопрос состоит в том, компен- « *?}ет ли дополнительная гибкость, созданная таким решением, повышенную слож- - гть. Аналогичный вопрос возникает и в отношении того, сколько раз и когда конкрет- - вычисляются параметры цикла: если они вычисляются только один раз. это приводит -ростым, но менее гибким циклам. Эти вопросы можно сформулировать следующим образом. Какой тип и какую область видимости имеет счетчик цикла? Чему равен счетчик цикла после завершения цикла? Следует ли разрешать изменения счетчика и параметров цикла внутри цикла, и ес- ли да, влияет ли такое изменение на управление циклом? Следует ли вычислять параметры цикла только один раз или это следует делать па каждой итерации? 7.4.1.2. Оператор DO в языках FORTRAN 77 и FORTRAN 90 Язык FORTRAN I содержал оператор со счетчиком DO, оставшийся без изменения в языках FORTRAN II и IV. Этот оператор имел последующую проверку, что отличало сю от циклов со счетчиком во всех других языках программирования. Общий вид этою опе- ратора приведен ниже: DO метка переменная = начальное значение, конечное значени- [, величина шага] 7.4. Операторы цикла 319
Здесь метка— последний оператор в теле цикла, а величина шага, если она не указыва- ется явно, по умолчанию равна 1. Параметры цикла могут быть лишь беззнаковыми це- лыми константами или простыми целыми переменными, принимающими положитель- ные значения. Оператор DO языка FORTRAN 77 по внешнему виду похож на аналогичный оператор языка FORTRAN IV, за исключением последующей проверки условий. Счетчик цикла может иметь типы INTEGER, REAL или DOUBLE-PRECISION. Параметры цикла могут быть выражениями и иметь положительные или отрицательные значения. Они вычисля- ются в начале выполнения оператора DO и их значения используются для вычисления количества повторений цикла (iteration count). Цикл управляется именно количеством повторений цикла, а не параметрами цикла, так что даже если параметры цикла будут изменены внутри цикла, что допускается, эти изменения не смогут повлиять на управле- ние циклом. Количество повторений цикла хранится во внутренней переменной, которая недоступна пользовательской программе. В конструкцию DO можно войти только через оператор DO, что делает этот оператор структурой с одним входом. Когда выполнение оператора DO завершается (независимо от того, каким именно образом это происходит), счетчик цикла имеет то значение, кото- рое было присвоено ему в последний раз. Таким образом, полезность счетчика цикла не зависит от того, как именно завершается цикл. Описание операционной семантики опе- ратора DO языка FORTRAN 77 приведено ниже: init__value := init_expression terminal_value := terminal_expression step_value := step_expression do_var := init_value iteration_count := max (int ( (terminal_value-init_value + step_value) / Step__value) t 0) loop: if iteration_count < 0 goto out [loop body] do_var := do_var + step_value iteration_count := iteration_count - 1 goto loop out: ... Язык FORTRAN 90 содержит оператор DO языка FORTRAN 77 и добавляет новую форму: [имя:] DO переменная = начальное значение, конечное значение [, величина шага] END DO [имя] В этом операторе DO счетчик цикла может иметь только тип INTEGER (как и его предшественник в языке FORTRAN 77). Другое изменение состоит в том, что этот опе- ратор использует специальное замыкающее ключевое слово (или фразу) END DO, вместо оператора с меткой. 320 Глава 7. Структуры управления на уровне операторов
7.4.1.3. Оператор for в языке ALGOL 60 Оператор for языка ALGOL 60 описан здесь для того, чтобы показать, как погоня за гибкостью может привести к чрезмерной сложности. Оператор for языка ALGOL 60 "гедставляет собой значительное обобщение оператора DO языка FORTRAN, как пока- зано в его описании с помощью РБНФ (расширенной формы Бэкуса-Наура): <for__stmt> —> for var:= <list_element> {, <list__element>} do <statement> <list_element> —> <expression> I <expression> step <expression> until <expression> I <expression> while <Boolean_expr> Значительное различие между этим и большинством других операторов со счетчиком заключается в том, что эта конструкция может комбинировать счетчик и булевское вы- гажение в механизме управления циклом. Три простейшие формы этого оператора ил- люстрируются следующими примерами: for count := 1, 2t 3, 4, 5, 6, 7, 8, 10 do list[count] := 0 for count := 1, step 1 until 10 do list[count] := for count := 1, count + 1 while (count <= 10) do list[count] := 0 Этот оператор становится намного сложнее, когда его различные простые формы • змбинируются друг с другом, как показано ниже: for index := 1, 4, 13, 41 step 2 until 47, 3 * index while index < 1000, 34, 2, -24 do sum := sum + index Эти операторы добавляют к переменной sum следующие значения: 1, 4, 13, 41, 43, 45, 47, 147, 441, 34, 2, -24 Конечно, возможна ситуация, в которой удобно использовать подобные сложные операторы, однако такие случаи слишком редки, чтобы служить оправданием усложне- ния языка. Оператор for языка ALGOL 60 более труден для понимания, чем кажется на первый взгляд, поскольку все выражения в операторе for вычисляются при каждом выполнении цикла. Таким образом, если выражение step содержит ссылку, например, на перемен- ную count, а операторы цикла изменяют эту переменную, то величина шага будет из- меняться на каждой итерации. Например, рассмотрим цикл i := 1; for count := 1 step count until 3 * i do i := i + 1 7.4. Операторы цикла 321
Оператор for заставляет присваивание (i : = i + 1) выполняться повторно, в то вре- мя как переменная count удваивается при каждом повторении (поскольку шаг всегда равен предыдущему значению переменной count). Переменная count увеличивается быстрее, чем значение выражения until (3 * i), и цикл не будет бесконечным, хотя на первый взгляд это не очевидно. Значения переменных и управляющих выражений в этом цикле приведены ниже*. i count step until 1 1 1 3 2 2 2 6 3 4 4 9 4 8 8 12 5 16 16 15 — завершает цикл Проектное решение, касающееся оператора for языка ALGOL 69, было следующим: счетчик цикла может быть либо целым, либо вещественным числом и объявляется по- добно всем другим переменным, так что его область видимости определяется его объяв- лением. Как и в языке FORTRAN 77, счетчик после завершения цикла имеет то значение, которое было присвоено ему при последнем повторении независимо от того, что именно вызвало завершение цикла. Параметры цикла, но не счетчик, могут изменяться в его те- ле. Переход в тело цикла извне не разрешается, а его параметры вычисляются при каж- дом повторении. Нелегко охарактеризовать операционную семантику полного оператора for языка ALGOL 60 со всеми его возможностями. Вместо этого, мы сначала дадим описание об- щего оператора for только в форме step-until: for_var := imit_expr loop: unitl := until_expr step := step_expr temp := (for_var - until) * SIGN(step) if (temp > 0 goto out [loop body] for_var := for_var + step goto loop out: ... Ниже приводится описание операционной семантики более сложного примера опера- тора for: for count := 10 step 2 * count until init * init, 3 * count while sum <= 1000 do sum := sum + count count := 10 loopl: if count > init * init goto loop2 sum:= sum + count count:=count+(2*count) goto loopl loop2: count:=3*count if sum>1000 goto out 322 Глава 7. Структуры управления на уровне операторов
sum:=sum + count goto loop2 7.4.14. Оператор for в языке Pascal Оператор for языка Pascal представляет собой образец простоты. Он имеет следую- _лй вид: for переменная:=начальное_значение (to)downto) конеч- .-:ое_значение do оператор Выбор (to | down to) позволяет значению счетчика цикла увеличиваться или умень- шаться с шагом 1. Проектное решение, касающееся оператора for языка Pascal, состоит = следующем: счетчик цикла должен иметь перечислимый тип, а его область видимости "ределяется его объявлением. При нормальном завершении цикла значение счетчика ."знобится неопределенным. Если выполнение цикла прекратилось преждевременно, то ; -етчик цикла имеет последнее присвоенное ему значение. Счетчик цикла не может из- •еняться внутри тела цикла. Начальное и конечное значения определяются выражениями •-обого типа, совместимого с типом счетчика, и могут изменяться внутри тела цикла, но, -зскольку они вычисляются только один раз, это не влияет на управление циклом. 7.4.1.5. Оператор for в языке Ada Оператор for языка Ada похож на свой аналог в языке Pascal. Он представляет собой _гкл с предварительной проверкой условия и имеет следующий вид: for переменная in [reverse] дискретный_диапазон loop end loop; Дискретный диапазон— это ограниченный подтип целого или перечислимого типа, -зпример, 1. . 10. Наиболее интересное новое свойство оператора for языка Ada относится к области видимости счетчика, которая ограничивается телом цикла. Счетчик неявно объявляется в -зчаде оператора for и неявно выходит из области видимости после завершения цикла. Рассмотрим, например, следующий фрагмент кода: 2OUNT : FLOAT := 1.35; for COUNT in 1..10 loop SUM := SUM + COUNT; end loop; Здесь переменная COUNT типа FLOAT не затрагивается оператором for. После заверше- -ия цикла переменная COUNT сохраняет тип FLOAT и значение 1.35. Кроме того, пере- менная COUNT типа FLOAT скрывается от кода, находящегося в теле цикла, маскируясь счетчиком цикла COUNT, который неявно объявляется как переменная, имеющая тип Z'.'TEGER и принимающая значения из дискретного диапазона. Переменной цикла в языке Ada нельзя присваивать никакого значения внутри тела цикла. Переменные, используемые для указания дискретного диапазона, могут изменять- ся внутри цикла, но, поскольку диапазон вычисляется только один раз, эти изменения не влияют на управление циклом. В языке Ada переходы внутрь тела цикла for не разре- шаются. Ниже приводится описание операционной семантики цикла for языка Ada: 7.4. Операторы цикла 323
[define for_var (переменная, имеющая дискретный диапазон значений)] [evaluate discrete range] loop: if[нет ни одного элемента, выходящего за пределы дискретного диапазона] goto out for_var:=[следующий элемент из дискретного диапазона] [тело цикла] goto loop out: [неопределенное значение переменной for__var] 7.4.16. Оператор for в языках С, C++ и Java Оператор for языка С имеет следующий общий вид: for (выражение_1; выражение_2; выражение__3) тело цикла Тело цикла может состоять из отдельного оператора, составного оператора или быть пустым. Поскольку операторы в языке С вычисляют результаты и, следовательно, могут рас* сматриваться как выражения, в операторе for выражения часто являются операторами. Первое выражение предназначено для инициализации и вычисляется только один раз в начале выполнения оператора for. Второе выражение является управлением цикла и вычисляется перед каждым выполнением тела цикла. Как это принято в языке С, нулевое значение означает “ложь”, а все ненулевые значения означают “истину”. Следовательно, если значение второго выражения равно нулю, то выполнение оператора for прекраща- ется; в противном случае операторы цикла продолжают выполняться. Последнее выра- жение в операторе for вычисляется после каждого выполнения тела цикла. Оно часто используется для увеличения счетчика цикла. Описание операционной семантики опера- тора for языка приводится ниже. Поскольку выражения в языке С также являются опе- раторами, вычисление выражений показано как вычисление операторов. выражение 1 loop: if выражение_2 = 0 goto out [тело цикла] выражение_3 goto loop out: ... Вот типичный пример цикла со счетчиком в языке С: for (index = 0; index <= 10; index++) sum = sum + list[index]; Все выражения в операторе for языка С являются необязательными. Отсутствие второго выражения интерпретируется как истинное выражение, так что оператор for без второго выражения потенциально является бесконечным циклом. Если первое и/или третье выражение отсутствуют, то не делается никаких предположений. Например, если отсутствует первое выражение, это означает, что никакой инициализации не происходит. 324 Глава 7. Структуры управления на уровне операторов
Отметим, что оператор for языка С не нуждается в счетчике. Он может легко моде- г вать перечисление и логические циклические структуры, как показано в следующем - 1- теле. Проектное решение, касающееся оператора for языка С, было следующим: не суще- .ет никаких явных счетчиков или параметров цикла. Все переменные, относящиеся к • л>. могут изменяться в тела цикла. Выражения вычисляются в порядке, указанном -..е Несмотря на тот факт, что это может привести к катастрофе, в языке С допускает- . - -ереход внутрь тела цикла for. Оператор for языка С более гибок, чем аналогичные операторы в других рассмот- гг-ных нами языках программирования, поскольку каждое из его выражений может - • •ючать составные операторы, которые, в свою очередь, допускают использование не- . • льких счетчиков цикла любого типа. Когда в одном выражении оператора for ис- льзуются составные операторы, они разделяются запятыми. Все операторы языка С •еют значения, и эта форма составного оператора — не исключение. Значением такого .. ставного оператора является значение его последнего компонента. Рассмотрим следующий оператор for: fer (countl = 0, count2 = 1.0; countl <= 10 && count2 <= 100.0; sum = ++countl + count2, count2 *= 2.5); Описание операционной семантики этого фрагмента приведено ниже: countl := 0 count2 := 0.0 1 эор: if count > 10 goto out if count2 > 100.0 goto out countl := countl + 1 sum := countl + count2 count2 := count2 * 2.5 goto loop cut: ... Оператор for языка С, приведенный выше, не нуждается в теле цикла, а потому и не '.«еет его. Все требуемые действия являются частью самого оператора for, а не его те- л Первое и третье выражения — это составные операторы. В обоих случаях вычисляет- ся все выражение, но результирующее значение для управления циклом не используется. Оператор for языка C++ отличается от своего аналога в языке С двумя особенностя- ми Во-первых, в дополнение к арифметическим выражениям он может использовать бу- -евское выражение для управления циклом. Во-вторых, первое выражение может содер- жать определения переменных. Например, for (int count = 0; count < len; count++) { ... } Область видимости переменной-счетчика цикла такая же, как и у оператора for в языке Ada. Однако в языке C++ область видимости переменной, определенная оператором for, начинается объявлением этой переменной и заканчивается концом функции, в ко- торой она объявлена. Выражения оператора цикла for не входят в составной оператор, являющийся телом цикла for. Объявления переменных в функциях языка С должны 7.4. Операторы цикла 325
предшествовать выполняемым операторам, так что приведенный выше оператор for в языке С недопустим. Оператор for языка Java аналогичен оператору for языка C++, за исключением того факта, что выражение, управляющее выполнением цикла, должно быть булевским (boolean), и область видимости переменной, определенной в первом выражении, ограни- чена телом цикла, как и в языке Ada. 7.4.2. Логически управляемые циклы Во многих случаях наборы операторов должны выполняться повторно, но управление повторениями основывается на булевских выражениях, а не на счетчике. В этих ситуаци- ях удобно использовать логически управляемые циклы. Действительно, такие циклы представляют собой более общий случай, чем циклы со счетчиком. Каждый цикл со счетчиком можно создать с помощью логически управляемого цикла, но не наоборот. Кроме того, напомним, что только ветвление и логически управляемые циклы играют существенную роль при выражении управляющей структуры в любой блок-схеме. 7.4.2.1. Вопросы разработки Поскольку логически управляемые циклы намного проще, чем циклы со счетчиками, список вопросов, связанных с ними, относительно короток. Один из таких вопросов отно- сится также и к циклам со счетчиком. Среди этих вопросов мало спорных или трудных. Управление должно основываться на предварительной или на последующей про- верке условий? Должен ли логически управляемый цикл представлять собой особый вид цикла со счетчиком или он должен быть отдельным оператором? 7.4.2.2. Примеры Некоторые императивные языки (например, Pascal, С, C++ и Java) имеют логически управляемые циклы как с предварительной, так и последующей проверкой условий, ко- торые не являются особым видом циклов со счетчиком. В языке C++ циклы с предвари- тельной и последующей проверкой условий имеют следующий вид: while (выражение) тело цикла Возможен также такой вариант: do тело цикла while (выражение) Эти операторы иллюстрируются следующим фрагментом кода на языке C++: sum = 0; cin » indat; while (indat >= 0) { sum += indat; cin » indat; } cin » value; 326 Глава 7. Структуры управления на уровне операторов
do { value /= 10; digits ++; • while (value > 0); Заметим, что все переменные в этих примерах являются целочисленными, cin — это ;-андартный входной поток (клавиатура), а » — оператор ввода. В варианте с предварительной проверкой условий (while) оператор выполняется до ~ех пор, пока результат выражения является истинным (ненулевым). В языках С, C++ и а-. а тело цикла с последующей проверкой условий (do) выполняется до тех пор, пока результат выражения не станет ложным (нулем). Единственное реальное различие между : -ераторами do и while заключается в том, что оператор do всегда заставляет тело _«ла выполняться хотя бы один раз. В обоих случаях оператор является составным. ' "л с ан ия операционной семантики этих двух операторов даны ниже: while do-while loop: loop: if выражение = 0 goto out [тело цикла] [тело цикла] if(выражение) # 0 goto loop goto loop И в языке С, и в языке C++ допускается переход внутрь тел обоих операторов цикла • hxle и do. Операторы while и do языка Java аналогичны операторам while и do в языках С и —. за исключением того факта, что управляющее выражение в языке Java должно быть : -евским, и, поскольку язык Java не имеет операторов goto, в тело цикла нельзя войти - • ?ткуда, кроме его начала. В языке FORTRAN 77 нет логически управляемых циклов с предварительной или по- : -е дующей проверкой условий. То же относится и языку FORTRAN 90. В языке Ada есть • .“лчески’управляемый цикл с предварительной проверкой условий, но нет цикла с по- . -едующей проверкой. Оператор логически управляемого цикла с последующей проверкой условий repeat-until языка Pascal отличается от операторов do-while в языках С, C++ и . ’-а тем, что он имеет противоположную логику управляющего выражения. Тело цикла гх.лолняется до тех пор, пока результат управляющего выражения не станет ложным, а -е истинным, как в языках С, C++ и Java. Оператор repeat-until необычен, поскольку его тело может представлять собой «5о составной оператор, либо последовательность операторов. Это единственная "давляющая структура в языке Pascal, имеющая такую гибкость. Она представляет со- : ? и еще один пример отсутствия ортогональности в структуре языка Pascal. Циклы с последующей проверкой условия редко бывают полезными и могут быть в -екотором смысле опасными, поскольку программисты иногда забывают о том, что тело «кого цикла всегда должно выполняться хотя бы один раз. Синтаксическое решение разместить последующую проверку условий после тела цикла, в котором она имеет се- мантическое значение, помогает избежать подобных проблем, делая логику такого опе- ратора более ясной. 7.4. Операторы цикла 327
7.4.3. Циклы с механизмами управления, размещенными пользователем В некоторых ситуациях программисту удобно выбирать расположение механизма управления циклом не в начале или конце цикла. Синтаксический механизм, позволяю- щий пользователю самому определять расположение управления циклом, относительно прост, так что его разработка не представляет трудностей. Наиболее интересный вопрос состоит в том, можно ли выйти из одного или нескольких вложенных циклов. Вопросы разработки такого механизма приведены ниже. Должен ли механизм проверки условий быть неотъемлемой частью выхода из цикла? Механизм может появляться в управляемом цикле или только в таком цикле, в ко* тором нет никаких других механизмов управления? Выходить можно только из одного тела цикла или из внешних циклов тоже? В некоторых языках, в том числе в языке Ada, есть операторы цикла без управления повторениями; они становятся бесконечными, если программист не добавит средства управления ими. Бесконечный цикл в языке Ada имеет вид: loop end loop Оператор exit в языке Ada может быть как условным, так и безусловным, кроме то* го, он может появляться в любом цикле. Его общий вид приведен ниже: exit [метка_цикла][when условие] При отсутствии необязательной части [when условие] оператор exit приводит к за- вершению выполнения только того цикла, в котором он появился. В качестве примера рассмотрим следующий фрагмент: loop if SUM >= 10000 then exit end if; end loop; Здесь при выполнении оператора exit управление передается первому оператору после конца цикла. Оператор exit с условием when приводит к завершению цикла, в котором он поя- вился, только если выполняется указанное условие. Например, приведенный выше цикл можно переписать так: loop exit when SUM >= 10000; end loop; 328 Глава 7. Структуры управления на уровне операторов
Любой цикл можно обозначить меткой, и если метка цикла включена в оператор exxt. то управление передается оператору, находящемуся непосредственно после ука- ной метки. Рассмотрим следующий сегмент кода: :UTER_LOOP: for ROW in 1 .. MAX-ROWS loop INNER—LOOP: for COL in 1 . . MAX_COLS loop SUM := SUM + MAT(ROW, COL); exit OUTER_LOOP when SUM > 1000.0; end loop INNER_LOOP; end loop OUTER_LOOP; -том примере оператор exit представляет собой оператор условного перехода на .гзый оператор после внешнего цикла. Возможен вариант, когда оператор exit ис- льзуется вместо выражения: exit when SUM > 1000.0; - ^том случае его можно рассматривать как оператор условного перехода на первый ератор после внутреннего цикла. Заметим, что операторы exit часто используются - обработки необычных или ошибочных условий. Языки С, C++ и Modula-2 имеют безусловные операторы выхода, не содержащие ме- • (break в языках С и C++ и оператор EXIT в языке Modula-2); в языках 7 . RTRAN 90 и Java, как и в языке Ada, есть операторы безусловного выхода с метками тератор EXIT в языке FORTRAN 90 и оператор break в языке Java), однако в языке .. а целью перехода может быть любой внешний составной оператор. Языки С и C++ имеют механизм управления continue, передающий управление .«жайшему внешнему циклу. Этот оператор не является оператором выхода из цикла. - позволяет пропустить оставшиеся операторы цикла при текущей итерации без пре- • гашения выполнения цикла. Например, рассмотрим следующий фрагмент кода: while (sum < 1000) { getnext(value); if (value < 0) continue; sum += value; "?ицательное значение приведет к пропуску оператора присваивания и передаче управ- ения в начало цикла. С другой стороны, в следующем фрагменте отрицательное значе- - е приведет к завершению цикла: while (sum < 1000) { getnext(value); if (value < 0) break; sum += value; В языках FORTRAN 90 и Java есть операторы, подобные оператору continue, но, • гоме того, они могут содержать метки, указывающие, какой именно цикл должен быть • годолжен. 7.4. Операторы цикла 329
Операторы exit и break обеспечивают наличие нескольких выходов из циклов, что иногда мешает читабельности программ. Однако необычные условия, которые приводят к необходимости завершить выполнение цикла, встречаются так часто, что существова- ние таких конструкций вполне оправданно. Более того, читабельность от этого серьезно не страдает, поскольку целью всех таких выходов является первый оператор после цик- ла, а не просто оператор, расположенный где-то в программе. Оператор break языка Java является исключением из этого правила, поскольку его целью может быть любой внешний составной оператор. 7.4.4. Циклы, основанные на структурах данных Нам осталось рассмотреть только один дополнительный вид циклических структур — циклы, зависящие от структур данных. Эти циклы управляются количеством элементов в структуре данных, а не счетчиком или булевским выражением. Такие операторы есть в языках COMMON LISP и Perl. В языке COMMON LISP функция dolist выполняет повторяющиеся операции над простыми списками, представляющими собой наиболее распространенную структуру данных в программах на языке LISP. Вследствие этого ограничения функция dolist является автоматической, т.е. она всегда неявно выполняет повторяющиеся операции над элементами списка. Эта функция вызывает выполнение ее тела один раз для каждого элемента списка. Оператор foreach языка Perl аналогичен функции dolist в языке COMMON LISP; он выполняет повторяющиеся операции над элементами списков или массивов. Например, @names = ("Bob", "Carol", "Ted", "Beelzebub"); foreach $name (@names) { print $name; } Более общий оператор цикла, основанный на структурах данных, использует структу- ры данных и функцию, определенные пользователем, для выполнения операций над ка- ждым элементом структуры. Такая функция называется итератором (iterator). Итератор вызывается в начале каждого повторения, и каждый раз при вызове он возвращает неко- торый элемент из конкретной структуры данных в некотором определенном порядке. Предположим, что программа работает с бинарным деревом, и данные в каждом узле должны быть обработаны в некотором конкретном порядке. Оператор цикла, определен- ный пользователем для дерева, мог бы успешно устанавливать переменную цикла в узлах дерева по одной при каждом повторении. При первом выполнении оператора цикла, оп- ределенного пользователем, следует выполнить специальный вызов итератора, чтобы получить первый элемент дерева. Итератор должен всегда помнить, какой элемент был обработан последним, чтобы обойти все узлы только по одному разу. Таким образом, итератор должен помнить предысторию. Оператор цикла, определенный пользователем, завершается, когда итератор больше не может найти ни одного элемента. Конструкцию for языков С, C++ и Java в силу ее высокой гибкости можно использо- вать для имитации оператора цикла, определенного пользователем. Предположим, что программа должна обработать узлы бинарного дерева. Если корень дерева обозначен пе- ременной root, и функция traverse устанавливает свои параметры так, чтобы они 330 Глава 7. Структуры управления на уровне операторов
• 'ззывали на следующий элемент дерева в требуемом порядке, можно использовать сле- гу ющий оператор: for (ptr = root; ptr == null; traverse(ptr)) { 3 этом операторе функция traverse представляет собой итератор. Оператор цикла, определенный пользователем, играет более важную роль в объектно- ггиентированном программировании, чем в ранних парадигмах программирования. При- ена этого заключается в том, что пользователи в настоящее время обычно конструируют 2?страктные типы данных для структур данных. В таких случаях оператор цикла, опреде- ленный пользователем, и его итератор должны быть созданы автором абстракции данных, госкольку представление объектов данного типа пользователю не известно. В языке C++ гераторы для типов, определенных пользователем, или классов часто реализуются либо -ерез дружественные функции класса, либо как отдельные классы итераторов. 7.5. Безусловный переход Оператор безусловного перехода передает управление выполнением в указанное '••есто программы. 7.5.1. Проблемы безусловного перехода Наиболее жаркие дебаты, связанные с разработкой языков программирования, в кон- je 1960-х годов шли вокруг вопроса, должен ли безусловный переход быть неотъемле- мой частью любого языка программирования высокого уровня, и если да, то следует ли гграничивать его использование. Безусловный переход, или оператор goto, является самым мощным оператором - равнения потоком выполнения программы. Однако неосторожное использование по- добных операторов может создать проблемы. Оператор goto обладает ошеломляющей мощью и большой гибкостью (все другие управляющие структуры можно построить с ~омощью оператора goto и оператора ветвления), но эта сила делает его использование : гасным. Если применять его без ограничений, наложенных либо языком, либо стандар- -зми программирования, оператор goto может сделать программу действительно нечита- бельной и, как результат, в высшей степени ненадежной и трудной для эксплуатации. Эти проблемы непосредственно вытекают из способности оператора goto вынуждать выполнение любого оператора в программе вслед за любым другим оператором, образуя "оследовательность выполнения программы, независимо от того, предшествует ли этот : "ератор первому оператору или следует за ним в тексте программы. Читабельность яв- ляется наилучшей, когда порядок выполнения операторов почти совпадает с порядком, в • отором они приводятся в программе — в нашем случае это может означать порядок выполнения “сверху-вниз”, к которому мы привыкли. Ограничение операторов goto та- • им образом, чтобы они могли передавать управление в программе только вниз, частич- но решает проблему. Подобные ограничения позволяют операторам goto передавать управление в некоторые разделы программы в ответ на ошибки или необычные условия, -:о не позволяет использовать их для построения циклов любого вида. 7.5. Безусловный переход 331
Несмотря на то что некоторые думающие люди еще раньше предлагали ограничить использование операторов goto, именно Эдсгер Дийкстра дал компьютерному сообщест- ву первое получившее широкое распространение разъяснение опасности операторов без- условного перехода. В своем письме он писал: “Оператор goto сам по себе слишком примитивен; он слишком похож на предложение запутать программу” (Dijkstra, 1968а). На протяжении первых нескольких лет после публикации точки зрения Дийкстры на оператор goto большое количество людей публично выступали за то, чтобы либо прямо запретить, либо по крайней мере ограничить использование операторов goto. Среди тех, кто не поддерживал предложение о полном исключении оператора goto, был Доналд Кнут (Donald Knuth), объяснявший, что бывают случаи, когда эффективность оператора goto перевешивает его вред для читабельности (Knuth, 1974). Было разработано несколько языков программирования, не содержавших оператора goto, — например, языки Modula-2 и Java. Однако самые современные популярные язы- ки программирования включают операторы goto. Керниган и Ритчи (Kemighan and Ritchie (1978)) назвали оператор goto бесконечно неправильным, однако, несмотря на это, он был включен в язык С, разработанный Ритчи. В языках, исключивших оператор goto из своего состава, предусмотрены дополнительные управляющие операторы (обычно в виде цикла или выходов из подпрограмм) для замены оператора goto в стан- дартных случаях его применения. 7.5.2. Виды меток Одни языки, например ALGOL 60 и С, используют для меток форму своих идентифи- каторов, другие (FORTRAN и Pascal)— беззнаковые целые константы. Язык Ada ис- пользует форму своих идентификаторов в качестве цели для оператора goto, но, когда метка ставится возле оператора, она должна отделяться символами <<OUT>>. Рассмот- рим следующий фрагмент программы: goto FINISHED; «FINISHED» SUM := SUM + NEXT; Угловые скобки облегчают поиск метки при чтении программы. В большинстве дру- гих языков метки приписываются к оператору с помощью двоеточия, как в следующем примере: finished: sum := sum + next; При разработке меток в языке PL/1 вновь была выбрана конструкция, обладающая предельной гибкостью и сложностью. Вместо использования меток в качестве простых констант язык PL/1 позволяет меткам быть переменными. В этом виде им можно при- сваивать значения и использовать в качестве параметров подпрограмм. Это позволяет оператору goto действительно передавать управление в любую точку программы, причем эта цель не может определяться статически. Несмотря на то что эта гибкость иногда бы- вает полезна, такое использование меток наносит слишком большой вред читабельности программ, который невозможно оправдать. Вообразите, что вы пытаетесь прочитать и понять программу с переходами, цели которых зависят от значений, присвоенных во время выполнения программы. Рассмотрите подпрограмму, имеющую несколько меток и оператор goto, цель которого является формальным параметром. Чтобы определить цель этого оператора goto, необходимо знать вызывающий программный модуль и фактиче- 332 Глава 7. Структуры управления на уровне операторов
:кое значение параметра, используемого при вызове. Реализация переменных ссылок ’акже сложна, в первую очередь из-за всевозможных способов, которыми переменные метки связываются со своими значениями. 7.5.3. Ограничения переходов Вследствие сложности проблем, присущих операторам goto, большинство языков ог- раничивает их использование. Для того чтобы показать, как можно ограничить безуслов- ен переход, рассмотрим язык Pascal. Метки в языке Pascal должны объявляться так, как 7>дто они являются переменными, но их нельзя передавать в качестве параметров, хра- нить в памяти или изменять. Область видимости метки такая же, как и у переменных, ря- дом с которыми она объявлена. В качестве части оператора goto метки должны быть просто константами, а не выражениями или переменными со значениями в виде меток. Пусть группа операторов представляет собой либо составной оператор, в том числе и олную подпрограмму, либо набор операторов в цикле repeat. Группа операторов яв- ~чется активной, если она начала, но не закончила свое выполнение. Ограничения языка Pascal устанавливают, что целью оператора goto не может быть оператор внутри груп- пы операторов, не являющейся активной. Следовательно, цель никогда не может при- надлежать группе операторов, находящейся на том же уровне и вложенной более глубо- • э. чем оператор goto. Это предотвращает наличие нескольких входов у управляющих структур. Важная проблема, связанная с ограничениями языка Pascal, состоит в том, что воз- можен переход во внешнюю подпрограмму. Поскольку подпрограмма, в которую осуще- ствляется переход, является внешней, она должна быть активной. Рассмотрим следую- щий пример: procedure subl; label 100; procedure sub2; goto 100; end; {sub2} 100: ... end; {subl} оператор goto в этом примере является допустимым. Он передает управление в роди- тельскую подпрограмму subl, прерывая выполнение подпрограммы sub2, в которой -^ходится оператор goto. Таким образом, оператор goto может прерывать выполнение сдной или нескольких процедур, но не может инициировать их выполнение путем пере- вода внутрь одной из них. Конечно, переход к оператору, являющемуся вызовом некото- рой процедуры, неявно приводит к началу выполнения этой процедуры. Переход из одной процедуры в другую крайне вреден для читабельности программы следовательно, нежелателен. Поскольку в языке Pascal цели операторов goto ограни- чены внешними группами операторов, за исключением подпрограмм, можно предполо- ч ить. что это сделано для повышения безопасности программ. 7.5. Безусловный переход 333
Положительным результатом таких ограничений в языке Pascal является возможность перехода из процедуры к ее родительской процедуре или другому предку. Это позволяет передавать ошибочные условия в родительские процедуры для исправления. Этот про- цесс, однако, лучше реализуется с помощью механизма обработки исключительных си- туаций, разработанного в этом языке. Обработка исключительных ситуаций будет обсу- ждаться в главе 13. Все операторы выхода из циклов, рассмотренные в разделе 7.4.3, в действительности являются замаскированными операторами goto. Однако они представляют собой значи- тельно ограниченные операторы безусловного перехода и не ухудшают читабельность программ, поскольку их отсутствие сделало бы код неестественным, и понять его было бы гораздо труднее. 7.6. Защищенные команды Альтернативные и различные формы операторов ветвления и циклических структур были предложены Дийкстрой (Dijkstra (1975)). Он стремился создать управляющие структуры, которые позволяли бы создавать правильные программы и не требовали бы их верификации или тестирования. Эта методология описана в работе Dijkstra (1976). Защищенные команды рассматриваются в этой главе, потому что они являются осно- вой двух лингвистических механизмов, разработанных позднее для параллельного про- граммирования в двух языках— GSP (Ноаге, 1978) и Ada. Параллельность в языке Ada обсуждается в главе 12. Конструкция ветвления, предложенная Дийкстрой, имеет следующий вид: if <булевское выражение> -> оператор [] <булевское выражение> -> оператор [] ... [] <булевское выражение> -> оператор fi Замыкающее зарезервированное слово fi представляет собой открывающее зарезерви- рованное слово, записанное наоборот. Эта форма замыкающего зарезервированного сло- ва была взята из языка ALGOL 68. Маленькие блоки, названные барьерами, используют- ся для того, чтобы отделить защищенные операторы и использовать последовательность операторов. Эта конструкция ветвления похожа на многовариантное ветвление, но с другой се- мантикой. Все булевские выражения вычисляются каждый раз при достижении этой кон- струкции в программе. Если истинными являются несколько выражений, то для выпол- нения случайным образом выбирается один из соответствующих операторов. Если ни одно выражение не является истинным, возникает ошибка времени выполнения про- граммы, приводящая к прекращению ее выполнения. Это вынуждает программиста рас- сматривать и перечислять все возможности, как это делается в операторе case языка Ada. Рассмотрим следующий пример: if i = 0 -> sum := sum + i [] i > j “> sum := sum = j [] j > i -> sum := sum + i fi 334 Глава 7. Структуры управления на уровне операторов
Если i = Onj > i, то эта конструкция случайным образом выбирает первый или тре- -ий операторы присваивания. Если i = j и i * 0, то возникает ошибка времени вы- - мнения программы, поскольку нет ни одного истинного условия. С помощью этой конструкции программист может использовать произвольный поря- р?к выполнения операторов. Например, чтобы найти наибольшее из двух чисел, можно •'спользовать следующий оператор: if х >= у -> max := х ;; у >= х -> max := у fi 7?едставленный фрагмент программы вычисляет требуемый результат, не переопреде- '«я решения. В частности, если х = у, то не имеет значения, что именно присваивается “еременной max. Этот вид абстракции обеспечивается недетерминированной семанти- • ?й такого оператора. Кроме того, конструкцию ветвления Дийкстры полезно использовать в программе, Услуживающей прерывания, которые имеют одинаковый приоритет. Семантику защищенных команд трудно описать точно. В отличие от разработки про- тамм, при описании семантики блок-схемы могут оказаться полезными. На рис. 7.1 по- • азана блок-схема, описывающая подход, который использует оператор ветвления Дийк- ;-ры. Эта блок-схема относительно неточная, что отражает трудности в понимании се- мантики защищенных команд. Структура цикла, предложенная Дийкстрой, имеет следующий вид: do <булевское выражение> -> <оператор> ;; <булевское выражение> -> <оператор> od Семантика этой конструкции заключается в том, что все булевские выражения вы- числяются при каждом повторении цикла. Если истинными оказываются несколько вы- ражений, то один из связанных с ними операторов выбирается для выполнения случай- -ь м образом, после чего все выражения снова вычисляются. Когда все выражения одно- временно станут ложными, цикл завершается. Рассмотрим следующий фрагмент программы, приведенный в слегка измененном ви- ре в работе Dijkstra (1975). Значения четырех переменных ql, q2, q3 и q4 должны быть лорядочены так, чтобы выполнялось условие ql < q2 < q3 < q4. do ql > q2 -> temp := ql; ql := q2; q2 := temp; ;; q2 > q3 -> temp := q2; q2 := q3; q3 := temp; q3 > q4 -> temp := q3; q3 := q4; q4 := temp; od Блок-схема, описывающая подход, используемый оператором цикла Дийкстры, пока- зана на рис. 7.2. Снова отметим, что семантика потока управления в этой конструкции не может быть изображена на блок-схеме точно. 7.6. Защищенные команды 335
Рис. 7.1. Блок-схема, описывающая подход, использующийся опера- тором ветвления Дийкстры 336 Глава 7. Структуры управления на уровне операторов
Вычислить все булевские выражения Рис. 7.2. Блок-схема, описывающая под- ход, использующийся оператором цикла Дийкстры 7.6. Защищенные команды 337
Описанные выше конструкции называются “защищенными командами Дийкстры”. Частично, интерес к ним объясняется тем, что они иллюстрируют, каким образом син- таксис и семантика операторов могут влиять на верификацию программ, и наоборот. Ве- рификация программ действительно невозможна, если используются операторы безус- ловного перехода. Верификация намного упрощается, если используются либо только циклы с условиями и операторы ветвления, подобные операторам ветвления языка Pascal, либо только защищенные команды. Аксиоматическая семантика защищенных команд определена в работе Gries (1981). Однако очевидно, что сложность реализации защищенных команд значительно увеличивается по сравнению с их детерминированны- ми аналогами. 7.7. Выводы Мы описали и обсудили множество управляющих структур на уровне операторов. Теперь на очереди их краткая оценка. Во-первых, мы получили теоретический результат, заключающийся в том, что для выражения вычислений абсолютно необходимы только последовательность, ветвление и логически управляемые циклы с предварительной проверкой условий (Bfthm and Jacopini, 1966). Этот результат широко используется теми, кто желает запретить все без- условные переходы. Конечно, достаточно уже и практических проблем, связанных с опе- раторами goto, для того чтобы осудить их без подыскивания теоретических причин. Од- но из применений оператора goto, которое многие считают достаточным для его оправ- дания, состоит в использовании его для преждевременного выхода из циклов в языках, в которых для этого не предусмотрены специальные операторы выхода. Не следует на основании результата Бема и Джакопини возражать против включения в язык любых управляющих структур, кроме ветвления и логически управляемых циклов с предварительной проверкой условий. Не существует ни одного широко используемого языка программирования, в котором был бы сделан такой шаг; кроме того, мы сомнева- емся, что такой язык вообще когда-нибудь появится, вследствие влияния такого шага на читабельность и легкость создания программ. Программы, написанные с использовани- ем исключительно операторов ветвления и логически управляемых циклов с предвари- тельной проверкой условий обычно имеют менее естественную структуру, являются бо- лее сложными и, следовательно, более трудными для создания и чтения. Например, мно- говариантная структура ветвления языка Ada намного облегчила написание программ на этом языке без явных негативных последствий. Другим примером является цикл со счет- чиком во многих языках, особенно, если этот оператор является таким же простым, как в языках Pascal и Ada. Не всегда ясно, оправдывает ли полезность управляющей структуры ее включение в языки программирования (Ledgard and Maccotty, 1975). Этот вопрос следует из вопроса: надо ли минимизировать размеры языков. И Вирт (1975) и Хоар (1973) настаивают на простоте при разработке языка, т.е. в языке должно быть только несколько управляющих операторов, и все они должны быть простыми. Разнообразие изобретенных управляющих структур на уровне операторов отражает расхождение во мнениях среди разработчиков языков программирования. После всех изобретений, обсуждения и оценок все еще нет единодушного мнения о том, какой именно набор управляющих операторов должен быть включен в язык. Многие совре- менные языки, конечно, имеют подобные управляющие операторы, но все еще остаются 338 Глава 7. Структуры управления на уровне операторов
Тэшие вариации в деталях их синтаксиса и семантики. Кроме того, сохраняются разно- юсия по поводу того, должен ли язык программирования содержать оператор goto; • ~ ки C++ и Ada используют этот оператор, а языки Modula-2 и Java — нет. Резюме Управляющие операторы в императивных языках программирования разделяются на -есколько категорий: операторы ветвления, многовариантные операторы ветвления, “еэаторы цикла и операторы безусловного перехода. Язык FORTRAN впервые ввел в практику программирования одновариантный опера- • г ветвления — логический оператор IF. Оператор ветвления в языке ALGOL 60 более . :жен и позволяет выбирать составные операторы, а также использовать необязатель- и оператор else. Многие управляющие структуры выиграли от использования со- юзного оператора, введенного в языке ALGOL 60. Арифметический оператор IF языка FORTRAN— это трехвариантный оператор • явления, требующий наличия других безусловных переходов. Язык FORTRAN ввел в практику две формы многовариантных операторов ветвления: • = числяемый оператор GO ТО и назначенный оператор GO ТО. В точном соответствии своими названиями они представляют собой многовариантные операторы ветвления. ~ератор case языка Pascal является представителем современных многовариантных “ераторов ветвления; он содержит инкапсуляцию выбираемых сегментов и неявные пе- техэды в конце каждого из них на одну точку выхода. Языки Modula-2, C++, FORTRAN 90, Java и Ada имеют операторы выхода из своих _ <лов: эти операторы заняли место одного из наиболее распространенных употребле- - *3 оператора goto. Итераторы, основанные на структурах данных, являются конструкциями цикла для 'габотки таких структур данных, как связные списки, мусор и деревья. Безусловный переход, или оператор goto, был частью большинства императивных • =чков. Проблемы, связанные с ним вызвали широкое обсуждение и дебаты. Вывод та- • з этот оператор должен остаться в большинстве языков, а опасность, которую он тедставляет, минимизируется за счет дисциплины программирования. Защищенные команды Дийкстры являются альтернативными управляющими конст- 7 * днями, имеющими положительные теоретические характеристики. Несмотря на то -? они не были одобрены в качестве управляющих конструкций языка программирова- - частично их семантика была реализована в механизме параллельности в языках CSP Ada. эезюме 339
Вопросы 1. Дайте определение управляющей структуры. 2. Дайте определение блока. 3. Какие вопросы разработки связаны со структурами ветвления? 4. Как обычно решаются проблемы, связанные со вложенными двухвариантными конструкциями ветвления? Что неправильно сделано в языке Modiula-2? 5. Какие вопросы разработки связаны с многовариантными структурами ветвления? 6. Что составляет основу управляющих операторов языка FORTRAN I? 7. Какие недостатки имеет арифметический оператор IF языка FORTRAN? 8. Что необычного есть в многовариантном операторе ветвления языка С? Какие про- ектные компромиссы были достигнуты при его разработке? 9. Какие проблемы возникают при разработке циклов со счетчиком? 10. Что представляет собой цикл с предварительной проверкой условий? Что пред- ставляет собой цикл с последующей проверкой условий? 11. Какое самое значительное изменение было сделано в операторе DO языка FORTRAN 77 по сравнению с языком FORTRAN IV? 12. Какие свойства оператора for языка ALGOL 60 делают использующие его про- граммы трудными для чтения? 13. В чем заключается разница между оператором for языка C++ и аналогичным опе- ратором языка Java? 14. Какие проблемы возникают при разработке циклов с условиями? 15. Какова основная причина изобретения управляющих операторов, определяемых пользователем? 16. Какие преимущества имеет оператор exit языка Ada над оператором break язы- ка С? 17. В чем заключается разница между оператором break языка C++ и аналогичным оператором в языке Java? 18. Что представляет собой управление циклом, определяемое пользователем? 19. Каковы два недостатка переменных меток в языке PL/1 ? 20. В чем состоит основная проблема, связанная с ограничениями, наложенными на использование оператора goto в языке Pascal? 21. Какие распространенные языки программирования заимствовали часть своей структуры у защищенных команд Дийкстры? 340 Глава 7. Структуры управления на уровне операторов
У п р с? <« е >4 w 1. Обоснуйте утверждение, что трехвариантный оператор ветвления языка FORTRAN I наиболее соответствовал ситуации во время его разработки. 2. Придумайте ситуацию, в которой переменная метка языка PL/1 имела бы большое преимущество. ?. Опишите три ситуации, в которых необходимо объединение цикла со счетчиком и логически управляемого цикла. 4. Сравните вычисляемый оператор GO ТО языка FORTRAN с оператором case языка Pascal, особенно с точки зрения читабельности и надежности. 5. Каковы возможные причины, по которым язык Pascal содержит цикл с последую- щей проверкой условия, в то время как язык ALGOL 60 такого цикла не имеет. *. Изучите свойства итератора языка CLU, описанного в работе Liskov (1984), и оп- ределите его преимущества и недостатки. Сравните набор управляющих операторов языка Ada с аналогичным набором в языке FORTRAN 77 и решите, какой из них лучше и почему. х. Назовите аргументы “за” и “против” использования уникального замыкающего за- резервированного слова в составном операторе. < Проанализируйте потенциальные проблемы читабельности, возникающие при ис- пользовании в качестве замыкающего зарезервированного слова для управляющих операторов обратной перестановки букв открывающего зарезервированного слова, например, case-esac в языке ALGOL 68. Рассмотрите распространенную ошиб- ку набора, заключающуюся в перестановке соседних букв. 1и. Перепишите приведенный ниже фрагмент кода с использованием структуры цикла на следующих языках. 10.1. Pascal. 10.2. FORTRAN 77. 10.3. Ada. 10.4. С, C++ или Java. k:= (j + 13) / 27 loop: if k > 10 then goto out k := k + 1 i := 3 * k - 1 goto loop out: ... Предполагается, что все переменные являются целочисленными. Определите, ка- кой язык для этого кода предоставляет наибольшую легкость написания програм- мы, наилучшую читабельность и оптимальную комбинацию этих свойств. Упражнения 341
11. Решите заново упражнение 10, только на этот раз предположите, что все перемен- ные и константы представляют собой числа с плавающей точкой, и измените опе- ратор к := к + 1 на к := к + 1.2 12. Перепишите приведенный фрагмент кода с использованием многовариантного оператора ветвления на следующих языках. 12.1. Pascal. 12.2. FORTRAN 90 (найдите описание этого языка в тексте). 12.3. Ada. 12.4. С, C++ или Java. (к = 1) or (к = 2) then j := 2 * к - 1 (к = 3) or (к = 5) then j := 3 * к + 1 (к = 4) then j : • = 4 * к - 1 (к = 6) or (к = 7) or (к = 8) then j := к Предположите, что все переменные являются целочисленными. Рассмотрите отно- сительные достоинства использования этих языков для данного конкретного кода. 13. Рассмотрите следующий оператор for в стиле языка ALGOL 60: for i := j + 1 step i * j until 3 * j do j := j + 1 Предположите, что начальное значение переменной j равно 1. Перечислите по- следовательность значений использованной переменной i, предполагая следую- щую семантику. 13.1. Все выражения вычисляются по одному разу при каждом входе в цикл. 13.2. Все выражения вычисляются перед каждым повтором. 13.3. Выражения step вычисляются один раз при входе в цикл, а выражения until вычисляются перед каждым повтором. 13.4. Выражения until вычисляются один раз при входе в цикл, а выражения step вычисляются перед каждым повтором сразу после увеличения счет- чика цикла. Все одновременно вычисляемые выражения вычисляются справа налево. Кроме того, присваивание всегда выполняется, как только оказывается вычисленной пра- вая часть оператора присваивания. 14. Рассмотрите следующий оператор case язык Pascal. Перепишите его, используя только двухвариантные операторы ветвления. case index - 1 of 2, 4: even := even + 1; 1, 3: odd := odd + 1; 0: zero := zero + 1; 342 Глава 7. Структуры управления на уровне операторов
else error := true end 1 5. Рассмотрите следующий фрагмент программы на языке С. Перепишите его, не ис-. пользуя операторы goto и break. 7 = -3; for (i=0; i < 3; i++) { switch (j + 2) { case 3: case 2; j—; break; case 0: j +=2; break; default: j = 0; i if (J > 0) break; j = 3 - i t*. В письме редактору журнала САСМ Рубин (Rubin, 1987) использует следующий сегмент кода в качестве доказательства того, что читабельность некоторых про- грамм, содержащих операторы goto, лучше, чем читабельность аналогичных про- грамм, не использующих операторы goto. Этот код находит первую строку матри- цы целых чисел размером п х п с именем х, не содержащую ничего, кроме нулей, for i := 1 to n do begin for j := 1 to n do if x[i,j] <> 0 then goto reject; writein(’Первая нулевая строка:', i); break; reject: end; Перепишите этот код без использования операторов goto на одном из следующих языков: С, C++, Pascal, Java или Ada. Сравните читабельность вашего кода с чита- бельностью кода, приведенного выше. !~. Приведите аргументы “за” и “против” исключительного использования булевских выражений в управляющих операторах языка Java (в отличие от языков С и C++, до- пускающих использование арифметических выражений в управляющих операторах). * “юажнения 343
В этой главе... Деннис Ритчи (Dennis Ritchie) Деннис Ритчи из корпорации Bell Laboratories был одним из основ- ных разработчиков операционной системы UNIX. Его первая вер- сия языка С затем была исполь- \ ~ ...' " - г--... ,. 8.1. Введение 8.2. Основы подпрограмм 8.3. Вопросы разработки подпрограмм 8.4. Среды локальных ссылок 8.5. Методы передачи параметров 8.6. Параметры, являющиеся именами подпрограмм 8.7. Перегруженные подпрограммы 8.8. Настраиваемые подпрограммы 8.9. Раздельная и независимая компиляция 8.10. Вопросы разработки функций 8.11. Доступ к нелокальным средам 8.12. Перегруженные операторы, определяемые пользователем 8.13. Сопрограммы зована при создании операцион- ной системы UNIX для компью- теров PDP-11.
Подпрограммы — основные строительные блоки, из которых состоят програм- мы — относятся к наиболее важным понятиям в области разработки языков программирования. В этой главе мы исследуем вопросы разработки подпрограмм, вклю- чая методы передачи параметров, локальные и глобальные среды ссылок, перегружен- ные и обобщенные подпрограммы, раздельную и независимую компиляцию, а также со- вмещение имен и проблемы, связанные с побочными эффектами, присущими подпро- граммам. Мы также кратко опишем сопрограммы, позволяющие создавать симметрич- ные модули управления. Методы реализации подпрограмм будут обсуждаться в главе 9. 8.1. Введение Языки программирования могут содержать два возможных средства абстракции: аб- стракцию процессов и абстракцию данных. На заре истории языков программирования высокого уровня программисты признавали и включали в языки только абстракцию про- цессов, которая была основным понятием во всех языках программирования. Однако в 1980-х годах люди стали понимать, что абстракция данных не менее важна. Абстракция данных обсуждается в главе 10. Первый программируемый компьютер (аналитическая машина Бэббиджа), построен- ный в 1840-х годах, позволял при необходимости повторно использовать карточки с на- борами команд. В современных языках программирования такой набор операторов запи- сывается в виде подпрограммы. Повторное использование совокупностей операторов приводит к различным видам экономии — от уменьшения объема памяти до сокращения времени программирования. Такое повторное использование представляет собой некую абстракцию, в которой детали вычислений, производимых подпрограммой, заменяются в программе оператором, вызывающим эту подпрограмму. Вместо того, чтобы объяснять, каким образом следует выполнять некие вычисления в некотором месте программы, это объяснение (набор операторов в подпрограмме) заменяется оператором вызова, что по- зволяет действительно абстрагироваться от деталей вычислений. Это улучшает чита- бельность программы, позволяя выявить ее логическую структуру и скрывая подробно- сти вычислений. 8.2. Основы подпрограмм 8.2.1. Общие свойства подпрограмм Все подпрограммы, рассмотренные в этой главе, за исключением сопрограмм, опи- санных в разделе 8.13, имеют следующие свойства. Каждая подпрограмма имеет один вход. На время выполнения вызываемой подпрограммы выполнение вызывающего ее модуля откладывается. Это приводит к тому, что в каждый момент времени вы- полняется только одна подпрограмма. Управление после выполнения подпрограммы всегда возвращается в вызывающий модуль. 346 Глава 8. Подпрограммы
Несмотря на то что подпрограммы в языке FORTRAN могут иметь несколько входов, •: - * ретный вид входа является относительно неважным, поскольку он не обеспечивает ни :л-ой счщественно новой возможности. В силу этих причин в этой главе мы будем игно- : -.говать возможность наличия нескольких входов в подпрограммы языка FORTRAN. Альтернативой указанным выше предположениям являются подпрограммы (раздел 8.13) и -хгзллельные модули, рассматриваемые в разделе 8.12. Методы в объектно-ориентированных языках тесно связаны с подпрограммами, об- . чдаемыми в этой главе. Основное отличие методов от подпрограмм заключается в :собе, которым они вызываются, а также в способе связи методов с классами и объек- iv и Несмотря на то что эти характеристики методов обсуждаются в главе 11, свойства, . ?_;ие для них и подпрограмм (параметры и локальные переменные), рассматриваются в “:й главе. 8.2.2. Основные определения Определение подпрограммы (subprogram definition) описывает ее связь с вызываю- _ <м модулем (т.е. интерфейс) и действия, которые абстрагируются подпрограммой. Вы- :в подпрограммы (subprogam call) представляет собой явное требование выполнить _: .2программу. Подпрограмма называется активной, если после получения вызова она - --хла свое выполнение, но еще не закончила его. Две основные разновидности подпро- тзмм — процедуры и функции — описываются в разделе 8.2.4. Заголовок подпрограммы (subprogram header), являющийся первой строкой ее оп- т еделения, преследует несколько целей. Во-первых, он указывает, что следующая за ним ; нтаксическая единица представляет собой определение подпрограммы некоего кон- фетного вида. Это указание часто выполняется с помощью специального слова. Во- = -орых. заголовок определяет имя подпрограммы. В-третьих, он может, но необязатель- -:. содержать список параметров. Эти параметры являются необязательной частью заго- -: зка. поскольку не все описания подпрограмм их имеют. Рассмотрим следующий пример заголовка: SUBROUTINE ADDER(параметры) ?•? — заголовок подпрограммы на языке FORTRAN с именем ADDER. В языке Ada за- ' . ловок подпрограммы ADDER мог бы выглядеть так: procedure ADDER(параметры) В заголовке подпрограммы на языке С не предусмотрено никакого специального слова. В языке С есть только один вид подпрограмм — функции — и заголовок функции распо- лзется по контексту, а не с помощью специального словд. Например, приведенная ниже :~гока может служить заголовком функции с именем adder, где спецификатор void : : качает, что эта функция не возвращает никакого значения: void adder(параметры) Профиль параметров (parameter profile) подпрограммы — это количество, порядок •1 типы формальных параметров. Протокол подпрограммы — это профиль подпрограм- мы плюс, если это функция, тип возвращаемого ею значения. В языках, в которых под- -рограммы имеют типы, эти типы определяются протоколом подпрограммы. Подпрограммы могут содержать как объявления, так и определения переменных. Именно так обстоит дело в языке С, в котором объявления могут использоваться для пре- s. 2. Основы подпрограмм 347
доставления информации о типе, но не для определения самой переменной. Объявления и определения необходимы, если на переменную нужно сослаться до того, как компилятор увидит ее определение. Объявления подпрограмм предоставляют информацию о ее интер- фейсе, который состоит из типов параметров, но не содержит тел подпрограмм. Это необ- ходимо, если компилятор должен оттранслировать вызов подпрограммы перед тем, как он увидит определение самой подпрограммы. В обоих случаях объявления необходимы для статической проверки типов. Объявления подпрограмм широко распространены в языке С, в котором они называются прототипами. Они также используются в языках Ada и Pascal, в которых иногда называются упреждающими или внешними объявлениями. Язык Java не позволяет объявлять свои методы, поскольку в областях их видимости неявно допускаются упреждающие ссылки на них. 8.2.3. Параметры Обычно подпрограммы описывают вычисления. Существует два способа доступа к данным, подлежащим обработке: прямой доступ к глобальной переменной (объявленной где-то в другом месте, но видимой в подпрограмме) и передача параметров. Данные, пе- редаваемые через параметры, доступны по имени, являющемуся локальным по отноше- нию к подпрограмме. Передача параметров представляет собой более гибкий способ, чем прямой доступ к глобальным переменным. По существу, подпрограмма, получаю- щая через параметры доступ к данным, подлежащим обработке, является параметризо- ванным вычислением. Она может выполнить свои вычисления на основании любых дан- ных, получаемых через параметры (при условии, что типы параметров совпадают с ожи- даемыми в подпрограмме). Если доступ к данным осуществляется через глобальные переменные, существует один способ обработать другие данные— присвоить новые значения этим глобальным переменным между вызовами подпрограмм. Широкий доступ к глобальным переменным может снизить надежность программы. Переменные, являю- щиеся видимыми в подпрограмме, где необходим доступ к ним, часто также видимы и в других подпрограммах, где они совсем не нужны. Эта проблема обсуждалась в главе 4 и рассматривается в разделе 8.11. В некоторых ситуациях удобно передавать в подпрограммы в качестве параметров не данные, а вычисления. В этих случаях имя подпрограммы, реализующей такое вычисле- ние, можно использовать как параметр. Этот вид параметров обсуждается в разделе 8.6. Передача данных в виде параметров обсуждается в разделе 8.5. Параметры, приведенные в заголовке подпрограммы, называются формальными па- раметрами (formal parameters). Иногда их считают фиктивными переменными, поскольку они не являются переменными в обычном смысле этого слова: в некоторых случаях они связываются со значениями, хранящимися в памяти, только после вызова подпрограммы, и такое связывание часто осуществляется с помощью других переменных программы. Операторы вызова подпрограмм должны содержать имя подпрограммы и список пара- метров, которые подлежат связыванию с формальными параметрами подпрограммы. Эти параметры называются фактическими (actual parameters). Они должны отличаться от формальных параметров, поскольку на вид формальных и фактических параметров накла- дываются разные ограничения, и, конечно, они используются совершенно по-разному. Практически во всех языках программирования соответствие между фактическими и формальными параметрами— или связывание фактических параметров с формальны- ми — осуществляется на основе их расположения: первый фактический параметр соот- 348 Глава 8. Подпрограммы
?е’ств\ет первому формальному параметру и так далее. Такие параметры называются позиционными (positional parameters). Для относительно коротких списков параметров . т способ вполне приемлем. Когда списки параметров достигают большой длины, программист легко может оши- •’&ся и расположить параметры в неправильном порядке. Одно из решений этой про- ~емы— предоставить возможность использования ключевых параметров (keyword - 1’rneters), в которых имя формального параметра, с которым должен быть связан фак- -еский параметр, указывается вместе с ним. Преимущество ключевых параметров за- • --?чается в том, что они могут появляться в списке фактических параметров в произ- - -ьном порядке. В языке Ada процедуры можно вызвать именно этим способом, как •: •азано ниже: EYMER (LENGTH => MY_LENGTH, LIST => MY__ARRAY, SUM => MY_SUM); "zecb определение процедуры SUMER содержит формальные параметры LENGTH. LIST Главный недостаток ключевых параметров состоит в том, что пользователь подпро- тзммы должен знать имена формальных параметров. Кроме ключевых параметров, языки Ada и FORTRAN 90 позволяют использовать и ~: :;шионные параметры. Эти два вида параметров можно смешивать в одном вызове. • > показано ниже: E’JMER (MY_LENGTH, SUM => MY_SUM, LIST => MY-ARRAY); Единственное ограничение, наложенное на такой способ указания фактических парамет- : в. заключается в том, что после первого появления в списке ключевого параметра все стальные параметры должны быть ключевыми. Это необходимо потому, что после по- чтения в списке ключевого параметра позиции следующих параметров уже нельзя точ- - определить. В языках C++, FORTRAN 90 и Ada формальные параметры могут иметь значения по “олчанию. Значение по умолчанию используется, когда формальному параметру, ука- з-ному в заголовке подпрограммы, не передается никакого фактического параметра, ’’.^смотрим следующий заголовок функции на языке Ada: function COMPUTE_PAY(INCOME : FLOAT; EXEMPTIONS : INTEGER := 1; TAX_RATE : FLOAT) return FLOAT; ’араметр EXEMPTIONS при вызове функции COMPUTE PAY можно пропустить, при •том вместо него используется значение 1. В вызовах функций на языке Ada вместо про- шейного параметра запятые не используются, поскольку единственным предназначе- -ием такой запятой является указание на позицию следующего параметра, который в данном случае не нужен, потому что все фактические параметры после пропущенного ао.тжны быть ключевыми. Рассмотрим следующий вызов функции: PAY := COMPUTE—PAY(20000.0z TAX_RATE => 0.15); 8.2. Основы подпрограмм 349
В языке C++, в котором нет ключевых параметров, правила использования парамет- ров по умолчанию совершенно другие. Параметры по умолчанию должны указываться в конце списка, поскольку соответствие между параметрами в языке C++ устанавливается по их позиции в списке. Если параметр по умолчанию в вызове пропущен, все остальные формальные параметры должны иметь значения по умолчанию. Заголовок функции COMPUTE PAY на языке C++ можно записать следующим образом: float compute_pay(float income, float tax_rate, int exemptions =1) Отметим, что параметры в этой функции указаны в другом порядке таким образом, что параметр по умолчанию указан последним. Пример вызова функции compute_pay на языке C++ приведен ниже: pay = compute_pay(20000.О, 0.15); В большинстве языков программирования, не имеющих значений по умолчанию для формальных параметров, количество фактических параметров в вызове должно совпа- дать с количеством формальных параметров в определении заголовка подпрограммы. Однако в языках С и C++ это не требуется. Если количество фактических параметров в вызове функции меньше, чем количество ее формальных параметров, ответственность за соответствие между параметрами, всегда являющееся позиционным, и разумное выпол- нение подпрограммы возлагается на программиста. Несмотря на то что этот способ, позволяющий использовать переменное количество па- раметров, очевидно предрасположен к ошибкам, он иногда бывает удобен. Например, функция print f в языке С может напечатать любое количество элементов (данных и/или литеральных строк). Подпрограммы на языке Ada должны иметь фиксированное количест- во параметров, так что встроенная процедура вывода на языке Ada печатает только одно значение. Такие подпрограммы вывода данных гораздо труднее использовать. ,8.2.4. Процедуры и функции Подпрограммы разделяются на две различные категории: процедуры и функции, ка- ждая из этих разновидностей может рассматриваться как способ расширения языка. Процедуры представляют собой наборы операторов, определяющих параметризованные вычисления. Эти вычисления активизируются отдельными операторами вызова. В дейст- вительности процедуры определяют новые операторы. Например, поскольку в языке Pascal нет оператора сортировки, пользователь может создать процедуру для сортировки массива данных и использовать вызов этой процедуры вместо отсутствующего в языке оператора сортировки. Процедуры могут вырабатывать результаты в вызывающем программном модуле двумя способами. Во-первых, если существуют переменные, не являющиеся формаль- ными параметрами, но остающиеся видимыми и в процедуре и в вызывающем модуле, то процедура может изменять их значения. Во-вторых, если подпрограмма имеет фор- мальные параметры, позволяющие передавать данные в вызывающий модуль, то эти па- раметры можно изменять. Функции по своей структуре напоминают процедуры, но они семантически модели- руют математические функции. Если функция является точной моделью, то она не имеет побочных эффектов. Это значит, что она не изменяет ни свои параметры, ни какие-либо другие переменные, определенные вне функции. 350 Глава 8. Подпрограммы
Функции вызываются посредством указания их имен в выражениях вместе с соответ- С73\ющими фактическими параметрами. Значение, вычисленное при выполнении функ- .. А1. возвращается в вызывающий модуль, заменяя собой сам вызов. Например, значение ь» зажения f (х) представляет собой результат вычисления функции f, вызванной с па- г^метром х. Единственным результатом работы функции, не имеющей побочных эффек- • в. является возвращаемое ею значение. Фчнкции определяют новые операторы, определяемые самим пользователем. Напри- jr. если в языке нет оператора, выполняющего возведение в степень, можно написать I -чиню, возвращающую значение одного из своих параметров, возведенное в степень. • данную вторым параметром. Ее заголовок в языке С имеет следующий вид: float power(float base, float exp) этом функцию можно вызвать так: result = 3.4 * power(10.О, х); Г’зндартные библиотеки функций языка С всегда содержат подобную функцию с име- z cw. Сравните ее с аналогичным оператором в языке FORTRAN, в котором возве- в степень является встроенной операцией: .-ISULT = 3.4 * 10.0 ** X В языках Ada и C++ пользователю разрешается перегружать операторы, определяя - = э:е функции. В этих языках пользователь мог бы определить оператор возведения в .-r-ень. который использовался бы аналогично встроенному оператору возведения в . ~г~ень языка FORTRAN. Перегрузка операторов, определенная пользователем, обсуж- . .г"ся в разделе 8.12. В большинстве распространенных языков программирования можно создавать как : -чции, так и процедуры. В языках С и C++ есть только функции. Однако эти функции ~>7 вести себя подобно процедурам. Их можно определить как функции, не возвра- щение никакого значения, указав тип возвращаемого ими значения зарезервирован- • • словом void. Поскольку выражения в этих языках могут использоваться как опера- * . автономный вызов функции, не возвращающей никакого значения, вполне допус- • В качестве примера рассмотрим следующий заголовок функции и ее вызов: void sort(int list[], int listlen); Г7(scores, 100) ; '/етоды в языках Java и C++ аналогичны функциям в языке С. 8.3. Вопросы разработки подпрограмм ~ едпрограммы представляют собой сложные структуры в языках программирования. ~ следует из того длинного списка вопросов, которые возникают при их разработке. ‘м из очевидных вопросов, связанных с разработкой программ, является выбор ме- :. г!ли методов передачи параметров. Большое разнообразие методов, применяемых в иных языках, отражает расхождение во мнениях по этому вопросу. С предыдущим 7 ?со.м тесно связан следующий: выполняется ли проверка типов фактических пара- :‘т ?з на предмет их соответствия типам формальных параметров. : 3. Вопросы разработки подпрограмм 351
Природа локальной среды подпрограммы в некоторой степени определяет природу самой подпрограммы. Наиболее важный вопрос здесь заключается в том, каким образом размещаются в памяти локальные переменные: статически или динамически. Как указывалось ранее, некоторые языки позволяют передавать имена подпрограмм в качестве параметров. Возникает простой вопрос — следует ли предусматривать такую возможность в языке? Если да, то возникает вопрос о том, что представляет собой среда ссылок подпрограммы, которая подлежит передаче в качестве параметра. Другой вопрос, связанный с предыдущими, состоит в следующем: выполняется ли проверка типов пара- метров подпрограмм, передаваемых как параметр. Следующий вопрос: могут ли определения подпрограмм помещаться внутри других определений подпрограмм. Далее, существуют вопросы, можно ли перегружать подпрограммы или делать их на- страиваемыми. Перегруженная подпрограмма имеет то же имя, что и другая подпро- грамма в той же самой среде ссылок. Настраиваемая подпрограмма — это подпрограм- ма, при разных вызовах осуществляющая вычисление над данными разных типов. Кроме того, полезный для создания значительных программных систем язык про- граммирования должен позволять выполнять компиляцию отдельных частей программы (в отличие от требования компиляции только полных программ). Если язык предостав- ляет средства для такого вида компиляции, возникает вопрос: насколько гибким и на- дежным должен быть этот механизм. Использовались два разных подхода к решению этой проблемы — раздельная компиляция и независимая компиляция. Ниже приводится итоговый список вопросов, связанных с разработкой подпрограмм вообще. Дополнительные вопросы, имеющие отношение к функциям, обсуждаются в разделе 8.10. Какой метод или методы передачи параметров используются? Осуществляется ли проверка типов фактических параметров на предмет их соот- ветствия типам формальных параметров? Каким образом размещаются в памяти локальные переменные: статически или динамически? Если подпрограммы могут передаваться в качестве параметров, что представляет собой среда ссылок такой подпрограммы? Если подпрограммы могут передаваться в качестве параметров, осуществляется ли проверка типов при вызове этих подпрограмм? Могут ли определения подпрограмм появляться внутри определений других под- программ? Можно ли перегружать подпрограммы? Могут ли подпрограммы быть настраиваемыми? Возможна ли раздельная или независимая компиляция? Эти вопросы и примеры проектных решений будут обсуждаться в следующих разделах. 352 Глава 8. Подпрограммы
8.4. Среды локальных ссылок В подпрограммах обычно можно определять их собственные переменные, создавая таким образом локальные среды ссылок. Переменные, определенные внутри подпро- "тамм. называются локальными, поскольку доступ к ним обычно ограничен подпро- -таммой. в которой они определены. В терминах главы 4 локальные переменные могут быть либо статическими, либо ди- -'мическими. Если локальные переменные являются автоматическими (stack-dynamic), -и помешаются в стек в начале выполнения подпрограммы и удаляются оттуда после ее • -вершения. Автоматические локальные переменные имеют несколько преимуществ, и счовное из них заключается в гибкости, которую они придают подпрограммам. Суще- .-венным моментом является то, что рекурсивные подпрограммы имеют автоматические -скальные переменные. Другое преимущество автоматических локальных переменных : г стоит в том, что некоторые ячейки памяти, выделяемой для хранения локальных пере- менных, могут использоваться совместно. Такое совместное использование памяти, оче- видно. невозможно для подпрограмм, являющихся активными в одно и то же время. Сейчас это уже не является таким большим преимуществом, каким оно было во времена • ?мпьютеров, имевших намного меньший объем памяти. Основные недостатки использования автоматических локальных переменных состоят ъ следующем. Во-первых, при каждом вызове подпрограммы затрачивается время, необ- . димое для размещения таких переменных в памяти, их инициализации (при необходи- • :сти) и удаления. Во-вторых, доступ к автоматическим локальным переменным должен г= -ь косвенным, в то время как доступ к статическим переменным является прямым. • тсвенность доступа объясняется тем, что местоположение конкретной локальной пере- менной в стеке можно определить только во время выполнения программы (см. главу 9). В большинстве компьютеров косвенная адресация медленнее, чем прямая. Наконец, при габоте с автоматическими локальными переменными подпрограммы не могут помнить :зою предысторию, т.е. они не могут сохранять значения локальных переменных между вызовами. Распространенным примером, иллюстрирующим необходимость подпро- тамм. помнящих свою предысторию, являются подпрограммы генерации псевдослу- -зйных чисел. При каждом вызове такой подпрограммы вычисляется одно псевдослу- -айное число с использованием предыдущего вычисленного псевдослучайного числа. Следовательно, это предыдущее число должно храниться в статической локальной пере- менной. Сопрограммы и подпрограммы, используемые в конструкциях циклических итераторов (см. главу 7), представляют собой другой пример необходимости подпро- -гамм. помнящих свою предысторию. Основное преимущество статических локальных переменных состоит в том, что они ?чень эффективны — доступ к ним осуществляется намного быстрее, поскольку он явля- ется прямым. Более того, они не требуют накладных расходов, связанных с расположе- нием переменных в памяти и удалением их из нее. Кроме того, они позволяют создавать ' одпрограммы, помнящие свою предысторию. Наибольшим недостатком этих перемен- ных является их неспособность поддерживать рекурсию. В языке ALGOL 60 и языках, произошедших от него, локальные переменные в под- программах по умолчанию являются автоматическими. В языках С и C++ локальные пе- геменные являются автоматическими, если они не объявлены специально как статиче- ские с помощью зарезервированного слова static. Например, в следующей функции 8.4. Среды локальных ссылок 353
на языке С (или C++) переменная sum является статической, а переменная count — автоматической. int adder(int list[]r int listen) { static int sum = 0; int count; for (count = 0; count < listlen; count ++) sum += list [count]; return sum; } Подпрограммы в языках Pascal, Modula-2 и Ada, как и методы в языке Java, имеют только автоматические локальные переменные. Как обсуждалось в главе 4, в средствах реализации языка FORTRAN 77 можно по выбору объявлять локальные переменные статическими или автоматическими. В боль- шинстве средств реализации, следуя традициям ранних версий языка FORTRAN, локаль- ные переменные считаются статическими. Действительно, поскольку версии языка FORTRAN, предшествовавшие языку FORTRAN 90, не допускают рекурсию, нет ника- кой необходимости в том, чтобы делать их автоматическими. Хранение переменных в памяти обычно не рассматривается как нечто, стоящее потери эффективности. Пользова- тели языка FORTRAN 77 могут сделать одну или несколько локальных переменных ста- тическими независимо от реализации, перечислив их имена в операторе SAVE. В языке FORTRAN 90 подпрограммы могут явно объявляться рекурсивными. В этом случае их локальные переменные являются автоматическими. Эта идея, заключающаяся в явном указании того, что данная функция может быть рекурсивной, происходит из языка PL/1. Цель этого — эффективная реализация нерекурсивных подпрограмм. 8.5. Методы передачи параметров Методы передачи параметров — это способы, которыми параметры передаются в под- программы и/или возвращаются из нее. Сначала мы сосредоточимся на основных семанти- ческих моделях передачи параметров. Затем обсудим различные средства реализации, изо- бретенные разработчиками языка для этих семантических моделей. Далее мы сделаем об- зор проектных решений для различных императивных языков и рассмотрим реальные методы воплощения моделей реализации. В заключение мы рассмотрим вопросы разработ- ки, встающие перед разработчиком языка при выборе метода передачи параметров. 8.5.1. Семантические модели передачи параметров Формальные параметры характеризуются одной из трех различных семантических моделей: 1) они могут получать данные от соответствующих фактических параметров; 2) они могут передавать данные фактическим параметрам; 3) они могут делать и то, и дру- гое. Эти три семантические модели были названы режимом ввода (in mode), режимом вывода (out mode) и режимом ввода-вывода (inout mode), соответственно. Существуют две концептуальные модели передачи данных при передаче параметров: либо фактическое значение физически перемещается (в вызывающий модуль, в вызы- ваемый модуль или в обоих направлениях), либо передается путь доступа к ней. Чаще всего путь доступа представляет собой обычный указатель. На рис. 8.1 показаны три се- мантические модели передачи параметров с использованием физического перемещения. 354 Глава 8. Подпрограммы
режим ввода Возврат Рис. 8.1. Три семантические модели передачи параметров с использованием физического перемещения 8.5.2. Модели реализации передачи параметров Разработчиками языков программирования было создано великое множество моделей - = описания реализации трех основных режимов передачи параметров. В следующем :22деле мы обсудим некоторые из них и оценим их достоинства и недостатки. 8.5.2.1. Передача по значению Когда параметр передается по значению (passed by value), значение фактического мгаметра используется для инициализации соответствующего формального параметра, •: *орый в дальнейшем действует как локальная переменная в подпрограмме, реализуя ч.»им образом семантику режима ввода. Передача по значению обычно реализуется путем реальной передачи данных, по- :• :льку доступ к ним с помощью именно этого метода более эффективен. Эта передача "2- -ке может быть реализована с помощью передачи пути доступа к значению фактиче- . *: го параметра в вызывающем модуле, однако при этом может потребоваться, чтобы : -зчение хранилось в ячейке памяти, защищенной от записи (доступной только для чте- - чь Защита ячейки памяти от записи не всегда является легким делом. Предположим, •' г подпрограмма, в которую передается параметр, в свою очередь передает его в дру- “ < подпрограмму. Это — вторая причина использования физической передачи. Как мы видим в разделе 8.5.3, язык C++ предоставляет удобный и эффективный метод защиты записи параметров, которые передаются с помощью передачи пути доступа к ним. Основной недостаток метода передачи по значению на основе физических перемеще- - й данных заключается в том, что для хранения формального параметра требуется до- мнительная память либо в вызываемой подпрограмме, либо в некоторой области вне •_ бывающего модуля и вызываемой подпрограммы. Кроме того, фактический параметр мл жен физически перемещаться в область памяти, отведенную для хранения формаль- 5 5. Методы передачи параметров 355
ного параметра. Объем памяти и операции перемещения могут быть дорогостоящими, если параметр имеет большой размер, как, например, длинный массив данных. В.5.2.2. Передача по результату Передача по результату (pass-by-value) представляет собой модель реализации ре- жима вывода. Когда параметр передается по результату, никакое значение в подпро- грамму не передается. Соответствующий формальный параметр действует как локальная переменная, но непосредственно перед возвращением управления обратно в вызываю- щий модуль его значение передается фактическому параметру, который должен пред- ставлять собой переменную. (Каким образом в вызывающем модуле можно было бы ссылаться на вычисленный результат, если бы он был литералом или выражением?) Если возвращается значение (а не путь доступа к нему), как это обычно происходит, передача по результату также требует дополнительной памяти и операций копирования, необхо- димых для передачи по значению. Как и при передаче по значению, трудности реализа- ции передачи по результату с помощью передачи пути доступа к данным обычно приво- дят к тому, что она реализуется на основе физического перемещения данных. В этом случае необходимо гарантировать, что первоначальное значение фактического парамет- ра не используется в вызывающем модуле. Одна из проблем, связанных с моделью передачи по результату, состоит в том, что может возникнуть коллизия фактических параметров, как в следующем примере: sub(pl, pl) В подпрограмме sub двум ее формальным параметрам, очевидно, можно присвоить два разных значения, предполагая, что они имеют разные имена. Если какой-либо из фор- мальных параметров присваивается соответствующему фактическому параметру, это значение присваивается переменной pl. Таким образом, порядок присваивания опреде- ляет значения фактических параметров. Поскольку этот порядок обычно зависит от вида реализации, может возникнуть проблема мобильности, которую трудно обнаружить. Вызов процедуры с двумя идентичными параметрами может привести также к раз- личным проблемам и при использовании других методов передачи параметров, как пока- зано в разделе 8.5.2.4. Другая проблема, которая может возникнуть при передаче по результату, заключает- ся в том, что разработчик средств реализации языка должен сделать выбор, когда именно вычислять адрес фактического параметра: во время вызова подпрограммы или при воз- вращении из нее. Предположим, что подпрограмма имеет параметр list [index]. Ес- ли переменная index изменяется подпрограммой либо через глоб^тьный доступ, либо как формальный параметр, то адрес переменной list [index] изменится в промежутке между вызовом и возвращением из подпрограммы. Разработчик средств реализации язы- ка должен выбрать, когда именно определяется адрес ячейки памяти, в которую должно быть возвращено значение: в момент вызова подпрограммы или в момент возвращения из подпрограммы. Это делает невозможным перенос программы между различными реа- лизациями языка, в которых сделан разный выбор этого момента времени. 8.5.2.3. Передача по значению и результату Передача по значению и результату' (pass-by-value-result) представляет собой мо- дель реализации режима ввода-вывода, в котором фактические значения физически пе- ремещаются. В действительности эта модель представляет собой комбинацию передачи 356 Глава 8. Подпрограммы
-. значению и передачи по результату. Значение фактического параметра используется 1/я инициализации соответствующего фактического параметра, который затем действует •к локальная переменная. Фактически, при передаче по значению и результату фор- ельные параметры должны храниться в локальной области памяти, связанной с вызы- •• демой подпрограммой. При завершении выполнения подпрограммы значение формаль- -: -о параметра передается обратно фактическому параметру. Передача по значению и результату иногда называется передачей по копии, посколь- фактический параметр вначале копируется в формальный параметр в точке входа в . ^программу, а затем — при ее завершении. Недостаток передачи по значению и результату, как и передачи по значению и пере- :- in по результату, рассмотренных отдельно, заключается в необходимости хранить па- .метры в нескольких местах и тратить время на копирование их значений. Как и пере- ша по результату, передача по значению и результату имеет проблемы, связанные с по- : :зком присваивания значений фактическим параметрам. 8.5.2.4. Передача по ссылке Передача по ссылке (pass-by-reference) — это вторая модель реализации режима вво- 11-вывода. Вместо того, чтобы передавать данные туда и обратно, как при передаче по зна- - гнию и результату, метод передачи по ссылке передает путь доступа к данным (обычно — -?эсто адрес) в вызываемую подпрограмму. Это открывает доступ к ячейке памяти, хра- --.дей фактический параметр. Таким образом, вызываемая подпрограмма может получить д.ступ к фактическому параметру в вызывающем программном модуле. В действительно- .-г! фактический параметр используется совместно с вызываемой подпрограммой. Преимущество передачи по ссылке состоит в том. что процесс передачи эффективен по себе с точки зрения как времени, так и пространства. Не требуется ни тиражиро- • 2’ь память, ни копировать что-либо. Однако у метода передачи по ссылке есть несколько недостатков. Во-первых, очень •хроятно, что доступ к формальным параметрам будет медленнее, поскольку необходим : один уровень косвенной адресации при передаче данных, как и при передаче по зна- • ению и результату. Во-вторых, если требуется только односторонняя связь с вызывае- ?й подпрограммой, то могут возникнуть неумышленные и ошибочные изменения фак- - -ческого параметра. Другая серьезная проблема, связанная с передачей по ссылке, заключается в том, что :гут возникнуть альтернативные имена (aliases). Этого следовало ожидать, поскольку пе- г-едача по ссылке открывает вызываемой подпрограмме пути доступа к данным, расширяя “zM самым ее доступ к глобальным переменным. Существует несколько ситуаций, в кото- ? ых при передаче параметров по ссылке могут образоваться альтернативные имена. Во-первых, может возникнуть противоречие между фактическими параметрами. Рас- .’.'отрим прототип процедуры-функции на языке С, имеющей два параметра, которые предаются по ссылке: void fun(int *first, int *second); Предположим, что при вызове функции fun одна и та же переменная передается дваж- ды. как показано ниже: fun(&total, &total); Следовательно first и second будут альтернативными именами. 3.5. Методы передачи параметров 357
Противоречия между элементами массива также могут привести к возникновению альтернативных имен. Допустим, что функция fun вызывается с двумя элементами мас- сива, указанными с помощью переменных индексов, как показано ниже: fun(& list [i], &list[j]) Если окажется, что i равно j, то first и second снова будут альтернативными име- нами. Противоречия между параметрами, являющимися элементами массива, и элементами массива, передаваемыми через имя массива, также являются возможной причиной появ- ления альтернативных имен. Рассмотрим случай, когда два формальных параметра под- программы являются скалярной величиной и массивом элементов того же типа: ( &list [ 1 ] , &list); Такой вызов может вызвать совмещение имен в функции funl, поскольку функция funl имеет доступ ко всем элементам массива list через второй параметр и доступ к отдельным элементам этого массива через первый параметр. Кроме того, совместить имена при передаче параметров по ссылке можно с помощью противоречий между формальными параметрами и глобальными переменными, которые являются видимыми в подпрограмме. Эти совмещения имен возможны, если язык предоставляет больше глобального дос- тупа, чем необходимо, как это иногда случается при использовании статического обзора данных. В качестве примера рассмотрим следующий код на языке Pascal: procedure bigsub; var global : integer; procedure smallsub(var local : integer); begin end; {smallsub} begin smallsub(global); end; {bigsub} Внутри процедуры smallsub имена local и global являются альтернативными. Как указывалось выше, основной причиной этого совмещения имен является то, что статиче- ский обзор данных часто обеспечивает слишком широкий доступ к глобальным пере- менным. Если переменная global в процедуре bigsub не была бы неявно видимой в процедуре smallsub, то имена local и global не были бы альтернативными в этой процедуре. Вследствие того, что переменная global должна передаваться как параметр в процедуру small sub, программист, возможно, забыл, что она уже является видимой в этой процедуре. Проблемы, возникающие в связи с этим видом совмещения имен, аналогичны про- блемам, возникающим при других обстоятельствах: это вредно для читабельности и, следовательно, для надежности. Кроме того, совмещение имен делает задачу верифика- ции программ исключительно трудной. Все эти возможные ситуации, связанные с совмещением имен, исключаются, если вместо передачи по ссылке используется передача по значению и результату. Однако при 358 Глава 8. Подпрограммы
•-?м вместо проблемы совмещения имен иногда возникают другие проблемы, обсуж- даемые в разделе 8.5.2.3. 8.5.2.5. Передача по имени Передача по имени (pass-by-name) — это метод передачи параметров в режиме вво- _ •-вывода, не соответствующий какой-то отдельной модели реализации, как показано - ке. Когда параметры передаются по имени, фактический параметр в действительности квально заменяется соответствующим формальным параметром во всех местах под- гэграммы, где он появляется. Это совершенно отличается от методов, обсуждавшихся z сих пор. В рассмотренных нами случаях формальные параметры связываются с фак- * вескими значениями или адресами во время вызова подпрограммы. При передаче по *ени формальный параметр также связывается с методом доступа во время вызова про- гаммы, однако фактическое связывание с некоторым значением или адресом отклады- : ается, пока формальному параметру не будет присвоено какое-нибудь значение, либо на -его не будет сделана ссылка. Целью такого позднего связывания в методе передаче параметров по имени является "экость. Это относится и к другим ситуациям, в которых мы сталкивались с различны- м моментами связывания. Например, связывание переменной с типом в языке APL про- водит позже, чем в языке FORTRAN, что позволяет более гибко использовать пере- •енные. Вид фактического параметра диктует выбор модели реализации передачи параметров ? имени. Это отличает параметры, передаваемые по имени, от параметров, передавае- r>ix с помощью других методов. Если фактический параметр представляет собой скв- ерную величину, то передача по имени равносильна передаче по ссылке. Если фактиче- ский параметр является константным выражением, то передача по имени эквивалентна ередаче по значению. Если фактический параметр представляет собой элемент массива, ‘ ? передача по имени может отличаться от любого другого метода, поскольку значение ‘чдексного выражения может изменяться во время выполнения программы между мо- •ентами времени, в которые производятся разные обращения к этому параметру. Это по- =оляет использовать разные появления формального параметра в вызываемой подпро- тамме для обращения к разным элементам массива. Этот способ более детально обсуж- дается чуть ниже. Если фактический параметр представляет собой выражение, содержащее перемен- яю. передача по имени отличается от любого другого метода. Это объясняется тем, что сражение вычисляется каждый раз, когда необходимо получить доступ к формальному -араметру в то время, когда управление выполнением программы достигает переменной. 1сли любая переменная в этом выражении сама по себе является доступной и изменяется -.эдпрограммой, то значение выражения может изменяться при каждой ссылке на фор- ельный параметр. Рассмотрим следующий пример программы, написанной на языке, подобном языку \LGOL: procedure BIGSUB; integer GLOBAL; integer array LIST[1:2]; procedure SUB(PARAM); integer PARAM; begin 8.5. Методы передачи параметров 359
PARAM := 3; GLOBAL := GLOBAL + 1; PARAM := 5 end; begin LIST[1] 2; LIST[2] := 2; GLOBAL := 1; SUB(LIST(GLOBAL)); end; После выполнения этой программы массив LIST содержит значения 3 и 5, причем оба эти значения устанавливаются в подпрограмме SUB. Доступ к элементу LIST [2] от- крывается, когда значение переменной GLOBAL увеличивается на 1 в подпрограмме SUB и становится равным 2. Основным преимуществом передачи по имени является гибкость, которую она пре- доставляет программисту*. Основной ее недостаток состоит в медленном выполнении процесса по сравнению с другими методами передачи параметров. Цена передачи пара- метров по значению с точки зрения эффективности выполнения обсуждается в разде- ле 8.5.5. Кроме высокой цены своих недостатков, передачу параметров по имени трудно реализовать. Она может ввести в заблуждение как читателей, так и авторов программ, использующих этот метод. Кроме того, некоторые простые операции невозможно вы- полнить при передаче параметров по имени. Классическим примером этого является подпрограмма swap, имеющая два параметра (см. упражнение 10). Понятие позднего связывания, на котором основывается передача по имени, ни в ко- ем случае не является странным или дискредитированным. Мощный механизм динами- ческого связывания и полиморфизма, являющийся неотъемлемой частью объектно- ориентированного программирования, представляет собой просто позднее связывание вызовов с подпрограммами или сообщений с объектами. (Полиморфизм обсуждается в разделе 8.8.) Ленивые вычисления — это еще один механизм, который является формой позднего связывания. Кратко говоря, они представляют собой вычисления частей функ- ционального кода только тогда, когда необходимость этих вычислений становится оче- видной. В императивных языках примером ленивого вычисления является сокращенное вычисление булевских выражений. Все вычисления выражений в функциональном языке Haskell являются ленивыми, как показано в главе 14. 8.5.3. Методы передачи параметров в основных языках программирования В языке FORTRAN для передачи параметров используется семантическая модель ввода-вывода, однако при этом не указывается способ передачи: по ссылке или по значе- нию и результату. В большинстве реализаций языка FORTRAN, предшествующего языку FORTRAN 77, параметры передавались по ссылке. В последующих реализациях, однако, для передачи параметров, представлявших собой простые переменные, часто использо- валась передача по значению и результату. В языке ALGOL 60 впервые был введен в практику метод передачи по имени. В каче- стве дополнительной возможности этот язык также позволяет передавать параметры по значению. В основном из-за сложности реализации передачи параметров по имени этот 360 Глава 8. Подпрограммы
е-од не вошел в последующие языки, ставшие популярными, за исключением языка 'A11LA67. В языке С используется передача параметров по значению. Семантика передачи по ени реализуется с помощью применения указателей в качестве параметров. Этот спо- : ' разработчики языка С скопировали у языка ALGOL 68. И в языке С. и в языке О+ 2 ггмальные параметры могут быть определены как указатели на константы. Соответст- ? -еший фактический параметр не обязан быть константой, поскольку в этом случае он г зсто преобразовывается в нее. Применение указателей позволяет достичь при исполь- • ванин передачи по ссылке той же эффективности, что и при передаче по значению. Язык C++ содержит специальный тип указателей, называемых ссылками, как показа- ' в главе 5. Ссылки часто используются для передачи параметров. Параметры, являю- щиеся ссылками, неявно разыменовываются, а их семантика представляет собой переда- ло ссылке. В языке C++ можно также определять параметры, являющиеся ссылками, • л< константы. Например, мы могли бы использовать такой заголовок функции- void fun(const int &pl, int р2, int &p3) { ... } * десь параметр pl передается по ссылке, но не может быть изменен функцией fun, па- -:метр р2 передается по значению, а параметр рЗ — по ссылке. Ни для параметра pl, - ♦ ня параметра р2 не нужно выполнять их явное разыменовывание в функции fun. Параметры, являющиеся константами, и параметры в режиме ввода не идентичны. Па- тл метры-константы очевидным образом реализуют режим ввода. Однако во всех широко - .тпространенных императивных языках, за исключением языка Ada. параметрам, переда- - емым в режиме ввода, в подпрограмме можно присваивать любые значения, даже если • • изменения никогда не отразятся на значениях соответствующих фактических парамет- - з Параметрам, являющимся константами, никогда и ничего присваивать нельзя. Как и в языках С и все параметры в языке Java передаются по значению. Однако, скольку в этом языке доступ к объектам может быть получен только с помощью ссылок, 'ъекгы, являющиеся параметрами, в действительности передаются по ссылке. Более того, . скольку ссылки не могут прямо указывать на скалярные величины, а в языке Java нет • -ззателей, скалярные величины в языке Java не могут передаваться по ссылке (хотя любые Гъскты, содержащие скалярные величины, передаваться по ссылке могут). Метод передачи параметров по значению и результату впервые был реализован в •:ыке ALGOL W (Wirth and Hoare, 1966) в качестве альтернативы неэффективному ме- ’' зу передачи по имени и вызывающему проблемы методу передачи по ссылке. В языках Pascal и Modula-2 по умолчанию для передачи параметров используется пе- г чача по значению, а передача по ссылке может быть указана путем приписывания сле- к формальному параметру зарезервированного слова var. Разработчики языка Ada определили версии трех семантических моделей передачи зраметров: ввода, вывода и ввода-вывода. Эти три режима соответственно называются ^резервированными словами in. out и in out, причем in является методом по умол- -анию. Рассмотрим следующий заголовок функциии на языке Ada: procedure ADDER(А : in out INTEGER; В : in INTEGER; C : out FLOAT) В языке Ada 83 формальным параметрам, передаваемым в режиме out, можно присваи- вать любые значения, однако на них нельзя ссылаться. На параметры, передаваемые в 3.5. Методы передачи параметров 361
режиме in, можно ссылаться, но им нельзя ничего присваивать. Совершенно естествен- но. параметрам, передаваемььм в режиме in out, можно как присваивать значения, так и ссылаться на них. Вопрос о том, как именно реализована передача параметров в режи- ме in out в языке Ada, представляет большой интерес и обсуждается в разделе 8.5.5. 8.5.4. Проверка типов параметров В настоящее время принято считать, что надежность программного обеспечения тре- бует. чтобы типы фактических параметров проверялись на совместимость с типами со- ответствующих формальных параметров. Без такой проверки типов небольшая опечатка может привести к ошибке в работе программы, которую, возможно, будет сложно обна- ружить, поскольку такие ошибки не распознаются компилятором или системой под- держки выполнения программ. Рассмотрим следующий вызов функции: RESULT := SUB1( 1 ) Здесь фактический параметр является целочисленной константой. Если формальный па- раметр функции SUB1 имеет тип числа с плавающей точкой, то ошибка не будет обна- ружена. если не предусмотрена проверка типов. Несмотря на то что целое число 1 и чис- ло с плавающей точкой 1 имеют одно и то же значение, их представления очень сильно отличаются друг от друга. Функция SUB1 не может вычислить правильный результат, получив целочисленный фактический параметр вместо числа с плавающей точкой. В языке FORTRAN 77 проверка типов не нужна, в то время как в языках Pascal, Modula-2. FORTRAN 90. Java и Ada она предусмотрена. Проверка типов в языках С и C++ требует отдельного разговора. В исходной версии языка С ни количество, ни типы параметров не проверялись. В языке ANSI С формаль- ные параметры функций можно было определить двумя способами. Первый способ был тем же, что и в исходной версии С, т.е. имена параметров перечислялись в скобках, а объявления типов следовали за ними, как в следующем примере: double sin(x) double х; { ... } Использование этого метода позволяет избежать проверки типов и применять вызовы, подобные приведенному ниже: double value; int count; value = sin(count); Такие вызовы допустимы, но бессмысленны. Альтернативой этому является метод прототипов (prototype method), в котором формальные параметры включаются в список, как показано ниже: double sin(double х) { ... } Также вполне допустим следующий вызов варианта функции sin: vdxue = sin(count); 362 Глава 8. Подпрограммы
' фактического параметра (int) сравнивается с типом формального параметра double). Несмотря на то что они не совпадают, тип int может быть приведен к типу double, поэтому выполняется приведение типов. Если приведение типов невозможно -пример, если фактический параметр должен был быть массивом) или количество па- метров является неверным, то распознается синтаксическая ошибка. Таким образом, в : .ыке ANSI С пользователь сам решает, следует ли выполнять проверку типов. В языке C++ формальные параметры всех функций должны быть указаны в их прото* • :зх. Однако проверки типов некоторых параметров можно избежать, заменив послед- --?ю часть списка параметров эллипсисом, как показано ниже: lгintf(const char* ...); э;зов функции printf должен иметь хотя бы один параметр, являющийся указателем •_ константную строку. Все остальное (включая отсутствие всех других параметров) яв- ится вполне допустимым. Способ, которым функция printf определяет, есть ли в вы- ье дополнительные параметры, основывается на использовании специальных символов : *\1раметре. являющемся строкой. Например, формат для вывода целого числа имеет : -л д. Он является частью строки, как в следующем примере: г гintf("Сумма равна %d\n", sum); 'мвол % сообщает компилятору о том, что в списке вывода есть еще один параметр. 8.5.5. Методы реализации передачи параметров Теперь рассмотрим вопрос о том, как в действительности реализуются различные >лели передачи параметров. В языке ALGOL 60 и производных от него языках обмен параметрами происходит .гез стек выполняемой программы (run-time stack). Стек выполняемой программы ини- . хзизируется и поддерживается системой поддержки выполнения программ. Этот стек _. :роко используется для управления подпрограммами и передачи параметров, как пока- -но в главе 9. В ходе последующего обсуждения мы будем предполагать, что стек ис- .льзуется для передачи всех параметров. Параметры, передаваемые по значению, копируются в ячейки стека. Эти ячейки затем . > жат хранилищем для соответствующих формальных параметров. Передача параметров результату реализуется как противоположность передаче параметров по значению. Зна- . ия. присвоенные фактическим параметрам, передаваемым по результату, помещаются в . ек. откуда они могут быть извлечены вызывающим программным модулем после завер- ения работы вызванной подпрограммы. Передача параметров по значению и результату жет быть реализована в соответствии со своей семантикой как комбинация передачи по -зчению и передачи по результат}’. Ячейка стека инициализируется вызовом и затем ис- /.ьзуется как локальная переменная в вызываемой подпрограмме. Передача параметров по ссылке, возможно, наиболее проста для реализации. Незави- :.‘мо от типа фактического параметра в стек должен помещаться лишь его азрес. Если зраметры являются литералами, то в подпрограмму передаются адреса литералов. Если ’зраметр является выражением, то компилятор должен построить код для вычисления заражения непосредственно перед передачей управления в вызываемую подпрограмму. Чдрес ячейки памяти, в которую код помещает результат своих вычислений, затем запи- сывается в стек. Компилятор должен гарантировать, что вызываемая подпрограмма не 8.5. Методы передачи параметров 363
сможет изменить параметры, являющиеся литералами или выражениями, как показано ниже. Доступ к формальным параметрам в вызываемой подпрограмме осуществляется путем косвенной адресации по ячейке стека, содержащей нужный адрес. Реализации пе- редачи по значению, по результату, по значению и результату, а также по ссылке, в кото- рых используется стек выполняемой программы, показаны на рис. 8.2. Рис. 8.2. Возможная реализация стека при использовании основных методов передачи параметров Неявная, но фатальная ошибка может возникнуть при передаче параметров по ссылке или по значению и результату, если не проявить осторожность при их реализации. До- пустим. программа содержит две ссылки на константу 10, и первая из этих ссылок явля- ется фактическим параметром в вызове некоей подпрограммы. Предположим далее, что подпрограмма по ошибке изменила формальный параметр, соответствующий константе 10. на константу 5. Компилятор этой программы может выделить отдельную ячейку па- мяти для значения 10 во время компиляции, как это компиляторы часто и делают, а затем использовать эту ячейку для всех ссылок на константу 10 в программе. Однако после возвращения из подпрограммы все последующие появления константы 10 будут замене- ны значением 5. Если компилятор допускает такую ситуацию, то возникают проблемы программирования, которые очень трудно диагностировать. В действительности именно так все и происходило во многих реализациях языка FORTRAN IV. Передача параметров по имени обычно реализуется с помощью процедуры, не имею- щей параметров, или фрагмента кода, называемой санком (thunk). Санк должен вызывать- ся при каждой ссылке на параметр, передаваемый по имени в вызываемой подпрограмме. Санк вычисляет в соответствующей среде ссылку на подпрограмму, передавшую фактиче- ский параметр. Санк связывается со своей средой ссылок в момент вызова, передающего параметр по имени, и возвращает адрес фактического параметра. Если ссылка на параметр является частью некоего выражения, то код ссылки должен содержать необходимое разы- менование, чтобы получить значение из ячейки, адрес которой был возвращен с помощью санков. Все это вместе взятое образует очень затратный процесс по сравнению с простой 364 Глава 8. Подпрограммы
• хвенной адресацией, используемой для передачи параметров по ссылке. Напомним, что .'я фактических параметров, являющихся скалярными величинами, передача по имени и хэедача по ссылке семантически эквивалентны. Стоимость реализации передачи по ссыл- •. сравнима со стоимостью косвенной адресации, в то время как передача по имени требу- вызова некоторой подпрограммы — хотя она не имеет параметров— и ее выполнения де я того, чтобы в итоге сделать то же самое. В описании языка Ada 83 указывается, что скалярные (неструктурированные) пара- метры должны передаваться как копии, т.е. параметры, передаваемые с помощью мето- де в in и out, должны быть локальными переменными, которые инициализируются ко- пиями значений соответствующих фактических параметров. Копии простых параметров, * еэелаваемых с помощью методов out и in out, должны копироваться обратно в со- ’ветствующий фактический параметр при завершении подпрограммы. Порядок получе- - 'я этих копий в случае, если их несколько, не определяется описанием языка. Вычисле- -ие параметров, передаваемых с помощью методов out и in out, выполняется до ле- те дачи управления в вызываемую подпрограмму. Допустим, что фактический параметр, ередаваемый методом out, имеет вид: LIST(INDEX) \дрес этого параметра вычисляется в момент вызова. Если бы оказалось, что перемен- -ая INDEX является видимой в вызываемой подпрограмме и подпрограмма изменила ее, -? адрес параметра не изменился бы. При использовании формальных параметров, являющихся массивами или записями, : ?здатели средств реализации языка Ada 83 имеют выбор между передачей параметров по начению и результату и передачей по ссылке. Не указывая метод реализации передачи ;-р\ ктурированных параметров, разработчики языка Ada 83 создают одну неявную про- чему. которая состоит в том, что два метода реализации в некоторых случаях могут при; вести к разным результатам работы программ. Это различие может возникнуть вследствие “?го. что метод передачи параметров по ссылке обеспечивает доступ к ячейке в вызываю- щей программе, и такой же доступ к этой ячейке обеспечивается, если фактический пара- метр одовременно является видимым как глобальная переменная. Таким образом, создает- альтернативное имя. Если вместо передачи параметров по ссылке используется передача "араметров по значению и результату, такой двойной доступ невозможен. Кроме того, возникает следующая дополнительная проблема. Допустим, что подпро- грамма завершилась аварийно (возникла исключительная ситуация). В этом случае фак- тический параметр, передаваемый по значению и результату, не изменится, в то время • ак при передаче параметров по ссылке соответствующий фактический параметр может вмениться до появления ошибки. Снова возникает различие между двумя методами геализации передачи параметров. Программы на языке Ada 83, вычисляющие разные результаты в зависимости от того, каким именно образом реализован метод in out. называются ошибочными (erroneous). Несмотря на такой ярлык, однако, не существует способа, с помощью которого компилятор мог бы находить ошибочное условие. Таким образом, ошибка обычно обнаруживается, только когда пользователь переносит программу с одной реализации языка на другую и выясняет, что эта программа не дает больше того же самого результата. Философия разра- ботки языка Ada 83 в этой ситуации состоит в том, что если программисты создают совме- щение имен, то они сами должны бороться с потенциальными проблемами. 8.5. Методы передачи параметров 365
Разработчики языка Ada 95 исключили из описания языка возможность выбора мето- да передачи структурированных параметров самим пользователем. Все структурирован- ные параметры передаются по ссылке. 8.5.6. Многомерные массивы в качестве параметров Функции отображения памяти, используемые для отображения индексных значений ссылок на элементы многомерных массивов в адреса памяти, обсуждались в главе 5. В некоторых языках, например, С и C++, при передаче многомерных массивов как пара- метров в подпрограмму компилятор должен быть способен построить отображающую функцию для этого массива прямо по тексту подпрограммы, поскольку подпрограммы могут компилироваться отдельно от программ, их вызывающих. Рассмотрим проблему передачи матрицы в функцию на языке С. Многомерные массивы в языке С в действи- тельности представляют собой массивы массивов и хранятся построчно. Функция ото- бражения для возрастающего порядка строк от двух аргументов должна иметь в качестве параметра количество столбцов, а не количество строк. Следовательно, в языках С и C++ при передаче матрицы в качестве параметра формальные параметры должны включать в себя количество столбцов, указанное во второй паре квадратных скобок. Это иллюстри- руется следующей скелетной программой на языке С: void fun(int matrix[][10]) { ... } void main() { int mat[5][10]; fun(mat); } Проблема, связанная с этим методом передачи матрицы как параметра, состоит в том, что такой способ передачи не позволяет программисту написать функцию, которая могла бы получать матрицу с разными количествами столбцов; для каждой новой матрицы с другим количеством столбцов нужно писать новую функцию. Это фактически не позво- ляет писать гибкие функции, которые можно было бы эффективно повторно использо- вать, если функции работают с многомерными массивами. В языках С и C++ существует способ обойти эту проблему, поскольку они связаны с арифметикой указателей. Матрица может передаваться как указатель, а фактические размеры матрицы можно передать как параметры. Тогда функция вычисляет функцию отображения памяти, определенную пользователем, на основе арифметики указателей каждый раз, когда нужно сослаться на элемент матрицы. Рассмотрим следующий прототип функции: void fun(float *mat__ptr, int num_rows, int num_cols) ; Для присвоения значения переменной х элементу [row] [col] матрицы, являющейся параметром функции fun, можно использовать следующий оператор: * (mat__ptr + (row * num__cols) + col) = x; Несмотря на то что этот оператор работает, он очевидно труден для восприятия и вслед- ствие своей сложности подвержен ошибкам. Трудность, связанную с чтением этого опе- ратора, можно обойти, используя макрос для определения функции отображения памяти, например: 366 Глава 8. Подпрограммы
=refine mat__ptr (r, c) (*(mat__ptr + (( r ) * num_cols) + ( c ))) ' четом этого оператор, приведенный выше, можно переписать в виде: ~at_ptr(row,col) = х; В других языках проблема передачи многомерных массивов в качестве параметров ? г дается иначе. Компиляторы языка Ada способны определять указанные размеры всех -^сивов, используемых как параметры, во время компиляции подпрограмм. В языке - 22 формальными параметрами могут быть неограниченные массивы. Неограниченный — это такой массив, в котором диапазон изменения индексов в описании массива -z задается. Диапазоны изменения индексов должны содержаться в определениях пере- гчных. представляющих собой неограниченные массивы. Подпрограмма, в которую -.: граниченный массив передается как фактический параметр, может получать инфор- иию о диапазоне изменения индексов вместе с самим параметром. Рассмотрим сле- . <тие определения: type MATRIX TYPE is array (INTEGER range <>, INTEGER range <>) of FLOAT; ::atrix_i : matrix_type (i. .100, 1..20); t. чкция, возвращающая сумму элементов массива типа MATRIX_TYPE, приведена ниже: function SUMER(МАТ : in MATRIX_TYPE) return FLOAT is SUM : FLOAT := 0.0; begin for ROW in MAT’range(1) loop for COL in MAT’range(2) loop r SUM := SUM + MAT(ROW,COL); end loop; — for COL ... end loop; —for ROW ... return SUM; end SUMER; ‘."оибут range возвращает диапазон изменения указанного индекса массива, являюще- еся фактическим параметром, при этом он действует независимо от размера или диапа- . иов изменения индексов этого параметра. В версиях языка FORTRAN, созданных до 1990 года, эта проблема решается сле- г ющим образом. Формальные параметры, являющиеся массивами, должны иметь объ- • зление после заголовка. В одномерных массивах индексы в таких объявлениях не игра- - * никакой роли. Однако для многомерных массивов индексы в таких объявлениях по- .-?.1яют компилятору построить функцию отображения памяти. Рассмотрим следующий -г и мер скелетной подпрограммы на языке FORTRAN: SUBROUTINE SUB(MATRIX, ROWS, COLS, RESULT) INTEGER ROWS, COLS REAL MATRIX(ROWS, COLS), RESULT END ‘ ча превосходно работает, поскольку фактический параметр COLS имеет значение, ис- ельзующееся как количество столбцов в определении передаваемой матрицы. Если *2ссив, подлежащий передаче, пока еще не заполнен полезными данными, чтобы опре- 3.5. Методы передачи параметров 367
делить его размер, то в подпрограмму могут передаваться как определенные пользовате- лем размеры массива, так и размеры его заполненной части. В таком случае определен- ные пользователем размеры используются в локальном объявлении массива, а размеры его заполненной части используются для управления вычислениями, в которых имеются ссылки на элементы массива. В качестве примера рассмотрим следующую подпрограм- му на языке FORTRAN 90: SUBROUTINE MATSUM(MATRIX, ROWS, COLS, FILED_ROWS, FILED_COLS, SUM) INTEGER ROWS, COLS, FILLED ROWS, FILLED_COLS, ROW INDEX, COL—INDEX REAL MATRIX(ROWS, COLS), SUM SUM =0.0 DO 20 ROW_INDEX = 1, FILLED_ROWS DO 10 COL INDEX = 1, FILLED-COLS SUM = SUM = MATRIX(ROW_INDEX, COL_INDEX) 10 CONTINUE 20 CONTINUE RETURN END В языке Java для передачи многомерных массивов в качестве параметров использует- ся способ, похожий на способ, применяемый в языке Ada. В языке Java массивы являют- ся объектами. Все они одномерны, однако их элементы могут быть массивами. Каждый массив наследует именованную константу (length), определяющую длину массива при создании соответствующего объекта. Формальный параметр, являющийся матрицей, ис- пользуется с двумя парами квадратных скобок, как показано в методе, приведенном ни- же, который выполняет то же, что и функция SUMER на языке Ada: float sumer(float mat[][]) { float sum = O.Of; for (int row = 0; row < mat.length; row++) { for (int col = 0; col < mat[row].length; col++) { sum += mat[row][col]; } //** for (int row - } //** for (int col- return sum; } Поскольку каждый массив имеет свою собственную длину, длина строк в матрице может быть различной. 8.5.7. Вопросы разработки Выбор метода передачи параметров связан с двумя важными аспектами: первый из них — эффективность, второй — способ передачи данных (односторонний или двусто- ронний). Принципы современного проектирования программного обеспечения диктуют, что доступ кода подпрограммы к данным, находящимся вне подпрограммы, должен быть минимизирован. Учитывая это, следовало бы выбирать передачу параметров в режиме ввода всякий раз, когда в вызывающий модуль через параметры не возвращаются ника- кие данные. Если никакие данные в подпрограмму не передаются, но при этом подпро- 368 Глава 8. Подпрограммы
эамма должна возвращать результаты обратно в вызывающий модуль, следует приме- -ять передачу параметров в режиме вывода. Режим ввода-вывода параметров использу- ется. только если данные должны перемещаться в обоих направлениях между вызываю- щим модулем и вызываемой подпрограммой. Существует практический аспект, связанный с передачей параметров, который нахо- дится в противоречии с указанным принципом. Иногда вполне оправданно передавать г>ти доступа к данным для одностороннего перемещения параметров. Например, когда большой массив передается в подпрограмму, которая не модифицирует его, односторон- ний метод предпочтительнее. Однако передача параметров по значению может потребо- вать. чтобы весь массив был перемещен в локальную область памяти, отведенную для ~сопрограммы. Это — слишком дорогой процесс с точки зрения как времени, так и объ- ема памяти. Вследствие этого большие массивы часто передаются по ссылке. Это имен- но та причина, по которой определение языка Ada 83 позволяет создателям средств реа- “изации языка самим делать выбор между двумя методами передачи структурированных параметров. Другим решением этой проблемы являются параметры, передаваемые в ныке C++ как константные ссылки. В качестве альтернативы используется также под- * од. состоящий в том, что пользователю позволяют самому выбирать метод передачи -араметров. Выбор метода передачи параметров в функции связан с другим вопросом разработки • зыков программирования — побочным эффектом функций. Этот вопрос обсуждается в г аз деле 8.10. 8.5.8. Примеры передачи параметров Рассмотрим следующую функцию на языке С: void swap(int a, int b) { int temp = a; a = b; b = temp; Предположим, что эта функция вызывается следующим образом: swapl(с, d) ; Напомним, что в языке С используется передача параметров по значению. Действия :>нкции swapl можно описать с помощью следующего псевдокода: а = с - передать значение первого параметра о = d - передать значение второго параметра temp = а а = b b = temp Несмотря на то что при завершении работы подпрограммы переменная а содержит зна- -ение переменной d, а переменная b — значение переменной с, значения переменных с и. д остаются неизменными, поскольку в вызывающий модуль ничего не возвращается. В языке Pascal функцию swapl можно записать в виде процедуры с той же семанти- кой режима ввода, как показано ниже: 8.5. Методы передачи параметров 369
procedure swapl(a, b: integer) temp : integer; begin temp := a; a : = b ; b := temp end; Теперь модифицируем функцию перестановки на языке С так, чтобы она работала с указателями в качестве параметров, что позволяет достичь эффекта передачи параметров по ссылке: void swap2(int *а, int *b) { int temp = *a; *a = *b; *b = temp; } Функцию swap2 можно вызвать следующим образом: swap2(&с, &d); Действия функции swap2 можно описать с помощью следующего псевдокода: а = &с - передать адрес первого параметра b = &d - передать адрес второго параметра t emp = * а *а = *Ь *b = temp В этом случае операция перестановки является успешной: значения переменных end действительно меняются местами. Функцию swap2 можно переписать на языке C++ с использованием ссылок в качест- ве параметров, как показано ниже: void swap2(int &а, int &b) { int temp = a; a = b; b = temp; } Эта простая операция перестановки невозможна в языке Java, поскольку в нем нет ни указателей, ни ссылок, аналогичных ссылкам в языке C++. В языке Java ссылка может указывать только на объект, а не на скалярную величину. В языке Pascal функцию swap2 можно переписать так: procedure swap2(var a, b: integer) temp : integer; begin temp := a; a : = b ; b := temp; end; Предположим, что процедура swap2 на языке Pascal вызывается следующим образом: swap2(1, list [ 1]); 370 Глава 8. Подпрограммы
В этом случае ее действия можно описать так: з = &i - передать адрес первого параметра с = - передать адрес второго параметра temp = *а -а = *Ь 'с = temp Несмотря на то что значение разыменованной ссылки *а (равное значению перемен- й i) изменяется до того, как изменится значение разыменованой ссылки *Ь (равное -зчению list [i]), это никак не влияет на правильность перестановки, поскольку ад- переменной list [i] вычисляется в момент вызова и после этого уже не изменяет- . - независимо от того, что происходит с переменной i. Семантика передачи параметров по значению и результату идентична семантике пере- 224 и по ссылке, за исключением того случая, когда возникает совмещение имен. Напом- - м. что в языке Ada передача по значению и результату в режиме ввода-вывода исполь- ется для передачи скалярных параметров. Для того чтобы исследовать передачу пара- етров по значению и результату, рассмотрим следующую функцию swap3, в которой предположению используется этот метод. Она записана с помощью синтаксиса, близ- ко к языку Ada. procedure swap3(a : in out integer, b : in out integer) is temp : integer; begin t emp := a ; a : = b; b := t emp; end swap3; ~ :>стим, что процедура swap3 вызывается следующим образом: s.%ap3(c, d) ; .е 'ствия процедуры swap3 при этом вызове можно описать так: 23ОГ_С = &с £tdr_d = &d = *addr_c с = *addr_d •emp = a = =‘b z = temp •addr_c = a 'sodr_d = b - передать адрес первого параметра - передать адрес второго параметра - передать значение первого параметра - передать значение второго параметра - вернуть значение первого параметра - вернуть значение второго параметра 2* им образом, снова эта подпрограмма перестановки работает правильно. Далее, рас- :трим вызов ?.<арЗ (i, list [i] ) ; - --ом случае действия процедуры swap3 можно описать следующим образом: =cdr_i = &i - передать адрес первого параметра 2?.dr_listi = &list[i] - передать адрес второго параметра : 5. Методы передачи параметров 371
a = *addr_i b = *addr_listi temo = a a = b b = temp *addr_i = a *addr listi = b - передать значение первого параметра - передать значение второго параметра - вернуть значение первого параметра - вернуть значение второго параметра В данном случае подпрограмма снова работает правильно, потому что адреса ячеек, в которые записываются возвращаемые значения параметров, вычисляются в момент вы- зова, а не в момент возврата из подпрограммы. Если бы адреса фактических параметров вычислялись в момент возврата из подпрограммы, результаты были бы неправильными. Рассмотрим, что произойдет при совмещении имен, возникающем, когда параметры передаются по значению и результату или по ссылке. Рассмотрим следующую скелетную программу, написанную с помощью синтаксиса, близкого к языку С: int i = 3; /* i — глобальная переменная */ void fun(int a, int b) { i = b; } void main() { int list [ 10]; list [i] = 5; fun(i, list [i]) ; } В функции fun используется передача параметров по ссылке, причем переменные i и а являются альтернативными именами. Если используется передача параметров по значе- нию и результату, то переменные i и а альтернативными именами не являются. В этом случае действия функции fun таковы: addr_i = &i addr_listi = &list [i] a = *addri b = *addr_listi i •-= b *addr_i = a ^addr—listi = b - передать адрес первого параметра - передать адрес второго параметра - передать значение первого параметра - передать значение второго параметра - установить i равным 5 - вернуть значение первого параметра - вернуть значение второго параметра В этом случае присвоение глобальной переменной i в подпрограмме fun изменяет ее значение с 3 на 5, а назад возвращается копия первого формального параметра (вторая снизу строка псевдокода), которая содержит значение 3. Здесь важно отметить, что если используется передача по ссылке, то возвращение копии уже не будет частью семантики передачи параметров, поэтому переменная i останется равной 5. Заметим также, что, поскольку адрес второго параметра вычисляется в начале выполнения функции fun, лю- бое изменение глобальной переменной i не повлияет на ее адрес, используемый в конце при возвращении значения переменной list [i]. 372 Глава 8. Подпрограммы
8.6. Параметры, являющиеся именами подпрограмм В программировании часто возникают ситуации, которые удобнее всего обрабаты- вать. если имена подпрограмм передаются как параметры другим подпрограммам. В ча- стности, это происходит, когда подпрограмма должна моделировать некую математиче- ;<\ю функцию. Например, подпрограмма, выполняющая численное интегрирование, оп- те деляет площадь фигуры, лежащей под графиком функции, вычисляя эту функцию в 'г.тьшом количестве различных точек. Такая подпрограмма должна применяться для ‘•обой заданной функции. Не должно возникать необходимости переписывать подпро- гамму для каждой функции, которую следует проинтегрировать. Следовательно, естест- венно. чтобы имя функции в программе, вычисляющей интеграл математической функ- ции. передавалось в подпрограмму интегрирования как параметр. Несмотря на то что эта идея вполне естественна и выглядит простой, детали ее рабо- ’ы могут вызвать замешательство. Если бы требовалось передать только код подпро- гаммы, то было бы достаточно передать отдельный указатель. Однако возникает не- сколько осложняющих обстоятельств. Во-первых, существует проблема проверки типов параметров при вызовах подпро- граммы, передаваемой как параметр. В исходном описании языка Pascal (Jensen and Л irth, 1974) позволялось передавать подпрограммы как параметры без включения ин- : ормации о типах их параметров. Если возможна независимая компиляция (невозмож- -зя в исходной версии языка Pascal), то компилятор не позволяет проверить даже пра- вильность количества параметров. В отсутствие независимой компиляции проверка со- вместимости параметров возможна, но представляет собой крайне сложную задачу и :бычно не выполняется. Язык FORTRAN 77 страдает от той же проблемы, но. поскольку проверка совместимости типов в языке FORTRAN 77 и так никогда не выполняется, она не является дополнительной проблемой. Если имя подпрограммы передается как параметр в языке ALGOL 68 или в поздней- ших версиях языка Pascal, типы формальных параметров включаются в список формаль- ных параметров, получаемых подпрограммой, так что совместимость типов параметров, передаваемых в подпрограмму при реальном вызове, можно выполнить статически. На- пример, рассмотрим следующий код на языке Pascal: procedure integrate(function fun(x : real) : real; lowerbd, upperbd : real; var result : real); var funval ; real; begin funval : = fun(lowerbd); end; Фактический параметр в вызове функции fun в процедуре integrate может быть ста- тически проверен на совместимость с типом формального параметра функции fun из списка формальных параметров процедуры integrate. В языках С и C++ функции не могут передаваться как параметры, но указатели на функции — могут. Типом указателя на функцию является ее протокол. Поскольку Прото- S.6. Параметры, являющиеся именами подпрограмм 373
кол содержит типы всех параметров, такие параметры могут быть полностью проверены на совместимость типов. В языке Modula-2 типы процедур используются для их передачи, как если бы они бы- ли переменными. Этот метод позволяет выполнять проверку совместимости типов пара- метров передаваемых подпрограмм, поскольку типы параметров являются частью типа процедуры. В языке FORTRAN 90 есть механизм описания подлежащих проверке типов параметров подпрограмм, которые сами передаются как параметры. В языке Ada под- программы нельзя использовать в качестве параметров. Вместо этого функциональные возможности, обеспечиваемые передачей подпрограмм в качестве параметров, в языке Ada достигаются с помощью настраиваемых функций, обсуждаемых в разделе 8.8. Более интересный аспект использования имен подпрограмм, передаваемых как пара- метры, связан с вопросом о правильной среде ссылок при выполнении передаваемой подпрограммы. Существуют три возможности. 1. Среда оператора вызова, который активирует передаваемую подпрограмм} (теневое связывание — shallow binding). 2. Среда определения передаваемой подпрограммы (глубокое связывание — deep binding). 3. Среда оператора вызова, передающего подпрограмму как фактический параметр (специальное связывание — ad hoc binding). Следующий пример программы иллюстрирует эти возможности. Допустим, что проце- дура SUB3 может вызывать процедуру SUB4. procedure S U В1; var х : integer; procedure SUB2; begin write(’x = ’, x); end; {sub2} procedure S U В 3; var x : integer; begin x : integer; x : = 3 ; SUB4(SUB2) end; {SUB3} procedure SUB4(SUBX); var x : integer; begin x : = 4 ; SUBX; end; {SUB4} begin {SUB1} x := 1; SUB3 end; {SUB1} Рассмотрим выполнение подпрограммы SUB2 при ее вызове из SUB4. При теневом свя- зывании среда ссылок такого выполнения совпадает со средой ссылок подпрограммы SUB4, так что ссылка на переменную х в подпрограмме SUB2 связывается с локальной пе- 374 Глава 8. Подпрограммы
' • • ? подпрограмме SUB4, и программа выводит строку х = 4. При глубоком :та ссылок выполнения подпрограммы SUB2 совпадает со средой ссылок . - - . .7?так что ссылка на переменную х в подпрограмме SUB2 связывается > семенной х в подпрограмме SUB1, и программа выводит строку х = 1. - <м связывании ссылка на переменную х в подпрограмме SUB2 связывается семенной х в подпрограмме SUB3, и программа выводит строку х = 3. - случаях подпрограмма, в которой объявляется другая подпрограмма, • * ее как параметр. В этих случаях глубокое связывание и специальное свя- ...п . г ддают между собой. Специальное связывание никогда не использовалось, । • - можно предположить, среда, в которой процедура оказывается в качестве зг ~. . /мее1 естественной связи с передаваемой подпрограммой. . . = «зывание неприемлемо для языков с блочной структурой из-за статическо- -• переменных. Допустим, что процедура SENDER передает процедуру SENT .гв процедуру RECEIVER. Проблема заключается в том, что процедура v жет не принадлежать статической среде ссылок процедуры SENT, делая -. м очень неестественным для процедуры SENT доступ к переменным проце- • --=> . С другой стороны, для любой процедуры в языках со статическим об- - х. включая процедуры, передаваемые как параметры, вполне нормальной шн. п . пня. когда среда ссылок определяется лексическим местонахождением ее - с ледовательно, для языков с блочной структурой более логичным является * е глчбокого связывания. Некоторые языки с динамическим обзором Дан- ии -. • ч? с используют теневое связывание. 3-7. Перегруженные подпрограммы - -гнный оператор имеет несколько значений. Значение конкретного экземп- 1п- _ -. - генного оператора определяется типами его операндов. Например, если , -. - veei два операнда с плавающей точкой в программе на языке С, он означает * - •. - > • .e.i с плавающей точкой. Однако, если тот же оператор имеет два целочис- г; - . ~-нда. ок означает целочисленное умножение. ~-жженная подпрограмма (overloaded subprogram)— это подпрограмма, имя . _ цдает с именем другой подпрограммы в той же среде ссылок. Каждая вер- - .иной подпрограммы должна иметь свой уникальный протокол, т.е. она - - -л1ься от других версий количеством, порядком, типами своих параметров - .вращаемого значения, если она является функцией. Значение вызова пере- - • --- -.программы определяется списком фактических параметров (и/или, воз- н • - ‘ ‘ возвращаемого значения при использовании функции). - Java и Ada содержат встроенные перегруженные подпрограммы. Напри- - • -имеет несколько версий функции вывода PUT. Наиболее широко исполь- • -. п • ^’ой функции, принимающие в качестве параметров строку, целое число и - _ . .’--л-ощей точкой. Поскольку каждая версия функции PUT имеет уникальные -. - _ . -ов. компилятор может однозначно различать вызовы функции PUT с раз- • параметров. : П тип возвращаемого значения в перегруженных функциях используется . • ?го определения вызываемой функции. Следовательно, две перегружен- * : - _ vor>T иметь одинаковый набор параметров и различаться только типом : * жженные подпрограммы 375
возвращаемого значения. Это возможно, поскольку в языке Ada не позволяются сме- шанные выражения, так что контекст функции может определять тип возвращаемогс функцией значения. В языках C++ и Java смешанные выражения допускаются, поэтому тип возвращаемого значения не играет никакой роли при однозначном определении пе- регруженных функций (или методов). Пользователи также могут создавать несколько версий подпрограмм с одним и тем же именем на языках Ada, Java и C++. Несмотря на то что нет никакой необходимости в том, чтобы эти подпрограммы описывали в основном один и тот же процесс, они обычно делают именно это. Например, конкретная программа может нуждаться в двух процеду- рах сортировки, одной — для целочисленных массивов и другой — для массивов чисел с плавающей точкой. Обе эти процедупы можно назвать SORT, поскольку типы их пара- метров различны. В приведенной ниже скелетной программе на языке Ada содержатся две процедуры с именем SORT: procedure MAIN is type FLOAT_VECTOR is array (INTEGER range <>) of FLOAT; type INT_VEXTOR is array (INTEGER range <>) of INTEGER; procedure SORT(FLOAT_LIST : in out FLOAT VECTOR; LOWER_BOUND : in INTEGER; UPPERJBOUKD : in INTEGER) is end SORT; procedure SORT(INT_LIST : in out INT_VECTOR; LOWER_BOUND : in INTEGER; UPPER_BOUND : in INTEGER) is end SORT; end MAIN; Перегруженные подпрограммы, имеющие параметры по умолчанию, могут привести к неоднозначным вызовам. В качестве примера рассмотрим следующий код на языке C++: void fun(float b = 0.0); void fun(); fund ; Этот вызов является нео 'позначным и приведет к ошибке компиляции. 8.8. Настраиваемые подпрограммы Повторное использование кода может значительно повысить производительность программного обеспечения, если есть возможность не писать разные подпрограммы, реализующие один и тот же алгоритм для данных, имеющих разные типы. Например, в этом случае программисту не нужно писать четыре разные подпрограммы сортировки для того, чтобы упорядочить четыре массива, отличающихся друг от друга только типом своих элементов. 376 Глава 8. Подпрограммы
Настраиваемая (generic! или полиморфная, подпрограмма (polimorphic subprogram) г • эазных вызовах получает параметры разных типов. Перегруженные подпрограммы гч-дставляют собой разновидность полиморфизма, называемую специальным полимор- физмом (ad hoc polimorphism). Функции языка APL обеспечивают более общий вид поли- . гоизма. Вследствие того что в языке APL применяется динамическое связывание, типы омметров можно не указывать, они просто связываются с типами соответствующих фак- чес ких параметров. Параметрический полиморфизм обеспечивается подпрограммами с настраиваемым 2?аметром. который используется для описания типов параметров подпрограммы. И в • -;ке Ada, и в языке C++ в процессе компиляции поддерживается разновидность пара- г’рического полиморфизма. 8.8.1. Настраиваемые подпрограммы в языке Ada В языке Ada параметрический полиморфизм обеспечивается конструкциями, позво- -юшими создавать различные версии программных единиц, получающих параметры “ззного типа. По запросу пользовательской программы компилятор создает, или конст- • ир\ет, экземпляры различных версий подпрограмм. Поскольку все версии подпро- гзммы имеют одно и то же имя, создается иллюзия, что одна подпрограмма при разных ьэ: зовах может обрабатывать данные разного типа. Поскольку программные единицы •’эго вида являются настраиваемыми по своей природе, иногда их называют настраи- ваемыми компонентами (generic units). Этот же механизм можно использовать для того, чтобы позволить вызывающей под- гограмме в разных ситуациях вызывать разные экземпляры настраиваемой подпро- гаммы. Такой способ полезен при обеспечении функциональности подпрограмм, пере- даваемых как параметры. Приведенный ниже пример иллюстрирует процедуру, имеющую три настраиваемых араметра, что позволяет подпрограмме получать в качестве параметра настраиваемый массив. Процедура реализует алгоритм обменной сортировки и может работать с любым массивом, элементы которого имеют числовой тип, используя диапазон индекса поряд- • эвого типа: generic type INDEX_TYPE is (о); type ELEMENT_TYPE is private; type VECTOR is array (INTEGER—TYPE range <>) of ELEMENT_TYPE; procedure GENERIC—SORT(LIST : in out VECTOR); procedure GENERIC_SORT(LIST : in out VECTOR) is TEMP : ELEMENT-TYPE; begin for TOP in LIST’FIRTS..INDEX_TYPE’PRED(LIST’LAST) loop for BOTTOM in INDEX—TYPE’SUCC(TOP)..LIST’LAST loop if LIST(TOP) > LIST(BOTTOM) then TEMP := LIST(TOP); LIST := LIST(BOTTOM); LIST(ВОТОМ) := TEMP; end if; end loop; — for BOTTOM ... 8.8. Настраиваемые подпрограммы 377
end loop; — for TOP ... end GENERIC_SORT; Некоторые части этой настраиваемой процедуры могут казаться довольно странными, если вы не знакомы с языком Ada. Однако понимать все детали синтаксиса в данном случае не обязательно. Типы массива и его элементов задаются двумя настраиваемыми параметрами данной процедуры, при этом допускается любой тип и диапазон изменения индекса. Настраиваемая сортировка— не более чем шаблон процедуры. Компилятор не гене- рирует для этой процедуры никакого кода, и она никак не влияет на выполнение программы, пока не будет создан экземпляр процедуры для некоторого конкретного ти- па. Экземпляр процедуры создается с помощью оператора, аналогичного приведенному ниже: procedure INTEGER_SORT is new GENERIC_SORT( INDEX TYPE => INTEGER; ELEMENT_TYPE => INTEGER; VECTOR => INT_ARRAY); В ответ на этот оператор компилятор создает версию процедуры GENERIC-SORT с именем INTEGER_SORT, сортирующую массивы типа INT_ARRAY, содержащие эле- менты типа INTEGER с индексами типа INTEGER. В процедуре GENERIC_SORT предполагается, что оператор > определен для элемен- тов сортируемого массива. Универсальность процедуры GENERIC SORT можно уси- лить, включив в нее функцию сравнения ее настраиваемых параметров. Напомним, что в языке Ada не допускается передача подпрограмм в качестве пара- метров других подпрограмм. Для того чтобы обеспечить эту возможность, в языке Ada используются настраиваемые формальные подпрограммы. В таком языке, как Pascal, подпрограммы могут передаваться как параметры, так что при конкретном вызове неко- торой подпрограммы для вычисления ее результатов можно использовать передаваемую подпрограмму. В языке Ada тот же результат достигается путем разрешения пользовате- лю создавать неограниченное количество экземпляров настраиваемой подпрограммы с разными используемыми подпрограммами. Например, процедуру integrate, описан- ную в разделе 8.6, можно написать на языке Ada следующим образом: generic with function FUN(X: FLOAT) return FLOAT; procedure INTEGRATE (LOWERED : in FLOAT; UPPERBD : in FLOAT; RESULT : out FLOAT) is FUNVAL : FLOAT; begin FUNVAL := FUN(LOWERED); end; Создать экземпляр этой процедуры для интегрирования определенной пользователем функции FUN1 можно с помощью следующего оператора: procedure INTEGRATE—FUN1 is new INTEGRATE(FUN => FUN1); 378 Глава 8. Подпрограммы
. чрь процедура INTEGER_FUN1 предназначена для интегрирования функции FUN1. 8.8.2. Настраиваемые подпрограммы в языке C++ Настраиваемые функции в языке C++ называются шаблонными функциями. Шаблон- - = : функции имеют следующий общий вид: template <class параметры> - определение функции, которая может иметь параметры, являю- щиеся классами .’аблонная функция должна иметь хотя бы один параметр, являющийся классом. Каж- й такой параметр имеет следующий вид: class идентификатор -пример, рассмотрим шаблонную функцию template <class Туре> Type max(Type first, Type second) { return first > second ? first : second; . ::сь Type — это параметр, указывающий тип данных, с которыми работает функция. <емпляры этой шаблонной функции можно создавать для работы с любым типом дан- для которого определен оператор >. Например, экземпляр процедуры для типа int - «ачестве параметра может иметь следующий вид: xnt maxfxnt first, int second) { return first > second ? first : second; Эти действия можно описать в виде макроопределения (макроса), однако в этом слу- зе оно может работать неправильно, если его параметрами являются выражения, •.•еюшие побочный эффект. Допустим, что макрос был определен следующим образом: refine max(a, b) ((а) > (b) ? (а) : (b) '-?т макрос является настраиваемым, т.е. он работает с данными любого числового ти- _ Однако он не всегда действует правильно, если вызывается с параметрами, имеющи- г побочный эффект, например: Гг.х (х++, у) ; ? приводит к следующему результату: :<++) > (У) ? (х++) : (у)) * -кдый раз, когда значение переменной х больше значения переменной у, значение пе- ? именной х увеличивается дважды. Экземпляры шаблонных функций в языке C++ создаются неявно, независимо от того, - - ? именно указано в вызове функции: ее имя или ее адрес, полученный с помощью опе- 7 адии &. Например, экземпляр шаблонной функции, определенной выше, можно создать 2=2жды с помощью следующего фрагмента кода: для параметров типа int и типа char, . гтветственно: : 3. Настраиваемые подпрограммы 379
int b, c; char d, e, f; c max(a,b); f = max(d, e); Ниже приведена версия настраиваемой подпрограммы на языке C++, описанная в • разделе 8.8.1. Она немного отличается от предыдущей версии, поскольку индексы мас- сивов в языке C++ должны быть целыми, а их нижней границей является нуль. template <class Туре> void generic_sort(Type list[], int len { int top, bottom; Type temp; for (top = 0; top < len - 2; top++) for (bottom = top + 1; bottom < len - 1; bottom) if (list[top] > list[bottom]) { temp = list[top]; list[top] = list[bottom]; list[bottom] = temp; } //* конец цикла for ( bottom = ... ] //** конец настраиваемой функции сортировки Ниже приведен пример создания экземпляра этой шаблонной функции: float flt_lit[100]; generic—sort(flt_list, 100); Настраиваемые подпрограммы на языке Ada и шаблонные функции в языке C++ имеют отдаленное отношение к подпрограммам, в которых типы формальных парамет- ров при вызове динамически связываются с типами фактических параметров. В этом случае нужен лишь один экземпляр кода, в то время как подходы, реализованные в язы- ках Ada и C++, требуют создания во время компиляции копии кода для каждого отдель- ного типа, а связывание вызовов с подпрограммами осуществляется статически. В языках Smalltalk, Java, Ada 95 и C++ вызовы динамически связываются с нужной версией метода, соответствующего типам фактических параметров. Эта тема обсуждает- ся в главе 11. 8.9. Раздельная и независимая компиляция Возможность компилировать части программы без компиляции всей программы су- щественна при создании больших систем программного обеспечения. Следовательно, языки, разрабатывемые для таких приложений, должны допускать такой вид компиля- ции. Имея такую возможность, программист при разработке или эксплуатации системы может перекомпилировать только те ее модули, которые подверглись изменениям. Как заново, так и ранее скомпилированные модули собираются в одно целое программой, на- зываемой редактором связей, которая является частью операционной системы. Если бы пакой возможности не было, то при каждом изменении системы потребовалась бы пол- ная перекомпиляция. Для больших систем это было бы слишком дорого. 380 Глава 8. Подпрограммы
3 этом разделе мы обсудим два разных подхода к компиляции частей программы, на- = заемых раздельной компиляцией и независимой компиляцией. Части программ, кото- ? г е могут компилироваться отдельно, иногда называются единицами компиляции. Термин раздельная компиляция (separate compilation) означает, что единицы компи- • = ^ии могут компилироваться в разное время, но при этом процессы их компиляции зави- : =’ друг от друга, если единицы компиляции имеют доступ к каким-либо сущностям дру- •-х единиц компиляции или используют их. Такая взаимозависимость нужна при проверке -терфейса. Сначала мы обсудим раздельную компиляцию в контексте языка Ada. Для надежной раздельной компиляции некоторого модуля компилятор должен иметь 2 ?сту п к информации о сущностях программы (переменных, типах и протоколах подпро- “тамм). которые используются в данном модуле, но объявлены где-то в другом месте. . шности пакета на языке Ada, видимые в других модулях, называются экспортируемы- 'И Информация о таких сущностях образует интерфейс пакета. Напомним, что протокол roue дуры включает в себя количество, имена и типы ее параметров, вместе с порядком, = котором они появляются. В протокол функции также включается тип возвращаемого учения. Системы реализации языка Ada хранят информацию об интерфейсе модуля в ?сту пной компилятору библиотеке. Информация об интерфейсе помещается в библио- -еку при каждой компиляции. В библиотеках хранятся библиотечные модули, представляющие собой уже скомпи- лированные единицы компиляции. В языке Ada единицами компиляции являются заго- *эвки подпрограмм, объявления пакетов (см. главу 10) и тела подпрограмм. Во время компиляции программного модуля на языке Ada все используемые сущно- :’и. объявленные вне данного модуля, подвергаются проверке на совместимость типов. В подпрограммах проверка совместимости типов выполняется для всего протокола. • гнкретной программной единице компиляции доступна не вся информация, хранящая- .3 в библиотеке. Имена тех программных модулей, в которых находятся нужные внеш- -ие сущности, перечисляются в операторе with в начале модуля, подлежащего компи- ляции. Используя оператор with, программист указывает внешние сущности, к которым должен иметь доступ компилируемый код. Например, приведенная ниже процедура ис- -?льзует сущности, находящиеся в двух внешних модулях GLOBALS и ТЕХТ_1О, и, сле- довательно, указывает их: with GLOBALS, TEXT_IO; procedure EXAMPLE is end EXAMPLE; Язык Modula-2 обеспечивает раздельную компиляцию таким же способом. Язык - ORTRAN 90 также допускает раздельную компиляцию своих подпрограмм и модулей. Раздельная компиляция в языке Ada будет обсуждаться более детально в главе 10 после ~?го. как мы рассмотрим возможности абстракции данных в этом языке. В некоторых языках, среди которых выделяются языки С и FORTRAN 77, допускает- ся независимая компиляция. При независимой компиляции (independent compilation) программные модули можно компилировать без информации о каких-либо еще про- граммных единицах. Наиболее важное свойство независимой компиляции заключается в том, что интерфей- сы между отдельно компилируемыми модулями не подвергаются проверке на совмести- мость типов. Интерфейс подпрограммы на языке FORTRAN 77 представляет собой список 8.9. Раздельная и независимая компиляция 381
параметров. Когда подпрограмма компилируется отдельно, типы ее параметров не хранят- ся вместе с компилируемым кодом или в библиотеке. Следовательно, при компиляции дру- гой программы, вызывающей данную подпрограмму, типы фактических параметров в вы- зове не могут проверяться на совместимость с типами формальных параметров подпро- граммы, даже если доступен машинный код вызываемой подпрограммы. Для языка FORTRAN 77 это не удивительно. Даже когда программа, вызывающая не- кую подпрограмму, и сама эта подпрограмма компилируются в одном и том же файле, они, по существу, компилируются независимо. Таким образом, интерфейс между программны- ми модулями на языке FORTRAN 77 никогда не проверяется на совместимость типов. Некоторые языки не допускают ни раздельной, ни независимой компиляции. Это оз- начает, что единственной единицей компиляции является полная программа. К таким языкам относятся язык FORTRAN II и исходная версия языка Pascal. Отсутствие как раз- дельной, так и независимой компиляции очень сильно ограничивает язык, делая факти- чески невозможным его промышленное использование. Совершенно ясно, что лучше иметь возможность независимой компиляции, чем совсем не иметь возможности компи- лировать отдельные части программы, хотя при этом не происходит проверка интерфей- сов программных модулей на совместимость типов. В более поздних версиях языков FORTRAN и Pascal это недостаток был устранен. 8.10. Вопросы разработки функций Для функций характерны следующие вопросы разработки. Допускаются ли побочные эффекты? Значения каких типов могут возвращаться функциями? 8.10.1. Побочные эффекты функций Вследствие побочных эффектов, которые характерны для функций, вызываемых в выражениях, как описано в главе 4, параметры в функции должны передаваться в режи- ме ввода. Некоторые языки фактически требуют выполнения этого условия; например, функции в языке Ada могут иметь только формальные параметры, передаваемые в ре- жиме ввода. Это эффективно предотвращает появление побочных эффектов при переда- че параметров или при возникновении совмещения имен параметров и глобальных пере- менных. В большинстве других языков, однако, функции могут иметь параметры, пере- даваемые либо по значению, либо по ссылке, допуская, таким образом, как побочные эффекты, так и совмещение имен. 8.10.2. Типы возвращаемых значений В большинстве императивных языков программирования типы переменных, которые могут возвращаться функциями, ограничены. В языках FORTRAN 77, Pascal и Modula-2 функции могут возвращать только неструктурированные типы. В языке С функции воз- вращают переменные любого типа, кроме массивов и функций. Однако в языке С функ- ции могут возвращать указатели как на массивы, так и на функции. Язык C++ похож на язык С, однако в нем функции могут возвращать также переменные определенных поль- зователем типов, или объекты классов. Язык Ada— единственный среди современных императивных языков, в котором функции могут возвращать переменные любого типа. 382 Глава 8. Подпрограммы
8.11. Доступ к нелокальным средам Несмотря на то что в основном связь между подпрограммами может осуществляться -ерез параметры, в большинстве языков предусмотрен еще один способ доступа к пере- менным — через внешние среды. Нелокальные переменные (nonlocal variables) подпрограммы — это те переменные, • вторые видимы в подпрограмме, но не объявлены в ней локально. Глобальные пере- менные (global variables)— это те переменные, которые видимы во всех модулях про- -таммы. В главе 4 обсуждались два метода доступа к нелокальным переменным: стати- -еский обзор и динамический обзор. Проблемы разработки этих способов рассматрива- •отся ниже. Основная проблема, связанная с применением статического обзора данных для со- вместного использования нелокальных переменных, состоит в следующем: структура -рограммы определяется главным образом доступностью локальных и глобальных пере- менных другим подпрограммам, а не хорошо продуманными проектными решениями. Кроме того, во всех случаях для доступа к нелокальным переменным предоставляется больше возможностей, чем требуется. Примеры, иллюстрирующие эту проблему, приве- дены в главе 4. Динамический обзор данных порождает два вида проблем. Во-первых, в промежутке времени от начала и до завершения выполнения подпрограммы локальные переменные являются видимыми для любой другой выполняемой подпрограммы, независимо от того, -ас коль ко близки эти подпрограммы по своему содержанию. Не существует способа за- щиты локальных переменных от такого доступа. Подпрограммы всегда выполняются в ~е посредственном окружении вызывающего модуля. Вследствие этого динамический ?бзор данных приводит к менее надежным программам, чем статический. Вторая проблема, связанная с динамическим обзором, — невозможность статической /роверки совместимости типов ссылок на нелокальные переменные, поскольку невоз- можно статически обнаружить объявление такой переменной. Реализация доступа к нелокальным переменным детально обсуждается в главе 9. 8.11.1. Блоки COMMON языка FORTRAN В языке FORTRAN доступ к блокам, в которых хранятся глобальные переменные, ществляется через оператор COMMON. В программе может быть сколько угодно таких '/оков, и все, кроме одного из них, должны иметь имя. (Один неименованный блок /.IXON, часто называемый пустым блоком COMMON, обладает иными свойствами по .гавнению с именованными COMMON-блоками.) Любая подпрограмма, в которой следует -\1>чить доступ к блоку COMMON или создать его, содержит оператор COMMON, име- ->ющий этот блок и задающий список переменных для доступа к содержимому этого г/ока. Блок COMMON создается в тот момент, когда компилятор находит первый оператор '’-'ON, задающий имя данного блока. В программе допускается неограниченное количество подпрограмм, которые могут : ? держать оператор COMMON, указывающий один и тот же блок. Далее, каждая подпро- "гамма, в которой указан данный блок, может определять свой собственный список пе- геменных, по количеству и типам переменных никак не связанный со списками пере- менных в других подпрограммах. Например, в рамках одной программы один и тот же 3.11. Доступ к нелокольным.средам 383
INTEGER Е(200) COMMON /BLOCK1/ BL0CK1 350 100 101 150 151 50 51 С >D > Е Рис. 8.3. Две точки зре- ния на блок COMMON блок в разных подпрограммах можно объявлять по-разному. Пусть в первой подпро- грамме содержатся следующие объявления: REAL А(100) INTEGER В(250) COMMON /BLOCK1/ А, В В другой подпрограмме объявления могут быть иными: REAL С(50), D(100) С, D, Е Идентификатор BLOCK1, отделенный косыми чертами, пред- ставляет собой имя блока. Две точки зрения на переменные в блоке BLOCK1 показаны на рис. 8.3. Здесь предполагается, что целочисленные и действительные переменные занимают оди- наковый объем памяти. Это может иметь смысл, только если блок BLOCK1 является просто совместно используемой памятью, а не хранит совме- стно используемые данные. Совместно используемая па- мять — главная причина того, что допускается такое измене- ние списков переменных. Простое нарушение порядка в списке переменных оператора COMMON может привести к тому, что одна и та же область памяти будет совместно использоваться переменными разных типов, порождая ошибку, которую труд- но обнаружить. Описание языка FORTRAN 90 указывает, что оператор COMMON относится к “нежелательным свойствам”. Это означа- ет, что он может быть включен только в следующую версию языка FORTRAN, а впоследствии будет из этого языка исклю- чен. Оператор EQUIVALENCE также является нежелательным свойством языка FORTRAN 90. 8.11.2. Внешние объявления и модули Языки Modula-2 и Ada используют статический обзор как средство совместного ис- пользования данных программными модулями. В обоих этих языках предусмотрен также альтернативный метод совместного использования данных. Он заключается в том, что в программных модулях разрешается указывать внешние модули, к которым требуется доступ. С помощью этого метода в каждом модуле можно точно указать другие модули, к которым нужен доступ, не больше и не меньше. В языке Modula-2 этот доступ может быть открыт только к указанным процедурам, переменным и типам данных из заданного внешнего модуля. В языке Ada программист может указывать только имя внешнего мо- дуля, который затем обеспечивает доступ ко всем своим типам, переменным и процеду- рам. Этот метод также возможен и в языке Modula-2. Указывать каждую сущность, к ко- торой требуется доступ, очевидно, слишком утомительно, поскольку список необходи- мых типов, переменных и процедур может быть очень длинным. Однако этот метод более безопасен, чем простое указание имени модуля. 384 Глава 8. Подпрограммы
Язык FORTRAN 90 также содержит модули, которые могут поддерживать выбороч- ное совместное использование нелокальных данных, предусматривающее проверку со- вместимости типов. Эти языковые свойства более тщательно рассматриваются в главе 10 в связи с абст- Г2к\1ией данных. В таких объектно-ориентированных языках, как C++ и Java, можно использовать • 2:с для инкапсулирования наборов данных, используемых совместно с другими клас- . - • Классы детально обсуждаются в главе 11. Б языке С нет вложенных подпрограмм, поэтому существует только один уровень об- 71 подпрограмм. Глобальные переменные можно создавать, помещая их объявления ~ : пределения функций. Доступ к переменной, объявленной в функции как внешняя, г;печивается с помощью оператора external. Все функции, определения которых .12.ют за объявлениями глобальных переменных в исходном файле, имеют доступ к ' •• переменным без объявления их в качестве внешних. Этот метод элегантен, но не- : пасен, и обеспечивает намного больше доступа, чем это обычно требуется. Глобальные переменные, определенные в других файлах программы на языке С. мо- * “ также быть доступными функциям, объявившим их в качестве внешних переменных. им образом, путем раздельной компиляции модулей можно создать отдельные, не егесекаюшиеся между собой области видимости переменных. С помощью этого метода 2:ст>п к переменным в этих раздельно компилируемых модулях ограничен только теми I чкниями, в которых эти переменные объявлены как внешние. 8.12. Перегруженные операторы, определяемые п о л ьзователем 3 программах на языках Ada и C++ программист может перегружать операторы. : -з-естве примера рассмотрим функцию на языке Ada, перегружающую оператор ум- - -ения (*) для вычисления скалярного произведения двух векторов. Скалярное произ- ?т7хчие двух векторов представляет собой сумму попарных произведений соответст- <иих элементов этих векторов. Предположим, что переменная VECTOR_TYPE была -гелелена как массив, содержащий элементы типа INTEGER. function "*"(А, В : in Vector_TYPE) return INTEGER is SUM: INTEGER := 0; begin for INDEX in A'range loop SUM := SUM + A(INDEX) * В(INDEX); end loop; — for Index ... return SUM; end . • длярное произведение, как указано в этом определении функции, будет вычислено, как ..“»ко звездочка появится между двумя операндами типа VECTOR_TYPE. Звездочка да- zz может перегружаться сколько угодно раз, поскольку определение функции имеет кальный протокол. Фчнкция вычисления скалярного произведения, приведенная выше, также может % ’ь написана на языке C++. Прототип такой функции может иметь следующий вид: 5 12. Перегруженные операторы, определяемые пользователем 385
int operator *(const vector &a, const vector &b, int len); Естественно, возникает вопрос: насколько можно перегружать оператор и нельзя ли его слишком перегрузить? Ответ таков: оператор можно перегружать значительно, это — дело вкуса. Аргумент против слишком сильной перегрузки операторов сводится в основном к снижению надежности программы. Во многих случаях гораздо более чита- бельной является программа, в которой для выполнения некоторой операции вызывается функция, чем та, в которой используется оператор, часто применяемый к операндам дру- гих типов. Даже в скалярном произведении не ясно, что именно умножается в простом операторе присваивания: С := А * В; Легко ошибочно предположить, что переменные А, В и С представляют собой скалярные величины. Кроме того, системы программного обеспечения обычно конструируются из модулей, создаваемых разными группами разработчиков. Если разные группы перегрузили один и тот же оператор разными способами, эти различия обязательно нужно исключить, перед тем как объединять модули в единую систему. 8.13. Сопрограммы Сопрограммы (coroutines)— это специальная разновидность подпрограмм. В отли- чие от отношения “главный — подчиненный”, обычно существующего между вызываю- щей и вызываемой подпрограммами, вызывающая и вызываемая сопрограммы равно- правны. Механизм управления сопрограммой часто называют моделью управления сим- метричными модулями. Действительное происхождение концепции управления симметричными модулями трудно определить. Одно из наиболее ранних опубликованных применений сопрограмм относилось к синтаксическому анализу (Conway, 1963). Первым языком высокого уров- ня, имевшим средства поддержки сопрограмм, был язык SIMULA 67. Напомним, что ис- ходной целью языка SIMULA было моделирование систем, часто требующее моделиро- вания независимых процессов. Это стало причиной, по которой в языке SIMULA 67 бы- ли разработаны сопрограммы. Другие языки, поддерживающие сопрограммы, — языки BLISS (Wulf et al., 1971), INTERLISP (Teitelman, 1975) и Modula-2 (Wirth, 1985). Сопрограммы имеют несколько входных точек, которые управляются самими сопро- граммами. Они также имеют средства поддержки своего состояния между вызовами. Это означает, что сопрограммы должны зависеть от своей предыстории и, следовательно, иметь статические локальные переменные. Повторные выполнения сопрограмм часто начинаются с точек, не совпадающих с их началом. Вследствие этого процесс активации сопрограмм называется возобновлением (resume), а не вызовом. Сопрограммы обладают одним свойством подпрограмм: одновременно может вы- полняться только одна сопрограмма. Однако вместо выполнения операций вплоть до конца сопрограммы часто выполняются частично и передают управление другой сопро- грамме. При повторной активации сопрограмма возобновляет выполнение с оператора, непосредственно следующего за точкой, из которой в предыдущий раз управление было передано в другую сопрограмму. Этот вид последовательности выполнения сопрограмм связан с особенностями работы многопроцессорных операционных систем. Даже при 386 Глава 8. Подпрограммы
. зльзовании одного процессора в таких системах все выполняющиеся программы за- икаются параллельно, совместно эксплуатируя ресурсы процессора. При работе с со- -. -раммами такой вид выполнения программ иногда называют квазипараллельностью. Обычно сопрограммы создаются в приложениях программным модулем, который на- : сзется главным модулем (master unit) и не является сопрограммой. После своего соз- . --ия сопрограммы инициализируют свой код и возвращают управление в главный мо- □ После создания всего семейства сопрограмм главный модуль возобновляет выпол- одной из сопрограмм, а затем члены семейства сопрограмм возобновляют друг - а в некотором порядке, пока их работа не завершится, если она действительно может .-ершиться. Если выполнение некоторой сопрограммы достигает конца ее кода, управ- :-ле передается главному модулю, который ее создал. Это— механизм завершения - г олнения набора сопрограмм по требованию. В некоторых программах сопрограммы - лолняются, пока работает компьютер. Моделирование карточной игры представляет собой пример задачи, которую можно •. ~ить с помощью некоторого набора сопрограмм. Предположим, что в игре участвуют .’oipe игрока, использующие одну и ту же стратегию игры. Такую игру можно модели- - ?зть с помощью главного модуля, создающего семейство сопрограмм, каждая из кото- : к имеет свой набор карт. В таком случае главная программа может начать моделиро- i-ие. возобновив выполнение сопрограммы одного из игроков, которая в свою очередь, . глав ход, может возобновить выполнение подпрограммы следующего игрока, и так да- . г пока игра не закончится. 7\ же форму возобновления можно использовать и для начала, и для продолжнения -. -.олнения сопрограмм. Допустим, что программные модули А и В являются сопрограммами. На рис. 8.4 по- _:зны два способа последовательного выполнения модулей А и В. На рис. 8.4, а выполнение сопрограммы А инициируется главным модулем. После не- • ?рого периода выполнения сопрограмма А передает управление сопрограмме В. Ко- 1- сопрограмма В на рис. 8.4, а впервые передает управление сопрограмме А, это озна- что сопрограмма А продолжает свое выполнение с того места, где она закончила . - е предыдущее выполнение. В частности, ее локальные переменные сохраняют свои -пения, полученные ими во время предыдущего выполнения. На рис. 8.4,6 показана ->тернативная последовательность выполнения сопрограмм А и В. В этом случае глав- -: м модулем инициируется выполнение сопрограммы В. 3 отличие от образцов, показанных на рис. 8.4, сопрограмма часто имеет цикл, со- :.гжащий возобновление. На рис. 8.5 показана последовательность выполнения при . м сценарии. В этом случае сопрограмма А запускается главным модулем. Внутри .: :его основного цикла сопрограмма А возобновляет выполнение сопрограммы В, кото- _=. в свою очередь, возобновляет выполнение сопрограммы А в своем основном цикле. : ’ 3. Сопрограммы 387
a) 6) Рис. 8.4. Две возможные последовательности выпол- нения двух сопрограмм без циклов ь в Рис. 8.5. Последовательность выполнения сопрограмм с циклами 388 Глава 8. Подпрограмм!
Резюме Процесс абстрагирования представлен в языках программирования подпрограммами, те деление подпрограммы описывает действия, которые выполняет подпрограмма. z : тв подпрограммы приводит к выполнению предусмотренных ею действий. Формальные параметры — это имена, которые используются А подпрограмме для . т лок на фактические параметры, полученные при вызовах подпрограммы. Подпрограммы могут быть либо функциями, которые моделируют математические : нкнии и используются для определения новых операций, либо процедурами, опреде- -<ши.ми новые операторы. Локальные переменные в подпрограммах могут размещаться динамически в стеке, 'еспечивая поддержку рекурсии, или статически, обеспечивая эффективность и зависи- : сть локальных переменных от своей предыстории. Есть три основные семантические модели передачи параметров— режим ввода, ре- * м вывода и режим ввода-вывода — и большое количество подходов к их реализации. При использовании параметров, передаваемых по ссылке, возникает совмещение .*ен между несколькими параметрами, а также между параметром и видимой нелокаль- - • й переменной. Доступ к нелокальной переменной обеспечивается несколькими разными способами: -егез внешние объявления, блоки глобальных данных, внешние модули, а также через .-этический и динамический обзор. Параметры, являющиеся именами подпрограмм, обеспечивают необходимый сервис, лнако их иногда трудно понять. Непрозрачность заключается в среде ссылок, доступной выполнении подпрограммы, передаваемой в качестве параметра. В языках Ada и C++ допускается перегрузка подпрограмм и операторов. Подпро- “тзммы можно перегружать, поскольку их различные версии различаются между собой • нами параметров или типами возвращаемых значений. Определения функций можно ;пользовать для приписывания операторам нового смысла. Подпрограммы в языках Ada и C++ могут быть настраиваемыми и использовать па- раметрический полиморфизм, так что компилятору для построения модулей, соответст- - ющих требуемому типу данных, можно передавать объекты нужного типа. Сопрограммы — это специальные подпрограммы, имеющие несколько входов. Их ажно использовать для перемежающегося выполнения подпрограмм. Вопросы 1. Каковы три основные характеристики подпрограмм? 2. Что означает выражение “активная подпрограмма”? 3. Что представляет собой параметрический профиль? Что такое протокол подпро- граммы? 4. Что такое формальные параметры? Что такое фактические параметры? 5. Какие достоинства и недостатки имеют ключевые параметры? Вопросы 389
6. Какие проблемы связаны с разработкой подпрограмм? 7. Какие достоинства и недостатки имеют динамические локальные переменные? 8. Каковы три основные семантические модели передачи параметров? 9. Что такое режимы, концептуальные модели передачи, достоинства и недостатки методов передачи параметров по значению, по результату, по значению и резуль- тату, по ссылке и по имени? 10. Каким образом возникают альтернативные имена при передаче параметров по ссылке? 11. В чем состоит отличие способов, которыми исходная версия языка С и язык ANSI С работают с фактическим параметром, тип которого не идентичен типу соответ- ствующего формального параметра? । 12. В чем заключается проблема, связанная с подходом, использованным в языке Ada, позволяющим разработчикам средств реализации языка самим решать, какие па- раметры передавать по ссылке, а какие — по значению и результату? 13. Назовите два основных вопроса, связанных с методами передачи параметров. 14. Сформулируйте два основных вопроса, возникающих при передаче имен подпро- грамм в качестве параметров. 15. Дайте определение понятий теневое и глубокое связывание для сред ссылок под- программ, передаваемых в качестве параметров. 16. Что такое перегруженная подпрограмма? 17. Что такое параметрический полиморфизм? 18. Что приводит к созданию экземпляров шаблонной функции в языке C++? 19. Дайте определение понятий раздельная и независимая компиляция. 20. Какие вопросы связаны с разработкой функций? 21. Чем сопрограммы отличаются от обычных подпрограмм? У п р о ж н е н и я 1. Приведите аргументы “за” и “против” пользовательской программы, создающей до- полнительные определения для существующих операторов, как это сделано в языках Ada и C++. Считаете ли вы такую определяемую пользователем перегрузку операто- ров хорошим или плохим способом программирования? Обоснуйте ваш ответ. 2. В большинстве реализаций языка FORTRAN IV параметры передавались по ссыл- ке с использованием исключительно передачи пути доступа к данным. Укажите достоинства и недостатки этого проектного решения. 3. Приведите аргументы в поддержку решения разработчиков языка Ada 83 позво- лить создателям средств реализации языка самим делать выбор между реализацией режима in out передачи параметров с помощью копирования или с помощью ссылок. 390 Глава 8. Подпрограммы
4. Язык FORTRAN имеет две слабо отличающиеся разновидности блоков COMMON: пустой и именованный. Одно различие между ними заключается в том, что пустой блок COMMON не может быть инициализирован во время компиляции. Можете ли вы определить причину, по которой пустой блок COMMON разработан именно таким образом? Подсказка, это проектное решение было сделано в самом начале разра- ботки языка FORTRAN, когда память компьютеров была довольно маленькой. 5. Допустим, вы хотите написать подпрограмму, печатающую заголовки на новых страницах вывода вместе с номерами страниц, начиная с 1 при первом вызове и увеличивая номер на 1 при каждом последующем вызове. Можно ли это сделать в языке Pascal без параметров и без ссылок на нелокальные переменные? Можно ли это сделать в языке FORTRAN? Можно ли это сделать в языке С? с». В языке FORTRAN подпрограммы могут иметь несколько входных точек. Когда это свойство бывает ценным? Напишите на языке Pascal процедуру ADDER, складывающую два целочисленных массива. Она должна иметь только два параметра, означающих два складываемых массива. Второй массив также должен содержать результат, полученный на выхо- де. Оба параметра должны передаваться по ссылке. Проверьте процедуру ADDER с помощью вызова ADDER (А, А). Здесь переменная А— это массив, который складывается сам с собой. Объясните результаты выполнения этой программы. 8. Решите задачу 7 с помощью языка С. °. Рассмотрите процедуру BIGSUB-из раздела 8.5.2.5. Замените два оператора при- сваивания значений элементам массива LIST следующими: LIST[1] := 3 LIST[2] := 1 Выполните новую программу вручную при следующих предположениях и сравни- те с результирующими значениями в массиве LIST в подпрограмме BIGSUB после возвращения из подпрограммы SUB. 9.1. Параметры передаются по значению. 9.2. Параметры передаются по ссылке. 9.3. Параметры передаются по имени. 9.4. Параметры передаются по значению и результату. .0. Рассмотрите следующую программу, написанную с использованием синтаксиса языка С: void main() { int value = 2, list[5] = {1, 3, 5, 7, 9}; swap(value, list [0]); swap(list[0], list [1]); swap(value, list[value]); void swap( int a, int b) { int temp; temp = a; a = b * "оажнения 391
b = temp; } Каковы все значения переменных value и list при каждом из трех вызовов функции s;.ap для каждой из следующих моделей передачи параметров? 10.1 . Передача по значению. 10.2 . Передача по ссылке. 10.3 . Передача по имени. 10.4 . Передача по значению и результату. 11. Приведите аргумент против применения как статических, так и динамических ло- кальных переменных в подпрограммах. 12. Приведите аргумент против того, что в языке С подпрограммы могут быть только функциями. 13. Пользуясь каким-либо учебником по языку FORTRAN, изучите синтаксис и семан- тику операторных функций. Обоснуйте необходимость их существования в языке FORTRAN. 14. Изучите методы определенной пользователем перегрузки операторов в языках C++ и Ada и напишите отчет, сравнивая эти два языка, пользуясь нашим критерием оценки языков. 15. Рассмотрим следующую процедуру на языке ALGOL 60. называемую схемой Йен- сена в честь Й.Йенсена (JJensen) из Regnecentralen (Copenhagen), разработавшего ее в 1960 году: real procedure SUM(ADDER, INDEX, LENGTH); value LENGTH; real ADDER; integer INDEX, LENGTH; begin real TEMPSUM; TEMPSUM := 0.0; for INDEX := 1 step 1 until LENGTH do TEMPSUM := TEMPSUM + ADDER; SUM := TEMPSUM; end; Что возвращается при каждом из следующих вызовов процедуры SUM, с учетом того, что параметры передаются по имени и что возвращаемое значение присваи- вается имени подпрограммы? 15.1. SUM (А, I, 100), где А — скаляр. 15.2. SUM (А [ I ] *А [ I ] , I, 100), где А — массив из 100 элементов. 15.3. SUM (А [ I ] *А [ I ], I, 100), где Аи В — массивы из 100 элементов. 392 Глава 8. Подпрограммы
Реализация подпрограмм Джон Кемени (John Kemeny) Л «он Кемени и его коллега ’:мас Курц (Thomas Kurtz) = ~ачале 1960-х годов разрабо- -аги в Дартмуте компиляторы нескольких диалектов языков -.GOL и FORTRAN В 1963 году - вмени приступил к созданию =зэ ка BASIC и закончил его ' 964 году В этой г л а g <? 9.1. Общая семантика вызовов и возвратов z 9.2. Реализация подпрограмм на языке FORTRAN 77 9.3. Реализация подпрограмм на языках, подобных языку ALGOL 9.4. Блоки 9.5. Реализация методов динамического обзора данных 9.6. Реализация параметров, являющихся именами подпрограмм Реализация подпрограмм 393
Цель этой главы — исследовать методы реализации подпрограмм. В ней читатель получит некоторое представление о том, каким образом “работают” языки про- граммирования, а также почему язык ALGOL 60 в начале 1960-х годов представлял со- бой вызов ничего не подозревавшим создателям компиляторов. Мы начнем с простей- шей разновидности подпрограмм — подпрограмм в языке FORTRAN 77, а затем перей- дем к более сложным подпрограммам в таких языках со статическим обзором данных, как Pascal и Ada. Возрастающие трудности, связанные с реализацией подпрограмм в этих языках, вызваны необходимостью поддержки рекурсии и механизмов доступа к нело- кальным переменным. Детально обсуждаются и сравниваются между собой два метода доступа к нелокаль- ным переменным в языках со статическим обзором данных— статические цепочки и дисплеи. Кратко описаны способы реализации блоков. Рассмотрены виды доступа к не- локальным переменным в языках с динамическим обзором данных. В заключение опи- сывается метод реализации параметров, представляющих собой имена подпрограмм. 9.1. Общая семантика вызовов и возвратов Вызов подпрограммы и операции возврата управления, взятые вместе, называются связыванием подпрограмм (subprogram linkage). Любой метод реализации подпро- грамм должен основываться на семантике связывания подпрограмм. Вызов подпрограммы в обычном языке программирования связан с большим количе- ством действий. Так, он содержит механизм передачи параметров. Если локальные пе- ременные не являются статическими, вызов приводит к выделению памяти для локаль- ных переменных, объявленных в вызываемой подпрограмме, и связывает их с этими ячейками памяти. Он должен сохранять текущее состояние выполняемого модуля, вы- звавшего подпрограмму; передавать управление коду подпрограммы и гарантировать, что оно будет возвращено в нужное место после выполнения подпрограммы. В заключе- ние вызов должен обеспечить доступ к нелокальным переменным, видимым в вызывае- мой подпрограмме. Возврат управления из подпрограммы также достаточно сложен. Если параметры подпрограммы передаются в режиме вывода и по копии, при возврате управления внача- ле следует присвоить фактическим параметрам локальные значения соответствующих формальных параметров. Затем должна быть освобождена память, использованная для хранения локальных переменных, и восстановлено текущее положение вызывающего программного модуля. Для возврата механизма доступа к нелокальным переменным в состояние, в котором он пребывал до вызова подпрограммы, также требуется выполнить несколько действий. В заключение управление должно быть возвращено вызывающему программному модулю. 9.2. Реализация подпрограмм на языке FORTRAN 77 Мы начнем с относительно простого примера — подпрограмм в языке FORTRAN 77. Все обращения к нелокальным переменным в языке FORTRAN 77 осуществляются через блоки COMMON. Поскольку блоки COMMON не относятся к механизму связывания под- программ, они здесь не обсуждаются ‘Другим упрощающим дело обстоятельством явля- ется тот факт, что в языке FORTRAN 77 подпрограммы не могут быть рекурсивными. 394 Глава 9. Реализация подпрограмм
Ьолее того, во многих реализациях переменные, объявленные в подпрограммах, разме- щаются статически. Семантика вызова подпрограмм в языке FORTRAN 77 требует выполнения следую- щих действий. 1. Сохранить текущее состояние выполняемого программного модуля. 2. Передать параметры. 3. Передать адрес возврата вызываемой подпрограмме. 4. Передать управление вызываемой подпрограмме. При возврате из подпрограмм в языке FORTRAN 77 выполняются следующие действия. 1. Если использовался метод передачи параметров по значению и результату, теку- щие значения этих параметров присваиваются соответствующим фактическим па- раметрам. 2. Если подпрограмма представляет собой функцию, то значение функции переме- щается в место, доступное вызывающему модулю. 3. Восстанавливается первоначальное текущее состояние вызывающего модуля. 4. Управление передается назад вызывающему модулю. Действия, связанные с вызовом и возвратом, требуют выделения памяти для следую- щих данных. Информация о состоянии вызывающего модуля. Параметры. Адрес возврата. Возвращаемое значение функции. ?-и данные вместе с локальными переменными и кодом подпрограммы формируют пол- - ю совокупность информации, необходимой подпрограмме для своего выполнения и . зврата управления вызывающему модулю. Подпрограмма на языке FORTRAN 77 состоит из двух отдельных частей: собственно * :да подпрограммы, являющегося неизменным, а также локальных переменных и дан- ->л. перечисленных выше, которые могут изменяться при выполнении подпрограммы. . ге эти части имеют фиксированные размеры. Формат, или структура, части подпрограммы, не являющейся кодом, называется за- ''исью активации (activation record), или активационной записью, поскольку данные, • :торые она описывает, относятся только к активации подпрограммы. Форма записи ак- - нации является статичной. Экземпляр записи активации derivation record instance) — это конкретный образец записи ак- " нации, или набор данных в форме активационной записи. Поскольку язык FORTRAN 77 не поддерживает рекурсии, в • аядый момент времени может существовать только одна ак- ~ иная версия данной подпрограммы и только один экземпляр 1лиси активации подпрограммы. Возможная структура записей кивации в языке FORTRAN 77 показана на рис. 9.1. Здесь и в давшейся части главы мы пропускаем сохраненное текущее Рис. 9.L Запись актива- ..стояние вызывающего модуля, поскольку это не относится к ции в языке FORTRAN 77 тедмету нашего обсуждения. ; 2. Реализация подпрограмм на языке FORTRAN 77 395
Поскольку экземпляр записи активации подпрограммы в языке FORTRAN 77 имеет фиксированный размер, его можно размещать в памяти статически. Фактически, его можно присоединить к коду подпрограммы. На рис. 9.2 показана программа на языке FORTRAN 77, состоящая из COMMON-блока, главного модуля и трех подпрограмм: А В и С. Хотя на рисунке показано, что все сегмен- ты кода хранятся отдельно от всех экземпляров записей активации, в некоторых случаях экземпляры записей активации присоединяются к соответствующему сегменту кода. Общая память Данные ” Локальные переменные Локальные переменные Параметры Адрес возврата Локальные переменные Параметры Адрес возврата Локальные переменные Параметры Адрес возврата Код < Рис. 9.2. Код и запись активации программы в языке FORTRAN 77 Компилятор не создает полную программу на языке FORTRAN 77, показанную на рис. 9.2. Действительно, поскольку существует независимая компиляция, четыре про- граммных модуля — MAIN, А, В и С — могут быть скомпилированы в разное время. По- сле компиляции каждого модуля его машинный код вместе со списком ссылок на внеш- ние подпрограммы и нелокальными переменными записывается в файл. Выполняемая программа, показанная на рис. 9.2, объединяется в одно целое редактором связей (linker), являющимся частью операционной системы. (Иногда редакторы связей называ- ются загрузчиками.) При вызове редактора связей для главной программы его первой задачей является поиск файлов, содержащих оттранслированные подпрограммы вместе с их экземплярами записей активации, и загрузка их в память. Он должен также опреде- 396 Глава 9. Реализация подпрограмм
’ !ть размер всех COMMON-блоков и разместить их в памяти. Затем редактор связей при- сваивает входные адреса вызываемых подпрограмм целевым адресам соответствующих ?э1зовов, находящихся в главном модуле. То же самое он делает со всеми вызовами, встречающимися в загружаемых подпрограммах, и всеми вызовами стандартных под* тограмм языка FORTRAN. В примере, показанном выше, редактор связей вызывался л/я модуля MAIN. Редактор связей должен был найти машинный код программ А, В и С вместе с их экземплярами активационных записей и загрузить в память вместе с кодом '• >л\ля MAIN. Затем он должен был вставить все целевые адреса для вызовов подпро- гамм А. В и С, а также вызовов всех библиотечных подпрограмм в подпрограммах А, В, и IIAIN. Кроме того, все ссылки на переменные в COMMON-блоках должны были быть вменены соответствующими адресами. Во многих случаях ссылки на нелокальные пе- теменные обрабатывают с помощью относительной адресации в блоке, избегая необхо- димости вставки адресов. В языке FORTRAN 90 подпрограммы могут быть вложенными (с помощью статиче- .• эго обзора) и рекурсивными. Это приводит к тому, что их реализация становится по- . жей на язык ALGOL. Методы реализации этих языков подробно обсуждаются в сле- : ющем разделе. 9.3. Реализация подпрограмм на языках, подобных языку ALGOL Рассмотрим реализацию связывания подпрограмм в языках со статическим обзором ж-ных, подобных языку ALGOL,— Pascal, Ada, FORTRAN 90 и Delphi, основное вни- -ние уделяя операциям вызова и возврата управления во все более усложняющихся си- 21ШЯХ. Детально описываются два связанных между собой, но разных подхода к реали- 2jhh доступа к нелокальным переменным, называемых статическими цепочками <atic chains) и индикаторами (dispays). Несмотря на то что в языках С, C++ и Java не допускаются вложенные подпрограм- их реализация похожа на реализацию других языков со статическим обзором дан- -=>.\. Отличие заключается в поддержке ссылок на нелокальные переменные, которая жет быть намного проще, если запрещаются вложенные программы. 9.3.1. Более сложные записи активации Связывание подпрограмм в языках, подобных языку ALGOL, осуществляется более . ^жным образом, чем связывание подпрограмм в языке FORTRAN 77. Это происходит следующим причинам. Обычно параметры могут передаваться двумя разными способами. Например, во многих языках они передаются по значению или по ссылке. Переменные, объявленные в подпрограммах, часто являются динамическими. Рекурсия добавляет возможность одновременно выполнять несколько активаций подпрограммы. Это означает, что одновременно может существовать несколько экземпляров подпрограммы, выполняющихся частично, активирующихся с помо- щью одного внешнего вызова подпрограммы и нескольких рекурсивных вызовов. Следовательно, для рекурсии требуется несколько экземпляров записей актива- ции. по одной на каждую активацию подпрограммы, которые могут существовать : 3. Реализация подпрограмм на языках, подобных языку ALGOL 397
одновременно. Для каждой активации нужна своя собственная копия формального параметра и динамически размещаемые локальные переменные вместе с адресами возврата. В языках, подобных языку ALGOL, для доступа к нелокальным переменным ис- пользуется статический обзор данных. Поддержка такого доступа к нелокальным переменным должна быть частью механизма связывания подпрограмм. Формат записи активации для данной подпрограммы в языках со статическим обзо- ром данных во время компиляции уже известен. В процедурах языка Pascal размер запи- сей активации также известен, поскольку все локальные данные процедуры имеют фик- сированный размер. Это условие не выполняется в некоторых других языках, в которых размер локального массива может зависеть от значения фактического параметра. В этих случаях формат записи активации является статическим, а ее размер — динамическим. В языках, подобных языку ALGOL, экземпляры записей активации должны создаваться динамически. Типичная запись активации для языка, подобного языку ALGOL, приведе- на на рис. 9.3. Вершина стека Рис. 9.3. Типичная запись активации в языке, подобном языку ALGOL Поскольку адрес возврата, статическая связь, динамическая связь и параметры поме- щаются в запись активации вызывающим модулем, они должны быть первыми элемен- тами записи активации. В этой главе мы предполагаем, что стек растет вверх. Следова- тельно, адрес возврата окажется на дне записи активации. Адрес возврата часто содержит указатель на сегмент кода вызывающего модуля и отно- сительный адрес команды, следующей за вызовом в этом сегменте кода. Статическая связь (static link), которую иногда называют указателем статического обзора, указывает на дно экземпляра записи активации статического предка. Она используется для доступа к не- локальным переменным. Статические связи детально обсуждаются в разделе 9.3.4. Дина- мическая связь (dynamic link) — это указатель на вершину экземпляра записи активации вызывающего модуля. В языках со статическим обзором данных эта связь используется при разрушении текущего экземпляра записи активации после выполнения процедуры. Ди- намическая связь нужна, поскольку в некоторых случаях в стеке находятся переменные, помещенные туда подпрограммой после записи активации. Например, там могут находить- ся временные переменные, необходимые для машинного кода подпрограммы. Таким обра- зом, даже зная размер записи активации, его нельзя просто вычесть из указателя на верши- ну стека для того, чтобы удалить запись. Фактические параметры в записи активации явля- ются значениями, или адресами, задаваемыми вызывающим модулем. 398 Глава 9. Реализация подпрограмм
Локальные скалярные переменные связываются с ячейками памяти в экземпляре за- писи активации. Локальные переменные, представляющие собой структуры, иногда раз- решаются в произвольном месте, а в запись активации заносятся только их дескрипторы * указатели на места их хранения. Локальные переменные размещаются в памяти и мо- ~ т инициализироваться вызываемой подпрограммой, поэтому они заносятся в запись активации последними. Рассмотрим следующую скелетную процедуру на языке Pascal: procedure sub(var total : real; part : integer); var list : array [1..5] of integer; sum : real; begin end; Запись активации подпрограммы sub показана на рис. 9.4. Локальная переменная sum Локальная переменная list [5] Локальная переменная list [4] Локальная переменная list [3] Локальная переменная list 12] Локальная переменная list [1] Параметр part Параметр total Динамическая связь Статическая связь Адрес возврата Рис. 9.4. Запись активации процедуры sub При активации процедуры динамически создается экземпляр записи активации этой -тоцедуры. Как указывалось ранее, формат этой записи во время компиляции является гиксированным, хотя размер записи в других языках, отличающихся от языка Pascal, • ?жет зависеть от вызова. Поскольку семантика вызова и возврата указывает, что под- “гограмма, вызванная последней, завершается первой, вполне резонно поместить созда- ваемые экземпляры записей активации в стек. Этот стек является частью системы под- держки выполнения программ и поэтому называется стеком выполняемой программы *.3. Реализация подпрограмм на языках, подобных языку ALGOL 399
(run-time stack), хотя мы будем называть его просто стеком. Каждая активация процеду- ры, независимо от того, является ли эта процедура рекурсивной или нет, создает новый экземпляр записи активации в стеке. Это обеспечивает требуемое разделение копий па- раметров, локальных переменных и адресов возврата. Напомним, что, как указывалось в главе 8, подпрограмма является активной, начи- ная с момента ее вызова и заканчивая моментом ее завершения. Когда подпрограмма становится неактивной, ее локальные области видимости перестают существовать и ее среда ссылок больше не имеет смысла. Таким образом, в этот момент ее экземпляр запи- си активации разрушается. 9.3.2. Пример без рекурсии и нелокальных ссылок Вследствие сложности реализации связывания подпрограмм мы рассмотрим их в не- сколько этапов. Во-первых, изучим пример программы, в которой нет обращений к не- локальным переменным и рекурсивных вызовов. В этом примере не используется стати- ческая связь. Затем мы обсудим, как реализовать рекурсию и доступ к нелокальным пе- ременным. Рассмотрим следующий скелетный пример программы: program MAIN_1; var Р : real; procedure A(X : integer); var Y : boolean; procedure C(Q : boolean); begin { C } . . . <----------3 end; { C } begin { A } <------------------2 C(Y) ; end; { A } procedure B(R : real); var S, T : integer; begin { В } <------------------1 A(S) ; end; {B} begin { MAIN_1 } В (P) ; end. { MAIN_1 } Последовательность вызовов процедур в этой программе такова: MAIN__1 вызывает В В вызывает А А вызывает С Содержимое стека в точках, отмеченных цифрами 1,2 и 3, показано на рис. 9.5. 400 Глава 9. Реализация подпрограмм
В точке 1 в стеке содержатся только экземпляры записей активации программы . 1AIN_1 и процедуры В. Когда процедура В вызывает процедуру А, в стек заносится эк- земпляр записи активации процедуры А. Когда процедура А вызывает процедуру С, в стек заносится экземпляр активации процедуры С. Когда выполнение процедуры С за- вершается, экземпляр ее записи активации удаляется из стека, а динамическая связь ис- пользуется для переопределения указателя на вершину стека. Аналогичный процесс про- исходит, когда завершается выполнение процедур А и В. После возврата управления из процедуры В в программу MAIN1 стек содержит только экземпляр записи активации программы MAIN_1. Заметим, что некоторые системы реализации языков программиро- вания в действительности не хранят в стеке экземпляр записи активации главной про- граммы, как это показано на рисунке. Однако описанная ситуация возможна, и это уп- гошает как саму реализацию, так и ее обсуждение. В данном примере и во всех осталь- ных примерах этой главы мы предполагаем, что стек увеличивается в порядке возрастания адресов. ЭЗА А ЭЗА- экземпляр записи активации Рис. 9.5. Содержимое стека в трех точках программы Совокупность динамических связей, содержащихся в стеке в данный момент време- -л. называется динамической цепочкой (dynamic chain), или цепочкой вызовов (call В ней описана динамическая история того, каким образом выполнение програм- • *ы достигло текущей точки, которая всегда находится в сегменте кода, экземпляр записи i-тивации которого расположен на вершине стека. Ссылки на локальные переменные 3. Реализация подпрограмм на языках, подобных языку ALGOL 401
могут быть представлены в коде с помощью смещения относительно начала записи ак- тивации в соответствующей локальной области видимости. Такое смещение называется локальным (local offset). Локальное смещение переменной в записи активации можно определить во время ком- пиляции, используя порядок, тип и размеры переменных, объявленных в процедуре, соот- ветствующей данной записи активации. Предположим для простоты, что все переменные в записи активации занимают по одной ячейке. Первую локальную переменную, объявлен- ную в процедуре, можно разместить в ячейке, номер которой вычисляется следующим об- разом: три плюс количество параметров, считая со дна стека (первые три ячейки предна- значены для хранения адреса возврата, статической связи и динамической связи). Вторая локальная переменная может быть размещена на одну ячейку ближе к вершине стека и так далее. Например, рассмотрим предыдущую скелетную программу. В процедуре А локаль- ное смещение переменной Y равно 4. Аналогично, в процедуре В локальное смещение пе- ременной S равно 4, а переменная Т имеет локальное смещение, равное 5. 9.3.3. Рекурсия Рассмотрим следующий пример программы на языке С, в которой для вычисления факториала используется рекурсия: int factorial(int n) { <------------- 1 if (n <= 1) return 1; else return (n * factorial (n - 1)); <------------- 2 } void main() { int value; value = factorial(30; <------------- 1 } Формат записи активации функции factorial показан на рис. 9.6. Заметим, что запись имеет дополнительное поле для возвращаемого значения функции. На рис. 9.7 показано содержимое стека, соответствующее трем разным ситуациям, когда выполнение программы дости- гает точки 1 в функции factorial. В каждом случае пока- зана дополнительная активация функции с неопределенным возвращаемым значением. Первый экземпляр активационной записи функции содержит адрес возврата в вызывающую функцию main. Остальные содержат адрес возврата в саму функцию, соответствующий рекурсивным вызовам. Рис. 9.6. Запись актива- ции функции factorial 402 Глава 9. Реализация подпрограмм
второй вызов ЭЗА main Первый ЭЗА factorial Третий ЭЗА factorial Второй ЭЗА factorial ЭЗА - передать значение первого адрес Рис. 9.7. Содержимое стека в точке 1 функции factorial 9.3. Реализация подпрограмм на языках, подобных языку ALGOL 403
Значение функции вершина стека Параметр Третий ЭЗА factorial Динамическая связь Статическая связь Возврат (в factorial) Значение функции п Параметр Второй ЭЗА factorial Первый ЭЗА factorial ЭЗА main Второй ЭЗА factorial Первый ЭЗА factorial ЭЗА main Динамическая связь Статическая связь Возврат (в factorial) Значение функции Параметр Динамическая связь Статическая связь п Возврат (в main) Локальная переменная значение Первый ЭЗА iactorial ЭЗА main В точке 2 функции factor.al В точке 2 функции factorial третий вызов выполнен первый вызов выполнен Значение функции Параметр Динамическая связь Статическая связь Возврат (в factorial) Значение функции п Параметр п Динамическая связь Статическая связь Возврат (в main) Локальная переменная вершина стека значение ЭЗА main Локальная переменная g вершина ,___стека значение В точке 2 функции factorial В точке 3 функции main второй вызов выполнен окончательные результаты ЭЗА - экземпляр записи активации Рис. 9.8. Содержимое стека во время выполнения функций main и factorial 404 Глава 9. Реализация подпрограмм
На рис. 9.8 показано содержимое стека, соответствующее трем разным ситуациям, когда выполнение программы достигает точки 2 в функции factorial. Точка 2 соот- ветствует случаю, когда оператор return уже выполнен, а запись активации из стека е ле не удалена. Напомним, что код функции умножает текущее значение параметра п на значение, возвращаемое при рекурсивном вызове функции. При первом вызове функция factorial возвращает значение 1. В этом случае копия параметра п в экземпляре за- писи активации имеет значение 1. Результат, равный 1, передается второй активации Ханкции factorial для умножения на значение ее параметра п, равное 2. Значение 2 возвращается первой активации функции factorial для умножения на значение ее па- раметра п, равное 3, что дает в результате значение 6, которое затем возвращается в первый вызов функции factorial в функции main. 9.3.4. Механизмы реализации нелокальных ссылок Существуют два основных способа доступа к нелокальным переменным в языках со статическим обзором данных: статические цепочки и индикаторы. Оба эти способа де- дльно изучаются в следующих разделах. Ссылка на нелокальную переменную требует выполнения двухэтапного процесса. Все нелокальные переменные содержатся в существующих экземплярах записей активации и, следовательно, находятся где-то в стеке. Для доступа к нелокальной переменной сначала ~>жно найти в стеке экземпляр записи активации, в котором содержится эта переменная. Затем следует использовать ее локальное смещение в экземпляре записи активации. Поиск нужного экземпляра записи активации представляет собой более интересную и тхдную проблему. Во-первых, заметим, что в данной подпрограмме видимыми и дос- '•пными являются только те переменные, которые объявлены в области видимости ста- тических предков. Во-вторых, если обращения к переменным статических предков нахо- дятся во вложенной процедуре, существование экземпляров записей активации всех этих ”гедков гарантируется правилами семантики языков со статическим обзором данных: •доцедуру можно вызвать, только если активны все программные модули, являющиеся ее статическими предками. Если конкретный статический предок не активен, его локаль- ные переменные не связаны с ячейками памяти, следовательно, открывать доступ к ним -е имеет смысла. Заметим, что, хотя в стеке должен существовать экземпляр активационной записи *'дедка, эта запись не обязана соседствовать с экземпляром записи активации его потом- • д Эта ситуация проиллюстрирована примером в разделе 9.3.4.1. Семантика ссылок на нелокальные переменные означает, что при поиске переменной = ? вложенных областях видимости следует сначала найти ближайшее вложенное объяв- ение. Таким образом, для обеспечения доступа к нелокальным ссылкам мы должны •меть возможность находить в стеке все экземпляры активационных записей, соответст- аюших статическим предкам. Это наблюдение приводит к двум методам, описанным в .дедующих разделах. Мы откладываем рассмотрение вопросов, связанных с блоками, до раздела 9.4, а в ставшейся части раздела 9.3 все области видимости определяются подпрограммами. ~.?скольку в языках С и C++ вложенные функции не допускаются (блоками создаются ?лько статические области видимости), все изложенное в данном разделе к этим языкам -е относится. <3. Реализация подпрограмм на языках, подобных языку ALGOL 405
9.3.4.1. Статические цепочки Статическая цепочка — это цепочка статических связей, соединяющих некоторые экземпляры активационных записей в стеке. Во время выполнения процедуры Р статическая связь ее экземпляра активационной записи указывает на экземпляр активационной записи программного модуля, являюще- гося статическим предком процедуры Р. Статическая связь экземпляра записи активации предка, в свою очередь, указывает на экземпляр записи активации программного модуля, являющегося его статическим предком, если он существует. Таким образом, все статиче- ские предки выполняемой подпрограммы связываются в статическую цепочку в порядке ослабления родственных связей (первым является непосредственный предок, за ним — его предок и так далее). Эту цепочку можно использовать для доступа к нелокальным переменным в языках со статическим обзором данных. Поиск нужного экземпляра записи активации, соответствующего нелокальной пере- менной, с помощью статических связей становится направленным. Чтобы найти экземп- ляр активационной записи, содержащий нелокальную переменную, следует в статиче- ской цепочке обнаружить экземпляр записи активации статического предка, содержащий эту переменную. Однако это можно сделать намного проще. Поскольку вложенные об- ласти видимости во время компиляции известны, компилятор может определить не толь- ко ссылку на нелокальную переменную, но и длину статической цепочки, которую нужно отмерить, чтобы достичь экземпляр записи активации, содержащий искомый нелокаль- ный объект. Будем называть статической глубиной (static depth) целое число, связанное со ста- тической областью видимости переменной и указывающее, насколько глубоко вложена эта область в самую внешнюю область видимости. У главной программы в языке Pascal статическая глубина равна 0. Если процедура А определяется только внутри главной про- граммы, то ее статическая глубина равна 1. Если процедура А содержит определение вложенной процедуры В, то статическая глубина процедуры В равна 2. Длина статической цепочки, которую нужно пройти, чтобы добраться до нужного эк- земпляра активационной записи, содержащей нелокальную ссылку на переменную X, в точности равна разности между статической глубиной процедуры, содержащей ссылку на переменную X, и статической глубиной процедуры, содержащей объявление перемен- ной X. Эта разность называется глубиной вложения ссылки (nesting depth), или смеще- нием по цепочке (chain offset). Фактическую ссылку можно представить в виде пары (смещение по цепочке, локальное смещение), состоящей из целых чисел, где смещение по цепочке является количеством связей, которые отделяют нужный экземпляр актива- ционной записи от ссылки на переменную (локальное смещение описано в разделе 9.3.2). В качестве примера рассмотрим следующую скелетную программу: program А; procedure В; procedure С; end; { С } end; { В } end; [ А } 406 Глава 9. Реализация подпрограмм
Статические глубины процедур А, В и С равны 0, 1 и 2, соответственно. Если бы проце- дура С ссылалась на переменную, объявленную в процедуре А, то смещение по цепочке у этой ссылки равнялось бы 2 (статическая глубина процедуры С минус статическая глу- бина процедуры А). Если бы процедура С ссылалась на переменную, объявленную в про- цедуре В, то смещение по цепочке у этой ссылки равнялось бы 1. Обращения к локаль- ным переменным обрабатываются с помощью того же самого механизма, при этом сме- щение по цепочке равно 0. Для того чтобы проиллюстрировать полный процесс нелокального доступа, рассмот- рим следующую скелетную программу на языке Pascal: program MAIN_2; var X : integer; procedure BIGSUB; var к, В, C : integer; procedure SUB1; var k, D: integer; begin { SUB1 } A := В + C; < ----------------- 1 end; { SUB1 ) procedure SUB2(X : integer); var В, E : integer; procedure SUB3; var C, E : integer; begin { SUB3 } SUB1; E := В + A; <---------------2 end; { SUB3 } begin { SUB2 ) SUB3; A := D + E; <-----------------3 end; { SUB2 ) begin {BIGSUB) SUB2(7); end; { BIGSUB} begin { MAIN_2 } BIGSUB; end; { MAIN_2 } Последовательность вызовов процедур такова: MAIN_2 вызывает BIGSUB. BIGSUB вызывает SUB2. 9.3. Реализация подпрограмм на языках, подобных языку ALGOL 407
SUB2 вызывает SUB3. SUB3 вызывает SUB1. Состояние стека во время выполнения программы при достижении точки 1 показано на рис. 9.9. Локальная переменная вершина " стека ЭЗА SU31 Динамическая связь Статическая связь Локальная переменная Возврат (в SUB3,1 Локальная переменная Локальная переменная ЭЗА SUB3 Динамическая связь Статическая связь С Возврат (в SUB2) Локальная переменная Локальная переменная ЭЗА SUB2 Параметр Динамическая связь Статическая связь Е В X Возврат (в BIGSUB) Локальная переменная Локальная переменная ЭЗА BIGSUB Локальная переменная Динамическая связь Статическая связь С В А Возврат (в МАГ ЭЗА MAIN.2 Локальная переменная X ЭЗА — экземпляр записи активации Рис. 9.9. Содержимое стека при дос- тижении точки 1 в программе MAIN2 В точке 1 внутри процедуры SUB1 происходит обращение к локальной переменной А, а не к нелокальной переменной А из процедуры BIGSUB. Пара (смещение по цепочке, ло- кальное смещение), соответствующая этой ссылке на переменную А, равна (0,3). Ссылка на переменную В относится к нелокальной переменной В из процедуры BIGSUB. Эту ссыл- ку можно представить в виде пары (1,4). Локальное смещение равно 4, поскольку смеще- 408 Глава 9. Реализация подпрограмм
ние. равное 3, соответствует первой локальной переменной (процедура BIGSUB не имеет параметров). Отметим, что если бы для простого поиска экземпляра записи активации, со- ответствующего объявлению переменной В, использовалась динамическая связь, то мы нашли бы переменную В, объявленную в процедуре SUB2, что было бы ошибкой. Если бы пара (1,4) использовалась для поиска по динамической цепочке, то была бы найдена пере- менная Е из процедуры SUB3. Статическая связь, однако, указывает на запись активации процедуры BIGSUB, соответствующую правильному экземпляру переменной В. Перемен- ная В в процедуре SUB2 не принадлежит среде ссылок в этой точке и (вполне законно) не доступна. Обращение к переменной С в точке 1 относится к переменной С, определенной в процедуре BIGSUB, которой соответствует пара (1, 5). После того как процедура SUB1 завершит свою работу, экземпляр ее активационной записи будет удален из стека, а управление будет возвращено в процедуру SUB3. Обра- щение к переменной Е в точке 2 относится к переменной, объявленной в процедуре 27В2, поскольку эта процедура является ближайшим статическим предком, содержащим объявление этой переменной. Ей соответствует пара (1.4). Локальное смещение равно 4, потому что переменная В является первой переменной, объявленной в процедуре SUB1, а процедура SUB2 имеет один параметр. Обращение к переменной А относится к пере- менной А. объявленной в процедуре BIGSUB, поскольку ни процедура SUB3, ни ее ста- тический предок SUB2 не имеют объявлений переменной с именем А. Этой ссылке соот- ветствует пара (2, 3). * После того как процедура SUB3 закончит свою работу, экземпляр ее активационной записи будет удален из стека, и в нем останутся только экземпляры записей активации программы MAIN 2, а также процедур BIGSUB и SUB2. В точке 3 внутри процедуры 7' 32 обращение к переменной А относится к переменной А из процедуры BIGSUB, единственной из активных подпрограмм, имеющей объявление этой переменной. Этот доступ осуществляется с помощью пары (1,3). В этой точке переменная D является не- видимой, таким образом, обращение к ней было бы семантической ошибкой. Эта ошибка может быть обнаружена, когда компилятор попробует вычислить пару (смещение по це- почке, локальное смещение). Обращение к переменной Е относится к локальной пере- менной Е из процедуры SUB2, которая является доступной с помощью пары (0, 5). Итак, обращения к переменной А в точках 1, 2 и 3 можно представить следующими арами целых чисел: (О, 3) (локальная) (2, 3) (на два уровня ниже) (1,3) (на один уровень ниже) В этом месте вполне резонно спросить, как поддерживается статическая цепочка во время выполнения программы. Если такая поддержка является слишком сложной, то дакт, что такой доступ прост и эффективен, становится неважным. В этом разделе мы не усматриваем параметры, передаваемые по имени, и параметры, являющиеся именами ^программ. Статическая цепочка должна модифицироваться при каждом вызове подпрограммы и возвращении из нее. Часть, связанная с возвращением из подпрограммы, является триви- альной: когда подпрограмма завершает свою работу, ее активационная запись удаляется в стека. После этого на вершине стека окажется экземпляр записи активации модуля, вызвавшего только что завершившуюся подпрограмму. Поскольку статическая цепочка, 9.3. Реализация подпрограмм на языках, подобных языку ALGOL 409
начинающаяся этим экземпляром активационной записи, никогда не изменялась, она ра- ботает правильно, так же, как она работала до вызова другой подпрограммы. Следова- тельно, никакие другие действия не нужны. Действия, которые необходимо выполнить при вызове подпрограммы, намного сложнее. Несмотря на то что соответствующая область видимости предка легко опреде- ляется во время компиляции, во время вызова должны быть найдены самые последние по времени создания экземпляры активационных записей, находящиеся в области види- мости предка. Для этого проводится поиск экземпляра активационной записи по дина- мической цепочке, пока не будет найден первый экземпляр в области видимости предка. Однако этого можно избежать, если обрабатывать объявления процедур и ссылки на них точно так же, как и для переменных. Когда компилятор встречает вызов процедуры, по- мимо всего прочего, он определяет, в какой именно процедуре была объявлена данная процедура. Такая процедура должна быть статическим предком вызываемой процедуры. Затем компилятор вычисляет глубину вложения, или количество вложенных областей видимости между вызывающим модулем и процедурой, в которой объявлена вызываемая процедура. Эта информация записывается в память и становится доступной для вызова данной процедуры при выполнении программы. В момент вызова определяется статиче- ская связь, содержащаяся в экземпляре активационной записи вызываемой процедуры. Для этого следует переместиться вниз по статической цепочке вызывающего модуля на количество звеньев, равное глубине вложения, вычисляемой во время компиляции. Снова рассмотрим программу MAIN__2 и состояние стека, показанное на рис. 9.9. Обра- батывая вызов процедуры SUB1 из процедуры SUB3, компилятор определяет, что глубина вложения процедуры SUB3 (вызывающего модуля) на два уровня меньше, чем глубина вложения процедуры, в которой объявлена вызываемая процедура SUB1, т.е. BIGSUB. При вызове процедуры SUB1 из процедуры SUB3 эта информация используется для установле- ния статической связи в экземпляре активационной записи процедуры SUB1. Этой статиче- ской связи присваивается указатель на экземпляр активационной записи, на которую ука- зывает вторая статическая связь в статической цепочке, считая от экземпляра активацион- ной записи вызывающего модуля. В этом случае вызывающим модулем является процедура SUB3, статическая связь которой указывает на экземпляр активационной записи ее предка (т.е. SUB2). Статическая связь экземпляра активационной записи процедуры SUB2 указывает на экземпляр записи активации процедуры BIGSUB. Таким образом, ста- тической связи нового экземпляра активационной записи процедуры SUB1 присваивается указатель на экземпляр активационной записи процедуры BIGSUB. Этот метод работает при связывании любых процедур, кроме случая, когда в качестве параметров в процедуру передаются имена подпрограмм. Эта ситуация рассматривается в разделе 9.6. Один недостаток использования статической цепочки для доступа к нелокальным пе- ременным заключается в том, что ссылки на переменные, находящиеся вне области ви- димости статического предка, выполняются неэффективно. При этом статическая цепоч- ка прослеживается звено за звеном, по одному звену на каждую вложенную область ви- димости, начиная со ссылки на переменную и заканчивая ее объявлением. Другой недостаток состоит в том, что программисту, разрабатывающему программу, продолжи- тельность выполнения которой очень важна, трудно оценить затраты времени на обра- ботку нелокальных ссылок, поскольку эти затраты зависят от глубины вложения между ссылкой и областью видимости объявления переменной. Еще более усложняет эту про- 410 Глава 9. Реализация подпрограмм
блему тот факт, что при последующих модификациях кода глубины вложений могут из- мениться, приведя таким образом к изменению времени обработки некоторых ссылок как в этом, так и, возможно, в других сегментах кода. 9.3.4.2. Индикаторы Единственной широко распространенной альтернативой статической цепочке являет- ся индикатор. При этом подходе статические связи объединяются в одном массиве, на- зываемом индикатором, вместо того, чтобы храниться в своих активационных записях. Содержание индикатора в любой конкретный момент времени представляет собой спи- сок адресов доступных экземпляров активационных записей в стеке — по одному на ка- ждую активную область видимости — в том порядке, в котором они вложены друг в друга. Каждый доступ к нелокальным переменным с помощью индикатора состоит из двух этапов, независимо от количества вложенных областей видимости, разделяющих ссылку л объявление переменной, к которой требуется получить доступ. Во-первых, с помощью статически вычисляемого значения, называемого смещением индикатора (display offset) и тесно связанного со смещением в цепочке, обнаруживается связь с нужной акти- вационной записью, которая хранится в индикаторе. Во-вторых, локальное смещение внутри экземпляра активационной записи вычисляется и используется точно так же, как л при применении статической цепочки. Нелокальная ссылка представляется упорядо- ченной парой целых чисел (смешение индикатора, локальное смещение). Нелокальную переменную можно удобно и быстро адресовать, дважды выполнив косвенную адреса- цию, основанную на смещениях. При каждом вызове подпрограммы и возврате из нее необходимо изменять содержи- мое индикатора, чтобы отражать новое состояние областей видимости. Теперь мы иссле- жу ем действия, которые следует выполнять для поддержания работы индикатора. Пред- положим, что параметры не могут быть именами подпрограмм и передаваться по имени. Эти более сложные случаи рассматриваются в разделе 9.6. Смещение индикатора зависит только от статической глубины процедуры, в которой "оявляется обращение к нелокальной переменной. Если статическая глубина процедуры ? равна 2, то связь с экземпляром записи активации процедуры Р всегда будет появлять- .« в индикаторе во второй позиции. Индексы индикатора отсчитываются от нуля, причем -улевая позиция используется для доступа к переменным, объявленным во внешней об- асти видимости (главном модуле). Указатель на позицию к в индикаторе адресует экземпляр активационной записи про- цедуры, имеющей статическую глубину, равную к. При вызове процедуры Р, имеющей статическую глубину, равную к, индикатор следует модифицировать, выполнив следую- щие действия. 1. Сохранить копию указателя, записанного в fc-й позиции индикатора, в новом эк- земпляре записи активации. 2. Поместить связь с экземпляром записи активации процедуры Р в £-ю позицию ин- дикатора. При завершении работы подпрограммы указатель, сохраненный в экземпляре записи активации завершенной подпрограммы, следует вернуть в индикатор. Затем экземпляр схтиси активации подпрограммы удаляется из стека точно так же, как это происходило работе со статической цепочкой. *.3. Реализация подпрограмм на языках, подобных языку ALGOL 411
Для того чтобы убедиться, что описанные выше действия всегда выполняются кор- ректно, рассмотрим три возможные ситуации, возникающие при вызове процедуры Р из процедуры Q. Обозначим через Psd статическую глубину процедуры Р, а через Qsd — статическую глубину процедуры Q. Рассмотрим три случая. 1. Psd = Qsd 2. Psd < Qsd 3. Psd > Qsd Воспользуемся следующей программой, представляющей собой скелетную версию программы из предыдущего примера программы MAIN 2. program MAIN_3; procedure BIGSUB; procedure SUB1; end { SUB1 } procedure SUB2; procedure SUB3; end { SUB3 } end { SUB2 } end { BIGSUB } end. { MAIN_3 } Первый случай может возникнуть, если процедура SUB2 вызывается процедурой SUB1. поскольку они обе имеют статическую глубину, равную 2. Состояние стека и ин- дикатор непосредственно перед вызовом и сразу после него показаны на рис. 9.10. а) ма:к_з вызывает bigsub; bigsub вызывает SUB2 б) SUB2 вызывает SUB1 ЭЗА — экземпляр записи активации Рис. 9.10. Изменение индикаторов, соответствующих вызывающей и вы- зываемой подпрограммам одинаковой статической глубины 412 Глава 9. Реализация подпрограмм
Для вызова, как всегда, необходимо, чтобы новый экземпляр активационной записи был помещен в стек. Новая среда ссылок содержит только подпрограммы SUB1, LIGSUB и MAIN 3. Поскольку значения статической глубины подпрограмм SUB1 и С ТВ 2 равны между собой, их индикаторные связи должны занимать в индикаторе одина- ковые места. Это означает, что их смещения в индикаторе должны быть равными. По- скольку индикаторные связи всегда хранятся в новом экземпляре активационной записи, никаких проблем не возникает. Следовательно, элемент индикатора, соответствующий подпрограмме SUB2. должен быть удален, и на его место должен быть записан новый элемент индикатора, соответствующий подпрограмме SUB1. В данном случае он являет- ся вторым элементом индикатора в соответствии со статической глубиной подпрограм- мы SUB1. После того как подпрограмма SUB1 завершит свою работу и вернет управле- ние подпрограмме SUB2, элемент индикатора, соответствующий подпрограмме SUB2, должен быть восстановлен на прежнем месте. Непосредственно перед удалением экзем- пляра активационной записи процедуры SUB1 из стека сохраненный экземпляр индика- тора перемещается из экземпляра обратно в индикатор. Второй случай может возникнуть, если подпрограмма SUB2 вызывает подпрограмму S7B3. Статическая глубина подпрограммы SUB2 равна 2, а подпрограммы SUB3— 3. Состояние стека и дисплея непосредственно перед вызовом и сразу после него показаны на рис. 9.11. a) MAIN_3 вызывает SIGSU3; BIGSUB вызывает SUB2 б) SUB2 вызывает SUB3 ЭЗА — экземпляр записи активации Рис. 9.11. Изменение индикатора в ситуации, когда статическая глубина вы- зывающей подпрограммы меньше статической глубины вызываемой подпро- граммы (Qsd < Psd) В данном случае в стеке, как обычно, создается новая запись активации, однако среда ссылок просто увеличивается на одну новую область видимости, соответствующую вы- зываемой подпрограмме. Таким образом, в индикатор можно просто добавить новый > казатель. В этом конкретном примере нет необходимости сохранять в экземпляре акти- вационной записи существующий указатель, записанный в индикаторе на месте, предна- значенном для нового указателя. Однако в общем случае это неверно. В других ситуаци- -\ это делать необходимо. Намного проще каждый раз сохранять существующий указа- тель. чем определять при каждом вызове подпрограммы, следует ли это делать. В качес- 9.3. Реализация подпрограмм на языках, подобных языку ALGOL 413
тве примера ситуации, в которой указатель необходимо сохранять, рассмотрим следую- щую подпрограмму SUB4: program MAIN 4; procedure BIGSUB; procedure SUB1; procedure SUB4; end; { SUB4 } end; { SUB1 } procedure SUB2; procedure SUB3; end; { SUB3 } end; { SUB2 ) end; { BIGSUB } end. { MAIN_4 } Предположим, что в ходе выполнения программы ее подпрограммы вызываются сле- дующим образом: MAIN_4 вызывает BIGSUB BIGSUB вызывает SUB2 SUB2 вызывает SUB3 SUB3 вызывает SUB4 Состояние стека и индикатора, полученное в результате этих вызовов, показано на рис. 9.12. Допустим теперь, что подпрограмма SUB1 вызывает подпрограмму SUB4. Это при- мер ситуации, при которой статическая глубина вызывающей подпрограммы меньше, чем вызываемой. В этом случае указатель, соответствующий подпрограмме SUB3, зани- мает в индикаторе место, на котором должен был бы находиться указатель, соответст- вующий подпрограмме SUB4. Обе эти подпрограммы имеют статическую глубину, рав- ную 3. Следовательно, существующий в индикаторе указатель следует сохранить перед тем. как на его место будет помещен новый указатель. Правильное состояние стека и ин- дикатора при выполнении подпрограммы SUB4 показано на рис. 9.13. Третий случай (Qsd > Psd) возникает, когда подпрограмма SUB3 вызывает подпро- грамму SUB1. Статическая глубина процедуры SUB3 равна 3, а процедуры SUB1 — 2. Состояния стека и индикатора непосредственно перед вызовом и сразу после него пока- заны на рис. 9.14. В данном случае оказывается, что два элемента индикатора, соответствующие подпро- граммам SUB2 и SUB3. должны быть временно удалены, поскольку они не принадлежат среде ссылок подпрограммы SUB1. Однако в действительности должен быть удален только один из этих указателей, соответствующий подпрограмме SUB2. Указатель, соот- ветствующий подпрограмме SUB3, может оставаться в индикаторе. Ссылки на переменные в подпрограмме SUB1 не будут использовать индикаторный указатель, соответствующий подпрограмме SUB3, поскольку переменные подпрограммы SUB3 не видны в подпрограм- ме SUB1. Таким образом, можно совершенно спокойно оставить этот указатель в индика- торе. Компилятор не будет генерировать код, имеющий доступ к элементам индикатора, находящимся выше элементов, соответствующих текущей активной подпрограмме. 414 Глава 9. Реализация подпрограмм
ЭЗА—экземпляр записи активации Рис. 9.12. Состояние стека и ин- дикатора перед тем, как подпро- грамма SUB1 вызовет подпро- грамму SUB4 в программе MAIN_4; пунктирные линии ука- зывают на неактивные указатели 33k — экземпляр записи активации Рис. 9.13. Состояние стека и ин- дикатора после того, как подпро- грамма SUB1 вызовет подпро- грамму SUB4 в программе MAIN_4 (Qsd < Psd) a)MAiN_4 вызывает в igsub; bigsub вызывает SUB2; 6)sub3 вызываетзиз! SUB2 вызывает гивз ЭЗА —экземпляр записи активации Рис. 9.14. Изменение индикатора в ситуации, при которой вызывающая подпрограмма имеет большую статическую глубину, чем вызываемая под- программа (Qsd > Psd) ?.3. Реализация подпрограмм на языках, подобных языку ALGOL 415
е d Переменные „ блока с b и g а и f Z Локальные « У переменные к X Экземпляр записи активации функции MAIN.5 Рис, 9.15, Хранение переменных, объ- явленных в блоке, в случае, когда бло- ки не рассматриваются как процеду- ры без параметров 9.5. Реализация методов динамического обзора данных Существуют, по крайней мере, два вида доступа к нелокальным переменным в языках с динамическим обзором данных: глубокий доступ и теневой доступ. Отметим, что ни глубокий, ни теневой доступ не связаны с понятиями глубокого и теневого связывания. Основное отличие между связыванием и доступом здесь состоит в том, что глубокое и теневое связывания приводят к разным с точки зрения семантики результатам, а глубо- кий и теневой доступ — нет. 418 Глава 9. Реализация подпрограмм
9.5.1. Глубокий доступ Когда программа на некотором языке с динамическим обзором данных обращается к -е; о к аль ной переменной, можно выполнить поиск объявления этой переменной в других •^программах, активных в данный момент, начиная с подпрограммы, вызванной рань- _е всех. Эта концепция чем-то напоминает доступ к нелокальным переменным в языках :: статическим обзором данных, за исключением того, что отслеживается динамическая, _ не статическая цепочка. Динамическая цепочка связывает в одно целое экземпляры за- ~ 'сей активации всех подпрограмм в порядке, обратном порядку их активации. Следова- тельно, динамическая цепочка— это именно то, что нужно для доступа к нелокальным •еременным в языках с динамическим обзором данных. Этот метод называется глубо- ким доступом (deep access), потому что он может потребовать глубокого поиска в стеке. Рассмотрим следующий пример программы: procedure С; integer х, z; begin х : u + v; end; procedure B; integer w, x; begin end; procedure A; integer v, w; begin end; procedure MAIN_6; integer v, u; begin end; Эта программа внешне похожа на программу, написанную на языке ALGOL», однако здесь не подразумевается никакой конкретный язык программирования. Предположим, что в ходе выполнения программы возникла следующая последовательность вызовов подпрограмм: MAIN—6 вызывает А А вызывает А А вызывает В В вызывает С На рис. 9.16 показано состояние стека в ходе выполнения процедуры С после этой последовательности вызовов. Отметим, что в экземпляре активационной записи нет ста- тических связей, которые в языках с динамическим обзором данных не нужны. 9.5. Реализация методов динамического обзора данных 419
ЭЗА ЭЗА В Локальная переменная Локальная переменная Динамическая связь Возврат (в В) Локальная переменная Локальная переменная Динамическая связь Z X ЭЗА А ЭЗА А ЭЗА мл: n_6 Возврат (в А) Локальная переменная Локальная переменная Динамическая связь Возврат(в А) Локальная переменная Локальная переменная Динамическая связь Возврат (в 4AIN_ Локальная переменная Локальная переменная X W V/ W ЭЗА — экземпляр записи активации Рис. 9.16. Состояние стека программы на языке с динамическим обзором данных Рассмотрим обращения к переменным х, и и v в процедуре С. Сначала обнаружива- ется ссылка на переменную х в экземпляре записи активации процедуры С. Поиск ссыл- ки на переменную и производится во всех экземплярах активационных записей в стеке, поскольку единственная переменная с этим именем существует только в программе MAIN_6. Для этого приходится отслеживать четыре динамические связи и проверять де- сять имен переменных. Ссылка на переменную v обнаруживается в самом последнем по времени создания экземпляре активационной записи процедуры А (ближайший экземп- ляр в динамической цепочке). Между методом глубокого доступа к нелокальной переменной в языках с динамиче- ским обзором данных и методом статических цепочек в языках со статическим обзором данных существуют два важных различия. Во-первых, в языках с динамическим обзором данных нельзя во время компиляции определить длину цепочки, по которой производит- ся поиск. Нужно осуществлять поиск во всех экземплярах активационных записей в це- почке. пока не обнаружится первый экземпляр, содержащий ссылку на искомую пере- менную. Это — одна из причин, по которым программы на языках с динамическим об- зором данных выполняются медленнее, чем программы на языках со статическим обзором данных. Во-вторых, активационные записи должны хранить имена переменных 420 Глава 9. Реализация подпрограмм
тою. чтобы их можно было найти, в то время как в реализациях языков со статиче- . /м обзором данных требуется хранить только их значения. (Имена при статическом оре не нужны, поскольку все переменные представляются в виде пар (смешение в це- -че. локальное смешение).) 9.5.2. Теневой доступ Теневой доступ (shallow access)— это метод, альтернативный с точки зрения реали- .. ли. но не семантики. Как указывалось выше, глубокий и теневой доступ имеют иден- 'чную семантику. При теневом доступе переменные, объявленные в подпрограммах, не мнятся в их активационных записях. Поскольку при динамическом обзоре в каждый 4: мент времени существует, по крайней мере, одна видимая копия переменной с указан- •<м именем, можно использовать совершенно другой подход. Один из вариантов .ече- vo доступа — отдельный стек для каждого имени переменной во всей программе. Ка- ждый раз новая переменная с конкретным именем создается с помощью объявления в • !чале активации подпрограммы, и для этой переменной в соответствующем стеке выде- ляется ячейка памяти. Каждая ссылка на это имя означает ссылку на переменную, нахо- гч цуюся на вершине стека, связанного с этим именем, поскольку вершина создается по- . ;едней. По завершении работы подпрограммы время жизни ее локальных переменных оканчивается, а стеки, соответствующие их именам, выталкиваются. Этот метод способ- ствует очень быстрому доступу к переменным, однако поддержка стеков при входе в " лпрограммы и выходе из них требует затрат. На рис. 9.17 показаны стеки, соответствующие переменным из ранее приведенного гимера программы в ситуации, аналогичной показанной на рис. 9 16. Кроме того, при теневом доступе можно использовать центральную таблицу, в которой для каждого имени переменной, появляющейся в программе, предусмотрена отдельная •чейка. Вместе с каждым элементом этой таблицы хранится бит, называемый активным, который указывает, связано ли данное имя с какой-то переменной в настоящий момент. Та- 1 им образом, любой доступ к любой переменной может быть осуществлен с помощью ^мешения в центральной таблице. Это смешение является статическим, так что доступ должен бьпь быстрым. Реализации языка SNOBOL используют именно этот подход. (В ячейках стека указаны имена программных модулей, содержащих объявление переменной) Рис. 9.17. Один из методов использова- ния теневого доступа при реализации динамического обзора данных Центральная таблица поддерживается довольно просто. При вызове подпрограммы требуется, чтобы все ее локальные переменные были логически размещены в централь- ной таблице. Если позиция новой переменной в центральной таблице уже является ак- 9.5. Реализация методов динамического обзора данных 421
тивной. т.е. если в этой позиции уже содержится некая переменная, время жизни которой еще не закончилось (что указывается активным битом), то значение старой переменной на время жизни новой следует сохранить в какой-нибудь ячейке памяти. Одновременно с началом жизни какой-либо переменной должен быть установлен активный бит в ее пози- ции в центральной таблице. Разработано несколько вариантов центральной таблицы и способов, с помощью кото- рых переменные, временно замещаемые другими переменными, хранятся в памяти. Один из вариантов — “скрытый” стек, в который записываются все объекты, подлежащие хра- нению. Поскольку подпрограммы выполняют вызовы и возвращают управление, и, сле- довательно. времена жизни их локальных переменных вкладываются друг в друга, этот метод работает хорошо. Второй вариант, возможно, наиболее ясен и прост для реализации. Используется цен- тральная таблица с отдельными ячейками, в которых хранятся только текущие имена всех переменных. Замененные переменные хранятся в записях активации тех подпро- грамм, в которых они создавались. При этом используются стеки, однако один из них всегда уже существует, так что дополнительные затраты минимальны. Выбор между теневым и глубоким доступом к нелокальным переменным зависит от относительной частоты вызовов подпрограмм и обращений к нелокальным переменным. Метод глубокого доступа обеспечивает быструю связь между подпрограммами, однако при его использовании обращения к нелокальным переменным, особенно к отдаленным (в смысле статической цепочки), требуют дополнительного времени. Метод теневого доступа ускоряет обращение к нелокальным переменным, особенно к отдаленным, одна- ко требует дополнительного времени на связь между подпрограммами. 9.6. Реализация параметров, являющихся именами подпрограмм Параметры, представляющие собой имена подпрограмм, детально обсуждались в гла- ве 8. Напомним, что языки со статическим обзором данных для связывания среды ссы- лок с активацией подпрограммы, которая передается как параметр, используют метод, называемый глубоким связыванием. Теперь мы исследуем, как именно можно реализо- вать глубокое связывание с помощью статической цепочки и индикаторов. 9.6.1 • Статические цепочки Предположим, что при реализации языка используется метод статических цепочек. Подпрограмма, которая передает имя некоей подпрограммы как параметр, должна иметь в качестве своего статического предка модуль, в котором была объявлена передаваемая подпрограмма. Если это не так, то имя передаваемой подпрограммы будет невидимым, и компилятор обнаружит синтаксическую ошибку. Таким образом, при синтаксически правильных вызовах компилятор может просто передать связь статическому предку пе- редаваемой подпрограммы вместе с ее именем. Затем при инициализации экземпляра ак- тивационной записи передаваемой подпрограммы эта связь заносится в его поле стати- ческих связей вместо связи, вычисляемой обычным путем. Завершение работы переда- ваемой подпрограммы не требует никаких особых действий. 422 Глава 9. Реализация подпрограмм
9.6.2. Индикаторы Предположим, что при реализации языка используются индикаторы. Подчеркнем еще _ что поддержка индикаторов, описанная в разделе 9.3.4.2, корректна, только если •сна подпрограмм не передавались как параметры и не применялась передача парамет- - в по имени. Использование индикаторов для вызовов подпрограмм в других ситуациях .: лилось к замене отдельного индикаторного указателя. Когда при вызове подпрограм- э. ей в качестве параметра передается имя некоей подпрограммы, в индикатор должны 'z ть помещены указатели на все статические предки этой подпрограммы. При этом в -дмяти должно сохраняться большое количество старых индикаторных указателей. По- .• эльку статическая среда активации подпрограммы, передаваемой как параметр, может •меть мало общего со статической средой вызываемой подпрограммы, во многих случа- ях в индикаторе приходится заменять несколько указателей. В некоторых реализациях "Ги каждом вызове подпрограммы, передаваемой как параметр, в памяти сохраняется весь существующий индикатор, причем часто он записывается в экземпляр активацион- -?й записи выполняемой подпрограммы. Когда подпрограмма, передаваемая как пара- метр, завершает свою работу, полностью сохраненный индикатор заменяет собой инди- катор. использованный для ее выполнения. 9.6.3. Ошибочное повторное обращение к среде ссылок Перейдем к обсуждению проблемы, описанной в главе 6: какая среда ссылок является -давильной при выполнении подпрограммы, передаваемой как параметр. Рассмотрим следующую скелетную программу, представляющую собой вариант программы, описан- -эй в работе Ghezzi and Jazayeri (1987): program MAIN_7; procedure SUB1; begin { SUB1 } end; { SUB1 } procedure SUB2(procedure SUBX); var SUM : real; procedure SUB3; begin { SUB3 } SUM := 0.0; end; { SUB3 } begin { SUB2 } SUBX; SUB2 (SUB3); end; { SUB2 } begin { MAINJ7 } SUB2(SUB1); end. { MAIN 7 } 9.6. Реализация параметров, являющихся именами подпрограмм 423
Подпрограмма MAIN_7 вызывает процедуру SUB2, передавая процедуру SUB1 как па- раметр. Затем процедура SU32 вызывает переданную процедуру SUB1. После возвраще- ния из процедуры SUB1 процедура SUB2 вызывает сама себя, передавая свою собствен- ную процедуру SUB3 как параметр. Теперь в стеке существует два экземпляра записи ак- тивации процедуры SUB2, причем верхний экземпляр используется для рекурсивного вызова. Затем верхняя активация процедуры SUB2 вызывает процедуру SUB3. В тот мо- мент, когда процедура SUB3 обращается к переменной SUM, существуют две версии этой переменной, по одной на каждую активацию процедуры SU32. в которой она объявлена. Поскольку среда ссылок процедуры SUB3 совпадает со средой ссылок вызывающей процедуры SUB2, которая передает процедуру S U33 как параметр, она относится к пер- вой активации процедуры SUB2. а не к последней по времени. В действительности, это совсем не очевидно для случайного читателя программы Ясно, что этот пример является надуманным. Однако аналогичные ситуации могут возникнуть и в более реалистичных программах. Проблема заключается в том. что, хотя интуитивно ясно, что среда ссылок должна относиться к самой последней по времени ак- тивации. это не всегда так. На рис. 9.18 показано состояние стека во время выполнения процедуры SUB3 из пре- ЭЗА — экземпляр записи активации Рис. 9.18. Состояние стека для примера програм- мы MAIN_ 7 с параметром, являющимся подпро- граммой (процедура SUB1 была вызвана, но уже завершила свою работу) 424 Глава 9. Реализация подпрограмм
5 е з ю м е Семантика связей между подпрограммами при своей реализации требует выполне- многих действий. В языке FORTRAN 77 эти действия относительно просты по сле- дим причинам: отсутствуют нелокальные ссылки, кроме ссылок через блоки локальные переменные обычно являются статическими, а рекурсия не поддер- ;. ’ся. В языках, подобных языку ALGOL, связь между подпрограммами намного -нее. Это является следствием требований поддержки доступа к нелокальным пере- tb’M с помощью статического обзора, наличия автоматических локальных перемен- и рекурсии. Подпрограммы в языках, подобных языку ALGOL, состоят из двух частей: собст- о кода, являющегося статическим, и активационной записи, являющейся динамиче- ' и хранящейся в стеке. Экземпляры активационных записей, кроме всего прочего, :.?жат формальные параметры и локальные переменные. Статические цепочки и индикаторы представляют собой два основных метода реа- лии доступа к нелокальным переменным в языках со статическим обзором данных. к;и\ методах пути доступа к переменным во всех статических предках можно уста- * , тьстатически. Доступ к нелокальным переменным в языках с динамическим обзором данных мож- -еализовать с помощью динамических цепочек или метода центральной таблицы пе- •?нных. Динамические цепочки обеспечивают медленный доступ к нелокальным пе- енным, но позволяют быстро выполнять вызовы подпрограмм и возвраты из них. *о1 центральной таблицы обеспечивает быстрый доступ к нелокальным переменным, тч: ном вызовы подпрограмм и возврат из них происходят медленнее. Подпрограммы, передаваемые как параметры, приносят определенную пользу, од- иногда в них трудно разобраться. Их запутанность объясняется неопределенноегью .-:ы ссылок, доступной во время выполнения таких подпрограмм. Подпрограммы, пе- .лваемые как параметры, можно реализовывать с помощью как статических цепочек. а и индикаторов. Дополнительная литература .низания методов статического и динамического обзора описана в книгах Pratt (1984) и Ghezzi and Jazayeri (1987), однако более детально она рассмотрена в книгах, посвя- щенных разработке компиляторов, например, Fischer and LeBlanc (1988). В О П р <1 € ьд 1. Назовите четыре причины, по которым реализация подпрограмм на языках, подоб- ных языку ALGOL, является более трудной, чем реализация подпрограмм на язы- ке, подобном языку FORTRAN 77. 2. В чем состоит различие между записью акз ивании и экземпляром записи активации? 3. Что такое адрес, статическая связь, динамическая связь и параметры, размешенные на дне активационной записи? Вопросы 425
4. Назовите два действия, которые следует выполнить при размещении нелокальной переменной в языках со статическим обзором независимо от используемого метода. 5. Дайте определение статической цепочки, статической глубины, глубины вло- жения и смещения в цепочке. 6. Объясните, как обнаруживается ссылка на нелокальную переменную при исполь- зовании статических цепочек. 7. В чем заключаются две потенциальные проблемы, связанные с методом статиче- ских цепочек? 8. Что такое индикатор? 9. Объясните, как обнаруживается ссылка на нелокальную переменную при исполь- зовании индикаторов. 10. Как представляются ссылки на переменные при использовании метода статических цепочек и метода индикаторов? 11. Какие изменения нужно внести в индикатор при вызове подпрограммы (при усло- вии, что параметры по имени не передаются и не могут быть подпрограммами)? 12. Сравните эффективность методов статических цепочек и индикаторов для доступа к локальным переменным, доступа к нелокальным переменным, при возвратах из подпрограмм и в целом. 13. Опишите два метода реализации блоков. 14. Опишите метод глубокого доступа при реализации динамического обзора. 15. Опишите метод теневого доступа при реализации динамического обзора. 16. Назовите два основных различия между методом глубокого доступа к нелокаль- ным переменным в языках с динамическим обзором данных и методом статиче- ских цепочек в языках со статическим обзором данных. 17. Сравните эффективность методов глубокого и теневого доступа с точки зрения выполнения вызовов и обеспечения доступа к нелокальным переменным. 18. Опишите метод реализации параметров, являющихся подпрограммами, с помощью подхода, основанного на реализации метода статических цепочек. 19. Опишите метод реализации параметров, являющихся подпрограммами, с помощью подхода, основанного на реализации метода индикаторов. 20. Всегда ли ближайший экземпляр предка, находящийся в данный момент в стеке, является правильным экземпляром активационной записи статического предка в языках, допускающих передачу подпрограмм в качестве параметров? У п р а ж н е н и я 1. Напишите алгоритм реализации индикатора, требуемого при входе в подпрограм- му, на языке, использующем статический обзор данных и всегда позволяющем пе- редавать подпрограммы в качестве параметров. 2. Напишите алгоритм реализации индикатора, требуемого при выходе из подпро- граммы, на языке, использующем статический обзор данных и всегда позволяю- щем передавать подпрограммы в качестве параметров. 426 Глава 9. Реализация подпрограмм
3. Покажите состояние стека со всеми экземплярами активационных записей, вклю- чая статические и динамические связи, когда выполнение следующей скелетной программы достигает точки 1 (при условии, что уровень подпрограммы BIGSUB равен 1). procedure BIGSUB; procedure А; procedure В; begin { В } . . . <---------------------1 end; { В } procedure С; begin { С } В; end; { С ) begin { А } С; end; { А } begin { BIGSUB } А; end; { BIGSIB } 4. Для скелетной программы в задаче 3 покажите индикатор, который является ак- тивным в точке 1 вместе с экземплярами активационных записей в стеке. 5. Покажите стек со всеми экземплярами активационных записей, включая статиче- ские и динамические цепочки, в момент, когда выполнение следующей скелетной программы достигает точки 1 (при условии, что уровень подпрограммы BIGSUB равен 1). procedure BIGSUB; procedure С; forward; procedure A(flag: boolean); procedure B; A(false) end; { В ) begin { A } if flag then В else C end; { A ) procedure C; procedure D; Упражнения
... <------------------------- 1 end; { D } D end; { C } A •crue); end; { BIGSUB; Последовательность вызовов, которые следует выполнить в этой подпрограмме, для того чтобы достичь оператора D, такова: B1GSUB вызывает А к вызывает В В вызывает А А вызывает С С вызывает В 6. Для скелетной программы из упражнения 5 покажите состояние индикатора, кото- рый является активным в точке 1 вместе с экземплярами активационных записей в стеке. 7. Несмотря на то что локальные переменные в процедурах на языке Pascal динами- чески размещаются в памяти в начале каждой их активации, укажите, при каких условиях значение локальной переменной в конкретной активации сохраняет зна- чение, оставшееся о г прежней активации. 8. В данной главе утверждается, что при доступе к нелокальной переменной с помо- щью динамической цепочки в языке с динамическим обзором данных имена пере- менных должны размещаться в активационных записях вместе с их значениями. Если эги действительно так, то каждое обращение к нелокальной переменной мо- жет потребовать выполнения последовательности довольно затратных сравнений строк, представляющих собой имена переменных. Разработайте альтернативу это- му сравнению, которая работала бы быстрее. 9. В языке Pascal допускается использование операторов goto с нелокальными це- лями. Как можно обработать такой оператор, если для нелокального доступа ис- пользуются статические цепочки? Подсказки', рассмотрите способ, с помощью ко- торого обнаруживается правильный экземпляр активационной записи статического предка вновь создаваемой процедуры (см раздел 9.3.4.1). 10. Повторите упражнение 9, используя индикаторы вместо статических цепочек. 11. Как можно модифицировать механизм индикаторов, описанный в данной главе, для того чтобы обеспечить доступ к локальным переменным без косвенной адресации? 12. Метод статических цепочек можно слегка расширить с помощью двух статических связей в каждом экземпляре активационных записей, в которых вторая связь указы- вала бы на подпрограмму, являющуюся статическим предком по отношению к стати- ческому предку экземпляра записи активации. Как это повлияет на время установле- ния связи между подпрограммами и скорость доступа к нелокальным ссылкам? 428 Глава 9. Реализация подпрограмм
4 Абстрактные f типы данных В этой главе... 10.1. Понятие абстракции 10.2. Инкапсуляция 10.3. Введение в абстракцию данных 10.4. Вопросы разработки типов 10.5. Примеры абстракции данных в разных языках 10.6. Параметризованные абстрактные типы данных Оле-Йохан Дал (Ole-Johan Dahl) Z ге-Йохан Дал и Кристен Найга- аод (Kristen Nygaard), работав- шие в Норвежском вычислитель- ном центре, в основном занима- лись компьютерным моделирова- нием Руководствуясь своими за- "2осами, они разработали и реа- лизовали языки моделирования SIMULA в 1964 году и SIMULA 67 а 1967 году Абстрактные типы данных 429
В этой главе исследуется поддержка абстракции данных в языках программирова- ния, начиная с ее истоков в языке SIMULA 67 и заканчивая ее формами в совре- менных языках. Среди новых идей, возникших в области методологии программирова- ния и разработки языков программирования за последние 25 лет, абстракция данных яв- ляется одной из самых значительных. Вначале рассмотрим общее понятие абстракции в программировании и языках про- граммирования. Затем обсудим инкапсуляцию в языках программирования. Далее дается определение абстракции данных, подкрепленное конкретным примером. После этого приводится краткое описание частичной поддержки абстракции данных в языке SIMULA 67. Полная поддержка языками программирования абстракции данных обсуж- дается в терминах двух конкретных языков: Ada и C++. Показана реализация одного и того же примера абстракции данных в каждом из этих языков. Это позволяет выделить как общие черты, так и различия в разработке языковых средств поддержки абстракции данных. В качестве альтернативы языку Ada кратко описана поддержка абстракции дан- ных в языке программирования Modula-2, а поддержка абстракции данных в языке Java рассматривается как альтернатива языку C++. В заключение показаны возможности соз- дания параметризованных абстрактных типов в языках Ada и C++. Заметим, что в этой книге фразы “абстракция данных” и “абстрактные типы данных” означают одно и то же. 10.1. Понятие абстракции Абстракция — это суждение или представление о некотором объекте, которое содер- жит только свойства, являющиеся существенными в данном контексте. Абстракция по- зволяет объединять экземпляры объектов в группы, внутри которых общие свойства объектов можно не рассматривать, т.е. абстрагироваться от них. Тогда внутри группы нужно изучать лишь те свойства, которые отличают ее отдельные элементы друг от дру- га. Это значительно упрощает элементы группы. При необходимости детального изуче- ния объектов нужно рассматривать их менее абстрактные представления. Абстракция — это эффективное средство против сложности программирования, поскольку оно позволя- ет программисту сосредоточиться на существенных свойствах объектов и проигнориро- вать менее важные свойства. В современных языках программирования есть два основных вида абстракции: абст- ракция процесса и абстракция данных. Понятие абстракции процесса — одно из наиболее старых понятий в области разра- ботки языков программирования. Даже язык Plankalkiil поддерживал абстракцию про- цесса. Все подпрограммы являются абстракциями процессов, поскольку они определяют способ, с помощью которого программа устанавливает, что необходимо выполнить не- который процесс, без уточнения деталей того, как именно это следует сделать (по край- ней мере, в вызывающей программе). Например, если в программе нужно упорядочить массив числовых данных некоторого типа, обычно используется подпрограмма, выпол- няющая сортировку. В той точке программы, в которой следует выполнить сортировку, помещается оператор следующего вида: sort__int (list, list__len) 430 Глава 10. Абстрактные типы данных
Этот вызов является абстракцией реального процесса сортировки, алгоритм которого -е определен. Вызов не зависит от алгоритма, который реализуется в вызываемой под- "гограмме. Единственными существенными свойствами подпрограммы sort_int являются имя . ~орядочиваемого массива, тип его элементов и тот факт, что вызов подпрограммы приводит к сортировке массива. Какой именно алгоритм выполняет 5: rr int, для пользователя не существенно. Абстракция процесса — ключевое понятие в программировании. Возможность абст- рагироваться от многочисленных деталей алгоритма, который выполняется подпрограм- ' ?й. позволяет создавать, читать и понимать большие программы. Напомним, что боль- шой в настоящее время считается программа, содержащая, по крайней мере, несколько -ь сяч строк кода. Все подпрограммы, включая параллельные подпрограммы (рассматриваемые в гла- = е 12) и обработку исключительных ситуаций (описанную в главе 13), являются абстрак- циями процессов. Эволюция абстракции данных сопровождается эволюцией абстракции процесса, по- скольку неотъемлемой и главной частью любой абстракции данных являются операции, • оторые определяются как абстракции процессов. 10.2. Инкапсуляция Инкапсуляция предшествует абстрактным типам данных и поддерживает их. Когда размер программы достигает нескольких тысяч строк, возникают две практиче- ские проблемы. С точки зрения программиста, рассмотрение программы как единого набо- ра подпрограмм не обеспечивает адекватного уровня организации программы и управления сю. Решить эту проблему можно, разделив программу на синтаксические контейнеры, ко- -срые содержат группы логически связанных подпрограмм и данных. Эти синтаксические контейнеры часто называются модулями, а процесс их разработки— модуляризацией. Вторая практическая проблема, связанная с большими программами, — повторная компи- ляция. Для маленькой программы повторная компиляция всей программы после каждой модификации стоит немного. Однако, когда размер программы возрастает до нескольких тысяч строк, стоимость повторной компиляции становится значительной. Таким образом, необходимо найти способ избежать повторной компиляции неизменных частей программы. Это можно сделать, составив программу из наборов подпрограмм и данных, каждый из ко- ’орых можно компилировать отдельно, без повторной компиляции остальной части про- граммы. Такой набор называется единицей компиляции. Инкапсуляция — это способ объединения в единое целое подпрограмм и данных, ко- торые они обрабатывают. Инкапсуляция, которая компилируется либо отдельно, либо независимо от других, является основой абстрактной системы и логической организации набора соответствующих вычислений. Следовательно, инкапсуляция решает обе практи- ческие проблемы, описанные выше. Инкапсуляции часто размещают в библиотеках и делают доступными для повторного использования в других программах. Люди пишут программы, размер которых превышает несколько тысяч строк, уже бо- лее 40 лет, так что техника создания инкапсуляций развивается уже довольно давно. 10.2. Инкапсуляция 431
Во многих алголоподобных языках программы можно организовывать в виде опреде- лений подпрограмм, вложенных в подпрограммы более высокого уровня, которые их ис- пользуют. Как обсуждалось в главе 4. метод организации программ, который использует контекст описания, далек от идеала. Кроме того, в некоторых языках программирования подпрограммы не являются единицами компиляции. Следовательно, их нельзя назвать подходящими конструкциями для инкапсуляции. В языке FORTRAN 77 подпрограммы можно собирать в файлах, независимо друг от друга компилировать и размещать в библиотеках. Наборы определений COMMON-блоков можно обрабатывать аналогичным образом. Это— эффективный прием организации программ, но при использовании таких инкапсуляций нет проверки интерфейса. Поэтому такой подход по своей природе небезопасен. В языке С наборы связанных между собой функций и определений данных можно помещать в файл, который компилируется независимо от других файлов. Несмотря на то что компиляторы языка С в настоящее время проверяют правильность интерфейса долж- ным образом определенных функций, они все еще не осуществляют проверку типов в определениях данных из других файлов. (Под “должным образом определенными” мы подразумеваем такие функции, которые не используют заголовки функций, не соответст- вующие стандарту ANSI С.) Таким образом, С-файлы также не обеспечивают безопас- ную инкапсуляцию. Многие современные языки, включая FORTRAN 90 и Ada, позволяют собирать набо- ры подпрограмм, типов и данных в модули, которые можно компилировать отдельно, подразумевая при этом, что информация об их интерфейсе сохраняется компилятором для проверки типа интерфейса при использовании их другим модулем. Рассматриваемые языки также содержат механизмы управления доступом к сущностям в этих модулях, что позволяет модулю содержать некоторые имена типов, которые являются видимыми во внешних модулях, при этом представления таких типов видимы для других сущностей только внутри модуля. Эти модули обеспечивают отличную инкапсуляцию. Они не толь- ко поддерживают точную и логичную организацию программы, но и делают эту органи- зацию ясной для читателя программы. Характерные особенности средств обеспечения инкапсуляции в языках SIMULA 67, Ada и C+f обсчждаются вместе с абстрактными типами данных в разделе 10.5. 10.3. Введение в абстракцию данных Абстрактный тип данных — это инкапсуляция, которая содержит только представле- ния данных одного конкретного типа и подпрограммы, которые выполняют операции с данными этого типа. С помощью управления доступом несущественные детали описания типа можно скрыть от внешних модулей, которые используют такой тип. Программные модули, которые используют абстрактный тип данных, могут объявлять переменные это- го типа, даже несмотря на то, что реальное представление типа скрыто от них. Экземп- ляр абстрактного типа данных называется объектом. Существует одна общая причина создания абстракции типа данных и абстракции процесса. Это — средство против сложности, способ сделать большие и/или сложные программы более управляемыми. Другие мотивы создания и преимущества абстрактных типов данных обсуждаются далее в этом разделе. Как и абстракция процессов, абстрак- ция данных допускает совершенно разные методологии программирования. 432 Глава 10. Абстрактные типы данных
В последние годы возрастает популярность новой методологии разработки про- граммного обеспечения— объектно-ориентированного программирования. Объектно- ориентированное программирование, описанное в главе 11, — это результат использова- ния абстракции данных при разработке программ, а абстракция данных — одна из его важнейших составных частей. 10.3.1. Число с плавающей точкой как абстрактный тип данных Понятие абстрактного типа данных, по крайней мере в терминах встроенных ти- пов, — не новое изобретение. Все встроенные типы данных, даже в языке FORTRAN I, являются абстрактными, хотя так их называют редко. Рассмотрим, например, числа с плавающей точкой. Большинство языков программирования включают в себя хотя бы один тип для представления таких чисел, что позволяет создавать переменные этого типа и выполнять с ними арифметические операции. В языках высокого уровня типы для представления чисел с плавающей точкой ис- пользуют ключевое понятие в абстракции данных: сокрытие информации. Реальное пре- дставление данных в ячейке памяти, предназначенной для хранения чисел с плавающей точкой, скрыто от пользователя. С ними можно выполнять лишь те операции, которые предусмотрены в языке. Пользователь не может создавать новые операции с данными этого типа, за исключением тех, которые можно сконструировать с помощью встроен- ных операций. Кроме того, нельзя непосредственно манипулировать частями реального представления чисел с плавающей точкой, поскольку это представление скрыто от поль- зователя. Так обеспечивается переносимость программ между конкретными реализация- ми языка, даже если эти реализации используют разные представления чисел с плаваю- щей точкой. 10.3.2. Абстрактные типы данных, определяемые пользователем Понятие абстрактных типов данных, определяемых пользователем, возникло относи- тельно недавно. Абстрактные типы данных, определяемые пользователем, должны иметь те же свойства, что и числа с плавающей точкой: 1) определение типа, позволяющее про- граммным модулям объявлять переменные этого типа, создавая при этом реальное пред- ставление этих переменных в памяти; 2) набор операций для манипуляций с объектами данного типа. Ниже приводится формальное определение абстрактного типа данных в контексте типов, определенных пользователем. Абстрактный тип данных — это тип данных, кото- рый удовлетворяет следующим двум условиям. Представление (определение типа) и операции над объектами данного типа со- держатся в одной синтаксической единице. Кроме того, переменные данного типа можно создавать и в других модулях. Представление объектов данного типа скрыто от программных модулей, исполь- зующих этот тип, так что над такими объектами можно производить лишь те опе- рации, которые прямо предусмотрены в определении типа. Программные модули, которые используют некоторый абстрактный тип данных, на- зываются клиентами этого типа. 10.3. Введение в абстракцию данных 433
Основные преимущества упаковки представления типа и операций в отдельную син- таксическую единицу те же, что и у инкапсуляции. Во-первых, это позволяет организо- вывать программу в виде логических единиц, которые можно компилировать отдельно. Во-вторых, появляется возможность модифицировать представления объектов данного типа или операции с ними в отдельной части программы. У сокрытия деталей представ- ления типа есть несколько преимуществ. Наиболее важное из них то, что клиенты не мо- гут “видеть” детали'представления объектов, и, следовательно, их код не зависит от это- го представления. Таким образом, представления объектов можно изменять в любое время, не требуя при этом вносить изменения в код клиентов. Интерфейс абстракции представляет некоторые (но не все) ее свойства. Другим очевидным и важным преимуществом сокрытия информации является повы- шенная надежность. Клиенты не могут непосредственно изменять основные представле- ния объектов ни преднамеренно, ни случайно, следовательно, возрастает целостность та- ких объектов. Объекты можно изменять только с помощью предусмотренных для этого операций. Важность сокрытия деталей представления абстрактного типа данных трудно переоценить. 10.3.3. Пример Предположим, что необходимо создать абстрактный тип данных для стека, который имеет следующие абстрактные операции. create(stack) destroy(stack) empty(stack) Создает и, возможно, инициализирует объект типа stack. Освобождает память, занимаемую стеком. Предикатная (булевская) функция, которая возвращает значение “истина”, если стек пуст, и “ложь” — в противном случае. push(stack,element) pop( stack) top( stack) Помещает указанный элемент в указанный стек. Удаляет верхний элемент из указанного стека. Возвращает копию верхнего элемента из указанного стека. Заметим, что некоторые реализации абстрактных типов данных не предусматривают операций создания и разрушения объектов. Например, простое определение переменной некоторого абстрактного типа данных может неявно создавать соответствующую струк- туру данных и инициализировать ее. Клиент типа stack может содержать следующую последовательность операторов: create(STK1); push(STKl, COLORI); push'STKl, COLOR2); if(not empty(STK1)) then TEMP := top(STKl); Предположим, что первоначальная реализация абстракции стека представляет его в виде следующих один за другим элементов массива. Позднее, из-за проблем с управле- нием памятью, связанных с массивами, стек стали представлять в виде связного списка. Поскольку была использована абстракция данных, такие изменения можно вносить в код, определяющий тип stack, но при этом не нужно вносить изменения в код ни одного 434 Глава 10. Абстрактные типы данных
'ента. использующего абстракцию стека. В частности, приведенную выше последова- 'г'ьность операторов можно оставить без изменения. Конечно, изменение в протоколе <?ой операции может потребовать изменения кодов клиентов. Если бы стек не был реализован как абстрактный тип данных, такие изменения при- те-и бы к изменению кодов клиентов, использующих тип stack, в соответствии с новым составлением стека. Предположим, что операции со стеком были реализованы в языке - 22 как операции с массивами. Изменение представления стека с массива на связный .- ‘сок потребовало бы модификации кодов клиентов, которые теперь должны были бы етедавать указатели, а не имена массивов в качестве параметров процедур, реализую- операции со стеками. В этом случае протоколы некоторых операций должны быть тменены, следствием чего является внесение изменений в коды клиентов. В заключение отметим, что цель абстракции данных — дать возможность программам "эеделять типы данных, с которыми можно обращаться как со встроенными типами. 10.4. Вопросы разработки типов Средства определения абстрактных типов данных в языке программирования должны : зеспечивать создание синтаксических единиц, которые могут инкапсулировать определе- типа и подпрограмм, выполняющих абстрактные операции. Должна существовать воз- “ эжность делать имя типа и заголовки подпрограмм видимыми в клиентах абстракции. Это тзволяет клиентам объявлять переменные абстрактного типа и манипулировать их значе- -иями. Несмотря на то что имя типа должно быть видимым извне, его определение должно : ыть скрыто. То же самое зачастую относится и к определениям подпрограмм — заголовки должны быть видимыми, но тела подпрограмм должны быть скрыты. Существует очень мало общих встроенных операций, которые можно выполнять с объектами абстрактных типов, в отличие от операций, предусмотренных определением ’ипа. Просто не существует большого количества операций, которые можно было бы применить к широкому кругу возможных абстрактных типов данных. К таким операциям зтносятся присваивания, а также проверки равенства и неравенства. Если язык не позво- ляет пользователям перегружать операцию присваивания, то она должна быть встроен- ной. Проверки равенства и неравенства в одних случаях должны быть заранее определе- ны. а в других — нет. Например, если тип — указатель, то равенство может означать ра- венство указателей, но пользователь может пожелать, чтобы такое равенство означало равенство структур, адреса которых хранятся в этих указателях. Некоторые операции нужны для большинства абстрактных типов данных, но, по- скольку они не универсальны, разработчик типа должен определить их сам. К таким опе- рациям относятся итераторы, конструкторы и деструкторы. Итераторы обсуждались в главе 7. Конструкторы используются для инициализации частей вновь создаваемых объ- ектов. Деструкторы используются для освобождения областей динамической памяти, ко- торые могут быть заняты частями объектов абстрактного типа. Как указывалось ранее, абстрактный тип данных инкапсулирует отдельный тип данных и связанные с ним операции. Языки Concurrent Pascal (Brinch Hansen, 1975), Smalltalk (Goldberg and Robson, 1983), C++ и Java непосредственно поддерживают абстрактные типы данных. Альтернативой этому является поддержка более общих конструкций, которые оп- ределяют любое количество сущностей, каждую из которых по отдельности можно сделать видимой вне содержащего ее модуля. Этот подход реализован в языках программирования 10.4. Вопросы разработки типов 435
Modula-2 и Ada. Мы назвали эти конструкции инкапсуляциями. Инкапсуляции являются не абстрактными типами данных, а их обобщениями, и в этом качестве инкапсуляции можно использовать для определения абстрактных типов данных. Отметим основные вопросы разработки типов с помощью инкапсуляции. Во-первых, следует ли ограничивать множество типов, которые могут быть абстрактными? Такое ограничение имеет некоторые преимущества. В частности, если абстрактными могут быть лишь указатели, то при разработке программ можно избежать больших проблем, связанных с повторной компиляцией. С другой стороны, некоторые исследователи счи- тают это очень сильным ограничением, поскольку оно имеет существенные недостатки. Второй вопрос разработки типов — можно ли параметризовать абстрактные типы дан- ных? Например, если язык программирования поддерживает параметризованные типы данных, можно разработать абстрактные типы данных для очередей, которые состоят из элементов любого скалярного типа. Параметризованные абстрактные типы данных об- суждаются в разделе 10.6. В заключение отметим, что существуют также вопросы, свя- занные с тем. как именно осуществляется управление доступом к данным, и как такое управление определяется. 10.5. Примеры абстракции данных в разных языках В этом разделе рассматривается поддержка абстракции данных в языках программи- рования SIMULA 67, Ada, C++ и Java. 10.5.1. Классы в языке SIMULA 67 Первые средства для прямой поддержки абстракции данных в языке, хотя и не полно- стью соответствующие нашему определению, появились в конструкции классов в языке SIMULA 67. 10.5.1.1. Инкапсуляция Определение класса в языке SIMULA 67 является описанием типа. Экземпляры (объекты класса) создаются в динамической памяти по запросу пользовательской про- граммы. и доступ к ним можно получить только через указатели. Таким образом, объек- ты класса являются динамическими. Общая синтаксическая форма определения класса в языке SIMULA 67 имеет вид: class class_name; begin -- объявления переменных класса — — определения подпрограмм класса -- -- раздел кода класса -- end cl ass_name; Раздел определения класса, содержащий код, выполняется только один раз, в момент создания объекта. Он служит конструктором класса и в этом качестве используется для инициализации переменных, определенных в классе. Вклад языка SIMULA 67 в разработку абстракции данных заключается в возможно- сти инкапсуляции, которой обладает конструкция класса. Интересно, что значимость 436 Глава 10. Абстрактные типы данных
свойства классов не признавалась еше несколько лет после завершения разработки SIMULA 67. Важность абстракции данных не осознавалась до конца 70-х г одов. ’ 0.5.1.2.Сокрытие информации .временные. объявленные в классе языка SIMULA 67. не скрыты от клиентов, кото- . сояают объекты этого класса. Доступ к таким переменным можно получить через . - 1ИИИ. выполняемые подпрограммами класса, или непосредственно по их именам. нарушает требование сокрытия информации при определении абстрактного типа ->i\. поскольку существует много способов доступа к сущностям класса. В результа- . • /аесы языка SIMULA 67 намного менее надежны, чем истинные абстрактные типы • ых Кроме того, поскольку клиенты класса зависят от определений переменных в ..се. изменения в этих определениях переменных могут вызвать изменения в кодах енгов. что усложняет поддержку таких программ. 10.5.1.3. Оценка Конструкция класса в языке SIMULA 67 обеспечивает инкапсуляцию, а нс сокрытие Формации, которое позволяет предохранять детали представления данных от измене- - клиентами класса. Язык SIMULA 67 был революционным в своей разработке конструкции класса. Ол- •о. поскольку этот язык программирования так и не получил широкого распростране- - мы упомянули его здесь только с точки зрения исторического интереса. Теперь вер- .ч'ся к рассмотрению двух современных языков программирования, которые обеспечи- - полную поддержку абстракции данных: Ada и C++. 10.5.2. Абстрактные типы данных в языке Ada Язык Ada обеспечивает средства инкапсуляции, которые можно использовать для моае- точания абстрактных типов данных, включая возможность скрывать их представление. 10.5.2.1. Инкапсуляция Конструкции инкапсуляции в языке Ada называются пакетами (packages). Пакеты * »г у г состоять из двух частей, каждая из которых также называется пакетом. Они назы- -.-•отся спецификацией пакета (specification package), которая обеспечивает интерфейс г ншеуляции. и телом пакета (body package), которое обеспечивает реализацию сущ- •остей, перечисленных в спецификации Не все пакеты имеют тело (пакеты, которые ин- •. псулируют только типы и константы, не имеют тел или не нуждаются в них). Спецификация пакета и связанное с ним тело пакета имеют одно и то же имя. Заре- зервированное слово body в заголовке пакета означает, что он является телом пакета. -Пенификацию и тело пакета можно компилировать отдельно при условии, что специ- фикация пакета компилируется первой. 10.5.2.2. Сокрытие информации Нет никаких ограничений для типов, которые можно описывать в спецификации па- кета и экспортировать из них. Пользователь может либо создать сущность, целиком ви- дим\ю клиентами, либо обеспечить лишь информацию о ее интерфейсе. Для этого ис- пользуются два раздела в спецификации пакета — в первом из них содержатся сущности, видимые для клиентов, а второй скрывает свое содержание. Например, если тип должен 10.5. Примеры абстракции данных в разных языках 437
быть экспортирован, но его представление скрыто, в видимой части спецификации по- мещается сокращенное объявление, в котором указывается только имя типа и тот факт, что его представление является скрытым. Представление типа помещается в части спе- цификации. которая называется закрытой (private) и начинается с зарезервированного слова private. Закрытый раздел всегда помещается в конце спецификации. Предположим, что тип под названием NODE_TYPE экспортируется пакетом, но его представление скрыто. Тип NODE_TYPE объявляется в видимой части спецификации пакета без деталей его представления, как показано ниже: type NODE_TYPE is private; В закрытом разделе объявление типа NODE_TYPE повторяется, но на этот раз с пол- ным определением типа: package LINKED—LIST_TYPE is type NODE—TYPE is private; private type NODE_TYPE; type PTR is access NODE_TYPE; type NODE_TYPE is record INFO : INTEGER; LINK : PTR; end record; end LINKED—LIST_TYPE; Если ни одна из сущностей в пакете не должна быть скрытой, не нужна и закрытая часть спецификации. Причина, по которой представления типов вообще приводятся в спецификации пакета, связана с вопросами компиляции. Клиент может видеть только спецификацию пакета (но не тело пакета), а компилятор должен размещать объекты экспортируемого типа при ком- пиляции клиента. Кроме того, клиент может компилироваться, когда в наличии есть только спецификация пакета абстрактного типа данных. Следовательно, компилятор должен иметь возможность определить размер объекта по спецификации пакета. Значит, представление типа данных должно быть открыто для компилятора, но не для кода клиента. Это именно та ситуация, которая определяется закрытым разделом спецификации пакета. Типы, которые объявляются как закрытые, так и называются— закрытые типы (private types). Закрытые типы данных имеют встроенные операции присваивания и сравнения. Все другие операции должны быть объявлены в спецификации пакета, кото- рая определяет тип. Альтернативой закрытым типам служат ограниченные закрытые типы (limited private types), которые описываются в закрытом разделе. Единственная синтаксическая разница между ними состоит в том, что ограниченные закрытые типы объявляются заре- зервированными словами limited private в видимой части спецификации пакета. Объекты ограниченного закрытого типа не имеют встроенных операторов. Такие типы полезны тогда, когда обычные встроенные операции присваивания и сравнения бес- смысленны или бесполезны. Например, присваивание и сравнение редко используются для стеков. Если нужно выполнить операции присваивания или сравнения, а их встроен- ные версии не подходят для этого, эти операции следует предусмотреть в спецификации 438 Глава 10. Абстрактные типы данных
лкета. Операция присваивания должна иметь форму обычной процедуры, в то время как лераторы проверки равенства и неравенства могут быть реализованы путем перегрузки л\ операторов применительно к новому типу. 10.5.2.3. Пример Ниже приведена спецификация пакета для абстрактного типа данных, описывающего .:ек: package STACKPACK is — Видимые сущности, или открытый интерфейс type STACKTYPE is limited private; MAX_SIZE : constant := ICO; function EMPTY(STK : in STACKTYPE) return BOOLEAN; procedure PUSH(STK : in out STACKTYPE; ELEMENT : in INTEGER); procedure POP(STK : in out STACKTYPE); function TOP(STK: in STACKTYPE) return INTEGER; — Часть, скрытая от клиентов private type LISTJL’YPE is array (1..MAX_SIZE) of INTEGER; type STACKTYPE is record LIST : LIST TYPE; TOPSUB : INTEGER range O..MAX_SIZE := 0; end record; end STACKPACK; Заметим, что операции создания и разрушения не включены в спецификацию пакета, оскольку в них нет необходимости. Тело пакета STACKPACK: with TEXT-IO; use TEXT_IO; package body STACKPACK is function EMPTY(STK: in STACKTYPE) return BOOLEAN is begin return STK.TOPSUB = 0; end EMPTY; procedure PUSH(STK : in out STACKTYPE; ELEMENT : in INTEGER) is begin if STK.TOPSUB >= MAX_SIZE then PUT_LINE("ОШИБКА - Переполнение стека); else STK.TOPSUB := STK.TOPSUB + 1; STK.LIST(TOPSUB) := ELEMENT; end if; end PUSH; procedure POP(STK : in out STACKTYPE) is begin if STK.TOPSUB = 0 10.5. Примеры абстракции данных в разных языках 439
then PUT LINE("ОШИБКА - Стек пуст"); else STk7?OPSU3 := Si К.TOPSUB -1; end if; end POP; function TOP(STK : in STACKTYPE'; return INTEGER is begin if SI?'. TOPSUB = c then ?UT_LINE ("ОШИБКА - Сте-- пуст"); else return STK.LIST(STK.T0PSU3); end if; end TOP; end 3TACKPACK; Первая строка кода в этом теле пакета содержит два оператора: with и use. Оператор with импортирует внешние пакеты, в данном случае- - ТЕХТ_1О, который обеспечивает ввод и вывод текста. Оператор use исключает необходимость явного определения ссылок на сущности названного пакета. Это позволяет использовать процедуру PUT LINE из ТЕХ? 10 без явного определения (в коде следовало бы писать TEXT 10. PUT LI NE (), если бы оператора use не было в спецификации пакета). Тело пакета должно содержать определения подпрограмм с заголовками, которые со- гласуюгся с заголовками подпрограмм из соответствующей спецификации пакета. Спе- цификация пакета 1арантируст. что эти подпрограммы 6}дут определены в соответст- вующем теле пакета. Следующая процедура. USE STACK, является клиентом пакета STACKPACK. Она ил- люстрирует возможное использование пакета. with SIACKPACK, ГЕХТ_10; use STACKSАСК, ТЕХТ_10; procedure USE7. STACKS is '.'PONE : INTEGER; HACK : STACKTYPE; — Создает объект типа STACKTYPE begin : j L : 1 с Г AC ? t , P -SH - STACK, 1*7) ; " L-E : 10P (STACK) ; PC?(S PACK; ; end USE STACKS; 10.5.2.4. Родственный язык: Modula-2 Модули в языке Modula-2 похожи на пакеты в языке Ada. так что они обеспечивают примерно такой же уровень поддержки абстрактных типов данных. Основное различие между этими двумя языками в отношении поддержки абстрактных типов данных состоит в том. что в языке Modula-2 все типы, представление которых скрыто в модулях, должны быть указателями. Это ограничение является альтернативой спецификациям пакетов в языке Ada. которые состоят из двух частей. Размер всех указателей одинаков, так что компилятору не обязательно видеть определение представления типа, чтобы позволить клиенту' этого типа создать объект. Другое преимущество такого ограничения заключа- 440 Глава 10. Абстрактные типы данных
. .-’в том. что код клиента не нужно компилировать снова после внесения изменений в 'сание абстрактного тина данных. То же справедливо и для абстрактных типов тнных •' яке Ada. являющихся указателями. Однако, если абстрактный тип данных в я тыс Ada . является указателем, код клиента должен быть заново скомпилирован после молнфи- . .ли определения типа. Недостатком ограничения, введенного в языке Modula-2 для артелей, является вынужденное их использование, что приводит к снижению уровня опасности программы. Компромиссным решением этой проблемы является тот факт. в языке Modula-2 указатели не инициализируются по умолчанию нулевым а фесом • > позволяет клиентам объявлять переменную абстрактного типа и использовать ее до ю- кдк она станет хранить адрес некоторой ячейки, выделенной в динамической намят 10.5.3. Абстрактные типы данных в языке C++ Я тык C++ был создан путем добавления новых свойств в язык С. Первым важным мнением стала поддержка объектно-ориентированного программирования. Посколь- • одними из основных компонентов объектно-ориентированного программирования яв- --отся абстрактные типы данных, язык С-+, очевидно, должен их поддерживать. В то время как языки Ada и Modula-2 обеспечивают инкапсуляцию, которая может . .ользоваться при моделировании абстрактных типов данных, в языке C++ ввезено по- - ’ие класса, который непосредственно поддерживает абстрактные типы данных. В язы- : С — классы — типы, а пакеты языка Ada и модули языка Modula-2 типами не являют- . - Пакеты и модули импортируются, позволяя импортирующей программной единице 'ъявлять переменные любого типа, определенного в пакете или модуле. В программе на - C++ переменные объявляются как сущности, имеющие тип данного класса Таким разом, классы гораздо больше похожи на встроенные типы, чем пакеты или модули >?граммная единица, которая видит пакет в языке Ada или модуль в языке Modula-2, меет доступ к любым открытым сущностям просто по их именам. Программная едини- . * на языке C++, которая объявляет экземпляр класса, также имеет доступ к любым от- • рытым сущностям в этом классе, но только через экземпляр этого класса. 10.5.3.1. Инкапсуляция Классы в языке C++ основаны на классах языка SIMULA 67 и являются расширени- ем типа struct языка С. Поскольку язык C++ является наследником языка SIMULA 67. -..асе в С — это описание типа данных. Данные, определенные в классе, называются данными-членами (data members), а тункпии, определенные в классе,— функциями-членами (member functions). Все ж- емпляры класса совместно используют единый набор функций-членов, но каждый эк- емпляр получает свой собственный набор данных-членов класса. Несмотря на то что ж- земпляры класса могут быть также и статическими (static), и динамическими (heap- -ynemic). мы рассмотрим только автоматические классы (stack-dynamic). Экземпляры та- классов всегда создаются при объявлении объекта. Кроме того, экземпляр класса пе- рестает существовать, выходя из своей области видимости. Классы могут иметь динами- ческие данные-члены, так что даже если экземпляр класса является автоматическим, он может включать в себя динамические данные-члены, которые размещаются в динамиче- ской памяти. Для управления динамической памятью в языке C++ предусмотрены опера- торы new и delete. 10.5. Примеры абстракции данных в разных языках 441
Функция-член класса описывается двумя разными способами. В классе можно помес- тить либо полное определение функции, либо только ее заголовок. Когда в определении класса приводятся и заголовок, и тело функции-члена, эта функция-член по умолчанию яв- ляется inline-подставляемой. Это означает, что ее код будет помешен в код вызывающей функции вместо использования обычного вызова и возврата управления. Если в определе- нии класса приведен только заголовок функции-члена, то ее полное определение помеща- ется вне класса и компилируется отдельно. Рекомендуется делать inline-подставляемыми небольшие по размеру функции-члены, поскольку они не занимают много места в коде клиента, и при этом экономится время на вызов функции и возврат из нее. 10.5.3.2. Сокрытие информации Класс в языке С+4- может содержать как скрытые, так и видимые сущности. Сущно- сти, которые должны быть скрыты, помещаются в разделе private, а видимые, или от- крытые, сущности записываются в разделе public. Таким образом, раздел public описывает интерфейс объектов класса. Существует также третья категория видимости, protected, которая в контексте наследования обсуждается в главе 11. Язык C++ дает возможность пользователю включать в определение класса функции, называемые конструкторами (constructors), которые используются для инициализации данных-членов вновь создаваемых объектов. Конструктор может также размещать дина- мические данные-члены нового объекта в динамической памяти. Конструкторы неявно вызываются при создании объекта класса. Конструктор имеет то же имя, что и класс, ча- стью которого он является. Это выглядит странно, но вполне безопасно. В классе может быть несколько конструкторов, при этом они, очевидно, являются перегруженными функциями. Конечно, каждый из них должен иметь уникальный набор параметров. В языке C++ класс может также содержать функцию, называемую деструктором (destructor), которая неявно вызывается при выходе экземпляра класса из своей области видимости. Все динамические объекты существуют до тех пор, пока они не будут явно уничтожены оператором delete. Как указывалось выше, автоматические экземпляры класса могут содержать динамические данные-члены. Деструктор такого экземпляра может содержать оператор delete, для того чтобы освобождать области динамической памяти, занимаемые динамическими данными-членами. Деструкторы часто используют- ся как средство отладки, при этом они просто выводят на экран или на принтер значения некоторых или всех данных-членов объекта перед тем, как они будут удалены из памяти. Имя деструктора — это имя класса, перед которым стоит тильда (~). Ни конструктор, ни деструктор не возвращают никаких значений и не используют операторы return. И конструкторы, и деструкторы можно вызывать явно. 10.5.3.3. Пример Примером абстрактного типа данных в языке С+* снова служит стек. ttinclude <iostream.h> class stack { private: //** Эти члены класса являются видимыми только для //других членов класса или друзей //(см. Раздел 10.5.3.4) int *stack__ptr; int max_len; int top__ptr; 442 Глава 10. Абстрактные типы данных
public: //** Эти члены являются видимыми для клиентов stack() { stack_ptr = new int [100]; //** Конструктор max_len = 99; top ptr = -1; } -stack() {delete [] stack_ptr;j; //★★ Деструктор void push(int number) { if (top_ptr == max_len) cout << ’’Ошибка в функции push - стек полон\п”; else stack_ptr[++top_ptr] = number; } void pop() { if (top__ptr == -1) cout<< ’’Ошибка в функции pop - стек пуст\п”; else top_ptr--; } int top() {return (stack_ptr[top_ptr]);} int empty() {return (top_ptr == -1);} Мы обсудим только некоторые аспекты данного определения класса, поскольку нет какой необходимости разбираться во всех деталях этого кода. Директива препроцес- : га «include используется для обеспечения видимости стандартного пакета ввода и = е.вода iostream, который содержит простой поток вывода, используемый кодом 'ъекта cout. Класс stack содержит три данных-члена— stack_ptr, max_len и - с ptr. Все они являются закрытыми данными-членами. Этот класс содержит также етыре открытые функции-члена— push, pop, top и empty, а также конструктор и сструктор. Конструктор использует оператор выделения динамической памяти new для “лзмещения в куче 100 элементов типа int. Он также инициализирует переменные = >:_1еп и top ptr. Деструктор предназначен для освобождения памяти, занятой месивом, который используется для реализации стека, когда объект типа st ас к выхо- 1‘т из своей области видимости. Этот массив размещается в динамической памяти кон- .трчктором. Поскольку в классе содержатся тела функций-членов, все они являются по молчанию inline-подставляемыми. Ниже приводится пример программы на языке C++, которая использует абстрактный •ип данных stack. void main() { int top_one; stack stk; //** Создает экземпляр класса stack stk.push (42); stk.push (17); top_one = stk.topt); stk.pop(); Когда во время выполнения программы достигается ее конец, переменные top_one и stk выходят из области своей видимости, что приводит к неявному вызову деструкто- 10.5. Примеры абстракции данных в разных языках 443
ра для объекта st И. В результате массив, являющийся частью объекта типа stack, уда- ляется из памяти. 10.5.3.4. Оценка Поддержка в языке C++ абстрактных типов данных с помощью конструкции класса по выразительности сравнима с поддержкой абстрактных типов данных в языке Ada с помощью пакетов. Оба языка обеспечивают эффективный механизм инкапсуляции и со- крытия данных общих типов. Основное различие между ними заключается в том, что классы являются типами, а пакеты в языке Ada— инкапсуляциями. Кроме того, понятие класса было разработано не только для абстракции данных, что обсуждается в главе 11. Одна из проблем разработки языка, вытекающая из наличия классов и отсутствия обобщенной инкапсуляции. состоит в том, что не всегда естественно связывать операции объеь гч с отдельными объектами. Например, предположим, что. имея абстрактные типы данных для матриц и векторов, необходимо определить операцию умножения матрицы на вектор. В какой ктасс следует поместить эту операцию? В языке С+4- ситуации такого типа разрешаются с помощью назначения функции, внешней по отношению к классу, его “ярмом". Функции-друзья имеют доступ к закрытым сущностям класса, другом которо- го они объявлены. Для умножения матрицы на вектор в языке C++ одним из решений является описание операции вне классов матрицы и вектора и объявление ее другом обо- их классов. Следующий скелетный код иллюстрирует этот сценарий: class Matrix; //4 * Объявление класса class vector { friend Vector multi ply(const Matrix^, const Vectors); 9 class Matrix { //** Определение класса friend Vector multiply(const MatrixS, const Vectors); С/нкция, использующая объекты классов Matrix и Vector V multiply(const MatrixS ml, const Vectors v]) { H матрицу, и вектор можно было бы описать в пакете на языке Ada, избежав подоб- ной проблемы. кроме функций, друзьями класса могут быть объявлены целые классы, тогда все за- ».ры I ые члены класса будут видимы всеми членами дружественных классов. 10Л.3.5. Родственный язык Java Поддержка абстрактных типов данных в языке Java почти такая же, как и в языке С г i.c । ь. однако, несколько важных различий. Все типы данных, определенные поль- зоваюлем в языке Java, являются классами, и все объекты размещаются в динамической нами hi, а доступ к ним осуществляется с помощью ссылок. Другое различие между под- держкой абстрактных типов данных в языках Java и C++ заключается в том, что подпро- граммы (методы) в языке Java могут быть описаны только внутри класса. Таким обра- зом. в классах языка Java нельзя помещать заголовки функций без их тел. 444 Глава 10. Абстрактные типы данных
В языке Java зарезервированные слова private и public являются лишь молифи- 2’орами, которые могут быть присоединены к определению переменной или метода, но -и не определяют закрытые и открытые разделы в описании класса. В то время как в языке C++ классы являются единственным способом инкапсуляции, «зыке Java есть и другой способ (уровнем выше, чем классы)— пакет. Пакеты могут : держать несколько определений класса, и классы в пакете являются “частичными” оузьями. Слово “частичные” здесь означает, что элементы класса в пакете, открытые ‘и защищенные (см. главу 11), либо не имеющие спецификатора доступа, являются ви- •4мыми во всех других классах в пакете. Сущности без спецификаторов доступа назы- зются сущностями, имеющими пакетную область видимости (package scope), по- • ольку они видимы внутри пакета. По этой причине язык Java не нуждается в явных Чявлениях друзей и не содержит дружественных функций или дружественных классов. в языке C++. Пакеты, которые часто содержат библиотеки, можно описывать иерар- чески. Стандартные библиотеки классов языка Java определяются в виде иерархии па- ктов. Область видимости пакета обсуждается далее в главе 11. Ниже приводится определение класса на языке Java для нашего примера стека. import java.io.*; class Stack_class { private int [] stack_ref; private int max_len, top_index; public Stack_class() { // Конструктор stack_ref = new int [100]; max_len = 99; top_index = -I; } public void push (int number) { if (top__index == max_len) System, out. print In (’’Ошибка в функции push — стек полон”); else stack_ref[++top_index] = number; public void pop() { if (top_index == -1) System, out. printin (’’Ошибка в функции pop — стек пуст”); else —top_index; } public int top() { return (stack_ref[top_index]);} public boolean empty() (return (top_index == -1);} Рассмотрим пример класса, который использует класс Stack class. public class Tst_Stack { public static void main(String[] args) { Stack_class myStack = new Stack_class(); myStack.push (42); myStack.push(29); System.out.printin(” 29 : ” + myStack.top()); myStack.pop(); System.out.printin(” 42 : ” + myStack.top()); 10.5. Примеры абстракции данных в разных языках 445
myStack.pop(); myStack.pop(); // Порождает сообщение об ошибке } } Стек— неудачный пример для языка Java, поскольку библиотека языка Java уже со- держит определение класса для стеков. Однако наше определение класса для стеков по- зволяет сравнить его реализацию в языке Java с реализацией в языке C++ из разде- ла 10.5.3. Одно из очевидных отличий заключается в отсутствии деструктора в Java- версии, необходимость в котором отпадает, так как в языке Java используется механизм неявного освобождения памяти. Другое значительное отличие состоит в использовании ссылок (а не указателей) для адресации объектов типа stack. 10.6. Параметризованные абстрактные типы данных Часто бывает удобно параметризовать абстрактные типы данных. Например, нужно иметь возможность разработать абстрактный тип данных для стека, в котором могут храниться элементы любого скалярного типа, а не писать отдельные абстракции стека для каждого скалярного типа. В следующих двух подразделах описываются возможно- сти, предусмотренные в языках Ada и C++ для конструирования параметризованных аб- страктных типов данных. 10.6.1. Язык Ada Настриваемые процедуры в языке Ada обсуждались и иллюстрировались в главе 8. Пакеты также могут быть настраиваемыми (generic), так что мы можем конструировать и настраиваемые, или параметризованные, абстрактные типы данных. Пример абстрактного типа данных для стека в языке Ada, приведенный в разде- ле 10.5.2, имеет два ограничения: 1) стеки этого типа могут хранить только элементы це- лого типа; 2) стеки могут иметь только до 100 элементов. Оба этих ограничения можно отменить, используя пакет, на основе которого можно создавать экземпляры для других типов элементов и любого желаемого размера. (Это — настраиваемое создание экземп- ляров, которое очень отличается от создания экземпляра класса при создании объекта.) Приведенная ниже спецификация пакета описывает интерфейс настраиваемого абстракт- ного типа данных для стека с указанными свойствами: generic МАХ_SIZE : POSITIVE; — Настраиваемый параметр — размер стека type ELEMENT__TYPE is private; — Настраиваемый параметр — — тип элемента package GENERIC—STACK is -- Видимые сущности, или открытый интерфейс type STACKTYPE is limited private; function EMPTY(STK : in STACKTYPE) return BOOLEAN; procedure PUSH(STK : in out STACKTYPE; ELEMENT : in ELEMENT—TYPE); procedure POP(STK : in out STACKTYPE); function TOP(STK : in STACKTYPE) return ELEMENT—TYPE; -- Скрытая часть 446 Глава 10. Абстрактные типы данных
private type LISTJTYPE is array (1..MAX_SIZE) of ELEMENT TYPE; type STACKTYPE is record LIST : LIST_TYPE; TOPSUB: INTEGER range O..MAX_SIZE := 0; end record; end GENERIC_STACK; Тело пакета GENERIC—STACK такое же, как и тело пакета STACKPACK в предыду- щем разделе, за исключением того, что формальный параметр ELEMENT в функциях ? JSH и ТОР имеет тип ELEMENT—TYPE вместо типа INTEGER. Следующий оператор создает экземпляр пакета GENERIC—STACK для стека, со- :’оящего из 100 элементов типа INTEGER: package INTEGER-STACK is new GENERIC-STACK(100, INTEGER); Можно также построить абстрактный тип данных для стека, состоящего из 500 эле- ментов типа FLOAT, как показано ниже: package FLOAT_STACK is new GENERIC—STACK(500, FLOAT); Эти примеры создания объектов порождают две различные версии исходного кода -хкета GENERIC-STACK во время компиляции. 10.6.2. Язык C++ Язык C++ также поддерживает параметризованные, или настраиваемые, абстрактные ’ '.лы данных. Для того чтобы сделать пример класса для стека в языке C++ из ггздела 10.5.3 настраиваемым по размеру стека, нужно изменить только конструктор, • зк показано ниже: stack(int size) { stk—ptr = new int [size]; max_len = size - 1; top = -1; Объявление объекта типа stack теперь запишем так: stack (150) stk; Определение класса stack может содержать оба конструктора, так что пользователь •: пользует размер стека по умолчанию или указывает какой-нибудь другой размер. Тип элементов в стеке можно превратить в параметр, сделав класс шаблонным. Тогда ~ л элементов может быть шаблонным параметром. Определение шаблонного класса ла stack приведено ниже. =include <iostream.h> template <class Type> // Тип является шаблонным параметром class stack { private: Type *stack ptr; int max_len; > 0.6. Параметризованные абстрактные типы данных 447
int top_ptr; public: // Конструктор для стека из 100 элементов stack() { siackjptr = new Type [100] ; max _len = 99; top ptr = -1; } // Конструктор для стека из заданного количества элементов stacktint size) { stack_ptr = new Type [size] ; max _ien = size - 1; too ptr = -1; } -stack() {delete stack_ptr;}; // Деструктор void push(Type number) { if(top_ptr == max_len) cout << "Ошибка в функции push — стек полон\п"; else stack_ptr [4- + top_otr] = number; } void pop() { if(top_ptr == -1) cout << "Ошибка в функции pop — стек пуст\п"; else top_ptr--; } Type top() {return (stack_ptr[top_ptr])}; int empty() {return (top_ptr == -1);} }; Как и в языке Ada, экземпляры шаблонных классов в языке C++ создаются во время компиляции. Отличие состоит в том, что в языке C++ создание экземпляра является не- явным: создание экземпляра происходит каждый раз при создании нового объекта, что требчег наличия версии шаблонного класса, которой пока не существует. Резюме 11онятие абстрактных типов данных и их использование в разработке программ было ключевым моментом в развитии программирования как технической дисциплины. Не- смотря на то что это понятие относительно простое, его использование стало удобным и безопасным только после разработки языков для его поддержки. Инкапсуляция является единицей компиляции, которая содержит набор логически связанных типов, объектов и подпрограмм. Инкапсуляция также обеспечивает управле- ние доступом к своим сущностям. Инкапсуляции предоставляют программист}' метод организации программ, который ограничивает количество повторных компиляций. Две основные особенности абстрактных типов данных состоят в упаковке данных вместе со связанными с ними операциями в единое целое и сокрытии информации. Язык может поддерживать абстрактные типы данных непосредственно или моделировать их с помощью более общих видов инкапсуляции. 448 Глава 10. Абстрактные типы данных
Язык SIMULA 67 реализовал первую конструкцию для инкапсуляции данных вместе с операциями над ними — класс. Однако конструкция класса в языке SIMULA 67 не обеспечила сокрытия информации. Языки Ada и Modula-2 реализовали инкапсуляции, которые можно использовать для моделирования абстрактных типов данных. Основное различие между ними состоит в ’ом. что в языке Modula-2 существует ограничение на экспортированные типы со скры- тыми представлениями указателей. Это ограничение позволяет изменять представления данных без повторной компиляции модулей, содержащих их определения, и кодов их клиентов. Язык Ada допускает экспортирование любого типа. Если экспортируются ти- ты. не являющиеся указателями, то при изменении представления данных модули- клиенты должны быть перекомпилированы. В языке C++ абстракция данных обеспечивается с помощью классов, подобных клас- сам в языке SIMULA 67. Классы являются типами, и экземпляры классов могут быть созданы теми же способами, что и объекты другого типа. Абстракции данных в языке .’а\а похожи на абстракции данных в языке C++, за исключением того, что объекты в •зыке Java размещаются в динамической памяти, и доступ к ним осуществляется с по- мощью ссылок. Кроме того, в языке Java существует конструкция инкапсуляции более высокого уровня, чем класс, — пакет. Отчасти вследствие доступности пакетов язык Java -е имеет ни дружественных функций, ни дружественных классов. И язык Ada, и язык C++ допускают параметризацию абстрактных типов данных: язык A da — с помощью пакетов, а язык C++ — посредством шаблонных классов. В О П Г С 1. Дайте определение абстрактного типа данных. 2. Какие преимущества дает разделение определения абстрактного типа данных на две части? 3. Какие требования предъявляются к разработке языка, который поддерживает абст- рактные типы данных? 4. Какие проблемы разработки языка связаны с абстрактными типами данных? 5. Чего нехватает в поддержке абстрактных типов данных языком SIMULA 67? б. Какие особенности классов в языке SIMULA 67 позднее стали основой объектно- ориентированных языков? Назовите две причины, по которым абстрактные типы данных в языке Modula-2 могут быть только указателями. S. Объясните, как в пакете языка Ada обеспечивается сокрытие информации. В чем заключается различие между типами private и limited private в языке Ada? 10. Как создаются объекты классов в языке C++? 11. Как создаются объекты классов в языке Java? 12. Почему в языке Java нет деструкторов? 13. Что такое конструктор? Что такое деструктор? Вопросы 449
14. Что такое дружественная функция? Что такое дружественный класс? 15. По какой причине язык Java не имеет ни дружественных функций, ни дружествен- ных классов? 16. Как создаются объекты шаблонных классов в языке C++? Упражнения 1. Разработайте пример абстрактного типа для стека на языке Pascal, предполагая, что определение стека, операции с ним и код, который его использует, находятся в одной и той же программе. 2. Какой важной части или частей определения абстрактного типа данных недостает в реализации типа для стека на языке Pascal из упражнения 1? 3. Разработайте пример абстрактного типа для стека на языке FORTRAN 77, исполь- зуя отдельную подпрограмму с несколькими входами для определения типа и опе- раций. 4. Сравните надежность и гибкость решения упражнения 3 на языках FORTRAN и Ada. 5. Модифицируйте класс в языке C++, определяющий абстрактный тип для стека, ис- пользуя представление стека в виде связного списка, и проверьте его вместе с ко- дом, который приведен в этой главе. 6. Некоторые разработчики программного обеспечения считают, что все импорти- руемые сущности должны уточняться именем экспортирующего программного модуля. Вы согласны с этой точкой зрения? Обоснуйте свой ответ. 7. Разработайте абстрактный тип данных для матрицы на языке, который вы знаете, включая операции сложения, вычитания и умножения матриц. 8. Разработайте абстрактный тип данных для очереди на языке, который вы знаете, включая операции постановки элемента в очередь, исключения элемента из очере- ди и проверки заполненности очереди. 9. Предположим, что был разработан абстрактный тип данных для стека, в котором функция top возвращала путь доступа (или указатель), а не копию верхнего эле- мента. Это не настоящая абстракция данных. Почему? Приведите пример, иллюст- рирующий эту задачу. 10. Напишите абстрактный тип данных для комплексных чисел, включая операции сложения, вычитания, умножения, деления, выделения действительной и мнимой части комплексного числа и построения комплексного числа из двух констант с плавающей точкой, переменных или выражений. Используйте языки Modula-2, Ada, C++ или Java. 11. Напишите абстрактный тип данных для очередей, элементы которых хранят имена, состоящие из 10 символов. Элементы очереди должны размещаться в динамиче- ской памяти. Операции с очередью: постановка элемента в очередь; исключение элемента из очереди и проверка заполненности очереди. Используйте языки Modula-2 , Ada, C++ или Java. 450 Глава 10. Абстрактные типы данных
4 W Поддержка - я я объектно- я я ориентиро- ванного программиро- вания В этой главе... 11.1. Введение 11.2. Объектно-ориентированное программирование 11.3. Вопросы разработки объектно- ориентированных языков 11.4. Об зор языка Smalltalk 11.5. Введение в язык Smalltalk 11.6. Примеры программ на языке Smalltalk 11.7. Гравные особенности языка Smalltalk Голдберг (Adele Goldberg) Голдберг провела 14 лет = *: следовательском центре : ‘. -ании Xerox в Пало-Альто •:xs Palo Alto Research . ?-ter), возглавляя группу по : ?::аботке и реализации языка I “ = talk. Она играла ведущую не только в разработке язы- = Smalltalk, но и в создании па- : з.1.*гмы пользовательского ин- тейса, основанного на ис- : -эзовании окон и пиктограмм. 11.8. Оценка языка Smalltalk 11.9. Поддержка объектно-ориентированного программирования в языке 0++ 11.10. Поддержка объектно-ориентированного программирования в языке Java 11.11. Поддержка объектно-ориентированного программирования в языке Ada 95 11.12. Поддержка объектно-ориентированного программирования в языке Eiffel 11.13. Реализация объектно-ориентированных конструкций задержка объектно-ориентированного программирования 451
Эта глава начинается с введения в объектно-ориентированное программирование. Затем обсуждаются основные вопросы разработки программ, касающиеся на- следования и динамического связывания. Далее приводится обзор языка Smalltalk и де- тальное описание подмножества этого языка, которое иллюстрируется двумя полными программами на языке Smalltalk. После этого дается краткое описание поддержки объ- ектно-ориентированного программирования в языках C++, Java, Ada 95 и Eiffel. 11.1. Введение Языки, поддерживающие объектно-ориентированное программирование, в настоящее время занимают прочное положение среди основных тенденций программирования. На- чиная с языка COBOL и заканчивая языком LISP, практически для каждого языка были разработаны объектно-ориентированные диалекты. К ним относятся языки C++, Ada 95 и CLOS, а также объектно-ориентированная версия языка LISP (Bobrow et al., 1988). Языки C++ и Ada 95, кроме объектно-ориентированного программирования, поддерживают процедурно-ориентированное и информационно-ориентированное программирование. Язык CLOS поддерживает также функциональное программирование. Некоторые из современных языков, разработанных для объектно-ориентированного программирования, не поддерживают другие парадигмы программирования, но продол- жают использовать некоторые основные структуры прежних императивных языков и внешне на них похожи. К таким языкам относятся Eiffel и Java. Кроме того, существует один полностью объектно-ориентированный язык, являющийся совершенно нетрадици- онным. — Smalltalk. Язык Smalltalk был первым языком, предназначенным для полной поддержки объектно-ориентированного программирования. Конкретный способ под- держки объектно-ориентированного программирования в разных языках обсуждается в этой главе. Данная глава является продолжением главы 10, поскольку объектно-ориентированное программирование— это, по сути, приложение принципа абстракции к абстрактным ти- пам данных. Так, в объектно-ориентированном программировании часть, общая для на- бора похожих абстрактных типов данных, выделяется в новый тип. Члены набора насле- дуют общие части от этого нового типа. Это называется наследованием, являющимся сердцевиной объектно-ориентированного программирования и языков, которые его под- держивают. 11.2. Объектно-ориентированное программирование 11.2.1. Введение Концепция объектно-ориентированного программирования (object-oriented programming) уходит корнями в язык SIMULA 67, но она не была полностью разработа- на, пока эволюция языка Smalltalk не привела к появлению языка Smalltalk 80 (в 1980 го- ду, конечно). Действительно, некоторые исследователи рассматривают язык Smalltalk в качестве единственного полностью объектно-ориентированного языка программирова- ния. Объектно-ориентированный язык должен обеспечивать поддержку трех ключевых языковых свойств: абстрактные типы данных, наследование и какой-либо частный вид динамического связывания. 452 Глава 11. Поддержка объектно-ориентированного программирования
Процедурно-ориентированное программирование, которое было наиболее популяр- -ой парадигмой разработки программного обеспечения в 1970-х годах, фокусировалось -а подпрограммах и библиотеках подпрограмм. Данные передавались подпрограммам аля вычислений. Например, массив целых чисел, подлежавший упорядочению, переда- вался в качестве параметра подпрограмме, сортирующей такие массивы. Информационно-ориентированное программирование сосредоточивает внимание на ззстрактных типах данных, детально рассмотренных в главе 10. В этой парадигме вы- деление, которое требуется выполнить для объекта, содержащего данные, определяется вызовом подпрограмм, связанных с этим объектом. Если объект представляет собой •ассив. подлежащий упорядочению, операция сортировки определяется в абстрактном "ипе данных для массива. Процесс сортировки активизируется путем вызова этой опера- ции для конкретного объекта, представляющего собой массив. Парадигма информацион- - ^-ориентированного программирования была популярной в 1980-х годах и хорошо об- : л уживалась средствами абстракции данных в языках Modula-2, Ada и некоторыми со- временными языками. Языки, поддерживающие информационно-ориентированное программирование, часто называются объектными (object-based) языками. 11.2.2. Наследование Во второй половине 1980-х годов для многих разработчиков программного обеспече- ия стало очевидным, что одной из наилучших возможностей для повышения произво- дительности их труда является повторное использование программ. Вполне очевидно, -’о абстрактные типы данных с их инкапсуляцией и управлением доступом должны ис- зльзоваться многократно. Проблема, связанная с повторным использованием абстракт- ах типов данных, почти во всех случаях заключается в том, что свойства и возможно- .-и существующих типов не вполне подходят для нового использования. Старые типы -.обходимо, по крайней мере минимально, модифицировать. Такие модификации могут ?э.ть трудновыполнимыми и требовать от человека понимания части, если не всего це- пком, существующего кода. Кроме того, во многих случаях модификации влекут за со- 'эй изменения во всех программах-клиентах. Вторая проблема, связанная с программированием, ориентированным на данные, за- ключается в том, что все определения абстрактных типов данных являются независимы- “л и находятся на одном и том же уровне иерархии. Это часто не позволяет так структу- глровать программу, чтобы она соответствовала своей проблемной области. Во многих . >чаях исходная задача содержит категории связанных между собой объектов, являю- щихся как наследниками одних и тех же предков (т.е. находящихся на одном и том же говне иерархии), так и предками и наследниками (т.е. состоящих в отношении некото- рой субординации друг с другом). Наследование позволяет решить как проблемы модификации, возникающие в резуль- тате повторного использования абстрактного типа данных, так и проблемы организации рограмм. Если новый абстрактный тип данных может наследовать данные и функцио- альные свойства некоторого существующего типа, а также модифицировать некоторые *з этих сущностей и добавлять новые сущности, то повторное использование значитель- но облегчается без необходимости вносить изменения в повторно используемый абст- рактный тип данных. Программисты могут брать существующий абстрактный тип дан- ных и создавать по его образцу новый тип, соответствующий новым требованиям задачи. Предположим, что в программе есть абстрактный тип данных для массивов целых чисел, 11.2. Объектно-ориентированное программирование 453
включающий в себя операцию сортировки. После некоторого периода использования программа модифицируется и требует наличия не только абстрактного типа данных для массивов целых чисел с операцией сортировки, но и операции вычисления арифметиче- ского среднего для элементов объектов, представляющих собой массивы. Поскольку структура массива скрыта в абстрактном типе данных, без наследования этот тип должен быть модифицирован путем добавления новой операции в эту структуру. При наличии наследования нет необходимости в модификации существующего типа; можно описать подкласс существующего типа, поддерживающий не только операцию сортировки, но и операцию для вычисления среднего арифметического. Абстрактные типы данных в объектно-ориентированных языках по примеру языка SIMULA 67 обычно называются классами (classes). Как и экземпляры абстрактных ти- пов данных, экземпляры классов называются объектами (objects). Класс, который опре- деляется через наследование от другого класса, называется производным классом (derived class), или подклассом (subclass). Класс, от которого производится новый класс, называется родительским классом (parent class), или суперклассом (superclass). Под- программы, определяющие операции над объектами класса, называются методами (methods). Вызовы методов называются сообщениями (messages). Весь набор методов объекта называется протоколом сообщений (message protocol), или интерфейсом со- общений (message interface) объекта. Сообщение должно иметь, по крайней мере, две части: конкретный объект, которому оно должно быть послано, и имя метода, опреде- ляющего необходимое действие над объектом. Таким образом, вычисления в объектно- ориентированной программе определяются сообщениями, передаваемыми от одного объекта к другому. В простейшем случае класс наследует все сущности (переменные и методы) роди- тельского класса. Это наследование можно усложнить, введя управление доступом к сущностям родительского класса. Например, в определениях абстрактных типов данных (в главе 10) некоторые сущности классифицируются как открытые, а другие— как за- крытые. Это управление доступом позволяет программисту скрыть части абстрактного типа данных от клиентов. Такое управление доступом обычно есть в классах объектно- ориентированных языков. Производные классы представляют собой другой вид клиен- тов, которым доступ может быть либо предоставлен, либо запрещен. Чтобы это учесть, некоторые объектно-ориентированные языки включают в себя третью категорию управ- ления доступом, часто называемую защищенной (protected), которая используется для предоставления доступа производным классам и запрещения доступа другим классам. В дополнение к наследуемым сущностям производный класс может добавлять новые сущности и модифицировать методы. Модифицированный метод имеет то же самое имя и часто тот же самый протокол, что и метод, модификацией которого он является. Гово- рят, что новый метод замещает (override) наследуемую версию метода, который поэто- му называется замещаемым (overriden) методом. Наиболее общее предназначение за- мещающего метода — выполнение операции, специфической для объектов производного класса и не свойственной для объектов родительского класса. Например, рассмотрим ие- рархию классов, в которой корневой класс описывает общие архитектурные характери- стики французских готических соборов. Этот корневой класс French_Gotic имеет ме- тод для рисования фасада обобщенного французского готического собора. Предполо- жим, что у класса French_Gothic есть три производных класса— Reims, Amien и Chartres, каждый из которых имеет метод для рисования конкретного фасада. Эти версии метода draw должны замещать метод draw из родительского класса. 454 Глава 11. Поддержка объектно-ориентированного программирования
Классы могут иметь два вида методов и два вида переменных. Наиболее широко ис- пользуемые методы и переменные называются методами и переменными экземпляра । instance methods and variables). Каждый объект класса имеет свой собственный набор переменных объекта, описывающих его состояние. Единственное различие между двумя объектами одного и того же класса заключается в состоянии их переменных объекта. Методы объекта оперируют только объектами данного класса. Переменные класса class variavles) принадлежат классу, а не объекту, так что они имеют только по одной копии в классе. Методы класса (class methods) могут выполнять операции над классом л. возможно, над объектами класса. В этой главе мы будем, в основном, игнорировать методы и переменные класса. Если класс, созданный путем наследования, имеет один родительский класс, то этот -роцесс называется одиночным наследованием (single inheritance). Если класс имеет -есколько родительских классов, то такой процесс называется множественным насле- дованием (multiple inheritance). Если несколько классов связаны между собой одиноч- -ым наследованием, то их взаимоотношения можно изобразить с помощью дерева на- следования (derivation tree). Взаимоотношения классов при множественном наследова- нии можно изобразить с помощью графа наследования (derivation graph). Разработка программы для объектно-ориентированной системы начинается с опреде- ления иерархии классов, описывающей отношения между объектами, которые войдут в '•рограмму, решающую поставленную задачу. Чем лучше эта иерархия классов соответ- .’з>ет проблемной части, тем более естественным будет полное решение. Недостаток наследования как средства, облегчающего повторное использование кода, : включается в том, что оно создает зависимость между классами в иерархии наследования. л’о чмаляет одно из преимуществ абстрактных типов данных, заключающееся в их взаим- - л независимости. Конечно, не все абстрактные типы данных должны быть полностью не- звисимыми, но в общем случае независимость абстрактных типов данных является одним . и\ самых сильных положительных свойств. Однако увеличение возможности повторно- - использования абстрактных типов данных без создания зависимостей между некоторы- из них может оказаться трудной задачей, если не совсем безнадежной. 11.2.3. Полиморфизм и динамическое связывание Третьим свойством объектно-ориентированных языков программирования является • 'д полиморфизма, обеспечиваемый динамическим связыванием сообщений с определе- - -ям и методов. Это свойство поддерживается путем разрешения определения поли- эфных переменных типа родительского класса, которые также могут ссылаться на Чекты любых подклассов данного класса. Родительский класс может определять ме- - л. замещаемый в его подклассах. Операции, определяемые этими методами, похожи, - ? должны уточняться для каждого класса в иерархии. Когда такой метод вызывается тгез полиморфную переменную, этот вызов динамически связывается с методом в со- “зетствующем классе. Одна из целей динамического связывания— обеспечить более е кое расширение программных систем при их разработке и поддержке. Такие про- тзммы можно писать для операций над объектами настраиваемых классов. Эти опера- _ являются настраиваемыми в том смысле, что их можно применять к объектам любо- класса, производного от одного и того же базового класса. Для иллюстрации динами- ке кого связывания рассмотрим пример с соборами. Если программа, использующая •лдсс French_Gothic, имеет полиморфную переменную cathedral типа класса * 1.2. Объектно-ориентированное программирование 455
French_Gothic, то эта переменная может ссылаться на объекты класса French Gothic, а также на объекты любых производных классов. Теперь, когда для вызова метода draw (который определен в классе French_Gothic и во всех его по- томках) используется переменная cathedral, этот вызов динамически связывается с соответствующей версией метода draw, выбранной по типу, на который в данном случае ссылается полиморфная переменная. Динамическое связывание через полиморфные переменные— мощная концепция. Предположим, что наш пример с соборами написан на языке С. Три варианта француз- ских готических соборов следует разместить в переменных типа struct. Может суще- ствовать одна функция draw, использующая оператор switch для вызова правильной функции рисования, соответствующей конкретному собору. Однако при такой реализа- ции именно программист отвечает за вызов подходящей версии функции рисования. В рамках объектно-ориентированного программирования сделать это намного легче. Предположим, что нам нужно дорисовать новый собор, скажем Paris. Это вынудило бы нас модифицировать конструкцию switch в общей функции рисования, а также в каждой подобным же образом организованной функции рисования соборов. При объект- но-ориентированном программировании, однако, добавление рисунка нового собора не влияет на существующий код. Во многих случаях разработка иерархии наследования приводит к одному или не- скольким классам, находящимся на такой высокой ступени иерархии, что создание объ- ектов подобных классов не имеет смысла. Предположим, что существовал класс building в качестве родительского класса, или класса-предка, для класса French_Gotic. Возможно, нет смысла реализовывать метод draw в классе building. Однако, поскольку все классы-потомки должны иметь реализацию такого метода, прото- кол (но не тело) этого метода включается в класс building. Этот абстрактный метод часто называется виртуальным методом (virtual method). Кроме того, любой класс, включающий в себя хотя бы один виртуальный метод, называется виртуальным клас- сом (virtual class). Объекты такого класса не могут быть созданы, поскольку не все его методы имеют тела. Любой подкласс виртуального метода, для которого следует созда- вать объекты, должен обеспечивать реализации всех наследуемых виртуальных методов. 11.2.4. Вычисления в объектно-ориентированных языках Все вычисления в полностью объектно-ориентированном языке выполняются с по- мощью передачи сообщения объекту для вызова одного из его методов. Ответом на со- общение является объект, возвращающий результат вычислений, выполненных этим ме- тодом. Выполнение программы на объектно-ориентированном языке можно описать как моделирование набора компьютеров (объектов), взаимодействующих друг с другом с помощью обмена сообщениями. Каждый объект— абстракция компьютера в том смыс- ле, что он хранит данные и обеспечивает выполнение процессов для манипуляции этими данными. Кроме того, объекты могут передавать и получать сообщения. В сущности, это основные свойства компьютера — хранить и обрабатывать данные, а также передавать и получать сообщения. Суть объектно-ориентированного программирования состоит в решении задач с по- мощью идентификации соответствующих реальных объектов и обработки, требуемой для этих объектов; и последующем моделировании этих объектов, их процессов и необ- ходимых связей между ними. 456 Глава 11. Поддержка объектно-ориентированного программирования
11.3. Вопросы разработки объектно- ориентированных языков При разработке свойств языков программирования, поддерживающих наследование и динамическое связывание, нужно рассмотреть большое количество вопросов. В этом разделе обсуждаются наиболее важные из них. 11.3.1. Исключительность объектов Разработчик языка, полностью полагающийся на объектную модель вычислений, обычно создает объектную систему, содержащую все другие концепции типа. В этом сценарии все, от наименьшего целого числа до полной программной системы, представ- ляет собой объект. Преимущество такого выбора заключается в элегантности и полном единообразии языка и его использования. Основной недостаток состоит в том, что про- стые операции должны выполняться с помощью передачи сообщений, которая часто де- лает их более медленными, чем в императивной модели, в которой такие операции реа- лизуются простыми и быстрыми машинными командами. В этой наиболее чистой модели объектно-ориентированных вычислений все типы яв- ляются классами. Нет никаких отличий между встроенными классами и классами, опре- деленными пользователем. В действительности, все классы обрабатываются одинаково и все вычисления выполняются путем передачи сообщений. Существует два варианта, альтернативных исключительному использованию объек- тов, один из них, являющийся общим для императивных языков, дополненных поддерж- кой объектно-ориентированного программирования, — сохранение императивной моде- ли типов и простое добавление объектной модели. Это порождает более крупный по размерам язык, структура типов которого приводит в замешательство всех пользовате- лей, кроме экспертов. Другой вариант— создать императивную структуру для элементарных скалярных типов. Это обеспечивает скорость операций с элементарными значениями, сравнимую с ожидаемой скоростью в императивной модели. К сожалению, эта альтернатива также ве- дет к усложнению языка. В любом случае необъектные значения нужно смешивать с объектами. Для этого следует использовать так называемые интерфейсные классы (wrapper classes) для необъектных типов, так что некоторые (обычно необходимые) опе- рации могут быть посланы объектам со значениями необъектного типа. В разделе 11.6 мы рассмотрим пример этого явления в языке Java. 11.3.2. Являются ли подклассы подтипами Обсуждаемый здесь вопрос довольно прост: “Сохраняется ли отношение “есть” меж- ду родительским классом и производными классами?”. Отношение “есть” гарантирует, что переменная производного класса может появляться везде, где допустимо использо- вание переменной типа родительского класса. Подтипы в языке Ada являются примера- ми простой формы наследования. Например, subtype SMALL_INT is INTEGER range -100..100; Переменные типа SMALL_INT обладают всеми операциями переменных типа INTEGER, но могут содержать только подмножество значений, возможных для перемен- 11.3. Вопросы разработки объектно-ориентированных языков 457
ных типа INTEGER. Кроме того, каждую переменную типа SMALL_INT можно исполь- зовать всюду, где используются переменные типа INTEGER. Следовательно, каждая пе- ременная SMALL_INT является в некотором смысле переменной типа INTEGER. Производный класс называется подтипом (subtype), если он состоит в отношении “есть” со своим родительским классом. Характеристики подкласса, которые подтвер- ждают, что он является подтипом, таковы: подкласс может только добавлять переменные и методы, а также замещать наследуемые методы “совместимым” образом. Под совмес- тимостью здесь понимается то, что замещающий метод может заменять замещаемый ме- тод, не вызывая сообщений об ошибках несовместимости типов. Одинаковое количество параметров, а также идентичные типы параметров и возвращаемых значений могли бы, конечно, гарантировать согласованность. В зависимости от правил совместимости типов в языке возможны менее строгие ограничения. Наше определение подтипа явно запрещает иметь сущности в родительском классе, которые не наследуются подклассом. 11.3.3. Реализация и наследование интерфейса Концепция сокрытия информации в абстрактных типах данных предоставляет клиен- там интерфейс доступа к своим возможностям, но скрывает их реализацию. Что еще сле- дует сказать о подклассах? Достаточно ли им видеть только интерфейс своего родитель- ского класса или они должны получить доступ к деталям реализации возможностей ро- дительского класса? Если для подкласса видимым является только интерфейс, то это называется наследованием интерфейса (interface inheritance). Если видимы также и де- тали реализации — это наследование реализации (implementation inheritance). Такое наследование обеспечивает разработчика языка готовыми компонентами, дос- тоинства и недостатки которых следует рассмотреть. Учет доступа подкласса к “скрытой” части родительского класса делает подкласс зависимым от этих деталей. Лю- бое изменение в реализации родительского класса потребует повторной компиляции подкласса, и во многих случаях такое изменение приведет к модификации подкласса. Это аннулирует преимущество сокрытия информации от клиентов подкласса. С другой стороны, сокрытие реализации родительского класса от подклассов может привести к неэффективному выполнению экземпляров таких подклассов. Причина этого может за- ключаться в разной эффективности прямого доступа к структурам данных и доступа к ним с помощью операторов, определенных в родительском классе. Например, рассмот- рим класс, определяющий стек, и подкласс, который должен содержать операцию воз- врата второго от вершины элемента. Если язык программирования использует наследо- вание реализации, то эту операцию можно описать как простой возврат элемента, пози- ция которого определяется вычитанием единицы из указателя на вершину стека. Однако, если разработчик языка выбрал наследование интерфейса, этот код должен выглядеть примерно так: int second() { int temp = top(); pop(); int temp_tesult = top Ox- push (temp); return temp_result; } 458 Глава 11. Поддержка объектно-ориентированного программирования
. чевидно, что этот процесс медленнее, чем прямой доступ ко второму от вершины эле- •ечту стека. Однако, если реализация стека изменилась, этот метод, возможно, также по- —ебчется изменить. Наилучшее решение для разработчика языка— предоставить разработчику про- ’t лммного обеспечения возможность использовать как наследование реализации, так и -’следование интерфейса, и позволить ему решить, ориентируясь по ситуации, какой из ?’тиантов предпочтительнее. 11.3.4. Проверка типов и полиморфизм В разделе 11.2 полиморфизм в объектно-ориентированной сфере определен как ис- - льзование полиморфного указателя или ссылки для доступа к методу, имя которого вмещается в иерархии классов, определяющей объект, на который указывает этот указа- тель или ссылка. Полиморфная переменная — это тип родительского класса, а родитель- .\ий класс, в свою очередь, определяет, как минимум, протокол метода, замещаемого в “?оизводных классах. Полиморфная переменная может ссылаться на объекты родитель- :<ого класса и производных классов, так что класс объекта, на который она ссылается, всегда определяется статически. Сообщения, передаваемые с помощью полиморфных -еременных, должны связываться с методами динамически. Проблема здесь заключается = определении момента, когда происходит проверка типов при этом связывании. Этот вопрос очень важен, поскольку он сравним с вопросом о природе языка про- таммирования. Если строгое типизирование является одной из основных целей разра- ботки языка, как во многих современных языках программирования, эта проверка типов должна выполняться статически, что налагает некоторые серьезные ограничения на от- ношения между полиморфными сообщениями и методами. Существуют два вида проверки типов, которую следует выполнять для сообщения и метода в строго типизированном языке: типы параметров сообщения сравниваются с ти- пами формальных параметров метода, а тип возвращаемого методом объекта— с ожи- даемым типом сообщения. Если эти типы должны в точности совпадать, то замещающий .<етод должен иметь то же количество и те же типы параметров, а также тот же тип воз- вращаемого значения, что и замещаемый метод. Единственным исключением из этого -равила может быть разрешение совместимости в отношении операции присваивания между фактическими и формальными параметрами, а также между возвращаемым типом метода и ожидаемым типом сообщения. Очевидной альтернативой статической проверке типов является откладывание про- верки до тех пор, пока полиморфная переменная не будет использована для вызова ме- “ода. Как и в других ситуациях, этот тип динамической проверки типов более дорог и от- кладывает обнаружение ошибок несовместимости типов. 11.3.5. Одиночное и множественное наследование Попытаемся ответить на вопрос: позволяет ли язык осуществлять множественное на- следование (в дополнение в одиночному наследованию)? Цель множественного наследо- вания— позволить новому классу наследовать от нескольких классов, описывающих разные абстракции. Например, на языке Java часто пишут аплеты, содержащие анима- цию. Такая анимация часто протекает параллельно в различных частях аплета. Аплеты поддерживаются классом Applet, а параллельность обеспечивается классом Thread. 11.3. Вопросы разработки объектно-ориентированных языков 459
Для такого аплета может оказаться необходимым наследование переменных и методов от обоих классов. В разделе 11.10.2 мы обсудим, как решается эта проблема в языке Java. Поскольку множественное наследование метода очень полезно, почему иногда разра- ботчики не включают его в язык программирования? Причины этого разделяются на две категории: сложность и эффективность. Дополнительная сложность порождается несколь- кими проблемами. Одна из них — конфликт имен. Например, если подкласс С наследует от обоих классов А и В, а классы А и В содержат наследуемую переменную sum, каким обра- зом класс С сможет ссылаться на две различные переменные с именем sum? Такая же си- туация возникает, если и класс А, и класс В являются производными от общего родитель- ского класса Z. Этот случай называется бриллиантовым наследованием (diamond inheritance). При этом классы А и В имеют наследуемые переменные из класса Z, и класс С наследует по две версии каждой из этих переменных (в предположении, что они являются наследуемыми) от классов А и В. Бриллиантовое наследование показано на рис. 11.1. Рис. 11.1. Пример бричлиантового наследования Вопрос эффективности является скорее теоретическим, чем практическим. В языке C++, например, поддержка множественного наследования требует еще одной дополни- тельной операции для каждого динамически связанного вызова метода (Stroustrup, 1995). Несмотря на то что эта операция выполняется даже, если программа не использует мно- жественного наследования, такой подход можно назвать относительно недорогим. Использование множественного наследования значительно усложняет организацию программы. Применяя множественное наследование, трудно разработать классы, кото- рые должны использоваться в качестве родительских. Поддержка систем, использующих множественное наследование, может быть более серьезной проблемой, поскольку мно- жественное наследование приводит к более сложным зависимостям между классами. Не- которым людям не ясно, что выгоды от множественного наследования стоят дополни- тельных усилий, затраченных на разработку и поддержку использующих его систем. 11.3.6. Размещение в памяти и удаление из памяти объектов Существует два вопроса разработки программ, связанные с размещением объектов в памяти и удалением из нее. Первый из них связан с местом, в котором размещаются объ- екты. Если поведение объектов аналогично поведению абстрактных типов данных, то их можно разместить где угодно. Это означает, что они могут быть либо статически разме- щены компилятором, либо размещены как автоматические объекты в стеке времени вы- полнения, либо созданы в динамической памяти с помощью такого оператора или функ- ции, как new. Преимущество их размещения в динамической памяти состоит в единооб- разии метода создания и доступа к ним через указатели или ссылки. Это упрощает операцию присваивания для объектов, которая выполняется только с помощью измене- 460 Глава 11. Поддержка объектно-ориентированного программирования
ния значения указателя или ссылки и позволяет неявно дифференцировать ссылки на объекты, упрощая синтаксис доступа. Вторая проблема связана с размещением объектов в динамической памяти. Вопрос состоит в том, является ли удаление объекта из памяти неявным или явным, или и тем. и другим. Если удаление объекта из памяти является неявным, требуется наличие такого неявного метода восстановления памяти, как счетчик ссылок или сборка мусора. Если размещение объекта в памяти может быть неявным, возникает вопрос, могут ли созда- ваться висячие указатели или ссылки? 11.3.7. Динамическое и статическое связывание Как мы уже обсуждали, динамическое связывание сообщений с методами в иерархии наследования— существенная часть объектно-ориентированного программирования. Вопрос в следующем: являются ли все связывания сообщений с методами динамически- ми. В качестве альтернативы можно позволить пользователю самому определять, быть ли конкретному связыванию динамическим или статическим. Преимущество этого под- хода состоит в том, что статическое связывание быстрее. Так что, если связывание не обязано быть динамическим, зачем платить за это замедлением работы программ? 11.4. Обзор языка Smalltalk Язык Smalltalk— характерный объектно-ориентированный язык программирования. В этом разделе описаны некоторые общие свойства языка Smalltalk. В разделе 11.5 рас- сматриваются отличительные особенности подмножества детального синтаксиса и се- мантики языка Smalltalk. В разделе 11.6 описываются два примера полных программ на языке Smalltalk. В разделе 11.7 обсуждается несколько крупномасштабных особенностей языка Smalltalk с точки зрения вопросов разработки программ, описанных в разделе 11.3. 11.4.1. Общие характеристики Программа на языке Smalltalk целиком состоит из объектов, и понятие объекта в этом языке является поистине универсальным. Действительно, все, от таких простых вещей, как целая константа 2, до сложных систем обработки файлов, представляет собой объект. Будучи объектами, они обрабатываются единообразно. Все они имеют локальную па- мять, присущие им возможности обработки данных, способность обмениваться сообще- ниями с другими объектами, а также возможность наследовать методы и переменные от предков. Сообщения могут быть параметризованы с помощью переменных, ссылающихся на объект. Ответы на сообщения имеют вид объектов и применяются для возврата запрошен- ной информации или для подтверждения, что требуемая работа выполнена полностью. Все объекты языка Smalltalk размещаются в динамической памяти и адресуются с помощью ссылок, которые неявно дифференцируются. В языке Smalltalk нет операторов или операции явного освобождения памяти. Все операции удаления объектов из памяти являются неявными и используют сборку мусора для освобождения памяти. В отличие от гибридных языков, вроде C++ или Ada 95, Дзык Smalltalk был создан в рамках только одной парадигмы разработки программного обеспечения— объектно- ориентированной. Кроме того, он ничем не похож на императивные языки. Его чистота цели реализована в простой, элегантной и единообразной разработке программ. 11.4. Обзор языка Smalltalk 461
11.4.2. Среда языка Smalltalk Среда языка Smalltalk совершенно отличается от сред, использующихся большинст- вом императивных языков. Система языка Smalltalk объединяет в единое целое редактор программ, компилятор, обычные функции оперативной системы и виртуальную машину. Интерфейс этой системы представляет собой оригинальный графический пользователь- ский интерфейс. Важным аспектом среды языка Smalltalk является то, что она написана почти полно- стью на этом языке, и пользователь может модифицировать ее, подстраивая под собст- венные нужды. В силу этого исходные тексты программ системы языка Smalltalk должны быть доступны пользователю. Повторим, что язык Smalltalk представляет собой нечто гораздо большее, чем просто язык программирования; это также и методология (объектно-ориентированная), и среда програм м ирован ия. 11.5. Введение в язык Smalltalk В этом разделе вводится подмножество языка Smalltalk. Тем, обсуждаемых здесь, достаточно, для того чтобы ощутить особенности программирования на этом языке. К наиболее важным свойствам языка, которые здесь не описаны, относятся большие ие- рархии классов, поддерживаемые системой языка Smalltalk и составляющие основу большинства программ на этом языке, а также мощная объектно-ориентированная среда, в которой разрабатываются программы. 11.5.1. Выражения Методы в языке Smalltalk состоят из выражений. Выражение определяет объект, яв- ляющийся его значением. Язык Smalltalk содержит четыре вида выражений: литералы, имена переменных, выражения сообщений и блоки выражений, которые в соответст- вующем порядке описаны ниже. 11.5.1.1. Литералы Наиболее распространенные литералы — числа, строки и ключевые слова. Числа — это литеральные объекты, представляющие числовые значения. Они совершенно отли- чаются от числовых литералов в обычных императивных языках, действующих наподо- бие именованных констант, поскольку они связаны с ячейками памяти, содержащими их значения. В языке Smalltalk числовые литералы — это объекты, характеризующиеся своими протоколами сообщений и результатами, которые они вырабатывают после по- лучения сообщений. Протокол сообщений числовых литералов, как и в случае других объектов, описывается в определении класса вместе с его наследуемыми определениями классов. В случае целого литерала класс называется Integer; он обеспечивает, помимо всего прочего, методы для выполнения обычных арифметических операций. С синтаксической точки зрения, строковый литерал представляет собой последова- тельность символов, разделенных апострофами. С семантической точки зрения, — это объект, способный отвечать на сообщения, получать доступ к отдельным символам, за- менять подстроки и выполнять сравнения с другими строками. 462 Глава 11. Поддержка объектно-ориентированного программирования
Ключевые слова являются идентификаторами, которые могут быть определены поль- зователем, и замыкаются двоеточием. Использование ключевых слов рассматривается в подразделе 11.5.1.3. 11.5.1.2. Переменные Имя в языке Smalltalk синтаксически похоже на имена в других языках программирова- ния: последовательность букв и/или цифр, начинающаяся с буквы. Переменные в языке Smalltalk разделяются на два вида: закрытые (private), являющиеся локальными в объекте, и открытые (shared), т.е. видимые вне объекта, в котором они “объявлены”. Имена закрытых переменных должны начинаться со строчных букв, а открытых — с прописных. Все переменные в языке Smalltalk являются ссылками и могут ссылаться только на объекты или классы. Они лишены типа; любая переменная может указывать на любой объект. Из обсуждаемых здесь переменных только открытые ссылаются на классы. Все переменные неявно дифференцируются, поэтому, называя имя ссылки в нашем обсужде- нии. мы часто имеем в виду объект, на который она ссылается. Переменные экземпляров могут быть либо именованными, либо индексированными. Именованные переменные соответствуют указателям на типы в императивном языке, не яв- ляющиеся массивами. К индексированным переменным экземпляров можно получить доступ не только по имени, но и через сообщения, параметры которых являются целыми числами. Большинство индексированных переменных используется подобно массивам в других язы- ках, однако индексирование само по себе осуществляется с помощью передачи сообщения. Целый параметр в сообщении для ссылки на индексированную переменную экземпляра соот- ветствует индексу в ссылке на элемент массива в обычном императивном языке. 11.5.1.3. Выражения сообщений Сообщения имеют форму выражений. Они обеспечивают средства связи между объ- ектами и указывают, какие операции объекта требуется выполнить. Выражения сообщений имеют две части: спецификацию объекта, который должен получить сообщение, и сообщение само по себе. Сообщение само по себе определяет се- лекторный вход, или метод, в объекте-получателе и, возможно, один или несколько па- раметров. Параметры, как и другие переменные, являются указателями на другие объек- ты. Вычисленное сообщение передается указанному объекту-получателю. Методы обсу- ждаются в разделах 11.5.2 и 11.5.6. В оставшейся части главы мы будем называть выражения сообщений просто сообще- ниями. Ответами на сообщения являются объекты. Сообщения во многом соответствуют вызовам функций в таких языках, как Pascal и С. Существуют три категории сообщений: унарные, бинарные и ключевые слова. Унар- ные сообщения— простейший вид сообщений, не имеющих параметров. Они имеют только две части: объект, которому они должны передаваться, и имя метода в объекте- получателе. Первый символ унарного сообщения определяет объект-получатель, послед- ний — метод этого объекта, подлежащий выполнению. Например, сообщение firstAngle sin передает лишенное параметров сообщение методу sin объекта firstAngle. Напом- ним, что все объекты адресуются указателями, так что объект firstAngle в действи- тельности является указателем на объект. Метод sin (возможно) возвращает числовой объект, являющийся значением синуса от значения firstAngle. 11.5. Введение в язык Smalltalk 463
Бинарные сообщения имеют один параметр, который передается указанному методу указанного объекта-получателя. Среди наиболее распространенных бинарных сообще- ний есть сообщения для арифметических операций, такие как 21 + 2 и sum/count В первом сообщении объектом-получателем является число 21, которому передается со- общение + 2. Так что сообщение 21 + 2 передает объект-параметр 2 методу + объекта 21. Код этого метода использует объект 2 для создания нового объекта, в данном случае, объекта 23. Если система уже содержит объект 23, то результатом будет ссылка на него, а не на новый объект. Может показаться странным рассматривать число 21 как объект, но в языке Smalltalk для чисел естественно быть объектами с определенными для них операциями. Обычные операции для целых объектов определены в классы Integer, экземплярами которого они являются. Во втором примере, приведенном выше, сообщение / count передается объекту, на который ссылается переменная sum. Это приводит к тому, что объект, на который ука- зывает переменная count, передается в качестве параметра методу / объекта, на кото- рый ссылается переменная sum. Выражения из ключевых слов обеспечивают соответствие фактических параметров сообщения формальным параметрам метода, т.е. ключевые слова в совокупности опре- деляют, какому именно методу направляется сообщение. Смещение ключевых слов и па- раметров в сообщениях увеличивает их читабельность. Методы, принимающие сообще- ния из ключевых слов, являются неименованными. Вместо этого такие методы иденти- фицируются самими ключевыми словами. Рассмотрим следующий пример: firstArray at: 1 put: 5 Это сообщение передает объекты 1 и 5 конкретному методу at:put: объекта fir- stArray. Ключевые слова at: put: идентифицируют формальные параметры метода, которым должны быть переданы объекты 1 и 5, соответственно. Метод, которому пере- дается это сообщение, содержит ключевые слова сообщения. Как указывалось выше, ме- тоды из ключевых слов не имеют имен; вместо этого они идентифицируются своими ключевыми словами. Эта канкатенация (в данном случае at:put:) называется пере- ключателем (selector). Выражения сообщений могут состоять из любого количества комбинаций трех вызо- вов выражений, как показано ниже: total-3*divisor. FirstArray at: index - 1 put: 77 Чтобы определить, как именно они вычисляются, должны быть известны приоритеты и ассоциативность операторов выражения. Унарные выражения имеют наивысший при- оритет, за ними идут бинарные выражения, а затем — выражения из ключевых слов. И унарные, и бинарные выражения ассоциируются слева направо. Заметим, что это со- вершенно отличается от правил приоритетов, обычно используемых в таких языках, как Ada и С. 464 Глава 11. Поддержка объектно-ориентированного программирования
В выражениях можно использовать скобки, чтобы установить принудительный поря- док вычисления операторов. Первое выражение, приведенное выше, содержит скобки только для иллюстрации, а не для того, чтобы изменить нормальный порядок вычисле- ний, который является следующим: (total-3)*divisor Это выражение передает 3 методу - объекта total. Значение переменной divisor затем передается методу * объекта, являющегося результатом первой операции. Выражение firstArray at: index - 1 put: 77 передает 1 методу - объекта index. Результат этой операции вместе с 77 затем переда- ется методу at: put: объекта firstArray. Сообщения могут быть организованы в виде каскада— т.е. несколько сообщений можно послать одному и тому же объекту без повторения имени объекта-получателя — путем разделения групп параметров-селекторов или методов с помощью точки с запятой. Сообщения передаются последовательно, в порядке их появления, слева направо. На- пример, ourPen home; up; goto: 5000500; down; home эквивалентно следующим сообщениям: ourPen home. ourPen up. ourPen goto: 5000500. ourPen down. ourPen home Эта последовательность сообщений рисует линию на экране дисплея (предполагается, что объект ourPen является экземпляром класса Реп). Объект класса Реп иллюстриру- ется в разделе 11.6.2. Заметим, что точки используются для разделения сообщений, передаваемых разным методам и размещенных на соседних строках. Это похоже на использование точек с за- пятой для разделения операторов в программах на языке Pascal. 11.5.2. Методы Общая синтаксическая форма метода в языке Smalltalk имеет следующий вид: шаблон_сообщения [|временные переменные |]операторы Здесь квадратные скобки являются метасимволами. Это значит, что все, находящееся внутри этих скобок, является необязательным. Поскольку в языке Smalltalk нет объявле- ний типов, при наличии временных переменных они только перечисляются в списке. Временные переменные существуют только во время выполнения метода, в котором они перечислены. В конце метода нет никакой пунктуации. Шаблон сообщения соответствует заголовку функции в таком языке, как С. Шаблоны сообщений, являющиеся прототипами сообщений, могут иметь одну из двух основных форм. Для унарных или бинарных сообщений включается только имя метода. Для сооб- щений из ключевых слов шаблон сообщения состоит только из ключевых слов и имен формальных параметров. 11.5. Введение в язык Smalltalk 465
Значение, возвращаемое методом, отмечается путем добавления к описывающему его выражению символа (А). Во многих случаях это — последнее выражение в методе. Если в методе не указано возвращаемое значение, то объект-получатель сам по себе является возвращаемым значением. Шаблон сообщения для унарного сообщения — это просто имя метода. Ниже приве- ден пример унардого метода: currentTotal A(oldTotal + newValue) Этот метод, названный currentTotal, возвращает значение выражения oldTotal + newValue. Бинарные методы в основном используются для вычисления арифметических опера- ций, определенных ранее, так что они здесь не обсуждаются. Общий вид шаблона сообщений, состоящих из ключевых слов, таков: ключ__1: параметр_1 ключ_2 : параметр__2 ... ключ_п: параметр_п Рассмотрим следующий пример метода из ключевых слов, который не возвращает ника- кого значения: х: xCoord у: yCoord ourPen up; goto xCoord@yCoord; down. В этом методе, соответствующем переключателю сообщения х: у:, объекту ourPen пе- редаются сообщения up, goto (которое использует два параметра xCoord и yCoord) и down. Шаблоном сообщения является просто список пар, состоящих из ключевых слов и имен формальных параметров в методе. Пример сообщения для этого метода приводится ниже: ourPen х: 300 у: 400 Дополнительные свойства методов, включая временные переменные, обсуждаются в разделе 11.5.6. 11.5.3. Операторы присваивания В языке Smalltalk операторы присваивания похожи, по крайней мере внешне, на one- раторы присваивания в таких языках, как Pascal и С. Любое выражение сообщения, литеральный объект или имя переменной может появляться в правой части оператора присваивания. Левая часть является именем переменной, а оператор обозначается сим- волами <-, как в следующем примере: total <- 22. sum <- total Конкретный объект, на который ссылается переменная, изменяется, когда эта перемен- ная появляется в левой части оператора присваивания. В данном случае переменная to- tal назначается ссылкой на объект 22. Затем переменная sum назначается ссылкой на этот же объект. Эта операция тесно связана с присваиванием указателей в языках Pas- cal и Ada. Напомним, что все методы передают информацию назад отправителю, пославшему им сообщение. Чтобы сохранить возвращенную информацию, выражение сообщения помеща- 466 Глава 11. Поддержка объектно-ориентированного программирования
г’ся в левой части оператора присваивания некоторой переменной. Эта переменная затем -означается ссылкой на возвращенную информацию, как в следующих примерах: index <- index + 1. netPay <- deducts grossPay: 350.0 dependents: 4 3 первом операторе присваивания сообщение +1 передается объекту, на который ссылается -еременная index. Переменная index назначается ссылкой на новый объект, являющийся гезчльтатом выполнения метода +. Во втором примере сообщение из ключевых слов rrsssPay: 350.0 dependents: 4 передается методу grossPay:dependents: объ- екта deducts. Переменная net Рау назначается ссылкой на объект, возвращаемый объектом reduct. Легко понять, что язык Smalltalk (в соответствии с нашим определением) является императивным языком, поскольку вычисления производятся с помощью выражений и зператоров присваивания, а результаты хранятся в переменных. 11.5.4. Блоки и управляющие структуры Один из наиболее необычных аспектов языка Smalltalk заключается в том, что опера- торы языка не обеспечивают создание управляющих структур. Эти структуры формиру- ются с помощью фундаментальной объектно-ориентированной парадигмы — передачи сообщений. Блоки позволяют объединять выражения в группы, которые можно использовать для создания управляющих конструкций. 11.5.4.1. Блоки Блок — это безымянный литеральный объект, содержащий последовательность вы- ражений. Блоки являются экземплярами класса Block. Сообщение можно послать бло- к>. поместив его непосредственно после блока. Блок определяется внутри квадратных скобок, а его компоненты, являющиеся выра- жениями, отделяются друг от друга точкой, как в следующем примере: [index <- index + 1. sum <- sum + index] Выражения в блоке являются отложенными во времени действиями, поскольку они не выполняются во время их встречи в программе; вместо этого они выполняются, только когда блоку передается унарное сообщение value, определенное в классе Block. На- пример, [sum <- sum + index] value передает сообщение value блоку, вызывая его выполнение. После завершения выпол- нения блока возвращается значение последнего выражения в нем. Блоки можно присваивать переменным и выполнять, передавая переменной сообще- ние value, как показано в следующем выражении addindex <- [sum <- sum + index] Выражение сообщения addindex value 11.5. Введение в язык Smalltalk 467
приведет к добавлению объекта index к объекту sum. Это выражение сообщения мож- но также присвоить переменной, как показано ниже: addindex <- [sum + index] sum <- addindex value Блоки всегда выполняются в контексте их определения, даже когда они передаются как параметры другому объекту. Таким образом, они семантически связаны с парамет- рами. передаваемыми по имени, в языке ALGOL 60. Блоки можно рассматривать в качестве объявлений процедур, появляющихся где угодно. Подобно процедурам, блоки могут иметь параметры. Параметры блоков опреде- ляются в начале блока в разделе, который отделяется от остальной части блока верти- кальной чертой (|). Спецификации формальных параметров требуют наличия двоеточия, приписанного слева к каждому параметру. Поскольку в языке Smalltalk не существу- ет объявленных типов, спецификации содержат только имена формальных параметров, которые перечисляются безо всякой разделяющей пунктуации. В качестве примера блока с параметрами рассмотрим следующий фрагмент программы: [:х :у | sum <- х + 10. total <- sum * у] Блоки предоставляют программисту средства для объединения выражений в единый набор, поэтому они являются естественным средством формирования управляющих структур на языке Smalltalk. 11.5.4.2. Итерация Блоки могут содержать выражения отношений, в этом случае они возвращают один из двух встроенных булевских объектов— true или false. Такие блоки иногда назы- ваются условными. Эти два объекта, true и false, имеют методы, обеспечивающие некоторые из возможностей построения управляющих структур. Циклы с предварительной проверкой условия могут быть созданы на основе метода с ключевыми словами whileTrue:. который предусмотрен в классе Block для передачи проверяемого блока другому блоку, содержащему условия выхода из цикла. Этот метод определен для всех блоков, возвращающих булевские объекты. Метод whileTrue: предназначен для передачи сообщения value объекту, содержащему этот метод (true или false), вызывая таким образом выполнение блока с параметрами, как в следующем примере: count <- 1. sum <- 0. [count <= 20] ’’Блок с условием выхода из цикла” whileTrue: [sum <- sum + count. Count <- count + 1] ’’Тело цикла” Несмотря на то что этот код имеет необычный вид и осуществляет необычные операции, процесс, с помощью которого он выполняется, значительно отличается от процесса, ис- пользуемого в императивных языках. Цикл реализуется следующим образом. Блок, содержащий код для добавления значения переменной count к значению переменной sum и увеличения на единицу значения пере- менной count, являющийся сегментом кода, выполнение которого подлежит контролю, передается в качестве параметра методу whileTrue: условного блока [count <= 20]. 468 Глава 11. Поддержка объектно-ориентированного программирования
‘е:од whileTrue: передает сообщение value условному блоку, вызывая таким обра- ти его вычисление. Результатом этого вычисления является булевский объект. Если ре- . ьтат — объект true, то метод whileTrue: вызывает вычисление параметра, передай- те с помощью сообщения whileTrue:. Его параметром является блок, содержащий сражения итерации. После их вычисления процесс повторяется с помощью повторной пе- гедачи блока выражений условному блоку [count <= 20]. Повторение прекращается, .:ли результатом вычисления блока [count <= 20] становится объект false. Предположим, что управляющим блоком в предыдущем примере был блок : jnt <= 2], а не [count <= 20]. Ниже приведены результаты отслеживания действий, которые производятся при выполнении модифицированного кода. Здесь сим- з ?л -> означает передачу сообщения, а комментарии разделяются кавычками. 2OUnt <- 1 sum <- 0 '.sum <- sum + count, count <- count + 1] -> [count <= 2] whiletrue value -> [count <= 2] "значение, посланное методом whileTrue” [ count <= 2] returns true whileTrue evaluates [sum <- sum + count, count <= count + 1] (sum <- 1; count <- 2) ’’результат вычислений” 'sum <- sum + count, count <- count +1] -> [count <= 2] wnileTrue value -> [count <= 2] "значение, посланное методом whileTrue” 'count <= 2] returns true whileTrue evaluates [sum <- sum + count, count <= count + 1] (sum <- 3; count <- 3) ’’результат вычислений” ;sum <- sum + count, count <- count +1] -> [count <= 2] whileTrue value -> [count <= 2] "значение, посланное методом whileTrue” [count <= 2] returns false Другая циклическая управляющая структура— простое повторение с контролем счетчика. Этим целям служит метод для целых чисел, называемый timesRepeat:. Ко- гда метод timesRepeat: передается целому числу с параметром в качестве блока, ко- личество выполнений этого блока равно этому целому числу. Например, xCube <- 1. 3 timesRepeat: [xCube <- xCube * x] вычисляет куб числа х с помощью довольно длительного процесса. Управляющие структуры, подобные операторам цикла for в языке ALGOL 68, могут быть построены на основе одного из методов для целых чисел. Два наиболее полезных из них — to: do: и tо: by: do:. Сначала рассмотрим метод to: do:. Параметр to: — это выражение с целыми числами, значение которого служит в качестве предельного значения цикла. Параметр do: — это блок, подлежащий выполнению с помощью метода для целых чисел. Метод to:do: генерирует последовательность значений, начиная с целого литерала, которому передается сообщение, и заканчивая значением параметра to:. Рассмотрим следующее сообщение: 1 to: 5 do: [sum <- sum +x] 11.5. Введение в язык Smalltalk 469
Блок выполняется пять раз. Внутренние значения, вычисленные и возвращаемые объек- том 1, равны 1, 2, 3,4 и 5. Блок, образующий тело цикла, может иметь параметры. Эти параметры неявно при- сваиваются внутренним значениям, созданным сообщением. Внутренние значения — это значения, возвращаемые числовым объектом, которому было передано полное сообще- ние. Рассмотрим следующее сообщение: 2 to: 10 by: 2 do: [:even | sum <- sum + even] Это сообщение приводит к пятикратному выполнению блока, но в этом случае пара- метр блока even имеет внутренние значения, равные 2,4, 6, 8 и 10. 11.5.4.3. Ветвление Конструкции ветвления также имеют обычный вид, но действуют необычным обра- зом. Объекты true и false содержат метод ifTrue:. Два аргумента сообщения ifTrue : if False: представляют собой части “то” и “иначе” конструкции ветвления. Сообщение передается булевскому выражению. Если значением выражения является объект true, то объекту true передается сообщение. В этом случае метод ifTrue: if False: передает сообщение value его первому аргументу и игнорирует второй аргумент. Если посылается сообщение false, имеет место противоположная си- туация. В качестве примера рассмотрим следующее сообщение: total = 0 ifTrue: [average <- 0] ifFalse: [average <- sum // total] Булевское выражение total = 0 вызывает передачу сообщения = 0 объекту total, который в свою очередь возвращает либо объект true, либо объект false. Объект, по- лученный в качестве результата (либо true, либо false), затем используется как полу- чатель сообщения, переданного методу ifTrue: ifFalse:. Двумя параметрами этого метода являются блоки “то” и “иначе”, один из которых подлежит выполнению. Опера- тор / / означает деление целых чисел. Объектам true и false могут передаваться четыре разных сообщения. Кроме сообщения ifTrue: if False:, есть еще сообщения ifTrue:, ifFalse: и ifFalse:ifTrue:. Семантика управляющих структур, которые мы только что обсудили, может пока- заться несколько странной, но для опытных пользователей языка Smalltalk она вполне естественна. Кроме того, возможность обрабатывать управляющие структуры в рамках такого подхода добавляет мощи и гибкости в модель передачи сообщений. Она также упрощает язык Smalltalk, устраняя необходимость в любой структуре, помимо объектов и передачи сообщений. 11.5.5. Классы Классы языка Smalltalk имеют четыре части: имя класса; имя суперкласса; объявления переменных экземпляров; объявления методов экземпляров и классов. 470 Глава 11. Поддержка объектно-ориентированного программирования
Переменные экземпляров невидимы для других объектов. Каждая переменная экзем- пляра ссылается на один объект, называемый ее значением. Значения всех переменных экземпляров вместе представляют текущее состояние экземпляра. В языке Smalltalk все классы сами по себе являются объектами. Это позволяет клас- сам получать сообщения. Определения классов могут содержать и методы классов, и ме- тоды экземпляров, при этом методы классов отвечают на сообщения, передаваемые классам, а методы экземпляров отвечают на сообщения, передаваемые экземплярам класса. Передача сообщения new классу в операторе присваивания создает экземпляр класса. При этом переменная, стоящая в левой части оператора, назначается ссылкой на вновь создаваемый объект. Например, фрагмент кода ourPen <- Pen new создает экземпляр класса Реп (передавая сообщение new классу Реп) и назначает пере- менную ourPen ссылкой на него. 11.5.6. Дополнительные сведения о методах В этом разделе мы исследуем некоторые свойства методов, не описанные в разде- ле 11.5.2. Следующий метод иллюстрирует использование временных переменных: first: х second: у | temp | temp <- х + у. temp > 1000 ifTrue: [у <- 1000]. А У Этот метод складывает значения двух параметров и помещает сумму во временную пе- ременную temp. Если значение переменной temp больше, чем 1000, второму парамет- ру у присваивается 1000. Значение переменной у является возвращаемым объектом. Псевдопеременная self — это имя объекта, ссылающегося на объект, в котором она появляется. В силу этого псевдопеременная self используется для передачи рекурсив- ных сообщений, или для передачи сообщений объекта самому себе. Имя объекта self часто используется для вывода сообщений об ошибках на экран дисплея, как в следую- щем примере: total = 0 ifTrue: [self error: ’Ошибка - невозможно вычислить сред- нее ’ ] ifFalse: [А sum // total] Если значение переменной total равно 0, этот код посылает сообщение об ошибке: ’Ошибка - невозможно вычислить среднее’ объекту, в котором находится этот код. В противном случае он возвращает значение выражения sum // total. Сообщение error, подобно другим сообщениям, направляется суперклассу объекта, которому оно было передано, если этот объект не содержит метод для его обработки. Если ни один другой класс-предок не имеет метода для обработки сообщения error, то это сообщение получает системный объект Object, содержащий метод error. Метод 11.5. Введение в язык Smalltalk 471
error из системного объекта Object печатает параметры сообщения и прекращает выполнение программы. В качестве примера рекурсии рассмотрим следующий метод, который понимается целочисленными объектами. Он взят из работы Goldberg and Robson (1983): factorial self = 0 ifTrue: [Л1]. self < 0 ifTrue: [self error ’Факториал не определен’] ifFalse: [Л self * (self - 1) factorial] Этот унарный метод для целочисленных объектов можно вызвать таким сообщением, как 5 factorial Первое булевское выражение self = 0 передает параметр 0 методу = целочисленного объекта, которому передается сообщение factorial. Сообщение ifTrue: [А1] за- тем передается результату выполнения метода =. Если результатом является объект true, как это может оказаться для сообщения 0 factorial, то отправителю сообще- ния factorial возвращается значение 1. Если результатом выполнения метода = явля- ется объект false, ничего не происходит, поскольку метод ifTrue: в объекте false определен так, что он ничего не делает. Следующее булевское выражение self < 0 передает параметр 0 методу < целочис- ленного объекта, которому передается сообщение factorial. Оставшаяся часть сооб- щения factorial (полное сообщение ifTrue: if False:) затем посылается резуль- тату метода <. Если результирующий объект— true, то объекту, которому было пере- дано сообщение factorial, передается сообщение об ошибке. Если результатом метода < является объект false, то выполняется блок из части ifFalse: метода ifTrue: if False :, а результат возвращается объекту— отправителю сообщения factorial. Чтобы понять сообщение ifFalse:, мы* должны уяснить приоритеты вычисления сообщений. В этом выражении есть два бинарных выражения (с операциями ♦ и -) и од- но унарное выражение (factorial). Напомним, что унарные выражения имеют более высокий приоритет, чем бинарные, если бинарные выражения не заключены в скобки. Кроме того, все выражения имеют левую ассоциативность. Теперь порядок вычислений ясен. Вначале -1 передается объекту self, создавая объект, меньше на 1, чем объект, которому было передано сообщение. Затем сообщение factorial передается этому новому объекту. Окончательный результат этого сообщения после выполнения всех ре- курсий передается вместе с методом * объекту self, являющемуся исходным объектом, которому было передано сообщение factorial. Результатом такого сообщения явля- ется значение факториала. Этот пример иллюстрирует схожесть рекурсий в языках со значительно различаю- щимися семантиками. 472 Глава 11. Поддержка объектно-ориентированного программирования
11.6. Примеры программ на языке Smalltalk 11.6.1. Простой обработчик таблиц Пример класса в этом разделе демонстрирует, что простые задачи обработки таблиц, обычно решаемые в императивных языках, также легко могут быть решены в языке Smalltalk. Задача состоит в том, чтобы написать программу, создающую записи и выпол- няющую поиск в таблице, содержащей названия отделов и их кодовые номера. Из-за от- сутствия в языке Smalltalk статических типов программу можно было бы использовать для любой таблицы, состоящей из двух параллельных массивов данных, где просмотры основаны на элементах первого массива. Одно из интересных свойств, использованных в программе, — динамическое связы- вание диапазона индексов с массивом. Два массива имеют размер, точно соответствую- щий количеству хранящихся в них данных. Каждое добавление новой записи просто уве- личивает размер таблицы. Несмотря на то что при этом эффективно используется па- мять, такой подход является в высшей степени затратным с точки зрения времени выполнения программы. Каждое добавление новой записи в таблицу приводит к созда- нию двух новых массивов и переносу содержимого старых массивов в новые— очень долгий процесс. Одним из недостатков программы является также отсутствие метода для удаления за- писи из таблицы. class name DeptCodes superclas s Object instance variable names names codes ’’Создать методы” "Создать экземпляр” new Л super new ’’Методы экземпляра” ’’Число записей в таблице" size А names size "Найти код отдела" at: name I index | index <- self indexOf: name. index = 0 ifTrue: [self error: ’Ошибка — кода нет в таблице’] ifFalse: [Acodes at: index] "Установить новый код; создать запись, если нужно" at: name put: code I index I index <- self indexOf: name. index = 0 ifTrue: [index <- self newIndexOf: name]. A codes at: index put: code "Найти индекс заданного названия отдела" 11.6. Примеры программ на языке Smalltalk 473
indexOf: name 1 to: names size do: [:index I (names at: index) = name ifTrue:[Aindex]]. A 0 ’’Создать новую запись с заданным именем и вернуть индекс” newIndexOf: name self grow. names at: names size put: name. A names size ’’Увеличить таблицу на один элемент и записать новое имя” grow I oldNames OldCodes | oldNames <- names. oldCodes <- codes. names <- Array new: names size + 1. codes <- Array new: names size + 1. names replaceFrom: 1 to: oldNames size with: oldNames codes replaceFrom: 1 to: oldCodes size with: oldNames ’’Проверка включения заданного имени” includes: name A (self indexOf: name) -= 0 ’’Проверка на пустоту” isEmpty A names isEmpty ’’Создать начальный пустой массив” initialize names <- Array new: 0. codes <- Array new: 0 Рассмотрим работу метода replaceFrom:. Впервые метод replaceFrom: исполь- зуется в операциях метода grow при перемещении элементов массива oldNames, проин- дексированных в диапазоне от 1 до size массива oldNames, в массив с именем names. Следующие выражения и вычисленные результаты иллюстрируют поведение экземп- ляра DeptCodes. Заметим, что метод isEmpty: — это унарный метод, наследуемый от системного объекта Object. Выражение Результат dCodes <- DeptCodes new Создает новый экземпляр dCodes initialize Создает пустой массив dCodes isEmpty true dCodes at: ’Физика’ put: 100 100 dCodes at: ’Химия’ put: 110 110 dCodes at: ’Биология’ put: 120 120 dCodes isEmpty false dCodes size 3 dCodes at: ’Химия’ 110 dCodes includes ’Физика’ true dCodes includes ’Кибернетика’ false 474 Глава 11. Поддержка объектно-ориентированного программирования
11.6.2. Графика в LOGO В этом разделе приведен пример программы, который иллюстрирует применение классов языка Smalltalk, предназначенных для рисования, характерного для “черепашьей графики” в LOGO. Этот класс под названием Реп является подклассом другого систем- ного класса BitBlt, который мы не будем обсуждать. Экземпляр класса Реп очень похож на шариковую ручку с компьютерным управле- нием, только пишущую не по бумаге, а по экрану дисплея. Объект класса Реп имеет три основных параметра: направление, положение и кадр (frame). Параметр “положение” имеет два значения — “поднят” и “опущен”. Значение “поднят” сигнализирует, что объ- ект не рисует во время движения, а значение “опущен” — что он рисует во время движе- ния. Направление измеряется по часовой стрелке в градусах, считая от нулевого направ- ления, где 0 означает направление вправо по экрану. Кадр объекта Реп представляет со- бой область на экране дисплея, в которой объект может рисовать, измеренную в битах. За пределами кадра экземпляр класса Реп рисовать не может. Начальное состояние экземпляра класса Реп таково: он направлен под углом 270 граду- сов. что соответствует направлению прямо вверх по экрану; расположен в точке с коорди- натами (300,400) и находится в положении “поднят”. Координаты (300,400) находятся в цен- тре экрана с предполагаемыми размерами, равными 600 бит в ширину и 800 бит в высоту. Текущие параметры экземпляра класса Реп можно получить из методов направления, местоположения и кадра. Метод кадра возвращает координаты верхнего левого и право- го нижнего угла области, в которой может рисовать перо. Кадр можно установить с помощью второго сообщения: frame: (aPoint extent: aPoint) Здесь объект aPoint — это пара позиций, выраженных в битах и разделенных знаком в” (@). Скобки нужны для того, чтобы установить требуемый порядок вычисления вы- ражений сообщения. Первое появление объекта aPoint в этом сообщении определяет координаты верхнего левого угла кадра. Второе появление устанавливает расстояние, на которое простирается кадр в направлении х и у, считая от верхнего левого угла. Напри- мер. сообщение frame: (50650 extent: 3006300) останавливает параметры кадра следующим образом: левый верхний угол— в точке 50.50) и правый нижний угол — в точке (350,350). Протокол сообщения для перемещения и рисования с помощью объектов класса Реп имеет следующий вид: down Устанавливает перо в положение для рисования. up Устанавливает перо в положение, в котором рисование не производится. turn: degrees Изменяет направление движения пера на количество граду- сов, определенное параметром. go: distance Перемещает перо в текущем направлении на количество би- тов, определенное параметром. goto: aPoint Перемешает перо в точку, координаты которой определяются параметром. Если перо в этот момент находится в положении “опущено”, оно рисует линию. 11.6. Примеры программ на языке Smalltalk 475
place: aPoint Устанавливает перо в позицию, координаты которой опреде- ляются параметром. Линия не рисуется. home Устанавливает перо в центр кадра. north Устанавливает направление пера, равное 270 градусам, что соответствует направлению прямо вверх вдоль экрана. Цвет линии, рисуемой экземплярами класса Реп, по умолчанию черный. Форма кон- чика пера по умолчанию представляет собой точку размером 1 на 1 бит. И цвет, и форма кончика пера могут изменяться. С помощью экземпляра класса Реп можно рисовать простые геометрические фигуры. Вначале необходимо создать экземпляр класса Реп: OurPen <- Pen new defaultNib: 2. OurPen up; goto: 8000300; down Теперь можно нарисовать треугольник: OurPen go: 100; turn: 120; go: 100; turn: 120; go: 100 На рис. 11.2. показан результат выполнения этого кода. На нем также видна осталь- ная часть экрана во время выполнения кода, что характеризует особенности пользова- тельского интерфейса системы Smalltalk. Приведенное выше сообщение можно несколько упростить с помощью блока, например: 3 timeRepeat: [OurPen go: 100. OurPen turn: 120] Рис. 11.2. Рисование треугольника на экране с помощью программы на язы- ке Smalltalk 476 Глава 11. Поддержка объектно-ориентированного программирования
Это сообщение можно обобщить для рисования любого равностороннего много- чгольника, повторив этот блок столько раз, сколько у него сторон, как в следующем фрагменте кода: numSides timesRepeat: [OurPen got: 100. OurPen turn: 360 // numSides] Таким образом создается класс для рисования равносторонних многоугольников общего вида. Следующий пример похож на код. приведенный в работе Goldberg and Robson (1983): class name Polygon superclas s Object instance variable names OurPen numSides sideLength “Методы класса” “Создаем экземпляр” new А super new getPen “Методы экземпляра” “Определить перо для рисования многоугольников” getPen ourPen <- Pen new defaultNib: 2 “Нарисовать многоугольник” draw numSides timesRepeat: [ourPen go: sideLength; turn: 360 // numSides] “Установить длину сторон” length: len , sideLength <- len “Установить количество сторон” sides: num numSides <- num Заметим, что переменная ourPen в определении класса, приведенном выше, начина- ется не с прописной буквы, как это было, когда она использовалась вне класса. Это про- исходит потому, что здесь она является локальной, закрытой переменной, и в то же вре- мя она должна быть глобальной переменной, когда используется вне определения кл« са. С помощью данного класса можно нарисовать последовательность многоугольников с различным количеством сторон, например: j MyPoly I MyPoly <- Polygon new. MyPoly length: 60. 3 to: 8 do: [:sides I MyPoly sides: sides. MyPoly draw] Приведенный код рисует фигуру, указанную на рис. 11.3. Линии, образующие фигу- р>. можно сделать толще, изменив форму кончика пера с помощью передачи следующе- го сообщения внутри определения класса: ourPen defaultNib: 4 11.6. Примеры программ на языке Smalltalk 477
Рис. 11.3. Концентрические многоугольники, нарисованные с помощью объ- екта Polygon Это изменит форму кончика пера и сделает ее квадратом размером 4 на 4 бита. Результат рисования тех же многоугольников с помощью нового кончика пера показан на рис. 11.4. WlreUM-ASfnpta t ant SmuUfton MBtofram-MVC дгорЫс» Polygon In tfance ereetkx •«amptoo «UNUplOl / «хлл|р4<1 |Му tolyl Му Roly * Polyton new Mytoly knfth W S to do. Hide* I My Poly <Me«: Ude*. MyPoly drew) •RMvton et ampler Рис. 11.4. Концентрические многоугольники с параметром nib, равным 4 478 Глава 11. Поддержка объектно-ориентированного программирования
11.7. Главные особенности языка Smalltalk 11.7.1. Проверка типов и полиморфизм Динамическое связывание сообщений с методами в языке Smalltalk выполняется сле- дующим образом. Сообщение, передаваемое объекту, вызывает поиск класса, которому этот объект принадлежит. Если класс не найден, поиск продолжается среди суперклассов для данного класса и так далее, вплоть до системного класса Object, который не имеет суперкласса. Класс Object является корнем дерева наследования классов, в котором каждый класс представляет собой узел. Если в этой цепочке метод не обнаружен, возни- кает ошибка. Важно помнить, что такой поиск метода является динамическим — он про- исходит, когда передается сообщение. Язык Smalltalk ни при каких условиях не связыва- ет сообщения с методами статически. Только в языке Smalltalk проверка типов является динамической, и ошибки несовмес- тимости типов возникают, только когда сообщение передается объекту, который не име- ет соответствующего метода, ни локально, ни через наследование. Эта концепция про- верки типов отличается от проверки типов в большинстве других языков. Проверка ти- пов в языке Smalltalk преследует простую цель— убедиться, что сообщение соответст- вует методу. Переменные в языке Smalltalk не имеют типов; ни одно имя не ограничивается ника- ким объектом. Исходя из этого язык Smalltalk поддерживает динамический полимор- физм. Все коды в языке Smalltalk являются настраиваемыми в том смысле, что типы пе- ременных не имеют значения до тех пор, пока они являются совместимыми. Смысл опе- рации (метода или оператора) над переменной определяется переменной класса, с которой она в данный момент связана. Предметом данного обсуждения является тот факт, что, если объекты, на которые ссылается выражение, содержат методы для обработки сообщений этого выражения, ти- пы объектов не имеют значения. Следовательно, все программы на языке Smalltalk яв- ляются настраиваемыми, и ни одна из них не связана с конкретным типом. 11.7.2. Наследование Подклассы языка Smalltalk наследуют все переменные экземпляра, методы экземпля- ра и методы класса своего суперкласса. Подкласс может также иметь свои собственные временные экземпляра, которые должны иметь имена, отличающиеся от имен перемен- -ых в его классе-предке. Кроме того, подкласс может определять новые методы и пере- гпределять методы, уже существующие в классе-предке. Когда подкласс имеет метод, •мя и протокол которого совпадают с именем и протоколом метода в классе-предке, ме- ’од подкласса маскирует метод класса-предка. Доступ к такому скрытому методу обес- печивается с помощью префиксного сообщения с псевдопеременной super. Это приво- дит к тому, что поиск метода начинается с суперкласса, а не локально. Поскольку сущности в родительском классе не могут быть скрыты от подклассов, все подклассы являются подтипами. Более того, все наследование является наследованием реализации. Язык Smalltalk поддерживает только одиночное наследование и не допускает множе- ственного наследования. 11.7. Главные особенности языка Smalltalk 479
11.8. Оценка языка Smalltalk Язык Smalltalk — небольшой по размеру язык, хотя система его реализации доста- точно велика. Синтаксис языка прост и систематичен. Язык Smalltalk создан на основе простой, но мощной концепции, в соответствии с которой все программирование можно выполнить с помощью только иерархии классов, построенной на основе наследования, объектов и передачи сообщений. По сравнению с обычными компилируемыми программами на императивных языках, эквивалентные программы на языке Smalltalk выполняются значительно медленнее. Хотя с теоретической точки зрения интересно, что индексирование массивов и циклы можно осуществить с помощью модели передачи сообщений, эффективность все же является важным фактором в оценке языков программирования. Следовательно, эффективность очевидным образом будет предметом большинства дискуссий о практической примени- мости языка Smalltalk. Динамическое связывание в языке Smalltalk позволяет ошибкам несовместимости ти- пов оставаться незамеченными до времени выполнения программы. Можно написать и откомпилировать программу, которая содержит сообщения, адресуемые несуществую- щим методам. Это приведет к большой проблеме, связанной с исправлением большего количества ошибок на поздних этапах разработки программы, чем это может оказаться в языках со статическими типами. Пользовательский интерфейс системы Smalltalk оказал большое влияние на разработ- ку программного обеспечения. Интегрированное использование окон, манипуляторы ти- па “мышь”, выпадающие и исчезающие меню доминируют в современных программных системах. Возможно, наибольшее влияние язык Smalltalk оказал на развитие объектно-ориенти- рованного программирования, ставшего в настоящее время наиболее распространенной методологией проектирования и программирования. 11.9. Поддержка объектно-ориентированного программирования в языке C++ В главе 2 описано, как язык C++ эволюционировал от языков С и SIMULA 67 в на- правлении поддержки объектно-ориентированного программирования. Классы в языке C++ с точки зрения их поддержки абстрактных типов данных обсуждались в главе 10. В данной главе исследуется поддержка языком C++ других существенных особенностей объектно-ориентированного программирования. Весь набор подробностей о классах C++, наследовании и динамическом связывании велик и сложен. В этом разделе обсуж- даются только самые важные темы, особенно те из них, которые непосредственно связа- ны с вопросами разработки языков, обсуждаемыми в разделе 11.3. 11.9.1. Общие свойства Поскольку одним из основных требований при создании языка C++ была его полная обратная совместимость с языком С, он сохраняет систему типов языка С и добавляет к ней классы. Следовательно, язык C++ одновременно имеет типы традиционного импера- тивного языка и структуру классов объектно-ориентированного языка. Это делает его 480 Глава 11. Поддержка объектно-ориентированного программирования
гибридным языком, поддерживающим как процедурное, так и объектно-ориентиро- ванное программирование. Объекты языка C++ могут быть расположены в памяти там же, где и переменные, размещаемые в языке С. Они могут статически размещаться компилятором, динамиче- ски располагаться в стеке или помещаться в динамической памяти с помощью оператора new. При использовании оператора delete происходит явное удаление объекта из па- мяти. В языке C++ нет неявного восстановления памяти. Все классы в языке C++ содержат (как минимум) один метод, называемый конструк- тором, который используется для инициализации данных— членов нового объекта. Конструкторы вызываются неявно при создании объекта. Если ни один из данных- членов не размещается в динамической памяти, конструктор выполняет такое размеще- ние. Если в определении класса конструктора нет, компилятор добваляет к нему автома- тический конструктор (trivial constructor). Этот конструктор, называемый конструктором умолчанию, вызывает конструктор родительского класса, если такой родительский <ласс существует (см. раздел 11.9.2). Большинство определений класса содержит метод, называемый деструктором destructor), который вызывается неявно, когда объект класса перестает существовать. Деструктор используется для удаления данных-членов из динамической памяти. Он мо- *ет также использоваться для создания записи части или всех данных о состоянии объ- екта непосредственно перед его уничтожением, обычно для отладки. В то время как язык Smalltalk не позволяет пользователю использовать никакое > правление доступом к переменным экземпляра и методам в классе, язык C++ предос- тавляет множество возможностей для такого управления доступом. Некоторые из них предназначены для управления тем, что именно клиент может видеть в определении .класса, а что — нет. Другие обеспечивают управление доступом для подклассов. Управ- ление доступом к сущностям класса в языке C++ обсуждается в разделе 11.9.2. 11.9.2. Наследование Класс в языке C++ может быть выведен из существующего класса, который является в таком случае его родительским, или базовым, классом. В отличие от языка Smalltalk, класс в языке C++ может также быть отдельным, не имеющим суперклассов. Напомним, что данные, описанные в определении класса, называются данными- членами этого класса, а функции, описанные в определении класса, называются его функциями-членами. Некоторые или все данные-члены и функции-члены базового клас- са могут наследоваться производным классом, который также может добавлять новые данные-члены и функции-члены и модифицировать наследуемые члены. Права доступа, которыми обладают члены подклассов, могут отличаться от прав доступа соответствую- щих членов базового класса. Это не позволяет рассматривать производные классы в язы- ке C++ в качестве подтипов. Напомним, как было указано в главе 10, что члены класса могут быть закрытыми, за- щищенными или открытыми. Закрытые члены доступны только функциям-членам и друзьям класса. И функции, и классы могут быть объявлены друзьями класса и в качест- ве таковых получить доступ к его закрытым членам. Открытые члены доступны всем функциям. Защищенные члены похожи на закрытые, за исключением их использования производными классами, правила доступа которых описаны ниже. Производные классы могут модифицировать доступ к своим наследуемым членам. Синтаксическая форма для производного класса описана ниже: 11.9. Поддержка объектно-ориентированного программирования... 481
class имя_производного_класса: вид_доступа имя_базового_класса {объявления данных-членов и функций-членов} Модификатор вид_доступа может быть зарезервированным словом public или private. Открытые и защищенные члены базового класса также являются открытыми и защищенными, соответственно, в производном классе. В закрытом производном классе и открытые, и защищенные члены базового класса становятся закрытыми. Таким обра- зом, в иерархии классов закрытый производный класс отрезает доступ ко всем членам всех классов-предков для всех классов-потомков, а защищенные члены могут быть, а мо- гут и не быть, доступными для последующих подклассов (после первого). Закрытые чле- ны базового класса наследуются производным классом, но они являются невидимыми для членов этого производного класса и, следовательно, не используются в нем. Рас- смотрим следующий пример: class base_class { private: int a; float x; protected: int b; float y; public: int c; float z; }; class subclass__l : public base_class { ... }; class subclass_2 : private base__class { ... }; В классе subclass_l переменные b и у являются защищенными, а переменные с и z — открытыми. В классе subclass_2 переменные Ь, у, с и z являются закрытыми. Ни один класс, производный от subclass_2, не может иметь членов, обладающих дос- тупом к любому члену класса base_class. Данные-члены а и х в классе base_class не доступны ни для класса subclass__l, ни для класса subclass_2. При выводе закрытого класса ни один член родительского класса не является по умолчанию видимым для экземпляров производного класса. Любой член, который дол- жен быть сделан видимым, реэкспортируется в производный класс. Этот реэкспорт в действительности делает член класса видимым, даже если производный класс является закрытым. Рассмотрим следующее определение класса: class subclass__3 : private base_class { base_class :: c; } Теперь экземпляр класса subclass__3 имеет доступ к переменной с. Пока речь идет о переменной с, дело обстоит так, как если бы производный класс был открытым. Двой- ное двоеточие (: :) в определении этого класса является оператором разрешения види- мости. Он уточняет класс, в котором определена данная сущность. 482 Глава 11. Поддержка объектно-ориентированного программирования
Рассмотрим следующий пример наследования в языке C++, в котором определен класс для связного списка общего вида, используемый для определения двух полезных подклассов: class single__linked_list { class node { friend class single_linked__list ; private: node *link;' int contents; }; private: node *head; public: single_linked_list() {head = 0}; void insert_at_head(int); void insert_at_tail(int); int remove_at_head(); int empty(); }; Вложенный класс node определяет ячейку связного списка, которая содержит целую пе- ременную и указатель на ячейку. Он указывает класс single_linked_list в качест- ве своего друга (friend), предоставляя таким образом объектам класса sin- gle_linked_list доступ к своим данным-членам link и contents. Это необходи- мо, поскольку содержащие их классы не имеют специальных прав доступа к членам своих вложенных классов. Содержащий класс single__linked_list имеет только одно данное-член, дейст- вующее в качестве указателя на голову списка. Он содержит конструктор, который про- сто инициализирует переменную head нулевым указателем. Четыре функции-члена по- зволяют вставлять узлы в один из двух концов списка, удалять узлы из одного конца списка и проверять, не пуст ли список. Следующие определения описывают классы для стека и очереди на основе класса single_linked_list: class stack: public single_linked_list { public: stack() {} void push(int value) { single_linked_list :: insert_at_head(int value); } int pop() { return single__linked_list :: remove_at_head () ; } }; class queue : public single_linked_list { public: queue(){} void enqueue(int value) { single__linked_list :: insert_at_tail (int value); } 11.9. Поддержка объектно-ориентированного программирования... 483
int dequeue() { single_linked__list : : remove_at_head() ; }; Заметим, что объекты обоих подклассов stack и queue имеют доступ к функции empty, определенной в базовом классе single_linked_list (поскольку при выводе этих подклассов их базовый класс определен как открытый). Оба подкласса определяют конструктор, который не выполняет никаких действий. Когда создается объект подклас- са, соответствующий конструктор вызывается неявно. Затем вызывается любой подхо- дящий конструктор из базового класса. Таким образом, в нашем примере, когда создает- ся объект типа stack, вызывается конструктор stack, не выполняющий никаких дей- ствий. Затем вызывается конструктор single_linked_list, выполняющий необхо- димую инициализацию. И класс stack, и класс queue страдают одним и тем же недостатком— объекты обоих классов имеют доступ ко всем открытым членам родительского класса s ingle_linked_list. Следовательно, объект stack может вызвать метод insert_at_tail, разрушив таким образом целостность стека. Аналогично, объект queue может вызвать метод insert_at_head. Подобные нежелательные вызовы возможны, .поскольку и класс stack, и класс queue являются подтипами типа single_linked_list. Эти два класса можно написать так, чтобы они не были подти- пами своего родительского класса, используя при их определении спецификатор доступа private вместо public. В этом случае им потребуется скопировать метод empty, по- скольку он будет скрыт от их экземпляров. Новые определения типов для стека и очере- ди под названием stack_2 и queue_2, соответственно, показаны ниже: class stack_2 : private single_linked_list { public: stack_2() {} void push(int value) { single_linked_list :: insert__at_head (int value); } int pop() { return single_linked_list :: remove_at_head(); } single_linked_list :: empty; }; class queue_2 : private single_linked__list { public: queue_2() {} void enqueue(int value) { single_linked_list :: insert_at_tail(int value); } int dequeue() { return single_linked_list :: remove_at_head(); } single_linked_list :: empty; }; Эти два варианта классов для стека и очереди иллюстрируют различие между подтипами и производными типами, не являющимися подтипами. 484 Глава 11. Поддержка объектно-ориентированного программирования
Причина, по которой нужны дружественные функции и классы, заключается в том, иногда нужно написать программу, имеющую доступ к членам двух различных клас- . в Предположим, что программа использует классы для векторов и матриц, и необхо- димо написать подпрограмму умножения объектов этих двух типов. В языке C++ функ- ция умножения просто объявляется другом обоих классов. Язык C++ обеспечивает возможность множественного наследования, позволяя -ескольким классам одновременно быть родительскими классами нового класса, --.апример, class А { ... }; class В { ... }; class С : public A, public В { ... }; -..часе С наследует все члены и от класса А, и от класса В. Если окажется, что классы А и ? содержат члены с одинаковыми именами, то на них можно однозначно ссылаться в : ?ъектах класса С с помощью оператора разрешения области видимости. Замещающий и замещаемый методы в языке C++ должны иметь одинаковые прото- • злы. Если в протоколах есть какое-либо различие, метод в подклассе рассматривается • ак новый метод, не имеющий никакого отношения к методу с таким же именем в роди- -ельском классе. 11.9.3. Динамическое связывание Все функции-члены, которые мы определяли до сих пор, являются статически свя- занными, т.е. вызов каждой из них статически связан с определением функции. В языке 1— указатель или ссылку, имеющую тип базового класса, можно использовать для ука- зания на объект любого класса, производного от этого родительского класса. При этом . казатель или ссылка становятся полиморфными переменными. Когда такая полиморф- ная переменная используется для вызова функции, определенной в одном из производ- ных классов, данный вызов должен быть динамически связан с определением соответст- вующей функции. Функции-члены, которые должны быть динамически связанными, следует объявлять как виртуальные функции путем приписывания перед их заголовками зарезервированного слова virtual, которое может появляться только в теле класса. Рассмотрим ситуацию, когда имеется базовый класс с именем shape вместе с набо- ром производных классов для фигур разного вида, таких как окружности, прямоугольни- ки и т.д. Если нужно изобразить эти фигуры на экране дисплея, то функция-член draw, предназначенная для изображения фигуры на экране, должна быть уникальной для каж- дого подкласса, или вида фигуры. Эти версии функции draw должны быть определены как виртуальные функции. Когда вызывается функция draw с указателем на базовый класс этих производных классов, такой вызов должен быть динамически связан с функ- иией-членом соответствующего производного класса. Описанную ситуацию можно про- иллюстрировать следующим примером: class shape { public: virtual void draw() = 0; } class circle : public shape { 11.9. Поддержка объектно-ориентированного программирования... 485
public: virtual void draw() { ... }; } class rectangle : public shape { public: virtual void draw() { ... ); } class square : public rectangle { public: virtual void draw() { ... }; } С учетом этих определений ниже приводятся примеры статически и динамически свя- занных вызовов в соответствии с данными выше определениями классов. square s; rectangle re- shape &ref_shape = s; // ссыпка на квадрат s ref_shape.draw(); // вызов, динамически связанный с // функцией draw в классе square r.drawO; // вызов, статически связанный с функцией // draw в классе rectangle Заметим, что функция draw в определении базового класса shape установлена равной нулю. Этот особый синтаксис используется для указания на то, что эта функция-член яв- ляется чисто виртуальной функцией (pure virtual function), т.е. она не имеет тела и не может быть вызвана. Ее следует переопределить в производных классах. Цель чисто виртуальной функции — обеспечить интерфейс функции без указания ее реализации. Это новая форма сокрытия информации, или инкапсуляции. Любой класс, содержащий чисто виртуальную функцию, называется абстрактным классом (abstract class). Ни один объект абстрактного класса не может быть создан. Строго говоря, абстрактный тип данных не может иметь конкретных объектов, а используется только для представления концепции типа. Подклассы такого типа, конечно, могут иметь объекты. Абстрактные классы в языке C++ используются для моделирования истинно аб- страктных типов данных. Если подкласс абстрактного класса не переопределяет чисто вир- туальную функцию своего родительского класса, эта функция остается чисто виртуальной. Абстрактные классы и наследование вместе взятые представляют собой мощные средства для разработки программного обеспечения. Они позволяют иерархически опре- делять типы, так что связанные между собой типы могут быть подклассами истинно аб- страктных типов, определяющих их общие абстрактные свойства. Динамическое связывание позволяет писать код, использующий члены наподобие функции draw до того, как будут написаны все или даже хотя бы одна из версий функ- ции draw. Новые производные классы могут быть добавлены на много лет позже, и при этом в код, использующий такие динамически связанные члены, не потребуется вносить никаких изменений. Это мощное свойство объектно-ориентированных языков. 486 Глава 11. Поддержка объектно-ориентированного программирования
11.9.4. Оценка Естественно сравнить объектно-ориентированные свойства языков C++ и Smalltalk, что и сделано в этом разделе. Наследование в языке C++ более запутано, чем управление доступом в языке Smalltalk. Использование управления доступом в определении класса и в производных классах наряду с возможностью объявлять дружественные функции и классы позволяет программисту на языке C++ осуществлять очень детализированное управление доступом к членам класса. Более того, несмотря на то, что реальная ценность множественного на- следования является предметом споров, язык C++ поддерживает его, в то время как язык Smalltalk позволяет использовать только одиночное наследование. На языке C++ программист может сам определять, какое связывание следует исполь- зовать — статическое или динамическое. Поскольку статическое связывание выполняет- ся быстрее, этот факт становится преимуществом в тех ситуациях, когда динамическое связывание не обязательно. Кроме того, по сравнению с языком Smalltalk даже динами- ческое связывание в языке C++ выполняется быстрее. Связывание вызова виртуальной функции-члена с определением функции в языке C++ имеет фиксированную стоимость независимо от того, насколько далеко в иерархии наследования это определение появля- ется. При вызове виртуальных функций требуется только на пять обращений к памяти больше, чем при статически связанных вызовах (Stroustrup, 1988). В языке Smalltalk, од- нако, сообщения всегда динамически связываются с методами, и чем дальше в иерархии наследования находится соответствующий метод, тем дольше выполняется его вызов. Из-за того, что пользователь сам может определять, какие связывания являются статиче- скими, а какие— динамическими, эти решения должны реализовываться в исходном проекте, в то время как позднее может потребоваться их изменить. Статическая проверка типов в языке C++ является значительным преимуществом над языком Smalltalk, в котором вся проверка типов осуществляется динамически. Програм- ма на языке Smalltalk может быть скомпилирована с сообщениями, предназначенными ня несуществующих методов, что обнаруживается затем при динамическом тестирова»- нии. В то же время компилятор языка C++ находит такие ошибки. Вообще говоря, ошиб- ки, обнаруживаемые компилятором, легче исправить, чем ошибки, выявляемые во время выполнения программы. Язык Smalltalk в принципе не имеет типов, т.е. весь код является действительно на- страиваемым. Это обеспечивает большую гибкость, но при этом приносится в жертву статическая проверка типов. Язык C++ обеспечивает создание настраиваемых классов с помощью шаблонных классов (как описано в главе 10), сохраняющих преимущества ста- тической проверки типов. Основное преимущество языка Smalltalk заключается в элегантности и простоте языка, являющейся результатом простой философии, лежащей в основе его разработки. Он ис- ключительно и всецело посвящен объектно-ориентированной парадигме и избегает ком- промиссов, диктуемых прихотями устоявшейся пользовательской базы. Язык C++, с другой стороны, представляет собой большой и сложный язык без целостной философии, за ис- ключением требования сохранить пользовательскую базу языка С. Одна из основных целей создания этого языка — сохранение эффективности и особенностей языка С одновременно с обеспечением преимуществ объектно-ориентированного программирования. Некоторые люди интуитивно чувствуют, что эти свойства языка C++ не всегда хорошо согласуются друг с другом и что в значительной степени сложность этого языка является излишней. 11.9. Поддержка объектно-ориентированного программирования... 487
Как указано в работе Chambers and Ungar (1991), система Smalltalk выполнила набор небольших тестов, написанных в стиле языка С, в 10 раз медленнее, чем оптимизирован- ный язык С. Программы на языке C++ требуют лишь не намного больше времени, чем эквивалентные программы на языке С (Stroustrup, 1988). Учитывая большое различие в эффективности между языками Smalltalk и C++, не приходится удивляться тому, что язык C++ имеет намного более широкое коммерческое применение, чем язык Smalltalk. Конечно, существует много факторов, определяющих это различие, но эффективность, несомненно, является аргументом в пользу языка C++. 11.10. Поддержка объектно-ориентированного программирования в языке Java Поскольку реализация классов, наследования и методов в языках Java и C++ анало- гичны, мы сосредоточимся в этом разделе только на тех областях, в которых Java значи- тельно отличается от C++. 11.10.1. Общие свойства Как и язык C++, Java не использует исключительно объекты. Однако в языке Java объ- ектами не являются только значения элементарных скалярных типов (булевского, символь- ного или численных типов). В нем нет типов перечисления или записи, а массивы пред- ставляют собой объекты. Причина, по которой в языке Java используются сущности, не яв- ляющиеся объектами, заключается в эффективности. Однако, как указано в разделе 11.3.1, наличие двух систем типов приводит к некоторым затруднениям. Например, в языке Java встроенная структура данных Vector может содержать только объекты. Таким образом, если вы хотите поместить значение элементарного типа в объект типа Vector, это значе- ние сначала следует поместить в некоторый объект. Это можно сделать, создав новый объ- ект интерфейсного класса для элементарного типа. Такой класс имеет переменную экземп- ляра данного элементарного типа и конструктор, принимающий значение элементарного типа в качестве параметра и присваивающий его своей переменной экземпляра. Например, чтобы поместить целое число 10 в объект типа Vector, на который ссылается переменная my Vector, используем следующий оператор: myVector.addElement(new Integer(10)); Здесь addVector— метод класса Vector, вставляющий новый элемент, а Integer — интерфейсный класс для простого типа int. В то время как в языке C++ классы могут не иметь родительских классов, в языке Java это невозможно. Все классы в языке Java должны быть подклассами корневого класса Object или некоторого класса, являющегося потомком класса Object. Причи- на, по которой в языке Java имеется один корневой класс, состоит в том, что существуют некоторые операции, необходимые повсеместно. Одна из этих операций— сравнение объектов между собой. Все объекты в языке Java явно размещаются в динамической памяти. Большинство из них размещается в памяти с помощью оператора new, однако в языке Java нет оператора явного удаления объекта из памяти. Для освобождения памяти используется процедура сборки мусора. 488 Глава 11. Поддержка объектно-ориентированного программирования
11.10.2. Наследование Язык Java непосредственно поддерживает только одиночное наследование. Однако ?н содержит некоторый вид виртуального класса, называемый интерфейсом, который обеспечивает некий вариант множественного наследования. Определение интерфейса аналогично описанию класса, за исключением того, что интерфейс может содержать только объявления именованных констант и методов (но не определения). Таким обра- зом. интерфейс— не более чем спецификация класса. (Напомним, что абстрактный класс в языке C++ может иметь переменные экземпляра, и в нем можно полностью оп- геделить все методы, кроме одного.) Обычно интерфейсы применяют для того, чтобы определить класс, наследующий некоторые методы и переменные от родительского класса и реализующий его интерфейс. Вернемся к сценарию наследования с привлечением аплетов языка Java, который мы кратко обсудили в разделе 11.3.5. Аплегы— это программы, интерпретируемые Web- броузерами после загрузки с Web-сервера. Вызовы аплетов включаются в HTML-код, описывающий Web-страницу. Все эти аплеты нуждаются в некоторых возможностях, ко- торые они могут наследовать от встроенного класса Applet. Когда аплет применяется для анимации, он часто определяется как аплет, запускаемый в своем собственном пото- ке управления. Эта параллельность поддерживается встроенным классом с именем 7г.read. Однако класс аплетов, использующий параллельность, не может наследовать переменные и методы от обоих классов Applet и Thread одновременно. Чтобы ре- шить эту проблему, язык Java использует встроенный интерфейс Runnable, поддержи- вающий интерфейс (но не реализацию) одного из методов класса Thread. Синтаксис за- головка такого аплета поясняется следующим примером: public class Clock extends Applet implements Runnable Несмотря на то что этот класс вводится для обеспечения множественного наследования, в данном случае требуется его дальнейшее усложнение. Чтобы объект класса Clock за- рекался параллельно, следует создать объект класса Thread, связанный с объектом класса Clock. Сообщение, управляющее параллельным выполнением объекта класса 71оск, должно быть передано соответствующему объекту класса Thread. Очевидно, что такой способ слишком груб и может привести к путанице. Параллельность и потоки языка Java обсуждаются в главе 12. В языке Java метод можно описать как final. Это означает, что он не может заме- чаться ни в одном классе-наследнике. Когда перед определением класса указывается за- резервированное слово final, это означает, что класс не может быть родительским ни для одного подкласса. 11.10.3. Динамическое связывание В языке C++ для того, чтобы позволить динамическое связывание, метод должен быть определен как виртуальный. В языке Java все вызовы методов являются динамиче- ски связанными, если вызываемый метод не определен как final (в этом случае он не может быть замещаемым, и все связывания являются статическими). 11.10. Поддержка объектно-ориентированного программирования... 489
11.10.4. Инкапсуляция В языке Java есть два вида конструкций инкапсуляции: классы и пакеты. Пакет — это логическая, а не физическая инкапсуляция. В определении любого класса можно указать, что он принадлежит конкретному пакету. Если для класса не указано имя пакета, кото- рому он принадлежит, то он помещается в безымянный пакет. Пакет создает новое про- странство имен. Любой метод или переменная, не содержащие модификатор доступа (private, protected или public), имеют так называемую пакетную область ви- димости (package scope). Все методы и переменные, не объявленные как закрытые, яв- ляются видимыми внутри пакета, в котором они объявлены. Это является расширением определения защищенных переменных по сравнению с языком C++, в котором защи- щенные члены видимы только в классе, где они определены, и в подклассах этого класса. Пакетная область видимости — это альтернатива дружественным классам и функциям в языке C++, обеспечивающим доступ к закрытым методам и переменным экземпляра в классе для других указанных методов или классов. В языке Java, если существуют мето- ды и переменные, не являющиеся открытыми, которые мы хотим сделать видимыми для других классов, они определяются как защищенные. Это скрывает их от внешних по от- ношению в пакету классов, не являющихся подклассами, но помешает их класс в один пакет с другими классами, которым мы хотим обеспечить доступ к этим переменным и методам. В действительности все не закрытые переменные и методы всех классов в па- кете являются дружественными. В качестве примера применения пакета предположим, что у нас имеется класс матриц и класс векторов, и нам нужна операция, использующая объект каждого из этих классов. Их переменные экземпляра можно определить как защищенные, а два класса поместить в один и тот же пакет. В этом случае операция, использующая объекты обоих классов, будет иметь доступ к этим переменным, которые, конечно, требуются в этом сценарии. Остается вопрос, куда поместить операцию, использующую матрицу и вектор, на- пример, произведение матрицы и вектора. Поскольку область видимости пакета позволя- ет применять операцию независимо от того, определена ли она в классе матриц, или в классе векторов, остается неясным, какому классу она принадлежит. Она может быть описана в одном из двух классов или в отдельном классе внутри пакета. 11.10.5. Оценка Способы поддержки объектно-ориентированного программирования в языках Java и C++ в целом похожи, но различаются несколькими важными аспектами. Сохраняя по- следовательную приверженность объектно-ориентированным принципам, вследствие не- достатка функций язык Java не позволяет использовать классы, не имеющие родитель- ских классов. Он также использует динамическое связывание в качестве “нормального” подхода к связыванию вызовов метода с его определением. Управление доступом к со- держимому определения класса проще, чем запутанное управление доступом в языке C++, которое варьируется от управления доступом при наследовании до дружественных функций. 490 Глава 11. Поддержка объектно-ориентированного программирования
11.11. Поддержка объектно-ориентированного программирования в языке Ada 95 Язык Ada 95 происходит от языка Ada 83, но имеет некоторые существенные допол- нения. В этом разделе представлен краткий обзор дополнений, разработанных для под- держки объектно-ориентированного программирования. Поскольку в языке Ada 83 уже были конструкции для построения абстрактных типов данных, которые обсуждались в главе 10, остается рассмотреть свойства, необходимые для поддержки наследования и динамического связывания. Требование минимальных изменений структуры типов и па- кетов языка Ada 83, а также как можно более широкое использование статической про- верки типов было одной из главных целей при разработке этих свойств. 11.11.1. Общие свойства Классы в языке Ada 95 представляют собой новую категорию, называемую мечены- ми типами (tagged types), которые могут быть либо записями, либо закрытыми типами. Они инкапсулируются в пакетах, что позволяет компилировать их отдельно. Мечеными эти типы называются потому, что каждый объект такого типа неявно содержит систем- ную метку (tag), которая служит признаком его типа. Подпрограммы, определяющие операции с мечеными типами, помещаются в тот же перечень объявлений, что и объяв- ление типа. Рассмотрим следующий пример: package PERSON^PKG is type PERSON is tagged private procedure DISPLAY(P : in out PERSON); private type PERSON is tagged record NAME : STRING(1..30); ADDRESS : STRING(1..30); AGE : INTEGER; end record; end PERSONJ?KG; Этот пакет определяет тип PERSON, который можно использовать как сам по себе, так и в качестве родительского класса по отношению к другим производным классам. В отличие от языка C++ в языке Ada 95 нет неявных вызовов конструкторов или де- структоров. Эти подпрограммы можно написать, но они должны быть явно вызваны про- граммистом. 11.11.2. Наследование Язык Ada 83 имеет ограниченный вид наследования для своих производных типов и подтипов. В обоих случаях новый тип можно определить на основе уже существующего типа, однако единственная модификация, которая позволяется при этом, — ограничение диапазона значений нового типа. Производные классы в языке Ada 95 основаны на ме- ченых типах. Новые сущности добавляются к наследуемым сущностям с помощью опре- деления записи. Рассмотрим следующий пример: 11.11. Поддержка объектно-ориентированного программирования... 491
with PERSON_PKG; use PERSON_PKG; package STUDENT_PKG is type STUDENT is new PERSON with record GRADE_POINT_AVERAGE : FLOAT; GRADE_LEVEL : INTEGER; end record; procedure DISPLAY (ST : in STUDENT); end STUDENT—PKG; В этом примере производный тип STUDENT имеет сущности из его родительского класса PERSON наряду С новыми сущностями GRADE_POINT_AVERAGE и GRADE_LEVEL. Он также переопределяет процедуру DISPLAY. Этот новый класс определяется в отдельном пакете, чтобы можно было вносить в него изменения без повторной компиляции пакета, содержащего родительский класс. С помощью этого механизма наследования невозможно предотвратить включение сущностей родительского класса в производный класс. Следовательно, производные классы могут лишь расширять родительские классы, и поэтому являются подтипами. Предположим, у нас есть следующие определение: Pl : PERSON; SI : STUDENT; FRED : PERSON := ("FRED", "321 Mulberry Lane", 35); FREDDIE : STUDENT := ("FREDDIE", "625 Main St.", 20, 3.25, 3) ; Поскольку STUDENT является подтипом типа PERSON, присваивание Pl := FREDDIE; должно быть законным и является таковым. Сущности GRADE_POINT_AVERAGE и GRADE LEVEL объекта FRED просто принудительно игнорируются при таком приведе- нии типов. Возникает очевидный вопрос: допустимо ли присваивание в противоположном на- правлении, т.е. можем ли мы присвоить объект типа PERSON объекту типа STUDENT? В языке Ada 95 это допустимо в виде включения сущностей в подкласс. В нашем примере допускается следующее выражение: SI := (FRED, 3.05, 2); Для того чтобы получить производный класс, не имеющий всех сущностей родитель- ского класса, используется библиотека дочерних пакетов. Библиотека дочерних паке- тов — это просто пакет, имени которого предшествует имя родительского пакета. Биб- лиотеку дочерних пакетов можно также использовать для определения дружеского клас- са или функции в языке C++. Например, если нужно написать подпрограмму, которая должна иметь доступ к членам двух разных классов, родительский пакет определяет один из классов, а дочерний пакет — другой класс. Тогда подпрограмма в дочернем па- кете может иметь доступ к членам обоих классов. Язык Ada 95 не поддерживает множественного наследования. Существует возмож- ность достичь аналогичного эффекта с помощью шаблонов, но это не так элегантно, как в языке C++, и здесь не обсуждается. 492 Глава 11. Поддержка объектно-ориентированного программирования
11.11.3. Динамическое связывание Язык Ada 95 поддерживает как статическое, так и динамическое связывание вызовов функций с их определениями в меченых типах. Динамическое связывание выполняется с помощью ассоциированных типов (classwide types), т.е. в некотором смысле всех типов, принадлежащих иерархии классов. Для меченого типа Т ассоциированный тип определя- ется как T’class. Если мы хотим написать процедуру, которая могла бы вызывать обе процедуры DISPLAY, определенные в классах PERSON и STUDENT, следует сделать следующее: procedure DISPLAY_ANY_PERSON(Р : in out PERSON1 class) is begin DISPLAY (P); end DISPLAY—ANY—PERSON; Эту процедуру можно вызвать из обоих приведенных ниже классов: with PERSON_PKG; use PERSON_PKG; with STUDENT_PKG; use STUDENT_PKG; P : PERSON; S : STUDENT; DISPLAY_ANY_PERSON(P); - вызов функции DISPLAY из класса PERSON DISPLAY_ANY_PERSON(S); - вызов функции DISPLAY из класса STUDENT ’ Мы можем сделать указатели полиморфными, определив их как указатели, имеющие ассоциированный тип, как в следующем примере: type ANY—PERSON—PTR is access PERSON*class Чисто абстрактные базовые типы в языке Ada 95 можно определить, указав зарезер- вированное слово abstract в определениях типов и подпрограмм. Кроме того, опреде- ления подпрограмм не могут иметь тел. Рассмотрим следующий пример: package BASЕ_PKG is type T is abstract tagged null record; procedure DO_IT (A : T) is abstract; end BASE_PKG; 11.11.4. Оценка Детальное сравнение объектно-ориентированных свойств языков C++ и Ada 95 на ос- нове их скудного описания, приведенного здесь, невозможно. Однако между ними мож- но выявить несколько существенных различий. Классы языка C++ имеют свою собственную систему типов, дополняющую обычную систему, в основном унаследованную от языка С. В языке Ada 95 механизм классов строит- ся внутри существующей системы типов. В результате, в языке Ada 95 нет тех странностей, которые были возможны в языке C++. Например, если класс B_CLASS является производ- ным от класса A_CLASS в программе на языке C++, а В — это указатель на объекты класса B_CLASS и А — это указатель на объекты A_CLASS, мы имеем следующее: 11.11. Поддержка объектно-ориентированного программирования... 493
В = A; // недопустимое присваивание А = В; // допустимое присваивание Совершенно очевидно, что в языке C++ способы множественного наследования луч- ше, чем в языке Ada 95. Однако использование библиотеки дочерних модулей для управ- ления доступом к сущностям родительского класса кажется более понятным решением, чем дружественные функции и классы в языке C++. Например, если при определении класса неизвестно, понадобятся ли в дальнейшем дружественные функции и классы, то потом, когда эта надобность станет очевидной, придется это определение изменить и скомпилировать снова. В языке Ada 95 новые классы в новых дочерних пакетах можно определить, не затрагивая родительский пакет. В языке C++ хорошо разработаны конструкторы и деструкторы для инициализации объектов и удачно осуществляется размещение объектов в динамической памяти. Язык Ada 95 не имеет таких возможностей. Другое различие между этими двумя языками заключается в том, что разработчик кор- невого класса в языке C++ должен решить, будет конкретная функция-член вызываться статически или динамически. Если выбор сделан в пользу статического выбора, а дальней- шее изменение системы потребовало динамического связывания, придется изменить кор- невой класс. В языке Ada 95 это проектное решение не следует принимать на этапе разра- ботки корневого класса. Каждый вызов функции может сам определить, будет он статиче- ски или динамически связанным, независимо от того, как разработан корневой класс. Более тонкое различие состоит в том, что динамическое связывание в языке C++ ог- раничено применением указателей и ссылок на объекты и не использует сами объекты. В языке Ada 95 такого ограничения нет, так что в этом случае язык Ada 95 гораздо больше отличается от языка C++. 11.12. Поддержка объектно-ориентированного программирования в языке Eiffel Язык Eiffel — это полностью объектно-ориентированный язык в том смысле, что он был разработан специально для поддержки объектно-ориентированного программирования и не основывается ни на одном из существующих языков. Кроме того, подпрограммы мож- но реализовывать только через объекты. Его синтаксис похож на синтаксис языков Pascal и Ada. Он имеет несколько особенностей, например, импликацию высказываний. Однако в этой главе мы обсуждаем только его поддержку объектно-ориентированного программиро- вания, особенно наследования и динамического связывания. 11.12.1. Общие свойства Язык Eiffel похож на язык Java, поскольку оба они имеют базовые типы, например скалярные, и объекты, определенные в классах. В первоначальной версии языка все объ- екты размещались в динамической памяти, и доступ к ним осуществлялся с помощью ссылок. В более поздних версиях добавились объекты второго вида, называемые расши- ренными объектами, которые размещаются в стеке. В языке есть три встроенных опера- тора для всех объектов: copy, clone и equal. Оператор сору копирует сущности из одного размещенного в памяти объекта в другой. Оператор clone сначала выделяет па- мять для объекта, а затем копирует сущности из другого объекта в эту новую область 494 Глава 11. Поддержка объектно-ориентированного программирования
памяти. Оператор equal проверяет, равны ли два объекта, сравнивая принадлежащие им сущности, а не только ссылки. Методы в языке Eiffel называются подпрограммами (routines), а переменные экзем- пляра — атрибутами (attributes). Подпрограммы и атрибуты класса вместе взятые назы- ваются свойствами (features). Ссылки определяются как обычно, например: stkl : stack; Однако требуется второй оператор для размещения объекта в памяти. Это делает опера- тор ! !, как показано ниже: !!stkl; Создание объекта неявно подразумевает инициализацию атрибутов с помощью стан- дартных значений, заданных по умолчанию. Для инициализации атрибутов нестандарт- ными значениями, заданными по умолчанию, пользователь может определить подпро- грамму-конструктор класса. Конструкторы определяются в разделе creation и явно вызываются оператором создания объекта. Например, в приведенном ниже фрагменте кода подпрограмма initComplex является конструктором: class Complex creation initComplex feature real_part, imag_part : REAL feature initComplex(r, i : REAL) is do real_part := r; imag_part : • i end; Теперь с помощью приведенного ниже кода мы можем создать ссылку на объект класса Complex, а также создать и инициализировать этот объект: cl : Complex; !!cl.initComplex(2.4, -3.2); В языке Eiffel нет явного оператора удаления объекта из памяти. Если на объект больше не нужно ссылаться, он неявно удаляется из памяти и процедура сборки мусора со временем освобождает занимаемую им память. 11.12.2. Наследование Родительский класс для некоторого класса указывается в разделе inherit, как пока- зано ниже: class square inherit rectangle 11.12. Поддержка объектно-ориентированного программирования... 495
Определение класса может содержать несколько разделов feature. Раздел feature без квалификатора доступа является видимым и для подклассов, и для клиен- тов. Если к зарезервированному слову feature присоединен квалификатор {попе}, то свойства, определяемые в этом разделе, становятся скрытыми от подклассов и клиентов. Если в качестве квалификатора используется имя класса, то свойства являются скрыты- ми от клиентов, но видимыми для подклассов. Следующий фрагмент кода иллюстрирует эти три уровня видимости: class child inherit parent feature - Свойства, определенные здесь, являются видимыми для - клиентов и подклассов feature {child} - Свойства, определенные здесь, скрыты от клиентов, - но являются видимыми для подклассов feature {none} - Свойства, определенные здесь, скрыты и от клиентов, - и от подклассов end; Наследуемые свойства могут быть скрыты в подклассе с помощью зарезервированно- го слова undefine. Это очевидным образом не позволяет подклассу быть подтипом. Для управления доступом клиентов к наследуемым свойствам можно использовать раз- дел export. Абстрактные классы определяются добавлением зарезервированного слова deferred в начале определения класса, как показано ниже: deferred class figure Такой класс содержит одно или несколько свойств, которые также являются отложенными (deferred). Любой подкласс отложенного класса, не являющийся сам по себе отложенным, конечно, должен содержать определения отложенных свойств родительского класса. Язык Eiffel поддерживает множественное наследование, которое определяется просто наличием нескольких разделов inherit. 11.12.3. Динамическое связывание Все связывания сообщений с методами в языке Eiffel являются динамическими. Под- программы в подклассах могут замещать наследуемые подпрограммы. Все типы фор- мальных параметров замещающей подпрограммы должны быть совместимыми в отно- шении операции присваивания с параметрами замещаемой подпрограммы. Более того, тип возвращаемого значения замещающей подпрограммы должен быть совместим в от- ношении операции присваивания с типом возвращаемого значения замещаемой подпро- граммы. Все замещаемые свойства должны быть определены в разделе redefine. Доступ к замещаемым свойствам можно обеспечить, занеся их имена в раздел rename. 496 Глава 11. Поддержка объектно-ориентированного программирования
11.12.4. Оценка Способы поддержки объектно-ориентированного программирования в языках Eiffel и Java похожи. Процедурное программирование в этих языках не поддерживается никогда. И в обоих случаях все связывания сообщений с методами являются динамическими. Ос- тавив язык Java в стороне, заметим, что по элегантности и ясности разработки классы и наследование в языке Eiffel уступают только языку Smalltalk. 11.13. Реализация объектно-ориентированных конструкций Есть, по крайней мере, два аспекта поддержки языками объектно-ориентированного программирования, которые ставят перед разработчиками языков интересные вопро- сы, — структуры памяти для хранения переменных экземпляра и динамическое связыва- ние сообщений с методами. В этом разделе мы кратко рассмотрим обе проблемы. 11.13.1. Хранение данных экземпляра В языке C++ классы определяются как расширения структур (record structures). В дей- ствительности, структура, которая в языке C++ обозначается зарезервированным словом struct, может содержать функции. Единственное различие между структурой в языке C++ и классом заключается в том, что все члены структуры по умолчанию являются от- крытыми, в то время как все члены класса по умолчанию являются закрытыми. Эта схо- жесть очевидно подразумевает, что экземпляры классов хранятся в памяти аналогично структурам. Мы будем называть такой вид структуры памяти для хранения экземпляров записью экземпляра класса (CIR— class instance record). Структура CIR является ста- тической, поэтому она создается во время компиляции и используется в качестве шабло- на при создании экземпляров класса. Подклассы могут просто расширять структуру C1R родительского класса, добавляя новые переменные экземпляра. Поскольку структура CIR является статической, доступ ко всем переменным экземп- ляра осуществляется так же, как и к полям записей, с помощью постоянных по величине смещений от начала записи экземпляра класса. В результате, такой доступ так же эффек- тивен, как и доступ к полям записей. Запись экземпляра класса, который имеет родительский класс, просто добавляет его новые данные к записям экземпляра родительского класса. Этот подход к реализации хранения данных экземпляра должен работать во всех объ- ектно-ориентированных языках, обсуждаемых в этой главе. 11.13.2. Динамическое связывание сообщений с методами Статически связанные методы в классе не нужно включать в структуру C1R данного класса. Однако, чтобы динамически связанные методы входили в эту структуру, следует включить в нее указатели на коды этих методов, которые устанавливаются во время соз- дания объекта. Вызовы метода могут быть затем связаны с соответствующим кодом с помощью его указателя в структуре экземпляра. Недостаток этого приема заключается в том, что каждый экземпляр должен был бы хранить указатели на все динамически свя- занные методы, которые могли быть вызваны из этого экземпляра. 11.13. Реализация объектно-ориентированных конструкций 497
Заметим, что список динамически связанных методов, которые могут быть вызваны из экземпляра класса, одинаков для всех экземпляров данного класса. Следовательно, список таких методов нужно хранить только в одном месте. Таким образом, в структуре CIR нужно хранить только один указатель на этот список, чтобы иметь возможность найти вызываемые методы. Структура для хранения этого списка иногда называется таблицей виртуальных методов (VMT — virtual method table). Вызовы методов можно представить в виде смещений в таблице VMT. Полиморфные переменные родительского класса всегда ссылаются на структуру CIR объекта соответствующего типа, так что по- лучение нужной версии динамически связанного метода гарантируется. Рассмотрим сле- дующий пример, в котором все методы предполагаются динамически связанными: class Small { public int a, b, c; public void draw() { ... } } class Large extends Small { public int d, e; public void draw() { ... } public void sift() { ... } } Структуры CIR классов Small и Large вместе с их таблицами VMT показаны на рис. 11.5. Рис. 11.5. Записи экземпляров классов и таблицы виртуальных методов Объектно-ориентированное программирование включает в себя три основных поня- тия: абстрактные типы данных, наследование и динамическое связывание. Объектно- ориентированные языки придерживаются этой парадигмы с помощью классов, методов, объектов и передачи сообщений. Обсуждение объектно-ориентированных языков программирования в этой главе вра- щается вокруг семи вопросов разработки языков: исключительность объектов, подклас- сы и подтипы, реализация и интерфейс наследования, проверка типов и полиморфизм, 498 Глава 11. Поддержка объектно-ориентированного программирования
одиночное и множественное наследование, динамическое связывание, явное и неявное удаление объектов из памяти. Язык Smalltalk — чисто объектно-ориентированный язык. Все сущности в нем явля- ются объектами и все вычисления выполняются через передачу сообщений. Методы конструируются из выражений. Выражение описывает объект, представляющий собой значение выражения. Управляющие структуры в языке Smalltalk, как и во всех других языках, конструируются с использованием объектов и сообщений. Несмотря на то что они выглядят как обычно, их семантика значительно отличается от соответствующих структур в императивных языках. В языке Smalltalk все подклассы являются подтипами. Все проверки типов и связывание сообщений с методами выполняются динамически, и любое наследование является одиночным. Язык Smalltalk не имеет явного оператора удаления объектов из памяти. Язык C++ обеспечивает поддержку абстракции данных, наследование и возможное динамическое связывание сообщений с объектами наряду со всеми обычными свойства- ми языка С. Это означает, что он имеет две различные системы типов. Несмотря на то что динамическое связывание в языке Smalltalk обеспечивает несколько большую гиб- кость программирования, чем гибридные языки, например C++, он намного менее эф- фективен. Язык C++ обеспечивает множественное наследование и явное удаление объек- тов из памяти. Язык C++ имеет разнообразные средства управления доступом к сущно- стям в классе, некоторые из которых не позволяют подклассам быть подтипами. Класс может содержать вызываемые неявно методы конструктора и деструктора. Язык Java, в отличие от гибридного языка C++, является полностью объектно- ориентированным. Все объекты размещаются в динамической памяти и доступны через ссылки на них. В языке нет явной операции удаления объектов из памяти. Методами яв- ляются только подпрограммы, и вызвать их можно только через объекты или классы. Непосредственно поддерживается только одиночное наследование, хотя возможна также и некоторая разновидность множественного наследования с помощью интерфейсов. Все связывания сообщений с методами — динамические, за исключением методов, которые не могут быть замещаемыми. Кроме классов, в качестве второй конструкции инкапсуля- ции язык Java содержит пакеты. Язык Ada 95 обеспечивает поддержку объектно-ориентированного программирования с помощью меченых типов, которые могут использовать наследование. Возможно динамиче- ское связывание с использованием ассоциированных типов. Производные типы являются расширениями родительских типов, если они не определены в библиотеке дочерних паке- тов. В этом случае сущности родительского типа могут быть исключены из производного типа. Вне библиотеки дочерних пакетов все подклассы являются подтипами. Язык Eiffel — это новый объектно-ориентированный язык, не основанный ни на од- ном из существующих языков. Он содержит средства управления доступом к сущностям в классах, для того чтобы ограничить доступ к ним клиентов, подклассов или их обоих. Подобно языку Java в языке Eiffel нет явной операции удаления объектов из памяти. Пользователь может (но не обязан) определять конструкторы и деструкторы в своих классах. Все связывания сообщения с методами в языке Eiffel являются динамическими. Резюме 499
2. В чем заключается разница между переменной класса и переменной экземпляра? 3. Что называется замещаемым методом? 4. Опишите ситуацию, при которой динамическое связывание является большим преимуществом. 5. Что такое виртуальный метод? 6. Кратко опишите семь основных вопросов разработки объектно-ориентированных языков, освещенных в этой главе. 7. Что представляет собой протокол сообщения метода? 8. Каким образом классы языка Smalltalk могут отвечать на сообщения? 9. Объясните, как действует следующий оператор языка Smalltalk: result <- first * second 10. Что представляют собой четыре части определения класса в языке Smalltalk? 11. Объясните, каким образом сообщения языка Smalltalk связываются с методами. Когда это происходит? 12. Какая проверка типов реализована в языке Smalltalk? Когда она происходит? 13. Какой вид наследования поддерживает язык Smalltalk? 14. Какие два основных действия должны выполняться при вычислениях на языке Smalltalk? 15. Для чего в языке Smalltalk предназначена псевдопеременная super? 16. По существу, все переменные в языке Smalltalk относятся к одному типу. Что это за тип? 17. Сколько параметров имеет бинарное сообщение в языке Smalltalk? 18. Объясните правила приоритетов в выражениях на языке Smalltalk. 19. Как можно вызвать выполнение блока на языке Smalltalk? 20. Для чего в языке Smalltalk предназначена псевдопеременная serve? 21. В каких областях памяти могут быть размещены объекты, созданные в программе на языке C++? 22. Как в языке C++ осуществляется удаление объектов из динамической памяти? 23. Все ли подклассы в языке C++ являются подтипами? 24. При каких обстоятельствах вызов метода в языке C++ может статически связы- ваться с методом? 25. Какой недостаток имеет разрешение пользователю определять, какие именно ме- тоды могут быть статически связанными? 26. Объясните разницу между двумя способами употребления зарезервированного слова private в языке C++. 500 Глава 11. Поддержка объектно-ориентированного программирования
27. Что представляет собой дружественная функция в языке C++? 28. Чем отличаются друг от друга системы типов языка Java и языка C++? 29. В каких областях памяти могут размещаться объекты, созданные в программе на языке Java? 30. Как в языке Java осуществляется удаление объектов из памяти? 31. Все ли подклассы в языке Java являются подтипами? 32. При каких обстоятельствах вызов метода в языке Java может статически связы- ваться с методом? 33. Все ли подклассы в языке Ada 95 являются подтипами? 34. Как определить вызов подпрограммы на языке Ada 95, чтобы он динамически свя- зывался с определением подпрограммы? Когда принимается это решение? 35. Для чего предназначен раздел creation в языке Eiffel? 36. Как определить свойство в языке Eiffel, чтобы оно было видимым для подклассов, но не для клиентов? 37. Что делает квалификатор {попе}, когда он приписывается перед свойством в языке Eiffel? 38. Как удаляются из динамической памяти объекты, созданные в программе на языке Eiffel? 39. При каких обстоятельствах вызов метода в языке Eiffel может статически связы- ваться с методом? 40. Как определить подкласс в языке Eiffel, чтобы он стал подтипом? ' *.... ,1..... .........^2%*. У п р о ж н я- н и я 1. Напишите на языке Smalltalk следующий цикл, написанный на языке Pascal: while count < 100 do begin sum := sum div (2 * count - 1); count := count + 1; end 2. Напишите на языке Smalltalk следующий цикл for, написанный на языке Pascal: for index := 10 downto 1 do sum := sum + index 3. Напишите на языке Smalltalk следующую конструкцию ветвления, написанную на языке Pascal: if count < 10 then answer ;= 1 else begin answer := 0; Упражнения 501
count := 0; end 4. Напишите на языке Smalltalk следующий цикл while, написанный на языке С: while (count < 100) { sum /= (2 * count -1); } 5. Напишите на языке Smalltalk следующий цикл for, написанный на языке С: for (index = 10; index > 0; index—) sum +=index; 6. Напишите на языке Smalltalk следующую конструкцию ветвления, написанную на языке С: if (count < 10) answer = 1; else answer = count = 0; 7. Напишите на языке Smalltalk пример метода, принимающего в качестве парамет- ров четыре целых значения, среди которых первые два являются числителем и знаменателем некоторой дроби, а последние два— аналогичным образом пред- ставляют другую дробь. Ваш метод должен возвращать объект, являющийся мас- сивом, состоящим из двух элементов, которые представляют собой числитель и знаменательлроизведения двух заданных дробей. 8. Сравните между собой динамическое связывание в языках Eiffel, C++, Smalltalk, Ada 95 и Java. 9. Сравните между собой управление доступом к сущностям класса в языках Eiffel, C++, Smalltalk, Ada 95 и Java. 10. Сравните между собой одиночное наследование в языках Eiffel, C++, Smalltalk, Ada 95 и Java. 11. Сравните между собой множественное наследование в языках Eiffel и C++. 12. Сравните множественное наследование в языке C++ с наследованием, обеспечи- ваемым интерфейсами в языке Java. 502 Глава 11. Поддержка объектно-ориентированного программирования
Никлаус Вирт (Niklaus Wirth) В это й г Л О Я F? 12.1. Введение 12.2. Введение в параллельность на уровне подпрограмм 12.3. Семафоры 12.4. Мониторы 12.5. Передача сообщений 12.6. Параллельность в языке Ada 95 12.7. Потоки языка Java 12.8. Параллельность на уровне операторов Никлаус Вирт из Швейцарского федерального технологического института (ЕТН) в Цюрихе с се- редины 1960-х годов постоянно занимается разработкой языков программирования Он покинул команду разработчиков языка ALGOL-68, чтобы посвятить себя языку ALGOL-W. Он также соз- давал языки Euler и PL/360 в 1960-х годах. С тех пор он раз- работал языки Pascal, Module, Modula-2 и Oberon. Параллельность 503
Эта глава начинается с описания различных видов параллельности на уровне под- программ, модулей и операторов. Во введении рассматриваются некоторые общие виды архитектур многопроцессорных компьютеров. Затем обсуждается параллельность на уровне модулей. Мы начнем с описания основных понятий, которые следует уяснить, пре- жде чем переходить к обсуждению параллельности на уровне модулей, включая синхрони- зацию конкуренции и синхронизацию взаимодействия. Затем описываются вопросы разра- ботки средств поддержки параллельности в языках программирования. Далее мы подробно обсудим на примерах программ три основных подхода к поддержке параллельности в язы- ках: семафоры, мониторы и передачу сообщений. Применение семафоров демонстрируется примером программы на псевдокоде. Для иллюстрации применения мониторов использу- ется пример программы на языке Concurrent Pascal, передачу сообщений показывает про- грамма на языке Ada. Детально описываются свойства языка Ada, поддерживающие парал- лельность. К ним относятся задачи, объявления entry, операторы accept, операторы select, предохранители (guards), операторы delay и операторы terminate. После об- суждения языка Ada дается краткое введение в новые свойства языка Ada 95, поддержи- вающие параллельность, включая защищенные модули и асинхронную передачу сообще- ний. В заключение приводится пример поддержки параллельности на уровне модулей в языке Java. Последний раздел главы посвящен обсуждению параллельности на уровне опе- раторов, включая краткое описание части средств поддержки параллельности в языке High- Performance FORTRAN. 12.1. Введение Параллельность естественным образом подразделяется на уровень машинных команд (выполнение нескольких машинных команд одновременно), уровень операторов (выпол- нение нескольких подпрограмм одновременно) и уровень программ (выполнение не- скольких программ одновременно). Поскольку с параллельностью на уровнях машинных команд и программ вопросы разработки языков программирования никак не связаны, мы не будем обсуждать их в этой главе. Здесь рассматривается параллелизм на уровнях под- программ и операторов, причем особое внимание уделяется уровню подпрограмм. Параллельное выполнение программных модулей может осуществляться либо физи- чески на отдельных процессорах, либо логически с помощью разделения времени вы- полнения программ в однопроцессорной компьютерной системе. На первый взгляд, па- раллельность кажется простым понятием, однако она ставит перед разработчиком языка программирования трудные задачи. Параллельные методы управления увеличивают гибкость программирования. Вначале они предназначались для решения некоторых задач, стоящих перед операционными систе- мами, но теперь их можно использовать и в других программных приложениях. Например, многие программные системы разрабатываются для моделирования реальных физических систем, некоторые зачастую состоят из совокупности параллельных подсистем. Для этих приложений ограниченные формы управления подпрограммами не подходят. Параллельность на уровне операторов совершенно отличается от параллельности на уровне модулей. С точки зрения разработчика языка, параллельность на уровне операто- ров в основном сводится к определению того, каким именно образом следует распреде- лить данные среди нескольких запоминающих устройств и какие операторы можно вы- полнять параллельно. 504 Глава 12. Параллельность
Данная глава посвящена обсуждению аспектов параллельности, относящихся в боль- шей степени к проблемам разработки языков программирования, и не является полным исследованием всех вопросов, связанных с параллельностью. Это было бы совершенно неуместно в книге о языках программирования. 12.1.1. Многопроцессорные архитектуры Большое количество различных компьютерных архитектур имеют более одного про- цессора и могут поддерживать некоторые виды параллельного выполнения программ. Прежде чем начать обсуждение видов параллельного выполнения программ и операто- ров, мы кратко опишем некоторые из этих архитектур. Первые компьютеры, имевшие несколько процессоров, в действительности имели один процессор общего назначения и один или несколько других процессоров, использовавших- ся только для выполнения операций ввода и вывода. Это позволяло компьютерам, появив- шимся в конце 1950-х годов, одновременно с выполнением одной программы осуществ- лять ввод или вывод данных для других программ. Поскольку этот вид параллельности не требует языковой поддержки, в дальнейшем мы не будем его рассматривать. В начале 1960-х годов появились машины, имевшие несколько полноценных процес- соров. Эти процессоры использовались как планировщики заданий, которые просто рас- пределяли отдельные задания из очереди пакета среди отдельных процессоров. Системы с такой структурой поддерживают параллельность на уровне программ. В середине 1960-х годов появились многопроцессорные компьютеры, имевшие не- сколько идентичных специализированных процессоров, получавших определенные ко- манды из одного потока данных. Например, некоторые машины имели несколько про- цессоров только для умножения чисел с плавающей точкой, в то время как другие маши- ны имели несколько полноценных модулей для выполнения всех арифметических операций над числами с плавающей точкой. Для таких машин были нужны компилято- ры, которые определяли бы, какие именно команды можно выполнять параллельно, и соответствующим образом планировали бы их выполнение. Системы с такой структурой поддерживали параллельность на уровне команд. Сейчас существует много различных многопроцессорных компьютеров, две наиболее общие категории которых описываются в следующих абзацах. Компьютеры, имеющие несколько процессоров, одновременно выполняющих одну и ту же команду, каждый со своим собственным набором данных, называются компьюте- рами, имеющими архитектуру с одним потоком команд и многими потоками данных (SIMD — single-instruction multiple-data). В SlMD-компьютере каждый процессор имеет свою собственную локальную память, причем один процессор управляет выполнением операций другими процессорами. Поскольку все процессоры, за исключением контрол- лера, выполняют одну и ту же команду одновременно, в программном обеспечении не нужна синхронизация. Возможно, наиболее широко используемыми SIMD-машинами являются так называемые векторные процессоры. Они имеют группы регистров, храня- щих операнды векторной операции, в которых одна и та же команда одновременно вы- полняется над целой группой операндов. Обычно наибольшую пользу из такой архитек- туры извлекают программы для научных вычислений, в которых часто применяются многопроцессорные машины. Компьютеры, имеющие несколько процессоров, действующих независимо, но опера- ции которых можно синхронизировать, называются компьютерами, имеющими архитек- 12.1. Введение 505
туру со многими потоками команд и многими потоками данных (MIMD— multiple- instruction multiple-data). Каждый процессор в MlMD-компьютере выполняет свой собст- венный поток команд. MIMD-компьютер может иметь две различные конфигурации — системы с распределенной и совместно используемой памятью. Распределенные MIMD- машины. в которых каждый процессор имеет свою собственную память, могут быть ли- бо построены в одном блоке, либо распределены на большой площади. MIMD-машины с совместной памятью, очевидно, должны обеспечивать некоторые средства синхрониза- ции для предотвращения конфликтов при доступе к памяти. Даже в распределенных MIMD-машинах требуется синхронизация ^ля совместного выполнения отдельной про- граммы. MIMD-компьютеры, являющиеся дорогими и более универсальными, очевид- ным образом поддерживают параллельность на уровне модулей. 12.1.2. Разновидности параллельности Существуют две разновидности параллельного управления модулями. Наиболее об- щая разновидность параллельности предполагает доступность нескольких процессов и одновременное, в буквальном смысле этого слова, выполнение одной и той же програм- мы несколькими программными модулями. Это— физическая параллельность (physical concurrency). Небольшое ослабление этой концепции параллельности позволяет программисту и программному обеспечению считать, что существует несколько процес- соров, обеспечивающих реальную параллельность, тогда как в действительности имеет место поочередное выполнение программ на одном процессоре. Это — логическая па- раллельность (logical concurrency). Она создает иллюзию одновременного выполнения программ различных пользователей многопрограммной компьютерной системы. С точки зрения программиста и разработчика языка, логическая параллельность ничем не отли- чается от физической. Задачей реализации языка является отображение логической па- раллельности в соответствующем программном обеспечении. И логическую, и физиче- скую параллельность можно использовать в качестве методологии разработки программ. В оставшейся части главы под словом параллельность (без уточнения) мы будем подра- зумевать логическую параллельность. Для визуализации потока выполнения команд в программе полезно представить себе нить, протянутую через операторы исходного текста программы. Каждый оператор, дос- тигнутый в ходе конкретного выполнения программы, как бы накрывается потоком, символизирующим это выполнение. Визуально следуя за этой нитью, проходящей через исходную программу, мы можем отследить поток выполнения, протекающий через ис- полняемый модуль. Поток управления (thread of control) в программе — это последо- вательность точек, которые достигаются при выполнении программы. Программы, имеющие сопрограммы (см. главу 8), хотя иногда и называются квази- параллельными, имеют один поток управления. Программы, выполняемые на основе фи- зической параллельности, могут иметь несколько потоков управления. Каждый процес- сор выполняет только один из потоков. Несмотря на то что выполнение логически па- раллельных программ в действительности может иметь только один поток управления, такие программы разрабатываются и анализируются только на основе их представления в виде программ, имеющих несколько потоков управления. Когда многопоточная про- грамма выполняется на однопроцессорной машине, ее потоки отображаются на один по- ток. Она становится в таком случае виртуально многопоточной программой. 506 Глава 12. Параллельность
Параллельность на уровне операторов является относительно простым понятием. Цик- лы, которые содержат операторы, выполняющие действия над элементами массива, развер- тываются так, что их обработку можно распределить среди многих процессоров. Напри- мер, цикл, который выполняет 500 повторений и содержит операторы, осуществляющие действия над одним из 500 элементов массива, можно развернуть так, что каждый из 10 от- дельных процессоров будет одновременно обрабатывать 50 элементов массива. 12.1.3. Почему нужно изучать параллельность Существует, по крайней мере, две причины, по которым следут изучать параллель- ность. Первая и главная заключается в том, что параллельность обеспечивает метод кон- цептуализации программных решений поставленных задач. Для большой части задач па- раллельность вполне естественна, примерно так же, как рекурсия является естественным способом решения некоторых задач. Многие программы предназначены для моделиро- вания физических сущностей и процессов. Зачастую моделируемые системы состоят из нескольких сущностей, которые все делают одновременно, — например, полет самолета в контролируемой области, работа ретрансляционной станции в коммуникационной сети и функционирование машин в промышленном производстве. Для точного моделирова- ния таких систем с помощью программного обеспечения необходимы программы, кото- рые могут выполнять параллельные вычисления. Вторая причина, по которой мы обсуждаем параллельность, — то, что в настоящее время широко используются многопроцессорные компьютеры, и это стимулирует созда- ние программ, эффективно использующих возможности аппаратного обеспечения. По- скольку и параллельность на уровне операторов, и параллельность на уровне модулей имеют большое значение, необходимо разрабатывать и включать в современные языки программирования средства для их поддержки. 12.2. Введение в параллельность на уровне подпрограмм Прежде чем обсудить поддержку параллельности в языках программирования, введем основные понятия параллельности и условия, при которых она становится полезной. За- тем рассмотрим вопросы разработки языков, поддерживающих параллельность. 12.2.1. Основные понятия Задача (task) — это модуль программы, который выполняется параллельно с другими частями той же самой программы. Каждая задача в программе обеспечивает один поток управления. Есть три свойства задач, отличающие их от подпрограмм. Во-первых, выполнение за- дачи может начинаться неявно, в то время как подпрограмма должна вызываться явно. Во-вторых, когда программный модуль вызывает задачу, он не обязан ожидать заверше- ния выполнения задачи для того, чтобы продолжить свою работу. В заключение, после завершения выполнения задачи управление может возвращаться, а может и не возвра- щаться в модуль, вызвавший это выполнение. Задача поддерживает связь с другими задачами через совместно используемые нело- кальные переменные, передачу сообщений или параметры. Если задача не поддерживает 12.2. Введение в параллельность на уровне подпрограмм 507
связи с другими задачами и никогда не вызывает выполнения никакой другой задачи, она называется отдельной (disjoint). Поскольку задачи часто работают совместно при моде- лировании и других вычислениях и вследствие этого не являются отдельными, они дол- жны использовать некоторые виды связи либо для синхронизации своего выполнения, либо для совместного использования данных, либо для того и другого одновременно. Синхронизация (synchronization)— это механизм, управляющий порядком выполне- ния задач. При совместном использовании данных, а также когда задачи взаимодействуют друг с другом или конкурируют между собой, нужны два вида синхронизации. Синхрони- зация взаимодействия (cooperation synchronization) между задачами А и В требуется, когда задача А должна ожидать, пока задача В закончит выполнение определенных действий, прежде чем задача А сможет продолжить свою работу. Синхронизация конкуренции (competition synchronization) между двумя задачами необходима, когда обе задачи нужда- ются в использовании некоторого ресурса, который невозможно использовать одновре- менно. В частности, если задача А нуждается в получении доступа к совместно используе- мой ячейке х в то время, когда эта ячейка находится в распоряжении задачи В, то задача А должна ожидать, пока задача В не закончит обработку ячейки х, независимо от того, что именно представляет собой эта обработка. Итак, при синхронизации взаимодействия зада- чам, возможно, придется ожидать завершения некоторой обработки, от которой зависит их правильное выполнение, в то время как при синхронизации конкуренции задачам, возмож- но, придется ожидать завершения любой другой обработки совместно используемых дан- ных, выполняемой в данный момент любой другой задачей. Простую форму синхронизации взаимодействия можно проиллюстрировать с помо- щью широко распространенной задачи “производитель-потребитель”. Эта задача воз- никла при разработке операционных систем, в которых один программный модуль выра- батывает некоторые данные или ресурс, а другие используют их. Эти данные обычно помещаются в буферной памяти вырабатывающим их модулем и удаляются из нее полу- чающим их модулем. Последовательность операций размещения данных в буферной па- мяти и удаления их оттуда должна быть синхронизирована. Модулю-потребителю необ- ходимо запретить извлекать данные из буфера, если буфер пуст. Аналогично, модуль- производитель не может помещать данные в буфер, если тот полон. Это приводит к за- даче синхронизации взаимодействия, поскольку пользователи совместно используемых структур данных должны координировать свои действия, для того чтобы буферная па- мять использовалась корректно. Синхронизация взаимодействия предотвращает ситуацию, когда две задачи обраща- ются к совместно используемой структуре данных точно в одно и то же время, что может разрушить целостность этой структуры. Чтобы обеспечить синхронизацию конкуренции, должен быть гарантирован взаимно исключающий доступ к совместно используемым данным. Чтобы пояснить задачу синхронизации конкуренции, рассмотрим следующий сцена- рий. Предположим, что задача А должна добавлять 1 к совместно используемой цело- численной переменной TOTAL, имеющей начальное значение 3. Кроме того, задача В должна умножать значение переменной TOTAL на 2. Каждая задача выполняет свою операцию с переменной TOTAL, извлекая ее значение, производя арифметическую опе- рацию и помещая новое значение назад в переменную TOTAL. Без синхронизации конку- ренции эти операции могут привести к трем разным результатам. Если задача А завер- шит свою операцию перед началом задачи В, то результат будет равен 8, что считается 508 Глава 12. Параллельность
правильным ответом. Но если обе задачи А и В извлекут значение переменной TOTAL до того, как одна из них присвоит ей новое значение, то результат окажется неправильным. Если задача А вернет свой результат первой, то значение переменной TOTAL будет равно 6. Этот случай показан на рис. 12.1.. Если задача В вернет свой результат первой, то зна- чение переменной TOTAL будет равно 4. Важность синхронизации конкуренции должна быть теперь очевидной. Значение л переменной total ’ 6 Задача А----------------1---------------1--------------1------------ Выбрать Добавить 1 Сохранить TOTAL TOTAL Задача В---------------------1---------------1----------------1----- Выбрать Умножить Сохранить TOTAL на 2 TOTAL Время-------------------------------------------------------------► Рис. 12.1. Необходимость синхронизации конкуренции Общий подход к обеспечению взаимно исключающего доступа к совместно используе- мому ресурсу заключается в том, что этот ресурс рассматривается как сущность, которая одновременно может принадлежать только одной задаче. Чтобы получить право владения совместно используемым ресурсом, задача должна запросить его. Завершив свое выполне- ние, задача должна освободить ресурс, чтобы он стал доступен другим задачам. Три метода обеспечения взаимно исключающего доступа к совместно используемым ресурсам — это семафоры, обсуждаемые в разделе 12.3; мониторы, описанные в разде- ле 12.4; и передача сообщений, рассматриваемая в разделе 12.5. Механизмы синхронизации должны иметь возможность задерживать выполнение за- дач. Синхронизация устанавливает порядок выполнения задач, определяемый этими за- держками. Чтобы понять, что происходит с задачами во время их существования, рас- смотрим, как осуществляется управление выполнением задачи. Независимо от того, сколько процессоров имеет машина, всегда существует возможность того, что задач больше, чем процессоров. Программа, называемая планировщиком (scheduler), управ- ляет распределением процессоров между задачами. Если не происходит никаких преры- ваний и все задачи имеют одинаковый приоритет, то планировщик просто предоставляет каждой задаче некоторый отрезок времени, например, 0,1 с. Когда подходит очередь ка- кой-либо задачи, планировщик позволяет процессору выполнять ее в течение некоторого времени. Конечно, есть несколько осложняющих ситуацию факторов, например, синхро- низирующие задержки и ожидание операций ввода и вывода. Задачи могут находиться в нескольких описанных ниже состояниях. 1. Новое состояние. Задача находится в новом состоянии, когда она уже создана, но ее выполнение еще не началось. 2. Состояние пуска или готовности к пуску. Запускаемая задача готова к пуску, но в данный момент не выполняется. Она либо еще не получила процессорное время от 12.2. Введение в параллельность на уровне подпрограмм 509
планировщика, либо была ранее запущена, но затем заблокирована одним из спосо- бов, описанных ниже в пункте 4. Задачи, готовые к пуску, помещаются в очередь, которую часто называют очередью задач, готовых к пуску (task ready queue). 3. Текущее состояние. Текущая задача — это задача, выполняемая в данное время, т.е. процессор предоставлен в ее распоряжение, и в данный момент выполняется именно ее код. 4. Заблокированное состояние. Заблокированная задача — это задача, которая была ранее запущена, но ее выполнение было прервано вследствие одного или несколь- ких различных событий (в основном, из-за операции ввода или вывода). Посколь- ку операции ввода и вывода выполняются намного медленнее, чем сама програм- ма, задача, начинающая такие операции, блокируется, и ей запрещается использо- вать процессор до тех пор, пока операции ввода-вывода не будут завершены. В дополнение к этим видам блокировки некоторые языки поддерживают операции, позволяющие пользовательской программе самой определять, какую именно зада- чу заблокировать. 5. Пассивное состояние. Пассивная задача больше не является активной ни в каком смысле. Задача становится пассивной после своего завершения. В некоторых язы- ках задача может перейти в пассивное состояние в результате явного требования пользовательской программы, например, при вызове метода stop в языке Java. Важный вопрос при выполнении задачи заключается в следующем: какую именно за- дачу перевести из состояния готовности в текущее состояние в момент, когда задача, яв- ляющаяся текущей, заблокирована, или ее отрезок времени истек? Этот выбор можно сделать с помощью нескольких алгоритмов, некоторые их которых основаны на опреде- лении уровней приоритета. Алгоритм выбора реализуется планировщиком. С параллельным выполнением задач и использованием общих ресурсов связана кон- цепция живучести. В среде последовательных программ некоторая программа обладает свойством живучести (liveness), если ее выполнение всегда продолжается и завершается нормальным образом. В общем смысле живучесть означает следующее: если предполагает- ся, что некоторое событие (скажем, завершение выполнения программы) должно состоять- ся, то оно в конце концов обязательно состоится. Это означает также, что процесс выпол- нения задачи осуществляется непрерывно. В среде параллельного выполнения программ и использования общих ресурсов живучесть задачи может исчезнуть, т.е. выполнение про- граммы нельзя будет продолжить, и, таким образом, она никогда не завершится. Предположим, что задачи А и В нуждаются в совместно используемых ресурсах X и Y для завершения своей работы. Далее, предположим, что задача А получает право на об- ладание ресурсом X, а задача В получает право на обладание ресурсом Y. После некото- рого периода выполнения задача А нуждается в ресурсе Y для продолжения своего вы- полнения, поэтому она запрашивает ресурс Y, но вынуждена подождать, пока задача В не освободит его. Аналогично, задача В запрашивает ресурс X, но должна подождать, пока задача А не освободит его. Ни одна из задач не освобождает используемые ими ресурсы, и в результате обе они теряют свою живучесть, вследствие чего выполнение программы никогда не завершится нормально. Этот частный вид потери живучести называется вза- имной блокировкой (deadlock). Взаимная блокировки — это серьезная угроза надежно- сти программы, и, следовательно, вопрос предотвращения таких ситуаций требует серь- езного рассмотрения как при разработке языка, так и при написании программ. 510 Глава 12. Параллельность
Теперь мы готовы обсудить некоторые лингвистические механизмы управления па- раллельными модулями. 12.2.2. Разработка языков для поддержки параллельности Для поддержки параллельности разработано много языков, начиная с PL/1 в середине 1960-х годов и заканчивая современными языками Ada 95 и Java. 12.2.3. Вопросы разработки языков программирования Наиболее важные вопросы поддержки параллельности языками программирования — синхронизация конкуренции и синхронизация взаимодействия — уже были детально об- суждены. Кроме них, существуют вопросы, связанные с тем, как и когда начинается и заканчивается выполнение задач, а также как и когда они создаются. Ниже приведены основные вопросы разработки языков, поддерживающих парал- лельность. Как обеспечивается синхронизация взаимодействия? Как обеспечивается синхронизация конкуренции? Как и когда начинается и заканчивается выполнение задачи? Как именно создаются задачи: статически или динамически? Помимо указанных, есть несколько менее важных вопросов разработки языков. Са- мый значительный из них — как обеспечить планирование задач. Однако для простоты наше обсуждение параллельности преднамеренно ограничивается только вопросами, пе- речисленными выше в этом разделе. Следующий раздел посвящен трем альтернативным ответам на вопросы разработки средств обеспечения параллельности: семафорам, мониторам и передаче сообщений. 12.3. Семафоры Семафор — очень простой механизм, используемый для синхронизации задач. 12.3.1. Введение Семафоры в 1965 году изобрел Эдсгер Дийкстра (Edsger Dijkstra) для синхронизации конкуренции с помощью взаимно исключающего доступа к совместно используемым структурам данных (Dijkstra, 1968а). Семафоры можно также использовать для синхро- низации взаимодействия. Семафор — это структура данных, состоящая из целого числа и очереди, хранящей дескрипторы задач. Дескриптор задачи — это структура данных, хранящая всю инфор- мацию о состоянии выполнения задачи. Для ограничения доступа к структуре данных семафор расставляет предохранители вокруг кода, имеющего доступ к этой структуре. Предохранитель можно использовать для того, чтобы открыть доступ к совместно ис- пользуемой структуре данных только одной задачи в каждый момент времени. Сема- фор — это реализация предохранителя. Неотъемлемой частью механизма предохранения является способ, гарантирующий успешное завершение выполнения заблокированного кода. Для этого запросы доступа, возникающие, когда доступ не может быть предостав- 12.3. Семафоры 511
лен, размещаются в очереди дескрипторов задач. Затем эти задачи получают доступ к данным и выполняют заблокированный код. По этой причине семафор должен иметь и счетчик, и очередь дескрипторов задач. С семафорами можно выполнять только две операции, первоначально названные Дийкстрой буквами Р и V, от двух голландских слов passeren (передать) и vrygeren (освободить) (Andrews and Schneider, 1983). Мы будем называть эти операции в ходе дальнейшего обсуждения wait и release. 12.3.2. Синхронизация взаимодействия В большей части этой главы мы рассматриваем пример совместно используемого бу- фера для иллюстрации различных подходов к синхронизации взаимодействия и синхро- низации конкуренции. Для синхронизации взаимодействия такой буфер должен иметь возможность определить и количество пустых позиций, и количество заполненных пози- ций в буфере. Это можно сделать с помощью счетчика, являющегося компонентом пе- ременной семафора. Одну переменную семафора, скажем emptyspots, можно исполь- зовать для хранения числа пустых ячеек в совместно используемом буфере, а другую, скажем fullspots, — для хранения количества заполненных ячеек в буфере. Очереди задач в этих двух семафорах хранят задачи, которые были заблокированы соответст- вующим семафором с помощью операции задержки. Наш буфер разработан как абстрактный тип данных, в котором все данные входят в буфер через подпрограмму DEPOSIT, а удаляются из него через подпрограмму FETCH. Поэтому подпрограмма DEPOSIT нуждается только в проверке семафора emptyspots, чтобы определить, есть ли в буфере пустые позиции. Если есть хотя бы одна, ее можно передвинуть вперед подпрограммой DEPOSIT, которая должна содержать операцию уменьшения значения счетчика переменной emptyspots. Если буфер полон, задача, вызвавшая подпрограмму DEPOSIT, должна подождать, пока в очереди переменной emptyspots не станет доступной какая-либо пустая ячейка. Когда выполнение подпро- граммы DEPOSIT завершается, она увеличивает значение счетчика семафора fullspots, чтобы отметить, что в буфере стало на одну заполненную ячейку больше. Подпрограмма FETCH действует прямо противоположным образом по отношению к подпрограмме DEPOSIT. Она проверяет семафор fullspots, чтобы обнаружить, содер- жит ли буфер хотя бы одну заполненную ячейку. При положительном ответе содержимое этой ячейки извлекается из нее, и счетчик семафора emptyspots увеличивается на 1. Ес- ли буфер пуст, то вызывающий процесс помещается в очередь семафора fullspots и ожидает, пока не появится заполненная ячейка. Когда подпрограмма FETCH заканчивает свою работу, она должна увеличить значение счетчика семафора emptyspots. Операции с семафорами часто не являются прямыми — они выполняются через под- программы ожидания и подпрограммы освобождения. Вследствие этого только что опи- санная операция DEPOSIT частично выполняется с помощью вызовов подпрограмм wait и release. Заметим, что эти подпрограммы должны иметь доступ к очереди за- дач, готовых к пуску. Для проверки счетчика заданной переменной семафора используется подпрограмма wait. Если это значение больше, чем 0, вызывающая задача может выполнять свою операцию. В этом случае значение счетчика переменной семафора уменьшается, для то- го чтобы отметить тот факт, что сущностей, количество которых он хранит, стало на од- ну меньше. Если значение счетчика равно 0, вызывающая задача должна быть помещена 512 Глава 12. Параллельность
в очередь ожидания, являющуюся компонентом данной переменной семафора, а процес- сор следует предоставить в распоряжение другой задачи, готовой к пуску. Для того чтобы открыть другой задаче доступ к сущностям, количество которых хра- нится в счетчике указанной переменной семафора, используется операция release. Ес- ли очередь данной переменной семафора пуста, т.е. нет ожидающих пуска задач, то под- программа release увеличивает значение своего счетчика (чтобы отметить тот факт, что количество управляемых сущностей, доступных в данный момент, увеличилось на единицу). Если есть одна или несколько задач, ожидающих пуска, то подпрограмма release перемещает одну из них из очереди семафора в очередь готовности. Ниже приводится точное описание подпрограмм wait и release. wait(aSemaphore) if счетчик переменной aSemaphore > 0 then уменьшить счетчик переменной aSemaphore else поместить вызывающую задачу в очередь переменной aSemaphore попытаться передать управление другой готовой к пуску задаче (Если очередь задач, готовых к пуску, пуста, возникнет взаимная блокировка) end release(aSemaphore) if очередь переменной aSemaphore пуста {нет ни одной ожидающей задачи} then увеличить счетчик переменной aSemaphore else поместить вызывающую задачу в очередь задач, готовых к пуску передать управление задаче из очереди переменной aSemaphore end Теперь мы можем привести пример программы, осуществляющей синхронизацию взаимодействия для совместного использования буфера. В данном случае совместно ис- пользуемый буфер хранит целые числа и имеет круговую логическую структуру. Он раз- работан для возможного использования несколькими задачами типа “производитель- потребитель”. Приведенный ниже код представляет собой определение задач типа “производитель- потребитель”. Для предотвращения потери значимости и переполнения буфера исполь- зуются два семафора, которые обеспечивают синхронизацию взаимодействия. Предпо- ложим, что буфер имеет размер BUFLEN, а подпрограммы, которые с ним действительно работают,— это описанные выше подпрограммы FETCH и DEPOSIT. Обращения к счетчику семафора обозначаются точкой. Например, если fullspots — это семафор, то к его счетчику можно обратиться с помощью выражения f ullspots. count. semaphore fullspots, emptyspots; fullspots.count := 0; emptyspots.count := BUFLEN; task producer; loop — вычислить значение VALUE — 12.3. Семафоры 513
wait(emptyspots); { ждать появления свободного места } DEPOSIT(VALUE); release(fullspots); { увеличить количество заполненных ячеек } end loop; end producer; task consumer; loop wait(fullspots); { убедиться, что буфер не пуст } FETCH(VALUE); release(emptyspots); { увеличить количество пустых ячеек } — получить значение VALUE -- end loop end consumer; Если буфер в данный момент пуст, семафор fullspots ставит задачу consumer в очередь ожидать, пока в буфере не появится заполненная ячейка. Если буфер в данный момент полон, семафор emptyspots ставит задачу producer в очередь ожидать, пока в буфере не появится свободное место. 12.3.3. Синхронизация конкуренции Описанный выше буфер не обеспечивает синхронизации конкуренции. Доступом к структуре можно управлять с помощью дополнительного семафора. Этот семафор не должен ничего подсчитывать. Он просто отмечает с помощью своего счетчика, исполь- зуется ли буфер в данный момент. Оператор wait открывает доступ к буферу, только если счетчик семафора равен 1, т.е. когда совместно используемый буфер в данный мо- мент не находится в распоряжении ни одной задачи. Если счетчик семафора равен 0, т.е. в данный момент буфер кем-то используется, задача помещается в очередь семафора. Заметим, что счетчик семафора должен быть инициализирован единицей. Очередь сема- фора всегда инициализируется пустой. Семафор, использующий бинарный счетчик, такой, например, какой мы используем для синхронизации конкуренции, называется бинарным семафором (binary semaphore). Приведенный ниже пример кода иллюстрирует применение семафора для обеспече- ния как синхронизации конкуренции, так и синхронизации взаимодействия при парал- лельном доступе к совместно используемому буферу. Семафор access используется для обеспечения взаимно исключающего доступа к буферу. Снова заметим, что могут существовать несколько производителей и потреби- телей. semaphore access, fullspots, emptyspots; access.count := 1; fullspots.count := 0; emptyspots.count := BUFLEN; task producer; loop — произвести значение VALUE — wait(emptyspots); { ожидать появления свободного места } 514 Глава 12. Параллельность
wait(access); { ожидать доступ } DEPOSIT(VALUE); release(access); { освободить доступ } release(fullspots); { увеличить количество заполненных мест } end loop; end producer; task consumer; loop wait(fullspots); { убедиться, что буфер не пуст } wait(access); { ожидать доступ } FETCH(VALUE); release(access); { освободить доступ } release(emptyspots); { увеличить количество пустых мест } -- получить значение VALUE -- end loop end consumer; Даже беглого взгляда на этот пример достаточно, чтобы убедиться в наличии некоторой проблемы. А именно, предположим, что, пока задача ожидает вызова wait (access) в за- даче consumer, другая задача считывает последнее значение из совместно используемого буфера. К счастью, этого случиться не может, поскольку вызов wait (access) резервирует значение в буфере для задачи, содержащей этот вызов, с помощью увеличения счетчика се- мафора fullspots. Есть один важный аспект, связанный с семафорами, который мы еще не обсуждали. Напомним предыдущее описание проблемы синхронизации конкуренции. Операции над совместно используемыми данными не должны прерываться. Если вторая операция мо- жет начинаться в то время, когда первая операция все еще выполняется, то совместно используемые данные могут быть повреждены. Семафоры сами по себе являются совме- стно используемыми объектами, так что операции над семафорами могут порождать ту же проблему. Следовательно, важно, чтобы операции над семафорами нельзя было пре- рывать. Многие компьютеры имеют команды (выполнение которых нельзя прерывать), разработанные специально для операций над семафорами. Если таких команд нет, то ис- пользование семафоров для синхронизации конкуренции становится серьезной пробле- мой, не имеющей простого решения. Язык PL/1 был первым языком программирования, допускающим параллельное вы- полнение задач. Он позволял программам пользователя выполнять любую подпрограмму параллельно с вызывающим ее модулем. Механизм синхронизации этих параллельных вычислений был, однако, совершенно неадекватным. Он состоял только из бинарных се- мафоров, называемых событиями (events), и был способен определить момент, когда за- дача закончила свое выполнение. Язык ALGOL 68, допускавший параллельность на уровне составных операторов, со- держал тип данных для семафоров под названием sema. 12.3.4. Оценка Применение семафоров для синхронизации взаимодействия создает небезопасную среду программирования. Не существует способа статической проверки правильности их применения, зависящего от семантики программы, в которой они появляются. Так, если 12.3. Семафоры 515
в примере с буфером пропустить оператор wait (emptyspots)в задаче producer, это может привести к переполнению буфера. Если в этом примере пропустить оператор wait (fullspots) в задаче consumer, это может привести к потере значимости. Если в обеих задачах пропустить операторы release, это может привести к взаимной блоки- ровке. Все это — примеры отказов механизма синхронизации взаимодействия. Проблемы надежности, связанные с использованием семафоров при синхронизации взаимодействия, возникают также при их использовании для синхронизации конкуренции. Если пропустить оператор wait (access)в какой-либо из задач, это может привести к несанкционированному доступу к буферу. Пропуск оператора release (access) в ка- кой-либо из задач приводит к взаимной блокировке. Все это — примеры отказов механизма синхронизации конкуренции. Заметив опасность использования семафоров, Пер Бринч Хансен (Per Brinch Hansen) написал: “Семафор — это элегантный инструмент синхрониза- ции для идеального программиста, никогда не допускающего ошибок” (Brinch Hansen, 1973). К сожалению, такие программисты — большая редкость. 12.4. Мониторы Один из способов решения задач, связанных с использованием семафоров в парал- лельной среде, — инкапсулировать совместно используемые структуры данных вместе с операциями и скрыть их представление, т.е. сделать совместно используемые структуры данных абстрактными типами данных. Это решение может обеспечить синхронизацию конкуренции без применения семафоров путем перекладывания ответственности за син- хронизацию на систему поддержки выполнения программ. 12.4.1. Введение Когда были сформулированы понятия абстракции данных, люди, вовлеченные в их разработку, попытались применить те же концепции к совместно используемым данным в параллельных средах программирования, для того чтобы создать мониторы. Следуя идеям Пера Бринча Хансена (Brinch Hansen, 1977, page xvi), Эдсгер Дийкстра предложил в 1971 году собрать все операции синхронизации над совместно используемыми данны- ми в отдельный программный модуль. Бринч Хансен формализовал это понятие приме- нительно к операционным системам (Brinch Hansen, 1973). В следующем году Хоар (Ноаге) назвал эти структуры мониторами (Ноаге, 1974). Первым языком программирования, содержащим мониторы, был Concurrent Pascal (Brinch Hansen, 1975). Языки Modula (Wirth, 1977), CSP/k (Holt et aL, 1978) и Mesa (Mitchell et al., 1979) также поддерживают мониторы. Дальнейшее обсуждение монито- ров основано на их реализации в языке Concurrent Pascal. Язык Concurrent Pascal— это язык Pascal, разработанный Виртом и дополненный тремя важными видами конструкций: классами из языка SIMULA 67, процессами (так в языке Concurrent Pascal называются задачи) и мониторами. Здесь мы обсудим свойства, связанные с поддержкой параллельного программирования: процессы и мониторы. Процесс в языке Concurrent Pascal имеет синтаксическую форму, похожую на проце- дуру. но его семантика совершенно иная. Все процессы являются типами, поэтому они определяются операторами type следующего вида: type имя_процесса = process (формальные параметры) — локальные объявления -- 516 Глава 12. Параллельность
тело процесса end Поскольку процессы являются типами, их определения — это просто шаблоны фак- тических процессов. Поскольку для создания процессов используются объявления пере- менных, процессы можно создавать как статически, так и динамически. Объявление пе- ременной в качестве процесса создает код процесса, но не делает ничего, кроме этого. Чтобы разместить локальные данные процесса и начать его выполнение, следует исполь- зовать оператор init с фактическими'параметрами, как показано ниже: init имя_переменной_процесса (фактические параметры) После выполнения оператора init процесс остается в состоянии текущей задачи на протяжении всей программы, если он не будет заблокирован. Общий вид мониторов в языке Concurrent Pascal таков: type имя_монитора = monitor (формальные параметры) — объявления совместно используемых переменных — — определения локальных процедур — — код инициализации — end Экспортированные процедуры монитора синтаксически отличаются от локальных процедур только тем, что они содержат зарезервированное слово entry в своих опера- торах procedure. Оператор init с фактическими параметрами используется для создания экземпляров мониторов. Это вызывает размещение переменных процесса в динамической памяти и выполнение кода инициализации. Существование переменных монитора, за исключени- ем переменных в процедуре монитора, начинается с выполнения оператора init и за- канчивается одновременно с завершением программы. Их область видимости ограничи- вается самим монитором. Экспортированные процедуры монитора можно вызвать либо процессами, либо процедурами в других мониторах. 12.4.2. Синхронизация взаимодействия Одно из наиболее важных свойств монитора заключается в том, что совместно ис- пользуемые данные размещаются именно в мониторе, а ни в одном модуле-клиенте. Та- ким образом, программист не синхронизирует взаимно исключающий доступ к совмест- но используемым данным с помощью семафоров или каких-либо иных механизмов. По- скольку все методы доступа содержатся в мониторе, для того, чтобы гарантировать синхронизированный доступ, можно выполнить реализацию монитора, просто поставив условие, что в один момент времени разрешается только один доступ. Вызовы процедур монитора неявно устанавливаются в очередь, если монитор занят в момент его вызова. 12.4.3. Синхронизация конкуронции Несмотря на то что взаимно исключающий доступ к совместно используемым данным является внутренним свойством монитора, взаимодействие процессов остается задачей программиста. В частности, программист должен гарантировать, что в совместно исполь- зуемом буфере не произойдет переполнения или потери значимости. Для этой цели язык Concurrent Pascal имеет специальный тип данных queue и две операции с ним: delay и 12.4. Мониторы 517
continue. Тип queue имеет вид семафора, и две указанные операции связаны с опера- циями семафора send и release. Переменная типа queue хранит процессы, которые ожидают своей очереди на доступ к совместно используемой структуре данных. Операция delay получает переменную типа queue как параметр. Ее действия сво- дятся к помещению вызывающего ее процесса в указанную очередь и отмене его прав на исключительный доступ к структурам данных монитора. Таким образом, процесс, вы- полняющий операцию delay, приостанавливается. В этом случае монитор доступен другим процессам. Вследствие этого операция delay отличается от операции семафора wait, поскольку операция delay всегда блокирует вызывающую ее задачу. Операция continue также получает параметр типа queue. Она разъединяет вызы- вающий ее процесс и монитор, освобождая, таким образом, монитор и предоставляя его в распоряжение других процессов; затем операция continue проверяет указанную очередь. Если эта очередь содержит некоторый процесс, этот процесс удаляется из нее и его выпол- нение, приостановленное операцией delay, запускается вновь. Операция continue от- личается от операции семафора release, поскольку операция release выполняет неко- торые действия, в то время как операция continue ничего не делает, если очередь пуста. На рис. 12.2. показана программа, содержащая четыре процесса и монитор, обеспе- чивающий синхронизированный доступ параллельно выполняемых процессов к совме- стно используемому буферу. Программа Рис. 12.2. Программа, применяющая .монитор для управления доступом к со- вместно используемому буферу С помощью типа данных queue, операций delay и continue можно создать мо- нитор, который управляет совместно используемым буфером, обеспечивая таким обра- зом как синхронизацию конкуренции, так и синхронизацию взаимодействия. В следую- щем примере совместно используемый буфер реализован как список с круговой логиче- ской структурой, состоящий из 100 целых чисел. 518 Глава 12. Параллельность
type databuf = monitor const bufsize = 100; var buf : array [1..bufsize] of integer; next in, next_out : 1..bufsize; filled : 0..bufsize; sender_q, receiver_q : queue; procedure entry deposit(item : integer); begin if filled = bufsize then delay (sender__q) ; buf[next_in] := item; next_in := (next_in mod bufsize) + In- filled := filled + 1; continue(receiver_q) end; procedure entry fetch(var item : integer); begin if filled = 0 then delay(receiver_q); item := buf[next_out]; next_out := (next_out mod bufsize) + In- filled := filled -In- continue (sender_q) end; begin filled := 0; next_in := 1; next_out := 1; end; Экземпляр типа databuf является абстракцией частного вида буфера, хранящего целые числа. Кроме того, буфер типа databuf координирует операции добавления и удаления его значений с помощью параллельных процессов. Целостность буфера гарай- тируется механизмами, использованными при его построении. Он защищен от перепол- нения и потери значимости, и параллельные процессы, использующие его, не могут раз- рушительным образом воздействовать друг на друга. Ниже приводится пример объявления процессов, которые могут использовать мони- тор databuf. type producer = process(buffer : databuf); var newvalue : integer; begin cycle — произвести значение newvalue — buffer.deposit(newvalue); 12.4. Мониторы 519
end end; type consumer = process(buffer databuf); var stored_value : integer; begin cycle buffer.fetch(stored value); — получить значение storedvalue — end end; Объявления type монитора databuf и два процесса producer и consumer мож- но поместить в разделе объявлений программы, в которой они должны быть использова- ны, как показано ниже: -- объявления type — var new_producer : producer; new consumer : consumer; new_buffer : databuf; begin init new_buffer, new_producer(new_buffer), new_consumer(new_buffer); end; Это может показаться несколько странным читателю, не знакомому с параллельными программами, поскольку не совсем ясно, как начинается или заканчивается программа. Наш пример программы начинается с выполнения оператора init, когда создается бу- фер и два процесса, и в это же время начинается выполнение процессов. Заметим, что циклы cycle . . . end и в процессе producer, и в процессе consumer являются бесконечными. Они не заканчиваются никогда. 12.4.4. Оценка Мониторы являются более удачным способом синхронизации конкуренции, чем се- мафоры, в основном из-за проблем, связанных с семафорами, как показано в разде- ле 12.3. Однако использование переменных типа queue для синхронизации взаимодей- ствия с помощью операций delay и continue порождает те же проблемы, что и сема- форы, применяемые для этой цели в других языках. 12.5. Передача сообщений Монитор— это надежный и безопасный метод обеспечения синхронизации конку- ренции при доступе к совместно используемым данным в параллельных модулях, ис- пользующих одну и ту же память. Рассмотрим задачу синхронизации работы модулей в распределенной системе, в которой каждый процессор имеет свою собственную память вместо общей совместно используемой памяти. Очевидно, монитор — не очень естест- венная конструкция в этой ситуации. Однако синхронизации в распределенной системе можно совершенно естественно достичь с помощью передачи сообщений. 520 Глава 12. Параллельность
12.5.1. Введение Первые попытки разработать языки программирования, обеспечивающие возмож- ность передачи сообщений между параллельными задачами, были предприняты Бринчем Хансеном (1978) и Хоаром (1978). Первые разработчики передачи сообщений изобрели также способ решения проблемы, возникающей, когда задачи, поддерживающие связь с данной задачей, посылают к ней несколько одновременных запросов. Было решено при- менить недетерминированный подход для того, чтобы выбор запроса, который должен стать первым, был справедливым. Эту справедливость можно определить по-разному, но, в общем, она означает, что всем запросам предоставляются равные шансы при под- держании связи с данной задачей. Недетерминированные конструкции для управления на уровне операторов, названные защищенными командами, были созданы Дийкстрой (1975). (Защищенные команды обсуждались в главе 7.) Защищенные команды являются основой конструкции, разработанной для управления передачей сообщения. 12.5.2. Концепция синхронной передачи сообщений Передача сообщений может быть либо синхронной, либо асинхронной. Асинхронная передача сообщений в языке Ada 95 описана в разделе 12.6. Здесь мы рассматриваем синхронную передачу сообщений. Основная идея синхронной передачи сообщений со- стоит в том, что задача часто находится в состоянии занятости и поэтому не должна пре- рываться другими модулями. Предположим, что задачи А и В выполняются одновремен- но, и задача А желает передать сообщение задаче В. Очевидно, если задача В занята, не- желательно позволять другой задаче прерывать ее. Это может прервать текущую обработку данных, выполняемую задачей В. Кроме того, сообщения часто вызывают вы- полнение связанной с ними обработки данных в получателе, что может оказаться бес- смысленным. если другая обработка данных осталась незавершенной. Альтернатива за- ключается в том, чтобы обеспечить лингвистический механизм, позволяющий задаче указывать другим задачам, когда именно она готова получать сообщения. Это похоже на ситуацию, когда руководитель инструктирует своего секретаря ни с кем его не соеди- нять, пока не закончится важное совещание. Позднее руководитель говорит секретарю, чтобы он соединил его с одним из звонивших во время совещания. Задачу можно разработать так, что она приостановит свое выполнение в некоторой точке либо потому, что она свободна, либо потому, что ей нужна информация от другого модуля, чтобы продолжить свое выполнение. Это похоже на человека, ожидающего важного звонка. Иногда остается только сидеть и ждать. В этой ситуации, если задача А хочет передать сооб- щение задаче В, а задача В готова к получению сообщения, то сообщение можно передавать. Эта реальная передача сообщения называется рандеву (rendezvouz). Заметим, что рандеву может состояться, только если и отправитель, и получатель желают, чтобы оно произошло. Информацию, содержащуюся в сообщении, можно передавать в обоих направлениях. И синхронизацию взаимодействия, и синхронизацию конкуренции задач удобно осу- ществлять с помощью модели передачи сообщений, как описано в дальнейших подраз- делах. 12.5. Передача сообщений 521
12.5.3. Модель передачи сообщения в языке Ada 95 Разработка задач в языке Ada частично основывается на работе Бринча Хансена и Хоара. которая в основу разработки кладет передачу сообщений, а для выбора среди конкурирующих задач используется недетерминированный подход. Полная модель задач в языке Ada сложна, и дальнейшее ее обсуждение должно быть ограничено. Здесь мы сосредоточимся на механизме синхронной передачи сообщений в языке Ada. В языке Ada задачи могут быть более активными, чем мониторы. Мониторы — это пассивные сущности, обеспечивающие средства управления совместно используемыми данными, хранящимися в них. Однако они предоставляют свои услуги только тогда, ко- гда в них возникает необходимость. Задачи, применяемые в языке Ada для управления совместно используемыми данными, можно рассматривать как менеджеры, которые мо- гут храниться вместе с управляемыми ими ресурсами. Они имеют несколько механизмов (как детерминированных, так и недетерминированных), что позволяет им осуществлять выбор среди конкурентных запросов на доступ к этим ресурсам. Задачи в языке Ada по форме похожи на пакеты. Они состоят из двух частей (спецификации и тела), имеющих одинаковые имена. Интерфейсом задачи являются ее входные точки, или места, в которых она может получать сообщения от других задач. Естественно, эти точки перечисляются в спецификации задачи. Поскольку рандеву мо- жет задействовать механизм обмена информацией, сообщения могут иметь параметры; следовательно, входные точки задачи также должны допускать наличие параметров, описанных в спецификации. Внешне спецификация задачи очень похожа на специфика- цию пакета для абстрактного типа данных. В качестве примера спецификации задачи в языке Ada рассмотрим следующую спе- цификацию, содержащую одну входную точку ENTRY_1 с одним входным параметром: task TASK_EXAMPLE is entry ENTRY_1(ITEM : in INTEGER); end TASK—EXAMPLE; Тело задачи должно содержать некую синтаксическую форму, соответствующую оператору entry в спецификации задачи. В языке Ada они уточняются операторами accept, начинающимися зарезервированным словом accept. Оператор (clause) accept состоит из ряда операторов, начинающегося с зарезервированного слова accept и заканчивающегося зарезервированным словом end. Операторы accept сами по себе относительно просты, однако другие конструкции, в которые они встраиваются, могут усложнить их семантику. Простой оператор accept имеет вид: accept имЯ—точки—входа (формальные параметры) do end ИМЯ—точки—входа; Имя точки входа в операторе accept соответствует имени, указанному в операторе entry из соответствующей спецификации задачи. Возможные параметры представляют собой средство для обмена данными между вызывающей и вызываемой задачей. Опера- торы. расположенные между ключевыми словами do и end, определяют операции, ко- торые должны выполняться во время рандеву. Совокупность этих операторов называется телом оператора accept. Во время реального рандеву выполнение задачи, пославшей сообщение, приостанавливается. 522 Глава 12. Параллельность
Задачи в языке Ada поддерживают связь друг с другом с помощью механизма ранде- ву. Всякий раз, когда входная точка задачи, или оператор accept, получает сообщение, которое она не готова принять по какой-либо причине, выполнение задачи, отправившей это сообщение, должно быть приостановлено, пока входная точка в задаче, получающей сообщение, не будет готова к его получению. Конечно, входная точка должна также помнить, какая именно задача послала ей сообщение, которое не было принято. Для этой цели каждый оператор entry в задаче имеет связанную с ним очередь. В очереди хра- нится список других задач, пытавшихся установить связь с входной точкой задачи. Ниже приводится скелетное тело задачи, спецификация которой была описана выше: task body TASK_EXAMPLE is begin loop accept ENTRY_!(ITEM : in INTEGER) do end ENTRY-!; end loop; end TASK—EXAMPLE; Оператор accept этого тела задачи является реализацией оператора entry, названного ENTRY—1 в спецификации задачи. Если выполнение задачи TASK_EXAMPLE начинается и достигает входной точки ENTRY_1 оператора accept прежде, чем какая-либо задача передаст сообщение в эту точку, то выполнение задачи TASK_EXAMPLE приостанавли- вается. Если какая-либо задача передает сообщение в точку ENTRY_1 в то время, когда выполнение задачи TASK_EXAMPLE приостановлено в операторе accept, происходит рандеву и выполняется тело оператора accept. Затем, вследствие наличия цикла задача переходит к выполнению оператора accept. Если задач, пославших сообщение в точку ENTRY_1, больше нет, то выполнение приостанавливается снова в ожидании следующе- го сообщения. В этом простом примере рандеву может произойти двумя основными способами. Во- первых, задача-получатель TASK—EXAMPLE может ожидать, пока другая задача передаст сообщение на вход ENTRY_1. После того как сообщение будет передано, произойдет рандеву. Именно эта ситуация была описана выше. Во-вторых, задача-получатель может оказаться занятой либо рандеву, либо другой обработкой данных, не связанной с ранде- ву, в то время, когда другая задача попытается передать сообщение в ту же самую вход- ную точку. В этом случае выполнение задачи-отправителя приостанавливается, пока за- дача-получатель не освободится для получения сообщения во время рандеву. Если не- сколько сообщений приходят в то время, когда задача-получатель занята, то задачи- отправители этих сообщений помещаются в очередь для ожидания рандеву. Два только что описанных рандеву иллюстрируются временными диаграммами на рис. 12.3. Задачи могут не иметь входных точек. Такие задачи называются акторами (actor tasks), поскольку они не ожидают рандеву, для того чтобы выполнить полезную работу. Акторы могут иметь рандеву с другими задачами, передавая им сообщения. В отличие от акторов, некоторые задачи могут иметь входные точки, но не иметь кода вовсе, либо иметь небольшое количество команд, помимо операторов, связанных с принятием сооб- щений. Таким образом, эти задачи могут лишь реагировать на сообщения, поступающие от других задач. Такие задачи называются серверами (server tasks). 12.5. Передача сообщений 523
Ожидать выполнения операторе accept TASK-EXAMPL E SENDER Ожидать выполнения операторе accept +•-------------------- Посылает сообщим—>. ’____________________! Продолжить '------------' Рандеву Время а) задача TASK-EXAMPLE ожидает сообщения от задачи TASK-EXAMPLE Занято Accept Ожидать выполнения оператора accept Посылает сообщение и приостанавливает свое выполнение SENDER Продолжить ’ выполнение" Рандеву Время б) задача SENDER ожидает освобождения задачи TASK-EXAMPLE Рис. 12.3. Два способа рандеву с задачей TASK_EXAMPLE Задача в языке Ada, передающая сообщения другой задаче, должна знать имя точки входа в эту задачу. Однако обратное неверно: входная точка задачи не обязана знать имя задачи, от которой она будет принимать сообщения. Эта асимметрия контрастирует с языком, извест- ным под названием CSP (Communicating Sequential Processes) (Hoar, 1978). В языке CSP, так- же использующем модель параллельности, основанную на передаче сообщений, задачи при- нимают сообщения только от явно именованных задач. Недостаток этой модели заключается в невозможности создания библиотеки задач для общего использования. Обычный графический метод описания рандеву, в котором задача А передает сооб- щение задаче В, показан на рис. 12.4. Задачи в языке Ada являются типами, и в этом качестве они могут быть либо безы- мянными, либо именованными. Задачу именованного типа в языке Ada можно создать динамически с помощью оператора new и ссылаться на нее с помощью указателя. В ка- честве примера рассмотрим следующий фрагмент программы. task type BUFFER is entry DEPOSIT(VALUE : in INTEGER); entry FETCH(VALUE : out INTEGER); end; type BUF_PTR is access BUFFER; 524 Глава 12. Параллельность
Задача a Рис. 12.4. Графическое представление рандеву, вызванного сообщением, переданным задачей А задаче В BUF : BUF_PTR; BUF := new BUFFER; Задачи объявляются в разделе объявлений пакета, подпрограммы или блока. Статиче- ски созданные задачи начинают свое выполнение одновременно с операторами кода, к которому присоединен этот раздел объявлений. Например, задача, объявленная в глав- ной программе, начинает свое выполнение одновременно с первым оператором в теле главной программы. Задачи, созданные оператором new, начинают свое выполнение не- медленно. Прекращение выполнения задачи, являющееся сложным вопросом, обсужда- ется далее в этом разделе. Задача может иметь несколько входов. Порядок, в котором соответствующие опера- торы accept появляются в задаче, определяется порядком, в котором могут принимать- ся сообщения. Если задача имеет несколько входных точек, и нужно, чтобы они могли получать сообщения в произвольном порядке, то в задаче используется оператор select для того, чтобы окружить им точки входа, как показано ниже: task body TASK_EXAMPLE is loop accept ENTRY_1(формальные параметры) do end ENTRY_1; or accept ENTRY—2(формальные параметры) do 12.5. Передача сообщений 525
end ENTRY_2; end select; end loop; end TASK—EXAMPLE; В этой задаче есть два оператора accept, каждый из которых имеет связанную с ним очередь. Действие оператора select при его выполнении заключается в проверке оче- редей, связанных с двумя операторами accept. Если одна из очередей пуста, а другая содержит хотя бы одно ожидающее сообщение, оператор accept, содержащий ожи- дающее сообщение, получает рандеву с задачей, пославшей первое полученное сообще- ние. Если оба оператора accept содержат пустые очереди, оператор select ожидает, пока не будет вызван один из входов. Если оба оператора accept содержат заполнен- ные очереди, один из операторов accept выбирается случайным образом для рандеву с одной из вызвавших задач. Цикл вынуждает оператор select выполняться повторно до бесконечности. Оператор end в операторе accept отмечает конец кода, определяющего или ссы- лающегося на формальные параметры оператора accept. Код, если он есть, между опе- ратором accept и следующим оператором or (или оператором end select в послед- нем операторе accept) называется расширенным оператором accept. Расширенный оператор accept выполняется только после выполнения связанного с ним (непосредственно предшествующего) оператора accept. Это выполнение расширенно- го оператора accept не является частью рандеву и может происходить параллельно с вызовом задачи. Выполнение отправителя сообщения приостанавливается на время ран- деву, но оно возобновляется (путем возврата в очередь готовых к пуску задач) по дости- жении конца оператора accept. Если оператор accept не имеет формальных парамет- ров, наличия операторов do-end не требуется, и оператор accept может состоять це- ликом из расширенного оператора accept. Такой оператор accept используется только для синхронизации. 12.5.4. Синхронизация взаимодействия Каждый оператор accept может иметь присоединенный к нему предохранитель в виде оператора when, задерживающего рандеву. Например, when not FULL(BUFFER) => accept DEPOSIT(NEW-VALUE) do Оператор accept вместе с оператором when может быть либо открытым, либо закры- тым. Если булевское выражение в операторе when истинно в данный момент времени, то оператор accept называется открытым; если булевское выражение ложно, то опе- ратор accept называется закрытым. Оператор accept, не имеющий предохранителя, всегда является открытым. Открытый оператор accept доступен для рандеву; закрытый оператор accept не может участвовать в рандеву. Предположим, что в операторе select находятся несколько защищенных операто- ров accept. Такой оператор select обычно помещается в бесконечный цикл. Цикл вынуждает повторяющееся выполнение оператора select, при каждом повторении ко- торого проверяется условие из оператора when. При каждом повторении цикла создает- 526 Глава 12. Параллельность
ся список открытых операторов accept. Если только один открытый оператор имеет заполненную очередь, то из нее извлекается сообщение и происходит рандеву. Если за- полненные очереди есть в нескольких открытых операторах accept, одна из этих оче- редей определяется случайным образом, из нее извлекается сообщение и происходит рандеву. Если очереди всех открытых операторов пусты, задача ожидает поступления сообщения в один из тех операторов accept, во время которого происходит рандеву. Если выполняется оператор select и все операторы accept закрыты, возникает ис- ключительная ситуация, или ошибка. Этого можно избежать, либо гарантируя, что усло- вие из оператора when всегда истинно, либо добавляя оператор else в оператор select. Оператор else может содержать любую последовательность операторов, за исключением оператора accept. Оператор select может содержать особый оператор terminate, который выбира- ется только тогда, когда данный оператор открыт, а любой другой оператор accept за- крыт. При выборе оператора select задача уже закончила свою работу, но еще не за- вершилась. Завершение задачи обсуждается ниже в этом разделе. 12.5.5. Синхронизация конкуренции Двойства, описанные выше, обеспечивают синхронизацию взаимодействия и связь между задачами. Далее мы обсуждаем, каким образом можно обеспечить взаимно ис- ключающий доступ к совместно используемым структурам данных. Если доступ к структуре данных должен управляться задачей, то взаимно исключаю- щий доступ можно обеспечить, объявив эту структуру данных внутри задачи. Семантика выполнения задачи обычно гарантирует взаимно исключающий доступ к структуре, по- скольку в каждый момент времени в задаче может быть активным только один оператор accept. Единственное исключение из этого правила допускается, когда задачи вложены в процедуры или другие задачи. Например, если задача, определяющая совместно ис- пользуемую структуру данных, имеет вложенную задачу, то эта вложенная задача может также иметь доступ к данной структуре, что может разрушить целостность данных. Сле- довательно, задачи, предназначенные для управления доступом к совместно используе- мой структуре данных, не должны определять другие задачи. Ниже приводится пример задачи на языке Ada для обеспечения синхронизированного доступа к буферу. Это очень похоже по эффекту на наш пример монитора. task BUF_TASK is entry DEPOSIT(ITEM : in INTEGER); entry FETCH(ITEM : out INTEGER); end BUF_TASK; task body BUF_TASK is BUFSIZE : constant INTEGER : = 100; BUF : array (1..BUFSIZE) of INTEGER; FILLED : INTEGER range 0..BUFSIZE := 0; NEXT_IN, NEXT_OUT : INTEGER range 1..BUFSIZE := 1; begin loop select when FILLED < BUFSIZE => 12.5. Передача сообщений 527
accept DEPOSIT(ITEM : in INTEGER) do BUF (NEXT_IN) :-= ITEM; end DEPOSIT; NEXT-IN :« (NEXT_IN nod BUFSIZE) + IN- FILLED FILLED +1; or when FILLED > 0 => accept FETCH(ITEM : out INTEGER) do ITEM := BUF(NEXT_OUT); end FETCH; NEXT-OUT (NEXT-OUT mod BUFSIZE) + IN- FILLED := FILLED - 1; end select; end loop; end BUF—TASK; В этом примере оба оператора accept являются расширенными. Они позволяют вы- полнять задачу BUF_TASK параллельно с вызывающими ее задачами. Задачи производителя и потребителя, которые могут использовать задачу BUF_TASK, имеют следующий вид: task PRODUCER; task CONSUMER; task body PRODUCER is NEW—VALUE : INTEGER; begin loop — произвести значение переменной NEW—VALUE — BUF—TASK.DEPOSIT(NEW_VALUE); end loop; end PRODUCER; task body CONSUMER is STORED—VALUE : INTEGER; begin loop BUF—TASK.FETCH(STORED—VALUE); — использовать значение переменной STORED—VALUE — end loop; end CONSUMER; 12.5.6 . Завершение задачи Рассмотрим понятие “завершение задачи”. Выполнение задачи закончено, если управление достигло конца его тела кода. Это должно когда-то произойти, поскольку при этом возникает исключительная ситуация, для которой не предусмотрен обработчик. (Обработка исключительных ситуаций в языке Ada описка в главе 13.) Если задача не создала никаких других задач, называемых подчиненными, то она завершается, когда ее выполнение заканчивается. Задача, создавшая подчиненные задачи, завершается, когда закончено выполнение ее кода, и все подчиненные ей задачи завершены. Задача может закончить свое выполнение во время ожидания в открытом операторе terminate. 528 Глава 12. Параллельность
В этом случае задача завершается, только когда ее хозяин (блок, подпрограмма или за- дача, создавшая ее) и все задачи, зависящие от хозяина, либо завершены, либо пребыва- ют в состоянии ожидания в открытом операторе terminate. В этом случае все задачи завершаются одновременно. Блок или подпрограмма не завершаются, пока все их под- чиненные задачи не завершатся. 12.5.7 . Приоритеты Как именованным, так и безымянным типам могут быть присвоены приоритеты. Это осуществляется с помощью указания компилятору. Например: pragma priority(выражение); Значение выражения определяет относительный приоритет задачи или определения типа задачи, в котором она появляется. Возможный диапазон значений приоритета зависит от конкретной реализации. Наивысший возможный приоритет может быть задан с помо- щью приписывания слова last к типу priority, который определяется во встроенном пакете System. Например, приведенный ниже код определяет наивысший приоритет в любой реализации pragma priority(System.priority'last); Приоритеты задач в языке Ada применяются только к задачам, находящимся в состоя- нии готовности. Они используются для определения порядка, в котором планировщик вы- бирает задачи для дальнейшего перевода их в текущее состояние. Если есть три задачи, ожидающие своей очереди в некотором операторе accept, и они имеют разные приорите- ты, эти приоритеты не влияют на то, какая именно задача первой получит рандеву. 12.5.8 . Бинарные семафоры Если доступ к структурам данных должен быть управляемым, и эта структура данных не инкапсулирована в некоторую задачу, то для обеспечения взаимно исключающего доступа следует использовать другие средства. Например, можно создать задачу бинар- ного семафора для использования вместе с задачей, ссылающейся на данную структуру данных. Такую задачу бинарного семафора можно было бы определить следующим об- разом: task BINARY_SEMAPHORE is entry WAIT; entry RELEASE; end BINARY—SEMAPHORE; task body BINARY-SEMAPHORE is begin loop accept WAIT; accept RELEASE; end loop; end BINARY—SEMAPHORE; Цель этой задачи — гарантировать, что операции WAIT и RELEASE выполняются по- очередно. 12.5. Передача сообщений 529
Задача BINARY_SEMAPHORE иллюстрирует упрощения, возможные, если сообщения в языке Ada используются только для синхронизации, не передавая при этом никаких данных. Особенно следует отметить простую форму операторов accept, не нуждаю- щихся в наличии тела. Задачу BINARY_SEMAPHORE для взаимно исключающего доступа к совместно ис- пользуемой структуре данных можно использовать точно так же, как и семафоры в при- мере из раздела 12.3. Конечно, это применение семафоров страдает от все тех же потен- циальных проблем, обсуждаемых здесь. Подобно семафорам мониторы можно моделировать с помощью возможностей задач в языке Ada. Задачи обеспечивают неявный взаимно исключающий доступ точно так же, как и мониторы. Таким образом, модель задач в языке Ada поддерживает как семафоры, так и мониторы. 12.5.9 . Оценка В отсутствие распределенных процессоров с независимыми запоминающими устройст- вами выбор между мониторами и передачей сообщений в качестве средства синхронизации конкуренции в некотором смысле является делом вкуса. Синхронизация взаимодействия при передаче сообщений меньше зависит от правильного использования, чем семафоры (которые требуются вместе с мониторами). Таким образом, передача сообщений немного лучше, чем остальные средства, даже в среде с совместно используемой памятью. Однако для распределенных систем передача сообщений является лучшей моделью обеспечения параллельности, поскольку она естественным образом поддерживает кон- цепцию отдельных процессов, выполняемых параллельно на отдельных процессорах. 12.6. Параллельность в языке Ada 95 Одной из целей при разработке языка Ada было улучшить возможности языка Ada 83 для поддержки параллельности. Использование исключительно модели передачи сооб- щений в языке Ada 83 для управления доступом к совместно используемым данным при- водит к медленному выполнению вследствие сложности механизма рандеву. Чтобы раз- решить эту ситуацию, язык Ada 95 использует защищенные объекты (protected objects), обеспечивающие более удобное и эффективное управление доступом к совместно ис- пользуемым данным. Язык Ada 95 также содержит метод обеспечения асинхронной свя- зи между задачами. Сначала обсудим защищенные объекты. 12.6.1. Защищенные объекты В языке Ada 83 доступ к совместно используемым данным управляется путем вложе- ния данных в задачу и разрешения доступа только через входы задачи, что неявно обес- печивает синхронизацию конкуренции. Одна из проблем, связанных с этим способом, состоит в том, что трудно эффективно реализовать механизм рандеву. Защищенные объ- екты в языке Ada 95 обеспечивают альтернативный способ поддержки синхронизации конкуренции, который не нуждается в применении рандеву. Защищенный объект не является задачей; он больше похож на монитор. Доступ к за- щищенным объектам можно получить либо через защищенные подпрограммы, либо че- рез входы, похожие на входы задач. Защищенные подпрограммы могут быть либо за- 530 Глава 12. Параллельность
шишенными процедурами, обеспечивающими взаимно исключающий доступ для чте- ния-записи данных защищенного объекта, либо защищенными функциями, обеспечи- вающими параллельный доступ для чтения-записи этих данных. Внутри тела защищен- ной процедуры текущий экземпляр вложенного защищенного модуля определяется в ка- честве переменной; внутри тела защищенной функции текущий экземпляр вложенного защищенного модуля определяется в качестве константы, позволяющей параллельный доступ только для чтения. Входные вызовы защищенного объекта обеспечивают синхронную связь с одной или несколькими задачами, использующими один и тот же защищенный объект. Эти входные вызовы обеспечивают доступ, похожий на доступ к данным, включенным в задачу. Проблему буфера, решенную с помощью задачи в языке Ada в предыдущем подраз- деле, можно проще решить с помощью защищенного объекта. protected BUFFER is entry DEPOSIT(ITEM : in INTEGER); entry FETCH(ITEM : out INTEGER); private BUFSIZE : constant INTEGER := 100; BUF : array (..BUFSIZE) of INTEGER; FILLED : INTEGER range 0..BUFSIZE := 0; NEXT_IN, NEXT_OUT : INTEGER range 1..BUFSIZE := 1; end BUFFER; protected body BUFFER is entry DEPOSIT(ITEM : in INTEGER) when FILLED < BUFSIZE is begin BUF(NEXT_IN) := ITEM; NEXT-IN : (NEXT_IN mod BUFSIZE) + IN- FILLED := FILLED + 1; end DEPOSIT; entry FETCH(ITEM : out INTEGER) when FILLED > 0 is begin ITEM : BUF(NEXT_OUT); NEXT-OUT := (NEXT-OUT mod BUFSIZE) + 1; end FETCH; end BUFFER; 12.6.2. Асинхронные сообщения Другое значительное усовершенствование языка Ada 95 по сравнению со средствами языка Ada 83 для поддержки параллельности состоит в способности задач передавать со- общения другим задачам. Рандеву в языке Ada 83 являются строго синхронными — и отправитель, и получатель должны быть готовы к связи перед тем, как они действитель- но свяжутся через рандеву. Задача в языке Ada 95 может содержать специальный оператор select, называемый асинхронным оператором select, который немедленно реагирует на сообщения, по- ступающие от других задач. Такой оператор может иметь одну из двух альтернатив за- 12.6. Параллельность в языке Ada 95 531
пуска: вызов входа или оператор delay. Кроме запускаемой части, асинхронный опера- тор select имеет прекращаемую часть, которая может содержать любую последова- тельность операторов языка Ada. Семантика асинхронного оператора select состоит в том, что он выполняет только одну из двух своих частей. Если происходит событие за- пуска (либо получение вызова entry, либо прекращение работы таймера delay), он выполняет запускаемую часть. В противном случае он выполняет прекращаемую часть. Приведенные ниже два примера асинхронных операторов select взяты из руководства пользователя языка Ada 95 (AARM, 1995). В первом фрагменте кода прекращаемый опе- ратор выполняется повторно (вследствие наличия цикла), пока не будет получен вызов Terminal.Wait_For_Interrupt. Во втором фрагменте кода функция, вызываемая в прекращаемом операторе, выполняется как минимум пять секунд. Если за это время код не закончится, происходит выход из оператора select. — Основной цикл команд для интерпретатора команд — loop select Terminal.Wait_For_Interrupt; PUT_Line("Выполнение прервано"); then abort — Выполнение этой части будет отложено до терминального -- прерывания Put_Line("-> "); Get_Line(Command, Last); Process_Command(Command (1..Last)); end select; end loop; — Вычисления, ограниченные во времени select delay 5.0; Put_Line("Вычисления не сходятся"); then abort - - Эти вычисления должны закончиться за 5 секунд; — если нет — предполагается, что они расходятся. Horrible_Complicated—Recursive_Function(X, Y); end select; 12.7. Потоки языка Java Параллельные модули в языке Java— это объекты, содержащие метод run, код ко- торого выполняется параллельно с другими методами и методом main. Есть два способа определить класс, объекты которого могут иметь параллельные методы. Один из них — определить подкласс встроенного класса Thread, обеспечивающего поддержку метода run. Часто, но не всегда, этот способ является вполне приемлемым. Напомним (см. гла- ву 11), что язык Java не поддерживает множественное наследование. Однако класс мо- жет быть производным от другого класса и реализовывать интерфейс, являющийся раз- новидностью абстрактного класса. Следовательно, класс может быть производным от своего естественного предка и реализовывать интерфейс Runnable, обеспечивающий частичную поддержку параллельности. 532 Глава 12. Параллельность
12.7.1 • Класс Thread Главными сущностями класса Thread являются два метода— run и start. Метод run всегда замещается в подклассах класса Thread. Это именно то место, в котором помещается код, определяющий действия потока. Метод start класса Thread запуска- ет его объекты как параллельные модули с помощью вызова своего метода run. Вызов метода start необычен тем, что управление возвращается немедленно в вызывающий модуль, который затем продолжает свое выполнение параллельно со вновь запущенным методом run. Когда прикладная программа на языке Java (в противоположность аплету) начинает свое выполнение, создается новый поток (в котором будет выполняться метод main) и вызывается метод main. Следовательно, все программы на языке Java выполняются с помощью потоков. Класс Thread необычен тем, что он не является естественным предком ни для одно- го другого класса. Он обеспечивает некоторые служебные функции для своих подклас- сов, но никак не связан с вычислениями, для которых эти подклассы предназначены. Не- смотря на это, класс Thread — единственный класс, доступный программисту для соз- дания параллельных программ на языке Java. Трудно точно описать, как именно работает планировщик языка Java, поскольку раз- личные реализации (Solaris, Windows 95 и др.) в настоящее время по-разному планируют потоки. Обычно, однако, планировщик предоставляет одинаковые отрезки времени каж- дому запускаемому потоку по круговой системе, пока эти потоки имеют одинаковый приоритет. Класс Thread имеет несколько методов для управления объектами класса Thread. Метод yield, не имеющий параметров, предназначен для принудительного освобожде- ния оставшейся части отрезка времени, выделенного выполняемому потоку. Поток не- медленно помешается в очередь задач, готовых к выполнению, становясь запускаемым. Затем планировщик выбирает из очереди задач, готовых к выполнению, поток, имеющий наивысший приоритет. Если нет других готовых к выполнению потоков, имеющих при- оритет выше, чем у потока, только что освободившего процессор, этот поток может стать следующим в очереди на получение интервала времени. Метод yield является статическим, так что его следует вызывать, указывая в качестве префикса имя класса, а не объекта. Метод sleep имеет один параметр— целое число миллисекунд, на которое должен быть заблокирован указанный поток. После того как истечет указанное количество мил- лисекунд, поток будет помещен в очередь задач, готовых к выполнению. Поскольку не- возможно узнать, как долго поток будет пребывать в очереди задач, готовых к выполне- нию, прежде чем начнет свое выполнение, то параметр метода sleep — это минималь- ное количество времени, в течение которого поток не будет выполняться. Метод sleep возбуждает исключительную ситуацию InterruptedException, которая должна об- рабатываться методом, вызвавшим метод sleep. Исключительные ситуации подробно описаны в главе 13. Метод suspend используется, когда выполнение потока должно быть временно при- остановлено, а затем через некоторое время он может быть запущен вновь. Варианты этой разновидности операторов в настоящее время являются общепринятыми в широко распространенных системах. Например, когда пользователь операционной системы 12.7. Потоки языка Java 533
Windows 95 указывает, что файл должен быть сохранен, а в каталоге уже есть файл с та- ким именем, операционная система просит пользователя подтвердить, что файл должен быть записан заново, перед тем как эта операция будет действительно выполнена. В кон- тексте потоков рассмотрим, каким образом пользователь просит систему остановить вы- полнение некоторого потока. Метод, выполняемый по требованию пользователя (например, щелчок на кнопке в окне), может сначала приостановить поток, выполнение которого пользователь хочет прервать, а затем либо остановить его, либо возобновить. Эти операции иллюстрируются следующим фрагментом программы: Thread bailgame; public void stopButton() { bailgame.suspend(); if (askUser( "Вы действительно хотите прекратить игру? (у/п))” bailgame.stop(); else bailgame.resume(); } Метод askUser отображает на экране сообщение, являющееся его параметром, а за- тем считывает с клавиатуры ответ пользователя. Если ответом является нажатие клави- ши у или Y, метод возвращает значение true; в противном случае он возвращает значе- ние false. Метод stop прекращает выполнение потока, переводя его в состояние за- вершения. Метод resume переводит поток из заблокированного состояния в очередь задач, готовых к выполнению. 12.7.2. Приоритеты Приоритеты потоков не обязаны быть одинаковыми. По умолчанию поток имеет тот же приоритет, что и создавший его поток. Если поток создан методом main, его приоритет по умолчанию равен константе NORM_PRIORITY, которая по умолчанию равна 5. Поток опре- деляет две другие константы, задающие приоритеты, MAX PRIORITY и MIN PRIORITY, значения которых равны 10 и 1, соответственно. Приоритет потока можно изменить с помо- щью метода setPriority. Новый приоритет может быть равен любой из заранее опреде- ленных констант или любому целому числу между значениями MIN_PRIORITY и MAX_PRIORITY. Метод get Priority возвращает текущий приоритет потока. Если существуют потоки с различными приоритетами, работа планировщика управ- ляется этими приоритетами. Когда выполнение текущего потока заблокировано или пре- кращено, либо отрезок времени, предоставленный в его распоряжение, истек, планиров- щик выбирает в очереди задач, готовых к выполнению, поток, имеющий наивысший приоритет. Поток с наименьшим приоритетом будет запущен, только если при этом в очереди задач, готовых к выполнению, нет потока с более высоким приоритетом. 12.7.3. Синхронизация взаимодействия В языке Java синхронизация взаимодействия достигается тем, что методы, имеющие доступ к совместно используемым данным, полностью выполняются до того, как другой метод начнет работать с тем же самым объектом. Иными словами, мы можем указать, 534 Глава 12. Параллельность
что если некоторый метод начал свое выполнение, то оно завершится до того, как любой другой метод начнет свою работу с тем же самым объектом. Такие методы блокируют этот объект, что предотвращает доступ к нему других синхронизированных методов. Та- кое указание осуществляется путем добавления модификатора synchronized к опре- делению метода, как показано в следующем скелетном определении класса: class ManageBuf { private int [100] buf; public synchronized void deposit(int item) { ... } public synchronized int fetch() { ... } } Два метода, определенных в методе ManageBuf. имеют модификатор synchronized, что предотвращает их взаимное влияние во время работы с одним и тем же объектом, даже если они вызываются разными потоками. В некоторых случаях количество операторов, манипулирующих совместно распреде- ленной структурой данных, значительно меньше, чем общее количество операторов ме- тода, в котором она расположена. В этих случаях лучше синхронизировать сегменты ко- да, имеющие доступ или изменяющие совместно используемую структуру данных, а не весь метод. Это можно сделать с помощью так называемого синхронизированного опе- ратора, имеющего следующий общий вид: synchronized (выражение) оператор Здесь выражение должно вычислить объект, а оператор может быть как простым, так и составным. На время выполнения этого оператора или операторов объект блокируется. Таким образом, оператор или операторы выполняются точно так же. как если бы они были телом синхронизированного метода. Объект, для которого определены синхронизированные методы, должен иметь свя- занную с ним очередь, хранящую синхронизированные методы, которые делали попытки выполнения в то время, как этот объект обрабатывался другим синхронизированным ме- тодом. Когда синхронизированный метод завершит свою работу с объектом, метод, на- ходящийся в очереди ожидания, если таковой существует, помещается в очередь задач, готовых к выполнению. 12.7.4. Синхронизация конкуренции Синхронизация взаимодействия в языке Java осуществляется с помощью методов wait и notify, определенных в классе Object, базовом классе всех классов в языке Java. Все классы, кроме класса Object, наследуют эти методы. Метод wait помещен в цикл, проверяющий условие законного доступа. Если это условие не выполняется, поток помещается в очередь ожидания. Метод notify предназначен для того, чтобы сооб- щить ожидающему потоку, что ожидаемое событие произошло. Методы wait и notify можно вызвать только внутри синхронизированного метода, поскольку они используют блокировку, наложенную на объект таким методом. Метод wait может возбудить исключительную ситуацию InterruptedException, являющуюся потомком исключительной ситуации Exception. Следовательно, любой код, 12.7. Потоки языка Java 535
вызывающий метод wait, должен также перехватывать исключительную ситуацию InterruptedException. Предположим, что условие, выполнения которого мы ожидаем, называется theCondition. Тогда метод wait удобно использовать следующим образом: try { while (!theCondition) wait (); — Выполнить все, что требуется, — когда условие theCondition станет истинным } catch (InterruptedException myProblem) { ... } Оператор try определяет область видимости обработки исключительной ситуации, а метод catch является обработчиком исключительной ситуации в операторе try. Следующая программа реализует круговую очередь для хранения значений типа int. Она иллюстрирует синхронизацию взаимодействия и синхронизацию конкуренции. // Очередь // Этот класс реализует круговую очередь для хранения целых // чисел. Он содержит конструктор для размещения и // инициализации очереди заданного размера. В нем описаны // синхронизированные методы для вставки чисел в очередь // и удаления их из нее class Qyeue { private int [] que; private int nextIn, nextOut, filled, queSize; public Queue(int size) { que = new int [size]; filled = 0; nextin = 1; nextOut = 1; queSize = size; } //** конец конструктора Queue public synchronized void deposit (int item) { try { while (filled == queSize) wait (); que[nextin] = item; nextin = (nextin % queSize) + 1; filled++; notify(); } //** конец оператора try catch(InterruptedException e) {} } //** конец метода deposit 536 Глава 12. Параллельность
public synchronized int fetch() { int item = 0; try { while (filled == C) wait (); item = que[nextOut]; filled—; notify(); } //★★ конец оператора try catch(InterruptedException e) {} return intern; } //** конец метода fetch } //♦* конец класса Queue Заметим, что обработчик исключительной ситуации (catch) здесь ничего не делает. Классы для определения объектов-производителей и объектов-потребителей, которые могли бы использовать класс Queue, можно определить следующим образом: class Producer extends Thread { private Queue buffer; public Producer(Queue que) { buffer = que; } public void run() { int new__item; while (true) { // — Произвести переменную new_item buffer.deposit(new_item); } } } class Consumer extends Thread { private Queue buffer; public Concumer(Queue que) { buffer = que; } public void run() { int stored—item; while (true) { // — Использовать переменную stored_item buffer, fetch (stored__item) ; } } } Следующий код создает объект класса Queue, а также объекты классов Producer и Consumer, приписанные к объекту класса Queue, и начинает их выполнение: Queue buffi = new Queue(100); Producer producer1 = new Producer(buffl); Concumer concumerl = new Concumer(buffl); 12.7. Потоки языка Java 537
producerl.start (); consumerl.start (); При необходимости мы могли бы определить оба класса Producer и Consumer как реализации интерфейса Runnable, а не как подклассы класса Thread. Единственная разница заключается в первой строке, которую теперь можно записать так: class Producer implements Runnable { Чтобы создать и запустить объект такого класса, необходимо создать объект класса Thread, связанный с этим объектом. Это иллюстрируется следующим кодом: producer producerl = new Producer(buff1); Thread producerThread = new Thread(producer1); producerThread.start (); 12.7.5. Оценка Поддержка параллельности в языке Java относительно проста, но эффективна. В язы- ке Java можно легко создать и мониторы, и семафоры. 12.8. Параллельность на уровне операторов В этом разделе мы кратко рассмотрим вопросы разработки языка для поддержки па- раллельности на уровне операторов. Целью разработки языка является создание меха- низма, который программист мог бы использовать для того, чтобы информировать ком- пилятор о возможных способах отображения программы на многопроцессорную архи- тектуру. Хотя язык ALGOL 68 содержал семафоры, которые предназначались для обеспечения параллельности на уровне операторов, здесь подобное их использование не обсуждается. В этом разделе рассматривается только один набор лингвистических конструкций для поддержки параллельности на уровне операторов одного языка. Далее описываются кон- струкции и их предназначение в терминах машины с SIMD-архитектурой, хотя они раз- работаны так, что могут быть полезными в различных архитектурных конфигурациях. Мы обсудим также вопрос, как с помощью языковых конструкций минимизировать об- мен данными между процессорами и запоминающими устройствами других процессоров. Предполагается, что доступ процессора к данным, находящимся в его собственной памяти, осуществляется быстрее, чем доступ к данным в памяти другого процессора. Хорошо раз- работанные компиляторы отлично справляются с этим процессом, но можно достичь на- много большего, если программист получит возможность предоставлять компилятору ин- формацию о возможной параллельности, которую можно было бы использовать. 12.8.1. Язык High-Performance FORTRAN Язык High-Performance FORTRAN (HPF) (ACM, 1993b)— это набор расширений языка FORTRAN 90, предназначенных для того, чтобы предоставлять компилятору ин- формацию, способствующую оптимизации выполнения программ на многопроцессор- ных компьютерах. Язык HPF содержит как новые операторы спецификации, так и внут- ренние, или встроенные, подпрограммы. В этом разделе обсуждаются только некоторые из новых операторов. 538 Глава 12. Параллельность
Основные операторы спецификации в языке HPF предназначены для указания коли- чества процессоров, распределения данных между запоминающими устройствами этих процессоров, а также выравнивания данных в памяти. Операторы спецификации в языке HPF появляются в программе в виде специальных комментариев. Каждый из них начи- нается префиксом ! HPFS, где знак ! — это символ, начинающий строку комментария в языке FORTRAN 90. Этот префикс делает их невидимыми для компиляторов языка FORTRAN 90, но легко распознается компиляторами языка HPF. Спецификация PROCESSORS имеет следующий вид: !HPF$ PROCESSORS procs (n) Этот оператор указывает компилятору количество процессоров, которые могут исполь- зоваться кодом, сгенерированным для этой программы. На основе этой информации со- вместно с другими спецификациями компилятор определяет, как именно распределены данные между запоминающими устройствами, связанными с этими процессорами. Оператор DISTRIBUTE указывает, что данные являются распределенными, а также отмечает вид используемого распределения. Его форма такова: •HPF$ DISTRIBUTE (вид) ONTO procs :: список_идентификаторов В этом операторе вид распределения данных может задаваться ключевым словом BLOCK или CYCLIC. Список идентификаторов — это имена массивов, подлежащих распределе- нию. Переменные, которые подлежат распределению вида BLOCK, разделяются на п одинаковых групп, каждая из которых состоит из смежных наборов элементов массивов, равномерно распределенных между запоминающими устройствами всех процессоров. Например, если массив из 500 элементов под названием LIST распределен как BLOCK между пятью процессорами, то первые 100 элементов массива LIST будут размещены в памяти первого процессора и т.д. Распределение CYCLIC указывает на то, что отдельные элементы массива циклически размещаются в запоминающих устройствах процессоров. Например, если массив LIST распределен как CYCLIC между пятью процессорами, то первый элемент массива LIST будет размещен в памяти первого процессора, второй элемент — в памяти второго процессора и т.д. Форма оператора ALIGN такова: ALIGN массив1_элементов WITH массив2_элементов Оператор ALIGN используется для связи между распределениями двух массивов. На- пример, оператор ALIGN listl(index) WITH list2(index+1) указывает, что элемент массива listl с номером index должен быть размещен в па- мяти того же процессора, что и элемент массива list2 с номером index+1 для всех значений переменной index. Ссылки на два массива в операторе ALIGN появляются вместе в некотором операторе программы. Размещение их в одной и той же памяти (т.е. в одном и том же процессоре) гарантирует, что ссылки на них будут максимально близ- кими друг к другу. Рассмотрим следующий фрагмент кода: REAL listJL (1000), list_2 (1000) INTEGER list-3 (500), list-4 (501) !HPF$ PROCESSORS proc (10) 12.8. Параллельность на уровне операторов 539
!HPF$ DISTRIBUTE (BLOCK) ONTO procs :: list_l, list_2 !HPF$ ALIGN list_3 (index) WITH list_4 (index+1) list_l (index) = list__2 (index) list_3 (index) = list_4 (index+1) При каждом выполнении приведенных выше операторов присваивания два указанных элемента массива будут помещены в память одного и того же процессора. Операторы спецификации в языке HPF в действительности только предоставляют компилятору информацию, которую он может использовать, а может и не использовать, для оптимизации генерируемого им кода. Что именно сделает компилятор, зависит от уровня его сложности и конкретной архитектуры используемой машины. Оператор FORALL указывает набор операторов, которые могут выполняться парал- лельно. Например, FORALL (index = 1:1000) list_l (index) = list__2 (index) означает присваивание элементов массива list_2 соответствующим элементам масси- ва list_l. В принципе, это означает, что правые части 1000 операторов присваивания могут быть вычислены до того, как будет выполнено хотя бы одно присваивание. Это позволяет параллельно выполнять все операторы присваивания. В этой главе описана только часть возможностей языка HPF. Однако этого должно быть достаточно для того, чтобы дать читателю общее представление о том, какого рода расширения языка полезны для программирования на компьютерах, возможно, имеющих большое количество процессоров. Резюме Параллельное выполнение может осуществляться на уровне подпрограмм, или моду- лей, либо на уровне операторов. Мы используем понятие физической параллельности, когда для выполнения параллельных модулей действительно используются несколько процессоров. Если параллельные модули выполняются на одном процессоре, мы приме- няем термин “логическая параллельность”. Логическую параллельность можно назвать базовой концептуальной моделью для любого вида параллельности. Параллельные языки должны обеспечивать две основные возможности: взаимно ис- ключающий доступ к совместно используемым структурам данных (синхронизация кон- куренции) и взаимодействие задач. Для синхронизации конкуренции и синхронизации взаимодействия между параллель- ными задачами можно использовать семафоры. Однако при этом легко совершить ошиб- ки, которые невозможно обнаружить с помощью компиляторов, редактора связей или системы поддержки выполнения программ. Мониторы — это абстракции данных, обеспечивающие естественный способ взаимно исключающего доступа к данным, совместно используемым несколькими задачами. Мо- ниторы есть в нескольких языках программирования. Синхронизация взаимодействия в языках с мониторами обеспечивается некоторыми видами семафоров. Язык Ada предусматривает для обеспечения параллельности сложные, но эффективные конструкции, основанные на модели передачи сообщений. Здесь основными параллельны- ми модулями являются задачи, взаимодействующие между собой с помощью механизма 540 Глава 12. Параллельность
рандеву, представляющего собой синхронную передачу сообщений. Рандеву — это дейст- вие, выполняемое задачей, получающей сообщение, посланное другой задачей. Язык Ada содержит как простые, так и сложные методы управления рандеву между задачами. Язык Ada 95 содержит дополнительные возможности для поддержки параллельности, в первую очередь, защищенные объекты и асинхронную передачу сообщений. Язык Java поддерживает параллельное выполнение модулей довольно простым, но эффективным способом. Любой класс, который либо является производным от класса Thread, либо реализует интерфейс Runnable, может замещать наследуемый метод run, что приводит к параллельному выполнению кода этого метода с другими такими же методами и методом main. Синхронизация взаимодействия указывается с помощью оп- ределения методов с синхронизированным доступом к совместно используемым данным. Небольшие разделы кода также могут быть синхронизированы. Синхронизация взаимо- действия осуществляется с помощью методов wait и notify. Язык High-Performance FORTRAN содержит операторы для определения того, каким именно образом следует распределить данные между модулями памяти, связанными со многими процессорами. В него также входят операторы для указания наборов операто- ров, которые должны выполняться параллельно. Д оно л к м г е я ь н с? с и < г Общие вопросы параллельности подробно обсуждены в работах Andrews and Schneider (1983), Holt et al. (1978) и Ben-Ari (1982). Концепция монитора и ее реализация в языке Concurrent Pascal разработаны и описаны в работе Brinch Hansen (1977). Впервые разработка модели передачи сообщений для управления параллельными моду- лями обсуждалась в работах Ноаге (1978) и Brinch Hansen (1977). Подробное обсуж- дение разработки модели задач в языке Ada можно найти в работе Ichbiah et al. (1979). Язык Ada 95 детально описан в руководстве пользователя AARM (1995). Язык High-Performance FORTRAN описан в спецификации ACM (1993b). В о п р or 1л 1. Назовите три уровня параллельности в программах. 2. Какой уровень программной параллельности лучше всего поддерживается SIMD- компьютерами? 3. Какой уровень программной параллельности лучше всего поддерживается MIMD- компьютерами? 4. Что представляет собой поток управления в программе? 5. Дайте определение следующих понятий: задача, отдельная задача, синхрониза- ция, синхронизация конкуренции и синхронизация взаимодействия, живучесть и взаимная блокировка. 6. Какие задачи не требуют никакой синхронизации? Вопросы 541
7. Назовите вопросы разработки поддержки параллельности в языке. 8. Опишите, как действуют операции wait и release над семафорами. 9. Что такое бинарный семафор? Что такое семафор-счетчик? 10. Назовите основные проблемы использования семафоров для синхронизации? 11. Какие преимущества имеют мониторы по сравнению с семафорами? 12. Дайте определение понятий: оператор accept, оператор entry, актор, сер- вер, расширенный оператор accept, открытый оператор accept, закры- тый оператор accept и завершенная задача. 13. Какая параллельность является более общей: параллельность, обеспечиваемая мо- ниторами, или параллельность, обеспечиваемая передачей сообщений? 14. Как создаются задачи в языке Ada: статически или динамически? 15. Для каких целей предназначен расширенный оператор accept? 16. Каким образом для задач в языке Ada обеспечивается синхронизация взаимодействия? 17. В чем заключается преимущество защищенных объектов в языке Ada 95 над зада- чами при обеспечении доступа к совместно используемым данным? 18. Опишите асинхронный оператор select в языке Ada 95. 19. Что именно программный модуль на языке Java может запускать параллельно с методом main в прикладной программе? 20. Что делает метод sleep языка Java? 21. Что делает метод yield языка Java? 22. Какие две конструкции языка Java могут быть синхронизированы? 23. Какие методы языка Java используются для поддержки синхронизации взаимодей- ствия? 24. Объясните, зачем язык Java содержит интерфейс Runnable. 25. Для чего предназначены операторы спецификации в языке High-Performance FORTRAN? 26. Для чего служит оператор FORALL в языке High-Performance FORTRAN? Упражнения 1. Объясните четко и ясно, почему синхронизация взаимодействия не является про- блемой в среде программирования, имеющей симметричный модуль управления, но не поддерживающей параллельность. 2. Что лучше предпринять системе при обнаружении взаимной блокировки? 3. Напишите задачу на языке Ada, реализующую универсальные семафоры. 4. Напишите задачу на языке Ada для управления совместно используемым буфером, таким же, как и в описанном в главе примере, но с применением задачи, реали- зующей семафоры из упражнения 3. 542 Глава 12. Параллельность
5. Ожидание занятости — это метод, при котором задача ожидает определенного со- бытия, непрерывно проверяя, не произошло ли оно. Какая главная проблема связа- на с этим подходом? 6. Предположим, что в примере “производитель-потребитель” из раздела 12.3 мы не- правильно заменили выражение release (access) в процессе-производителе на выражение wait (access). Что может произойти при выполнении системы в ре- зультате этой ошибки? 7. По любой книге, посвященной программированию на языке ассемблера машины VAX, определите, какие машинные команды поддерживают создание семафоров. 8. По любой книге, посвященной программированию на языке ассемблера компьюте- ров, использующих процессор Intel Pentium, определите, какие машинные команды поддерживают создание семафоров. 9. Предположим, что две задачи А и В должны совместно использовать переменную BUF_SIZE. Задача А добавляет 2 к переменной BUF_SIZE, а задача В вычитает из нее 1. Предположим также, что эти арифметические операции выполняются в виде трехэтапного процесса: извлечение текущего значения, выполнение арифметиче- ской операции и возвращение нового значения назад. В отсутствие синхронизации конкуренции какова возможная последовательность событий и какие значения яв- ляются результатами этих операций? Предполагается, что начальное значение пе- ременной BUF_SIZE равно 6. 10. Сравните механизмы синхронизации конкуренции в языках Java и Ada. 11. Сравните механизмы синхронизации взаимодействия в языках Java и Ada. 12. Что произойдет, если процедура монитора вызовет другую процедуру в том же са- мом мониторе? Упражнения 543
13.2. Обработка исключительных ситуаций в языке PL/1 13.3. Обработка исключительных ситуаций в языке Ada 13.4. Обработка исключительных ситуаций в языке C++ 13.5. Обработка исключительных ситуаций в языке Java wm*** (cusyur UlJKSira; Эдсгер Дийкстра, лауреат пре- мии Тьюринга (АСМ, 1972), в настоящее время работает в университете г.Остин (шт. Техас) (University of Texas-Austin) в должности почетного профес- сора компьютерных наук (Schlumberger Chair in Computer Science). Он был членом коман- ды, начавшей разработку систе- мы под названием “The Multiprogramming System", став- шей первой в мире операцион- ной системой с параллельными процессами. Эта структура по- зволяла доказывать отсутствие опасности взаимной блокировки и другие факты, свидетельст- вующие о правильной работе системы. В 1976 году Дейкстра написал книгу “Наука програм- мирования" (“A Discipline of Programming"). Обработка исключительных ситуаций 545
В этой главе сначала описываются основные понятия, связанные с обработкой ис- ключительных ситуаций, в частности, исключительные ситуации, обнаруживае- мые аппаратным и программным обеспечением; обработчики исключительных ситуаций и возбуждение исключительных ситуаций. Затем формулируются и обсуждаются вопро- сы обработки исключительных ситуаций, в частности, связывание исключительных си- туаций с их обработчиками, продолжение выполнения, встроенные обработчики исклю- чительных ситуаций и блокирование исключительных ситуаций. В оставшейся части главы описываются и анализируются средства обработки исключительных ситуаций в четырех языках программирования: PL/1, Ada, C++ и Java. 13.1. Введение в обработку исключительных ситуаций Большинство компьютерных систем способны обнаружить некоторые ошибки вы- полнения программ, например, переполнение памяти при вычислениях с плавающей точкой. Многие языки программирования разрабатываются и реализуются таким обра- зом, чтобы программа пользователя либо обнаруживала, либо пыталась обработать такие ошибки. В этих языках возникновение подобных ошибок просто приводит к прекраще- нию выполнения программы и передаче управления операционной системе. Обычной реакцией операционной системы на ошибку выполнения программы является вывод ди- агностического сообщения, которое может быть либо очень содержательным, либо очень загадочным, после чего следует прекращение выполнения программы. При операциях ввода-вывода, однако, ситуация иногда отличается от описанной вы- ше. Например, оператор READ в языке FORTRAN может перехватывать ошибки ввода и условия достижения конца файла, которые обнаруживаются устройством ввода. В обоих случаях оператор READ может указать метку некоторого оператора в программе пользо- вателя, который обрабатывает эти ошибки. В случае достижения конца файла совершен- но ясно, что данное условие не всегда рассматривается как ошибка. Во многих случаях это не более, чем признак того, что один вид обработки данных завершен и следует на- чать другой. Несмотря на очевидную разницу между достижением конца файла и собы- тиями, которые всегда являются ошибками, например, неудавшийся ввод данных, в язы- ке FORTRAN такие ситуации обрабатываются одним и тем же механизмом. Рассмотрим следующий оператор READ языка FORTRAN: READ(UNIT=5, FMT=1000, ERR=100, END=999) WEIGHT Раздел ERR указывает, что управление должно быть передано оператору с меткой 100, если в операторе чтения возникнет ошибка. Раздел END указывает, что управление должно быть передано оператору с меткой 999, если при выполнении операции чтения будет достигнут конец файла. Таким образом, язык FORTRAN использует простые ветви и для ошибки ввода, и при достижении конца файла. Есть категория серьезных ошибок, которые не могут быть найдены аппаратным обес- печением, но могут обнаруживаться кодом, сгенерированным компилятором. Например, ошибки, заключающиеся в выходе индекса массива за пределы допустимого диапазона, почти никогда не диагностируются аппаратным обеспечением (было лишь несколько компьютеров, действительно обнаруживавших ошибки, связанные с выходом индекса за пределы допустимого диапазона, с помощью аппаратного обеспечения), однако они 546 Глава 13. Обработка исключительных ситуаций
приводят к фатальным ошибкам, которые часто выявляются только в процессе выполне- ния программы. Обнаружение ошибок, связанных с выходом индекса массива за пределы допустимого диапазона, иногда необходимо при разработке языка. Например, компиляторы языков Pascal и Java должны генерировать код для проверки правильности каждого выражения, содержащего индексы. В языке С проверка выхода индекса массива за пределы допустимо- го диапазона не производится, поскольку стоимость такой проверки не компенсируется вы- годами от обнаружения подобных ошибок. В некоторых компиляторах некоторых языков проверка выхода индекса массива за пределы допустимого диапазона может быть выбрана при необходимости либо программой, либо в команде, выполняемой компилятором. Многие современные языки программирования обеспечивают механизмы, которые мо- гут действовать, лишь когда выполняются некоторые условия, обнаруживаемые аппарат- ным или программным обеспечением. Они также позволяют программисту самостоятельно определять другие необычные события и использовать те же механизмы для их обработки. Совокупность этих механизмов называется обработкой исключительных ситуаций. Возможно, основная причина, по которой некоторые языки программирования не со- держат обработки исключительных ситуаций, — сложность их включения в язык. 13.1.1. Основные понятия Мы будем называть и ошибки, обнаруживаемые аппаратным обеспечением, например ошибки чтения диска, и необычные ситуации, например достижение конца файла (которые также обнаруживаются аппаратным обеспечением), исключительными ситуа- циями. Далее мы расширим понятие исключительной ситуации, отнеся к ней также ошибки или необычные условия, обнаруживаемые программным обеспечением. Соот- ветственно, мы будем называть исключительной ситуацией (exception) любое необыч- ное событие, ошибочное или нет, обнаруживаемое либо аппаратным, либо программным обеспечением, которое может потребовать особой обработки. Особая проверка, которая может потребоваться при обнаружении исключительной ситуации, называется обработкой исключительной ситуации (exception handling). Эта обработка выполняется программным модулем, называемым обработчиком исключи- тельной ситуации (exception handler). Исключительная ситуация возбуждается (raised), когда происходит связанное с ней событие. Обработчики исключительных ситуаций обычно различаются по типам исключительных ситуаций. Достижение конца файла поч- ти всегда требует некоторого специального действия программы. Однако, очевидно, что это действие может не подходить для обработки исключительной ситуации, связанной с переполнением памяти при вычислениях с плавающей точкой. В других случаях единст- венным результатом может быть генерация сообщения об ошибке с дальнейшим пре- кращением выполнения программы. Иногда следует проигнорировать определенные исключительные ситуации на неко- торое время. Это можно сделать с помощью блокирования исключительной ситуации. Заблокированную исключительную ситуацию позднее можно разблокировать. Отсутствие отдельных или особенных средств для обработки исключительных ситуа- ций в языке не препятствует обработке определенных пользователем и обнаруживаемых программным обеспечением исключительных ситуаций. Такие исключительные ситуа- ции, обнаруживаемые в программном модуле, часто обрабатываются модулем, вызвав- шим его. Один из способов обработки исключительных ситуаций — пересылка вспомо- 13.1. Введение в обработку исключительных ситуаций 547
гательного параметра, который используется в качестве переменной состояния. Пере- менной состояния в вызываемом модуле присваивается некоторое значение, соответст- вующее правильному и/или нормальному его выполнению. Непосредственно после воз- врата из вызванного модуля вызывающий модуль проверяет переменную состояния. Ес- ли эта переменная указывает, что возникла исключительная ситуация, может активизироваться обработчик, находящийся в вызывающем модуле. Во многих библио- течных функциях языка С применяется именно этот подход, состоящий в том, что воз- вращаемое значение используется в качестве индикатора ошибки. Второй способ— передача в подпрограмму метки в качестве параметра. Конечно, это можно сделать только в языках, допускающих использование меток в качестве пара- метра. Передача метки позволяет вызываемому модулю передать управление в другую точку вызывающего модуля, если возникает исключительная ситуация. Как и в первом случае, обработчиком часто является сегмент кода вызывающего модуля. Такое исполь- зование меток является общепринятым в языке FORTRAN. Третий способ — создать обработчик в виде отдельной подпрограммы, передаваемой в качестве параметра в вызываемый модуль. В этом случае подпрограмма-обработчик предоставляется вызывающим модулем, а вызываемый модуль вызывает обработчик, ес- ли возбуждается исключительная ситуация. Одна из проблем, связанных с таким подхо- дом, заключается в том, что передачу подпрограммы-обработчика следует выполнять при каждом вызове, независимо от того, желательно это или нет. Более того, чтобы об- рабатывать несколько видов исключительных ситуаций, необходимо было бы передавать несколько разных модулей-обработчиков, усложняя код. Если нужно обрабатывать ситуацию в модуле, в котором она обнаружена, обработчи- ком является просто сегмент кода этого модуля. У встроенной в язык обработки исключительных ситуаций есть несколько очевидных преимуществ. Во-первых, без обработки исключительных ситуаций код, необходимый для обнаружения ошибок, может сильно запутать программу. Предположим, что подпро- грамма содержит выражения, состоящие из десяти операций деления, каждая из которых имеет знаменатель, равный нулю. Без встроенной обработки исключительных ситуаций, каждой из этих операций должен предшествовать условный оператор для обнаружения возможной ошибки деления на нуль. Наличие в языке встроенной обработки исключи- тельных ситуаций могло бы позволить компилятору вставлять такие проверки в код по требованию программы. Другое преимущество языковой поддержки обработки исключительных ситуаций — возможность передавать исключительные ситуации для обработки в другие модули. Это позволяет обрабатывать исключительную ситуацию, возбужденную в одном программ- ном модуле, в некотором другом модуле, являющемся его динамическим или статиче- ским предком. В таком случае можно использовать один обработчик исключительных ситуаций для большого количества программных модулей. Такое повторное использова- ние может привести к значительной экономии стоимости разработки и уменьшению раз- мера программы. Язык, поддерживающий обработку исключительных ситуаций, поощряет своих поль- зователей рассматривать все события, которые могут возникнуть во время выполнения программы, и предусматривать возможность их обработки. Это намного лучше, чем иг- норировать такие возможности и просто надеяться, что ничего плохого не случится. Описанное преимущество связано с необходимостью предусмотреть действия для всех 548 Глава 13. Обработка исключительных ситуаций
возможных значений условного выражения в многовариантной конструкции ветвления, как это требуется в языке Ada. В заключение отметим, что существуют программы, в которых обработку не оши- бочных, но необычных, ситуаций можно упростить с помощью обработки исключитель- ных ситуаций, а без этого структура программы станет слишком запутанной. 13.1.2. Вопросы разработки Теперь мы изучим некоторые из вопросов разработки системы обработки исключи- тельных ситуаций как неотъемлемой части языка программирования. Такая система должна допускать наличие как встроенных, так и определенных пользователем исключи- тельных ситуаций и их обработчиков. Рассмотрим следующую скелетную подпрограмму, содержащую обработку исключительной ситуации: void example() { average = sum / total; return; /* Обработчики исключительных ситуаций */ when zero_divide { average = 0; printf (’’Ошибка - знаменатель (total) равен нулю\п”); } //♦* пример функции Исключительная ситуация, состоящая в том. что знаменатель равен нулю, перехватыва- ется в функции, передающей контроль соответствующему обработчику, который затем выполняется. Первый вопрос, связанный с разработкой обработчиков исключительных ситуаций, определенных пользователем, — это их вид. По существу, вопрос заключается в выборе между созданием обработчиков в виде законченных программных модулей или в виде сегментов кода. В предыдущем примере они могут быть встроены в модуль, возбуж- дающий исключительную ситуацию, которую они должны обрабатывать, как это сделано в приведенном выше примере, или могут быть встроены в другой модуль, например, в модуль, вызывающий тот модуль, в котором возбуждается исключительная ситуация. Если обработчик является отдельным модулем и язык использует статическую об- ласть видимости, он может находиться в той же области видимости, что и код, в котором возникла исключительная ситуация. Это упрощает обмен данными между' этими двумя модулями. Если обработчик представляет собой отдельный модуль, находящийся вне области видимости модуля, который может возбудить соответствующую исключитель- ную ситуацию, то обмен данными производится с помощью параметров. Другой важный вопрос заключается в том, как связать возникновение исключитель- ной ситуации с ее обработчиком. Это вопрос возникает на двух разных уровнях. На уровне модулей вопрос состоит в том. как одну и ту же исключительную ситуацию, воз- никающую в разных точках модуля, можно связать с разными обработчиками внутри модуля. Например, в подпрограмме, приведенной выше, есть обработчик исключитель- ной ситуации, связанной с делением на нуль в определенном операторе (показан только один из них). Предположим, однако, что в функции есть несколько других выражений с операторами деления. Для этих операторов данный обработчик, возможно, не подойдет. 13.1. Введение в обработку исключительных ситуаций 549
Итак, следует предусмотреть связывание исключительной ситуации, которая может воз- буждаться в конкретном операторе, с конкретным обработчиком, даже если одна и та же исключительная ситуация может возбуждаться во многих операторах. На более высоком уровне вопрос связывания возникает, когда в модуле, в котором возникла исключительная ситуация, нет локального обработчика исключительных си- туаций. В этом случае разработчик должен решить, передавать ли исключительную си- туацию в некоторый другой модуль и, если да, то в какой. То, как именно происходит эта передача и насколько далеко, влияет на удобство написания обработчиков исключитель- ных ситуаций. Например, если обработчик должен быть локальным, то нужно написать много обработчиков, что усложняет как написание, так и чтение программы. С другой стороны, если исключительная ситуация передается куда-либо, то один обработчик мо- жет обработать одну и ту же исключительную ситуацию, возбуждающуюся в нескольких программных модулях. Для этого может потребоваться обработчик более общего вида, чем хотелось бы. Еще один важный фактор — как именно, статически или динамически, связываются исключительные ситуации с обработчиками, т.е. зависит связывание от синтаксической структуры программы или от последовательности ее выполнения. Как и в других языко- вых конструкциях, статическое связывание исключительных ситуаций легче понять и реализовать, чем динамическое связывание. После того как обработчик исключительных ситуаций будет выполнен, управление либо передается куда-нибудь в программу вне кода обработчика, либо выполнение про- граммы просто прекращается. Мы будем называть это вопросом о продолжении управ- ления после выполнения обработчика, или просто продолжением (continuation). Прекра- тить выполнение программы — очевидно, проще всего, и во многих исключительных ситуациях, связанных с ошибками, это является наилучшим решением. Однако в других ситуациях, в частности связанных с необычными, но не ошибочными событиями, следу- ет продолжить выполнение программы. В этих случаях нужно принять некоторые со- глашения, для того чтобы определить, с какой точки программы должно продолжаться ее выполнение. Это может быть оператор, возбудивший исключительную ситуацию, оператор, следующий за ним, или, возможно, некоторый другой модуль. Вернуться к оператору, возбудившему исключительную ситуацию, вероятно, и хорошее решение, но в случае исключительной ситуации, связанной с ошибкой, его надо принимать, только если обработчик каким-либо образом способен модифицировать значения или операции, приведшие к возбуждению исключительной ситуации. В противном случае исключи- тельная ситуация просто возникнет повторно. Необходимую модификацию при исклю- чительной ситуации, связанной с ошибкой, часто очень трудно выполнить. Даже если это возможно, ее нельзя назвать нормальной практикой. Это позволяет программе удалять симптомы проблемы без устранения ее причины. Два вопроса о связывании исключительных ситуаций с обработчиками и продолже- нии выполнения программы проиллюстрированы на рис. 13.1. Другой вопрос разработки: если пользователю позволено определять исключитель- ные ситуации, как их описать? Требуется, чтобы исключительные ситуации были объяв- лены в частях спецификации тех программных модулей, в которых они возбуждаются. Областью видимости объявленной исключительной ситуации обычно является область видимости программного модуля, содержащего ее объявление. 550 Глава 13. Обработка исключительных ситуаций
Выполнение кода Обработчики исключительных ситуаций Рис. 13.1. Поток управления при обработке исключительной ситуации Если язык предусматривает встроенные исключительные ситуации, возникает не- сколько других вопросов. Например, должна ли система поддержки выполнения про- грамм, написанных на этом языке, предусматривать по умолчанию обработчики встро- енных исключительных ситуаций, или пользователь должен сам написать обработчики для всех исключительных ситуаций? Кроме того, могут ли исключительные ситуации возбуждаться явно пользовательской программой: это удобно, если существуют ситуа- ции, распознаваемые программным обеспечением, в которых пользователь предпочел бы использовать встроенный обработчик. Следующий вопрос: должны ли ошибки, распознаваемые аппаратным обеспечением, рассматриваться как исключительные ситуации, которые могут быть обработаны поль- зовательской программой? Если нет. то все исключительные ситуации, очевидно, явля- ются ситуациями, распознаваемыми программным обеспечением. В связи с этим возни- кает вопрос: должна ли программа содержать какие-либо встроенные исключительные ситуации? В заключение отметим, что остается вопрос: можно ли временно или навсегда забло- кировать исключительные ситуации, встроенные или определенные пользователем? Этот вопрос в некоторой степени философский, особенно для встроенных исключительных ситуаций, связанных с ошибками. Предположим, что некий язык имеет встроенную ис- ключительную ситуацию, возбуждающуюся, когда индекс массива выходит за пределы допустимого диапазона. Многие думают, что эта ошибка должна обнаруживаться всегда, и, следовательно, программа не должна иметь возможности отключить выявление этих ошибок. Другие утверждают, что проверка выхода индекса массива за пределы допусти- мого диапазона слишком затратна для промышленного программного обеспечения, в ко- тором, предположительно, код в достаточной степени свободен от ошибок, так что ошибки выхода индекса массива за пределы допустимого диапазона не должны возникать. 13.1. Введение в обработку исключительных ситуаций 551
Вопросы, связанные с обработкой исключительных ситуаций, можно подытожить следующим образом. Как и где определяются обработчики исключительных ситуаций и какова их об- ласть видимости? Как возникновение исключительной ситуации связывается с ее обработчиком? С какой точки программы продолжается ее выполнение, если оно вообще про- должается, после завершения выполнения обработчика исключительной ситуа- ции? (Это — вопрос о продолжении.) Как задать исключительные ситуации, определяемые пользователем? Следует ли предусматривать по умолчанию обработчики исключительных ситуаций для программ, которые не предусматривают своих собственных обработчиков? Могут ли встроенные исключительные ситуации возбуждаться явно? Являются ли ошибки, распознаваемые аппаратным обеспечением, исключитель- ными ситуациями, которые могут быть обработаны? Существуют ли какие-либо встроенные исключительные ситуации? Следует ли предусматривать возможность блокирования исключительных ситуаций? 13.1.3. Исторический обзор Язык PL/1 (ANSI, 1976) впервые реализовал концепцию, в соответствии с которой пользовательская программа могла непосредственно вовлекаться в обработку исключи- тельных ситуаций. Язык позволял пользователю писать обработчики исключительных ситуаций для большого списка исключительных ситуаций, предусмотренных в языке. Более того, язык PL/1 ввел концепцию исключительных ситуаций, определенных поль- зователем, позволявшую программам создавать исключительные ситуации, распозна- ваемые программным обеспечением. Эти исключения используют те же механизмы, что и встроенные исключительные ситуации. С тех пор как был создан язык PL/1, разработано несколько альтернативных методов обработки исключительных ситуаций. В частности, языки CLU (Liskov et. Al., 1984), Mesa (Mitchell et al., 1979), Ada, COMMON LISP (Steele, 1984), ML (Milner et al., 1990), C++, Modula-3 (Cardelli et al., 1989), Eiffel и Java имеют средства для обработки исклю- чительных ситуаций. Теперь мы готовы к проверке этих средств в четырех из перечисленных языках про- граммирования. 13.2. Обработка исключительных ситуаций в языке PL/1 Помимо других новаторских усилий, разработчики языка PL/1 бились над первым лингвистическим механизмом для обработки исключительных ситуаций. В соответствии со своим стилем работы в этой области они предусмотрели очень мощные и очень гиб- кие средства. Однако, как и в некоторых других конструкциях языка PL/1, его средства для обработки исключительных ситуаций трудны для понимания, реализации и правиль- ного использования. 552 Глава 13. Обработка исключительных ситуаций
Язык Р1/1 обеспечивает обработку встроенных исключительных ситуаций и позволяет пользователям определять их собственные исключительные ситуации. 13.2.1. Обработчики исключительных ситуаций Определенный пользователем обработчик имеет вид блоков выполняемого кода. Они встречаются везде, где может появляться выполняемый оператор, и имеют вид ON условие [SNAP] BEGIN; END; Здесь условие— это имя соответствующей исключительной ситуации. Вместо блока BEGIN-END можно использовать одно ключевое слово SYSTEM хпя указания на то. что должен использоваться обработчик исключительных ситуаций, поддерживаемый систе- мой. Ключевое слово SNAP в случае его использования указывает на то, что динамиче- ская последовательность выполнения программы во время возбуждения исключительной ситуации должна быть выведена на печать. Эта информация обратного отслеживания по- зволяет программисту определить, каким образом выполнение программы достигло точ- ки, в которой возникла исключительная ситуация. Такая информация, несомненно, по- лезна для отладки программы. Средой ссылок обработчика исключительных ситуаций в языке PL/1 является код, в который он встроен. Поскольку обработчики исключительных ситуаций не имеют пара- метров, обычно они помещаются вблизи от мест, где могут возникнуть соответствующие исключительные ситуации. 13.2.2. Связывание исключительных ситуаций с обработчиками Связывание исключительных ситуаций с обработчиками в языке PL/1 является динами- ческим. Оператор ON определяет связывание исключительной ситуации с ее обработчиком. Поскольку он является выполняемым, положение оператора в программе имеет чрезвы- чайно важное влияние на его действие. Если бы оператор ON был декларативным, была бы только одна исключительная ситуация в одном блоке. В действительности, заданной ис- ключительной ситуации могут соответствовать несколько операторов ON, даже в одном и том же блоке. Связывание, определенное оператором ON. продолжает действовать до того момента, пока не будет выполнен новый оператор ON для той же самой исключительной ситуации, либо не произойдет выход из блока, в котором это оператор находится. 13.2.3. Продолжение В языке PL/1 встроенные обработчики для разных исключительных ситуаций преду- сматривают разные действия, связанные с продолжением выполнения программы. Для некоторых исключительных ситуаций выполнение возвращается к оператору, возбудив- шему исключительную ситуацию; другие условия приводят к прекращению выполнения программы. Обработчики, определенные пользователем, могут передать управление в любую часть программы, в которую они пожелают, после выполнения обработки исклю- чительной ситуации. Однако не существует механизма, определяющего адрес оператора, возбудившего исключительную ситуацию, так что часто вернуться к нему невозможно. Выбор между двумя действиями в системных обработчиках был сделан на основе того, 13.2. Обработка исключительных ситуаций в языке PL/1 553
может ли обработчик определить причину проблемы и успешно продолжить выполнение программы. Например, считалось, что при некоторых арифметических ошибках успеш- ное продолжение обработки невозможно. В других случаях, таких как исключительная ситуация CONVERSION (для ошибок преобразования строк в числа), исправление оши- бок считалось возможным, и управление возвращалось оператору, приведшему к воз- никновению исключительной ситуации, после завершения выполнения обработчика. Проектные решения языка PL/1, касающиеся продолжения выполнения программы, час- то непонятны программистам. 13.2.4. Другие проектные решения Исключительные ситуации, определенные пользователем, создаются в программах на языке PL/1 с помощью простого объявления следующего вида: CONDITION имя_исключительной_ситуации Для всех встроенных исключительных ситуаций существуют встроенные обработчи- ки. Эти обработчики могут быть прерваны обработчиками, определенными пользовате- лем. Исключительные ситуации, определенные пользователем, должны возбуждаться яв- но с помощью оператора следующего вида SIGNAL условие(имя_исключительной_ситуации) Любое условие может явно возбуждаться оператором SIGNAL, хотя встроенные ис- ключительные ситуации обычно возбуждаются неявно при определенных аппаратных или программных условиях. Оператор SIGNAL, примененный к исключительной ситуа- ции, которая в данный момент отменена, не выполняет никаких действий. Язык PL/1 определяет 22 встроенные исключительные ситуации, начиная с таких арифметических ошибок, как ZERODIVIDE, и заканчивая такими программными ошиб- ками, как SUBSCRIPTRANGE. Встроенные исключительные ситуации подразделяются на три категории: (1) те, которые всегда разрешены; (2) те, которые разрешены по умолча- нию, но могут быть заблокированы пользовательским кодом; и (3) те, которые заблоки- рованы по умолчанию, но могут быть разблокированы пользовательским кодом. Процесс блокирования и разблокирования условий выполняется с помощью припи- сывания имени или имен исключительных ситуаций в качестве префикса к оператору, блоку или процедуре, как в следующем примере: (SUBSCRIPTARANGE, NOOVERFLOW): BEGIN; END; В этом случае исключительная ситуация SUBSCRIPTRANGE является разблокированной, а исключительная ситуация OVERFLOW— заблокированной. Значения, заданные по умолчанию для этих исключительных ситуаций, противоположны указанным — NOSUBSCRIPTARANGE и OVERFLOW. Префикс NO можно приписать к любой исключи- тельной ситуации, разрешенной в данный момент (чтобы заблокировать ее). 554 Глава 13. Обработка исключительных ситуаций
13.2.5. Пример Приведенная ниже программа иллюстрирует два простых, но широко распространен- ных, примера использования обработчиков исключительных ситуаций в языке PL/1. Программа вычисляет и печатает распределение входных оценок с помощью массива счетчиков. Есть десять категорий оценок (0-9, 10-19, ..., 90-100). Оценки сами по себе используются для вычисления индексов в массиве счетчиков для каждой из категорий оценок. Неправильная входная оценка обнаруживается с помощью выявления ошибок индексации в массиве счетчиков. Оценка, равная 100. играет особую роль в вычислении распределения оценок, поскольку все категории имеют десять возможных значений оце- нок, за исключением наивысшей, которая имеет одиннадцать значений (90, 91. ..., 100). (Тот факт, что более вероятны оценки А, чем В или С, является убедительным свиде- тельством великодушия учителей.) Оценка, равная 100, также обрабатывается тем же об- работчиком исключительных ситуаций, который используется для обработки неправиль- ных входных данных. GRADE DISTRIBUTION: PROCEDURE OPTIONS (MAIN) DECLARE FREQ(1:10) FIXED INIT ((10) 0), NEW GRADE FIXED, LIMIT 1 FIXED, LIMIT 2 FIXED, INDEX FIXED; /* Обработчики исключительных ситуаций */ ON ENDFILE (SYSIN) GOTO FINISH; ON SUBSCRIPTRANGE BEGIN; IF NEW GRADE = 100 THEN FREQ(10) = FREQ(10) + 1; ELSE DO; PUT LIST (’ВХОДНАЯ ОЦЕНКА:’ I I NEW GRADE || ’ВЫХОДИТ ЗА ДОПУСТИМЫЕ ПРЕДЕЛЫ’) SKIP; GOTO INPUT LOOP; END; END; /* Тело основной программы */ INPUT LOOP: DO; GET LIST (NEW GRADE); INDEX = NEW GRADE /10+1; (SUBSCRIPTRANGE): FREQ(INDEX) = FREQ(INDEX) + 1; END INPUT LOOP; FINISH: PUT LIST (’ГРАНИЦЫ ЧАСТОТЫ’) SKIP(2); DO INDEX = 0 TO 9; LIMIT 1 = 10 * INDEX; LIMIT 2 = LIMIT 1 + 9; IF INDEX = 9 THEN LIMIT 2 = 100; PUT LIST (LIMIT 1, LIMIT 2, FREQ(INDEX+1)); END; END GRADE-DISTRIBUTION; 13.2. Обработка исключительных ситуаций в языке PL/1 555
Три различных события запускают обработку исключительных ситуаций в програм- ме. Среди них только одна исключительная ситуация является ошибкой; остальные две просто сигнализируют о том, что происходит нечто особенное. В одном случае оценка равна 100: в другом — достигается конец входных данных. Заметим, что обработчик для исключительной ситуации SUBSCRIPTRANGE позволя- ет продолжать выполнение программы при неправильных оценках даже несмотря на то, что встроенный обработчик для этой исключительной ситуации, замещаемый данной программой, может привести к прекращению ее выполнения. 13.2.6. Оценка Язык PL/1 предоставляет программисту мощные средства для распознавания и обра- ботки исключительных ситуаций. За высокий уровень их гибкости, однако, приходится платить. Основным примером этого является динамическое связывание исключительных ситуаций с их обработчиками, которое порождает трудности при написании и чтении программ, связанные с проблемами динамической области видимости. Действительно, область видимости обработчика исключительной ситуации является динамической, зна- чит, невозможно определить по листингу, какое связывание происходит в любой задан- ной точке программы. Вследствие динамического связывания легко получить обработ- чик, непреднамеренно используемый для некоей исключительной ситуации, в действи- тельности синтаксически далекой от заданной и полностью неприемлемой в данном случае. Рассмотрим следующий простой фрагмент кода: (SUBSCRIPTRANGE): BEGIN; ON SUBSCRIPTRANGE BEGIN; PUT LIST(’ОШИБКА - НЕВЕРНЫЙ ИНДЕКС В МАССИВЕ SUBSUM’); GO ТО FIXIT; END; ON SUBSCRIPTRANGE BEGIN; PUT LIST(’ОШИБКА - НЕВЕРНЫЙ ИНДЕКС В МАССИВЕ BLK ’); GO ТО QUIT; END; LABEL1:; BLK(I, J, K) = SUM; EN D; Если в код, расположенный между двумя обработчиками исключительной ситуации SUBSCRIPTRANGE, оказывается включенным оператор GO ТО LABEL1, то первый об- работчик выполняется, если исключительная ситуация возникает при присваивании зна- чений элементу массива BLK. Это может привести к активизации неверного обработчи- ка, что вызывает (по крайней мере) большое недоумение пользователя (он может ре- шить, что ошибка произошла в массиве SUBSUM, а не в массиве BLK). 556 Глава 13. Обработка исключительных ситуаций
Другая серьезная проблема является следствием гибкости правил продолжения вы- полнения программ в языке PL1. Они трудны для реализации, пагубно сказываются на читабельности, как и оператор goto, кроме того, трудно научиться использовать их эф- фективно. Поскольку механизм обработки исключительных ситуаций в языке PL'l представля- ется слишком сложным, разработчики других языков не стали его копировать. Более ог- раниченная модель была предложена в 1975 год> в работе (Goodenough. 1975). в которой исключительные ситуации статически связывались с их обработчиками. Еще более огра- ниченная модель была описана в языке CLU в середине 1970-х годов (Liskov et al.. 1984). В более поздних языках способы обработки исключительных ситуаций, по крайней мере частично, основаны на способах, предложенных в языке CLLI. 13.3. Обработка исключительных ситуаций в языке Ada Обработка исключительных ситуаций в языке Ada — мощное средство для построения более надежных программных систем. Она включает в себя то лучшее, что есть в обра- ботке исключительных ситуаций в языках PL/1 и CLU. 13.3.1. Обработчики исключительных ситуаций Обработчики исключительных ситуаций обычно являются локальными по отноше- нию к коду, в котором возбуждается исключительная ситуация. Поскольку это обеспечи- вает их той же самой средой ссылок, параметры для обработчиков исключительных си- туаций не обязательны, а потому и не допускаются. Обработчики исключительных ситуаций имеют следующий общий вид: when выбор_ситуации {I выбор_ситуации } => последовательность_операторов Здесь скобки являются метасимволами, т.е. их содержимое может либо отсутство- вать, либо повторяться любое количество раз. Выражение выбор_ситуации имеет вид: имя_исключительной_ситуации !t others Имя исключительной ситуации указывает конкретную исключительную ситуацию или ситуации, для обработки которых предназначен обработчик. Последовательность опера- торов — это тело обработчика. Зарезервированное слово others указывает на то, что обработчик предназначен для обработки любых исключительных ситуаций, не назван- ных ни в одном локальном обработчике. Обработчики исключительных ситуаций могут быть включены в блоки или в тела подпрограмм, пакетов или задач. Независимо от блока или модуля, в котором они появ- ляются, обработчики помещаются вместе в раздел exception, который должен нахо- диться в конце блока или модуля. Например, обычный вид раздела exception показан в следующем фрагменте кода: begin — блок или тело модуля - exception 557 13.3. Обработка исключительных ситуаций в языке Ada
when exception_name_l => — первый обработчик — when exception_name__2 => -- второй обработчик -- — другие обработчики -- end; Любой оператор, допустимый в блоке или модуле, в котором появляется обработчик исключительных ситуаций, также допустим и в обработчике. 13.3.2. Связывание исключительных ситуаций с обработчиками Когда блок или модуль, возбуждающий исключительную ситуацию, содержит обработ- чик для этой исключительной ситуации, она может статически связываться с этим обработ- чиком. Если исключительная ситуация возбуждается в блоке или модуле, не имеющем об- работчика для этой конкретной ситуации, то она передается в некоторый другой блок или модуль. Способ, которым исключительные ситуации передаются в другие модули, зависит от сущности программы, в которой возникает исключительная ситуация. Когда исключительная ситуация возбуждается в процедуре, либо при выполнении ее объявлений, либо при выполнении ее тела, а процедура не имеет обработчика для этой ситуации, такая исключительная ситуация неявно передается в вызывающий программ- ный модуль в точку вызова. Этот подход отражает философию разработки, заключаю- щуюся в том, что передача исключительной ситуации должна отслеживаться через путь управления (через динамических предков), а не через статических предков. Если вызывающий модуль, в который была передана исключительная ситуация, так- же не имеет обработчика для нее, она передается дальше в модуль, вызвавший данный модуль. Это продолжается при необходимости вплоть до главной программы. Если ис- ключительная ситуация была передана главной программе, а ее обработчик все еще не найден, выполнение программы завершается. С точки зрения обработки исключительных ситуаций блок в языке Ada рассматрива- ется как лишенная параметров процедура, “вызываемая” родительским блоком, когда управление выполнением достигает первого оператора в блоке. Когда в блоке возбужда- ется исключительная ситуация (либо в объявлениях, либо в выполняемых операторах), а блок не имеет обработчика для нее, эта исключительная ситуация передается в следую- щую охватывающую ее область видимости, которая является кодом, “вызвавшим” дан- ную процедуру. Точка, в которую передается исключительная ситуация, — это просто оператор, следующий за концом блока, в котором эта ситуация возникла, называющийся точкой “возврата”. Когда исключительная ситуация возбуждается в теле пакета, которое не имеет обра- ботчика для данной исключительной ситуации, такая ситуация передается в раздел объ- явлений модуля, содержащего объявление пакета. Если пакет оказался библиотечным модулем (который компилируется отдельно), то выполнение программы завершается. Если исключительная ситуация возбуждается на внешнем уровне в теле задачи, и эта задача содержит обработчик для данной исключительной ситуации, то этот обработчик выполняется, а задача помечается как завершенная. Если задача не содержит обработчи- ка для данной исключительной ситуации, то она просто помечается как выполненная, а исключительная ситуация никуда не передается. Механизм управления задачей слишком 558 Глава 13. Обработка исключительных ситуаций
сложен, для того чтобы дать разумный и простой ответ на вопрос, куда должны переда- ваться ее необработанные исключительные ситуации. Исключительные ситуации также могут возникать при выполнении разделов объяв- лений в подпрограммах, блоках, пакетах и задачах. Предположим, что некая функция вызывается для того, чтобы инициализировать переменную в операторе ее объявления, как показано ниже: procedure RIVER is CURRENT_FLOW : FLOAT := GET_FLOW; begin end RIVER; Допустим, что GET_FLOW— это функция без параметров. Если функция GET_FLOW возбуждает и передает исключительную ситуацию в модуль, вызвавший ее, то эта ис- ключительная ситуация возбуждается вновь в этом объявлении. Размещение переменных в памяти во время выполнения объявлений также может возбудить некую исключитель- ную ситуацию. Когда исключительные ситуации возникают во время выполнения объявлений проце- дур, пакетов и блоков, они передаются точно так же, как если бы исключительная ситуа- ция возбудилась в соответствующем разделе кода. Если это происходит в задаче, она помечается как завершенная, дальнейшее выполнение объявлений переменных прекра- щается, и возбуждается встроенная исключительная ситуация TASKING_ERROR в точке активизации задачи. 13.3.3. Продолжение Выполнение блока или модуля, возбудившего исключительную ситуацию, вместе с выполнением всех модулей, в которые эта ситуация передавалась, но не обрабатывалась там, всегда прекращается. Управление никогда не возвращается неявно в возбудивший исключительную ситуацию блок или модуль после ее обработки. Управление просто пе- редается оператору, следующему за разделом exception, который всегда находится в конце блока или модуля. Этот приводит к немедленному возврату на наивысший уровень управления. Для того чтобы решить, с какого места может продолжаться выполнение программы после выполнения обработчика исключительной ситуации в программном модуле, ко- манда по разработке языка Ada имела небольшой выбор, поскольку по требованиям спе- цификации языка Ada (Department of Defence, 1980a) программные модули, возбудившие исключительную ситуацию, могли продолжать свое выполнение или возобновлять его. Однако в случае блока оператор можно попытаться выполнить снова после того, как он возбудил исключительную ситуацию и она была обработана. Предположим, что некий оператор, который может возбудить исключительную ситуацию, и ее обработчик поме- щены в блок, находящийся внутри цикла. Следующий пример фрагмента кода, полу- чающего с клавиатуры четыре целых числа из требуемого диапазона, иллюстрирует этот вид структуры: type AGE—TYPE is range 0..125; type AGE_LIST_TYPE is array (1..4) of AGE_TYPE; 13.3. Обработка исключительных ситуаций в языке Ada 559
package AGE_IO is new INTEGER—10 (AGE_TYPE); use AGE_10; AGE_LIST : AGE_LIST_TYPE; begin for AGE_COUNT in 1..4 loop loop — цикл для повторного ввода при возникновении -- исключительной ситуации EXCEPT—BLK: begin — инкапсулирование обработки исключительной ситуации PUT_LINE("Введите целое число в диапазоне 0..125"); GET(AGE_LIST(AGE_COUNT) ) ; exit; exception when DATA—ERROR => — Введенная строка не является числом PUT_LINE("Неверное числовое значение"); PUT—LINE("Пожалуйста, попробуйте снова"); when CONSTRAINT—ERROR => — Введенное значение < О -- или > 125 PUT—LINE("Число выходит за пределы диапазона"); PUT—LINE("Пожалуйста, попробуйте снова"); end EXCEPT—BLK; end loop; -- последний оператор бесконечного цикла -- для повторного ввода, -- когда возникает исключительная ситуация end loop; — конец цикла AGE_COUNT in 1..4 Управление остается во внутреннем цикле, содержащем только блок, пока на входе не будет получено правильное число. 13.3.4 . Другие проектные решения Язык Ada предусматривает пять встроенных исключительных ситуаций: CONSTRAINT ERROR NUMERIC-ERROR PROGRAM_ERROR STORAGE ERROR TASKING^ERROR Каждая из них в действительности является целой категорией исключительных ситуаций. Например, исключительная ситуация CONSTRAINT—ERROR возбуждается, когда индекс массива выходит за пределы допустимого диапазона; когда возникает ошибка, связанная с выходом за пределы допустимого диапазона числовой переменной, имеющей ограни- ченный диапазон изменения; когда производится ссылка на поле записи, отсутствующее в выделенном объединении, и в некоторых других ситуациях. Исключительные ситуации, определенные пользователем, можно задавать с помощью объявления следующего вида: список—имен_исключительных—ситуаций: exception 560 Глава 13. Обработка исключительных ситуаций
Такие исключительные ситуации обрабатываются точно так же, как и встроенные, за ис- ключением того, что они должны возбуждаться явно. Для встроенных исключительных ситуаций предусмотрены обработчики по умолча- нию, каждый их которых приводит к прекращению выполнения программы. Исключительные ситуации явно возбуждаются оператором raise, имеющим сле- дующий вид: raise [имя_исключительной—ситуации] Единственным местом, где оператор raise может появляться без указания имени ис- ключительной ситуации, является обработчик исключительной ситуации. В этом случае оператор raise повторно возбуждает ту исключительную ситуацию, которая привела к выполнению обработчика. Следствием является передача исключительной ситуации в соответствии с правилами, описанными выше. Оператор raise в обработчике исключи- тельной ситуации используется, если нужно вывести сообщение об ошибке в точке, в ко- торой возникла исключительная ситуация, а обрабатывать ее где-нибудь в другом месте. Прагма в языке Ada является директивой компилятору. Некоторые проверки во время выполнения программы, являющиеся частями встроенных исключительных ситуаций, могут быть заблокированы в программах на языке Ada с помощью прагмы SUPPRESS, простой вид которой приведен ниже: pragma SUPPRESS(имя_проверки) Директива pragma SUPPRESS появляется только в разделах объявлений. При этом указанная в директиве проверка может быть отложена в соответствующем блоке или программном модуле, частью которого является данный раздел объявлений. Директива SUPPRESS не приводит к явному возбуждению исключительных ситуаций. Несмотря на то что этого и не требуется, большинство компиляторов языка Ada реализуют директиву pragma SUPPRESS. Примеры проверок, которые могут быть заблокированы с помощью указания компиля- тору, приведены ниже. В программах на языке Ada обычно выполняются проверки INDEX_CHECK и RANGE_CHECK. Имя INDEX_CHECK относится к проверке выхода ин- декса массива за пределы допустимого диапазона. Имя RANGE CHECK относится к про- верке таких условий, как диапазон значения, которое должно быть присвоено переменной, относящейся к подтипу. Если условия, предусмотренные проверками INDEX_CHECK и RANGE_CHECK, не выполняются, возбуждается исключительная ситуация CONSTRAINT_ERROR. Проверки DIVISION_CHECK и OVERFLOW_CHECK— это подав- ляемые проверки, связанные с проверкой NUMERIC—ERROR. Приведенная ниже директива pragma блокирует проверку выхода индекса массива за пределы допустимого диапазона: pragma SUPPRESS(INDEX_CHECK); Существует опция директивы SUPPRESS, позволяющая указанной исключительной ситуации в дальнейшем ограничиваться только конкретными объектами, типами, подти- пами и программными модулями. 13.3. Обработка исключительных ситуаций в языке Ada 561
13.3.5 . Пример Следующий пример имеет то же предназначение и использует те же обработки ис- ключительных ситуаций, что и программа на PL/1, приведенная ранее в этой главе. Этот код определяет распределение входных оценок с помощью массивов счетчиков для деся- ти категорий оценок. Недопустимые оценки распознаются с помощью проверки невер- ных индексов, используемой при увеличении выбранного счетчика. with TEXT-IO; use TEXT_IO; procedure GRADE-DISTRIBUTION is package INTEGER—TEXT—IO is new INTEGER—IO(INTEGER); use INTEGER—TEXT—IO; FREQ: array (1..10) of integer := (others => 0) ; NEW—GRADE; INDEX, LIMIT_1, LIMIT_2 : INTEGER; begin loop GET (NEW—GRADE) ; INDEX := NEW_GRADE /10+1; begin — блок обработчика исключительной ситуации — CONSTRAINT-ERROR FREQ(INDEX) := FREQ(INDEX) + 1; exception when CONSTRAINT-ERROR => if NEW-GRADE = 100 then FREQ(10) := FREQ(10) + 1; else PUT ("Ошибка — новая оценка: ”); PUT(NEW_GRADE); PUT("выходит за пределы допустимого диапазона”); NEW_LINE; end if; end; — конец блока обработчика исключительной — ситуации CONSTRAINT-ERROR end loop; exception — Этот обработчик содержит все окончательные — вычисления when END_OF_FILE => PUT (’’Границы Частоты: ”) ; NEW_LINE; NEW_LINE; for INDEX in 0..9 loop LIMIT-1 := 10 * INDEX; LIMIT_2 := LIMIT-1 + 9; if INDEX = 9 then LIMIT_2 := 100; end if; PUT (LIMIT-1) ; PUT(LIMIT_2); PUT(FREQ(INDEX+1)); NEW_LINE; end loop; — for INDEX in 0..9 end GRADE-DISTRIBUTION; 562 Глава 13. Обработка исключительных ситуаций
Заметим, что код для обработки неверных входных оценок находится в своем собствен- ном локальном блоке. Это позволяет программе продолжить выполнение после обработ- ки такой исключительной ситуации, как в предыдущем примере, в котором программа считывала числа с клавиатуры. 13.3.6 . Оценка Как и в некоторых других языках, обработка исключительных ситуации в языке Ada представляет собой некий консенсус представлений об этом предмете, по крайней мере он был им во время разработки языка (в конце 1970-х и начале 1980-х годов). Очевидно, что эта обработка имеет преимущество над обработкой исключительных ситуаций в язы- ке PL/1. Некоторое время язык Ada был единственным широко распространенным язы- ком, включавшим обработку исключительных ситуаций. Ситуация изменилась с появле- нием обработки исключительных ситуаций в языках C++ и Java. 13.4. Обработка исключительных ситуаций в языке C++ Обработка исключительных ситуаций в языке C++ была одобрена комитетом по стан- дартизации ANSI C++ в 1990 году и затем была реализована в языке C++. Ее разработка частично основывалась на обработке исключительных ситуаций в языках CLU, Ada и ML. 13.4.1. Обработчики исключительных ситуаций В разделе 13.3 показано, что язык Ada использует программные модули или блоки для указания области видимости обработчиков исключительных ситуаций. Язык С+ж для этой цели использует специальную конструкцию, которая начинается зарезервированным сло- вом try. Конструкция try содержит составной оператор, называемый оператором try, и список обработчиков исключительных ситуаций. Составной оператор определяет область видимости последующих обработчиков. Общий вид этой конструкции таков: try { // * Код, который может возбуждать исключительную ситуацию } catch(формальный_параметр) { //* Тело обработчика } catch(формальный_параметр) { //* Тело обработчика } Каждая функция catch представляет собой обработчик исключительной ситуации. Функция catch может иметь только один формальный параметр, подобный формаль- ному параметру в определении функции в языке C++, включая возможность указания в качестве формального параметра эллипсиса (...). Формальный параметр может быть про- сто спецификатором типа, например, float, как в прототипе функции. Когда информа- ция об исключительной ситуации передается в обработчик, параметр содержит тип и имя переменной, используемой для этой цели. Например, пользователь может определить в 13.4. Обработка исключительных ситуаций в языке C++ 563
качестве исключительной ситуации некий класс и включить в него столько данных- членов, сколько необходимо. В противном случае формальный параметр является просто типом, и его предназначение заключается в однозначной идентификации обработчика. Обработчик с эллипсисом в качестве формального параметра является универсальным (catch-all handler); он активизируется при любых исключительных ситуациях, если ранее не был выбран другой обработчик. Процесс, с помощью которого исключительные си- туации связываются с обработчиками, обсуждается в разделе 13.4.2. В языке C++ обработчики исключительных ситуаций могут содержать любой код на этом языке. 13.4.2. Связывание исключительных ситуаций с обработчиками Исключительные ситуации в языке C++ возбуждаются только явным оператором throw, имеющим следующий общий вид: throw[выражение] Квадратные скобки являются метасимволами, используемыми для того, чтобы указать на необязательность выражения. Оператор throw без операнда может появляться только в обработчике. При этом он повторно возбуждает исключительную ситуацию, которая за- тем обрабатывается где-нибудь в другом месте. Это в точности повторяет использование оператора raise в языке Ada без указания имени исключительной ситуации. Ключевое слово throw было выбрано потому, что слова signal и raise являются именами функций в стандартной библиотеке функций ANCI С. По типу выражения throw выбирается конкретный обработчик, который, конечно, должен иметь “соответствующий” тип формального параметра. В этом случае слово “соответствующий” означает следующее: обработчик с формальным параметром типа Т, const Т, Т& (ссылка на объект типа Т) или const Т& соответствует оператору throw с выражением, имеющим тип Т. Если тип Т является классом, “соответствующим” являет- ся обработчик, параметр которого имеет тип Т, или любой класс, являющийся предком класса Т. Существуют более запутанные ситуации, в которых выражение throw соот- ветствует формальному параметру, но здесь они не рассматриваются. Исключительная ситуация, возбужденная конструкцией try, приводит к немедленному завершению выполнения кода в данной конструкции try. Поиск соответствующего обра- ботчика начинается с обработчика, непосредственно следующего за конструкцией try, и выполняется последовательно, пока не будет найден подходящий обработчик. Это означа- ет, что если некоторый другой подходящий обработчик предшествует обработчику, точно соответствующему данному выражению throw, то точно соответствующий обработчик не будет использован. Если в программе есть оператор catch с эллипсисом в качестве формального параметра, он будет подходить для любого выражения throw, так что беспо- лезно помещать такие операторы catch где угодно, кроме конца списка обработчиков. Исключительные ситуации обрабатываются локально, только если соответствующий обработчик также является локальным. Если не найден ни один подходящий локальный обработчик, исключительная ситуация передается в модуль, вызвавший функцию, в ко- торой эта ситуация возникла. Если во всей программе не найден ни один подходящий обработчик, то выполнение программы завершается. 564 Глава 13. Обработка исключительных ситуаций
13.4.3. Продолжение выполнения программы После выполнения обработчика управление переходит к первому оператору, следую- щему за конструкцией try (оператору, находящемуся непосредственно после последнего обработчика в последовательности обработчиков). Обработчик может повторно возбудить исключительную ситуацию с помощью оператора throw, не содержащего выражения. В этом случае такая исключительная ситуация передается в вызывающий модуль. 13.4.4. Другие проектные решения В контексте вопросов разработки языка, перечисленных в разделе 13.1.2, обработка исключительных ситуаций в языке является довольно простой. Существуют только оп- ределенные пользователем исключительные ситуации, и они не указываются (хотя могут быть объявлены как новые классы). Нет обработчиков по умолчанию, которые были бы предназначены для обработки исключительных ситуаций, обнаруживаемых системой. Исключительные ситуации нельзя заблокировать. Функция в языке C++ перечисляет типы исключительных ситуаций (типы выражений throw), которые могут возникать. Это осуществляется с помощью приписывания заре- зервированного слова throw, за которым в скобках следует список этих типов, к заго- ловку функции. Например, заголовок функции int fun() throw (int, char*) { ... } указывает, что функция f un может возбудить исключительные ситуации типа int или char*, и никакие другие. Если типы в операторе throw являются классами, то функция может возбудить любую исключительную ситуацию, производную от перечисленных классов. Если заголовок функции имеет оператор throw, и эта функция возбуждает ис- ключительную ситуацию, не указанную в списке из оператора throw и не являющуюся производной от класса, указанного там, то возникает фатальная ошибка. Список типов может быть пустым. Это означает, что функция не возбуждает ни одной исключительной ситуации. Если в заголовке функции нет спецификации throw, то функция может воз- буждать любую исключительную ситуацию. Список типов исключительных ситуаций не является частью типа функции. Когда исключительная ситуация завершает выполнение конструкции try, все авто- матические и динамические переменные, помещенные в стеке и динамической памяти кодом, выполнявшим эту конструкцию до возникновения исключительной ситуации, удаляются из памяти. Следовательно, обработчик никогда не сможет получить доступ к этим переменным. 13.4.5. Пример Следующий пример имеет тот же смысл, что и предыдущие, и использует обработку исключительных ситуаций, как и программа на языке PL/1, приведенная в разделе 13.2.5. Этот код определяет распределение входных оценок с помощью массивов счетчиков для десяти категорий оценок. Недопустимые оценки распознаются с помощью проверки не- верных индексов, используемой при увеличении выбранного счетчика. 13.4. Обработка исключительных ситуаций в языке C++ 565
♦include <iostream.h> void main() { //* Может возбуждаться любая исключительная //* ситуация int new_grade, index, limit_l, limit_2, freq[10] = {0,0,0,0,0,0,0,0,0,Ob- short int eof_condition; try { while (1) { if (!cin >> new_grade) //* Когда входной поток cin //* обнаруживает конец файла, throw eof_condition //* возбуждается исключительная //* ситуация eof_condition //* index = new_grade / 10; ( try { if (index < 0 I| index > 9) throw(new_grade); freq[index]++; } //* конец внутреннего блока try catch(int grade) { //* Обработчик для ошибки, // связанной с индексами if (grade == 100) freq[9]++; else cout « ’’Ошибка — новая оценка: ”<< grade « ’’выходит за пределы допустимого диапазона” « endl; } //* конец функции catch(int grade) } //* конец блока while(1) } //* конец внешнего блока try catch(short int) { //* Обработчик eof cout « ’’Границы Частоты” « endl; for(index = 0; index < 10; index++) limit_l = 10 * index; limit_2 = limit_l + 9; if (index == 9) limit_2 = 100; cout « limit_l « limit_2 « freq[index] « endl; } //★ конец цикла for (index == 9) } //★ конец функции catch(short int) } //* конец функции main Эта программа иллюстрирует механизм обработки исключительных ситуаций в языке C++. Однако обе исключительные ситуации в данном примере лучше обрабатывать дру- гими средствами. Условие достижения конца файла легче обработать с помощью про- стой проверки условия выхода из цикла while, содержащего выражение со входным потоком cin. Более того, исключительная ситуация, связанная с выходом индекса мас- сива за пределы допустимого диапазона, обычно обрабатывается в языке C++ с помо- щью перегрузки операции индексирования, которая затем может возбуждать исключи- 566 Глава 13. Обработка исключительных ситуаций
тельную ситуацию, а не путем непосредственного распознавания операции индексирова- ния с помощью условного выражения, как в приведенном примере. 13.4.6. Оценка В некоторых отношениях механизм обработки исключительных ситуаций в языке C++ похож на соответствующий механизм языка Ada: связывание исключительных си- туаций с обработчиками является статическим, и необработанные исключительные си- туации передаются в модуль, вызвавший данную функцию. Но в других аспектах разра- ботка языка C++ совершенно отличается от языка Ada. Здесь нет встроенных распода- ваемых аппаратурой исключительных ситуаций, которые может обрабатывать пользователь, и исключительные ситуации не именуются. Это приводит к странной структуре, при которой исключительные ситуации связываются с обработчиками с по- мощью типа параметра, причем сам формальный параметр может отсутствовать Тип формального параметра обработчика определяет условия, при которых он вызывается, но при этом природа возникшей исключительной ситуации может никак не влиять на его действия. Следовательно, использование встроенных типов определенно не способствует повышению читабельности программы. Намного лучше иерархически определять клас- сы с осмысленными именами, которые можно использовать для определения исключи- тельных ситуаций. 13.5. Обработка исключительных ситуаций в языке Java В главе 12 пример программы на языке Java содержал обработку исключительных ситуаций с небольшими пояснениями. В этом разделе описываются детали возможно- стей языка Java, связанных с обработкой исключительных ситуаций. Обработка исключительных ситуаций в языке Java основывается на механизме, реали- зованном в языке C++, но больше соответствует объектно-ориентированной парадигме. 13.5.1. Классы исключительных ситуаций Все исключительные ситуации в языке Java являются объектами классов, производ- ных от класса Throwable. Система языка Java содержит два системно-ориентирован- ных класса исключительных ситуаций, являющихся подклассами классов 7r.ro. г.с_ е. Error и Exception. Класс Error и производные от него классы связаны с такими ошибками, порождаемыми интерпретатором языка Java, как исчерпанная динамическая память. Эти исключительные ситуации никогда не возбуждаются пользовательскими программами и не обрабатываются там. Существуют два класса, являющихся непосред- ственными наследниками классов Exception, RuntimeException и ZOF:: ' . г.. Как следует из их названий, исключительная ситуация lOExcepticr. возбуждается при возникновении ошибки в операциях ввода или вывода, каждая из которых определена в качестве метода в различных классах, объявленных в пакете j ava. io. Существуют определенные системные классы, являющиеся производными от класса RuntimeException. В большинстве случае исключительная ситуация RunuireFxcc-p- Lon возбуждается, когда пользовательская программа порождает ошибку. Например, класс ArraylndexOutBoundsException, определеный в пакете java.util, обычно воз- 13.5. Обработка исключительных ситуаций в языке Java 567
буждает исключительную ситуацию, являющуюся производной от исключительной си- туации RuntimeException. Другой широко распространенной возбуждаемой исклю- чительной ситуацией является NullPointerException. Пользовательская программа может определять свои собственные классы исключи- тельных ситуаций. В языке Java принято соглашение о том, что определенные пользова- телем исключительные ситуации являются подклассами класса Exception. 13.5.2. Обработчики исключительных ситуаций Обработчики исключительных ситуаций в языке Java имеют тот же вид, что и в языке C++. Однако каждая функция catch должна иметь параметр, а классы исключительных ситуаций должны быть производными от встроенного класса Throwable. Синтаксис конструкции try в языке Java в точности такой же, что и в языке C++. 13.5.3. Связывание исключительных ситуаций с обработчиками Возбуждение исключительной ситуации происходит довольно просто. Экземпляр класса исключительной ситуации является операндом оператора throw. Определим ис- ключительную ситуацию с именем MyException следующим образом: class MyException extends Exception { public MyException() { public MyException(String message) { super (message); } } Эту исключительную ситуацию можно возбудить оператором throw new MyException(); Экземпляр исключительной ситуации для оператора throw создается отдельно от оператора throw, как показано ниже MyException myExceptionObject = new MyException(); throw myExceptionOblect; Один из двух конструкторов, включенных в наш новый класс, не имеет параметров, а другой имеет параметр в виде объекта типа String, который он передает в суперкласс (Exception) для отображения. Таким образом, наша новая исключительная ситуация может быть возбуждена следующим образом: throw new MyException (’’сообщение, определяющее местоположение ошибки”); Связывание исключительных ситуаций с обработчиками в языке Java менее сложно, чем в языке C++. Если исключительная ситуация возбудилась в составном операторе конст- рукции try, она связывается с первым обработчиком (функцией catch), непосредст- венно следующим за оператором try. параметр которого принадлежит тому же классу, что и объект возбужденной исключительной ситуации, или является предком по отноше- нию к нему. Если найден подходящий обработчик, оператор throw связывается с ним, и этот обработчик выполняется. 568 Глава 13. Обработка исключительных ситуаций
Исключительные ситуации могут быть обработаны, а затем снова возбуждены, если включить оператор throw без операнда в конец обработчика. Повторно возбужденная исключительная ситуация не будет обработана той же конструкцией try. которой она первоначально была возбуждена, так что зацикливания не возникает. Это повторное воз- буждение обычно осуществляется, когда следует выполнить некоторое локальное дейст- вие, а затем — дальнейшую обработку вложенным оператором try или вызывающим модулем. Оператор throw в обработчике также может возбудить исключительную си- туацию, отличающуюся от той. что передала управление данному разработчику: одна ис- ключительная ситуация может возбуждать другую. 13.5.4. Продолжение выполнения программы Обработчик, найденный в последовательности обработчиков из конструкции try, выполняется, а программа продолжается с оператора, следующего за конструкцией try. Если обработчик не обнаружен, производится поиск обработчиков во вложенных конст- рукциях try, начиная с самого нижнего уровня. Если в результате этого процесса не бу- дет найден ни один обработчик, исключительная ситуация передается в модуль, вызвав- ший данный метод. Если вызов метода осуществлялся из оператора try. поиск обработ- чика продолжается в наборе обработчиков, присоединенном к данной конструкции. Передача исключительной ситуации продолжается, пока не будет выявлен первоначаль- ный модуль, вызвавший функцию, в которой возникла исключительная ситуация. В при- кладной программе этим модулем является модуль main. Если подходящий обработчик не найден нигде, выполнение программы завершается. Во многих случаях обработчики исключительных ситуаций содержат оператор return, чтобы завершить выполнение метода, в котором возникла исключительная ситуация. Для того чтобы убедиться в том, что исключительные ситуации, которые могут быть возбуждены в операторе try, всегда обрабатываются некоторым методом, используется специальный обработчик, подходящий для всех исключительных ситуаций, производных от класса Exception, с определением обработчика с параметром типа Exception, как показано ниже: catch (Exception genericObjet) { } Поскольку имя класса всегда соответствует самому себе или другому классу-предку, любой класс, производный от класса Exception, соответствует исключительной ситуа- ции Exception. Конечно, такой обработчик должен всегда помещаться в конец списка, при этом он блокирует использование любого обработчика, следующего за ним в конст- рукции try. Таким образом, поиск подходящих обработчиков выполняется последова- тельно и заканчивается, когда соответствие найдено. Объект, являющийся параметром обработчика исключительной ситуации, не так беспо- лезен, как это могло показаться. При выполнении программы система поддержки выполне- ния программ языка Java хранит в памяти имя класса каждого объекта в программе. Метод getClass можно использовать для того, чтобы получить объект, хранящий имя класса, который в свою очередь получен методом getName. Таким образом, можно получить имя класса фактического параметра оператора throw, вызвавшего выполнение обработчика. Для приведенного выше обработчика это осуществляется следующим образом: 13.5. Обработка исключительных ситуаций в языке Java 569
genericObjecu.getClass().getName() Сообщение, связанное с объектом, являющимся параметром, которое может быть создано в конструкторе, получено с помощью оператора ici.ericObject. getMessage () 13.5.5. Другие проектные решения Оператор throws размещается в программах на языке Java в том же месте, что и спецификация throw в программах на языке C++. Однако семантика оператора throws значительно отличается от семантики оператора throw в языке C++. Появление имени исключительной ситуации в операторе throws в методе на языке Java указывает на то, что данный класс исключительных ситуаций или любой из произ- водных от него классов может быть возбужден этим методом. Например, если в методе указано, «по он может возбудить исключительную ситуацию lOException, это означа- ем чго он может возбудить объект класса lOException или объект любого из произ- водных от lOException классов, такого как EOFException. Исключительные ситуации из классов Error и RuntimeException, а также про- изводных от них классов называются непроверенными исключительными ситуация- ми (unchecked exceptions). Все другие исключения называются проверенными исклю- чительными ситуациями (checked exceptions). Непроверенные исключительные ситуа- ции никогда не анализируются компилятором. Однако компилятор гарантирует, что все доверенные исключительные ситуации, которые могут возбуждаться методом, либо пе- j шелепы в операторе throws, либо обрабатываются в методе. Причина, по которой лесы ? * гог и RuntimeException, а также производные от них являются непрове- ренными. заключается в том, что их может возбудить любой метод. В операторе throws, принадлежащем методу, нельзя объявить больше исключи- тельных ситуаций, чем в методе, который он замещает, хотя меньше объявить можно. Таким образом, если в некоем методе нет оператора throws, то его не может иметь ни один метод, замещающий данный. Метод может возбуждать любую исключительную си- зуацию. указанную в его операторе throws, а также любую исключительную ситуацию, производную от нее. Если метод не возбуждает непосредственно конкретную исключи- тельную ситуацию, но вызывает другой метод, который мог бы возбудить эту ситуацию, данная исключительная ситуация должна быть указана в его операторе throws. По этой причине в примере, приведенном ниже, в операторе throws заголовка метода bu: 1аГЗ 1st. использующего метод readLine, должна быть указана исключительная ситуация lOException. Метод, вызывающий другой метод, в операторе throws которого перечислены кон- кретные проверенные исключительные ситуации, имеет три возможности для обработки этих исключительных ситуаций. Во-первых, он может перехватывать исключительную ситуацию и обрабатывать ее сам. Во-вторых, он может перехватывать исключительную ситуацию и возбуждать другую исключительную ситуацию, указанную в его операторе throws. В-третьих, он мог бы объявить исключительную ситуацию в своем операторе throws и не обрабатывать ее, а передавать во вложенный оператор try, если он суще- ствует. или в модуль, вызвавший данный метод, если вложенного оператора try в мето- де нет. 570 Глава 13. Обработка исключительных ситуаций
Обработчиков исключительных ситуаций по умолчанию в языке Java нет, и заблоки- ровать исключительные ситуации невозможно. 13.5.6. Пример Ниже приводится класс на языке Java с возможностями, которыми обладала про- грамма на языке С-ь-ь из раздела 13.4: import java.io.*; // Определение исключительной ситуации, // относящейся к обнаружению конца входных данных class NegativelnputException extends Exception { public NegativelnputException() { System, out .printin (’’Достигнут конец входных данных”); } //* конец конструктора } //* конец класса NegativelnputException class GradeDist { int newGrade, index, limit_l, limit_2; int [] freq = {0, 0, 0, 0, 0, 0, 0, 0, 0, Ob- void buildDistO throws lOException { // Ввод: список целых чисел, представляющих собой ранги, // за которыми следует отрицательное число // Вывод: распределение оценок в процентах по каждому из // диапазонов 0-9,10-19, ..., 90-100 DatalnputStream in = new DatalnputStream(System.in); try { while (true) { System.out.printin("Пожалуйста, введите оценку"); newGrade = Integer.parselnt(in.readLine()); if (newGrade < 0) throw new NegativelnputException(); index = newGrade / 10; try { freq[index]++; } //* конец внутреннего оператора try catch(ArraylndexOutOfBoundsException) { if (newGrade == 100) freq [9]++; else System.out.printin("Ошибка - новая оценка: + newGrade + "выходит за пределы допустимого диапазона); } //* конец функции catch (Arrayindex... } //* конец цикла while (true) ... } //* конец внешнего оператора try catch(NegativelnputException) { 13.5. Обработка исключительных ситуаций в языке Java 571
System.out.printin("ХпГраницы Частоты\п); for (index = 0; index < 10; index++) { limit_l = 10 * index; limit_2 = limit_l + 9; if (index == 9) limit_2 = 100; System.out.printin("" + limit_l + " - " + limit_2 + " " + freq[index]) ; } //* конец цикла for(index =0; ... } //* конец функции canch (NegativelnputException ... } //* конец метода buildDist В программе определена исключительная ситуация NegativelnputException, возникающая при вводе отрицательного числа. Ее конструктор выводит сообщение при создании объекта данного класса, а обработчик осуществляет вывод результатов работы метода. Исключительная ситуация ArraylndexOutOfBoundsException является встроенной и возбуждается интерпретатором. Во обоих случаях обработчик не содержит имени объекта в качестве параметра. Ни в одном из этих случаев имена не служат ника- ким целям. Заметим, что все обработчики получают объекты в качестве параметров, но часто это бывает вредно. 13.5.7. Оператор finally Существуют ситуации, при которых процесс должен быть выполнен независимо от того, возбудил ли оператор try исключительную ситуацию или нет, и перехватил ли не- кий метод возбужденную исключительную ситуацию или нет. Одним из примеров такой ситуации является закрытие файла. Другой пример — наличие некоторых внешних ре- сурсов, которые должны быть освобождены в методе независимо от того, каким именно образом завершилось его выполнение. Для этих целей был разработан оператор finally, помещаемый в конце списка обработчиков сразу после конструкции try. В общем случае полная конструкция блока выглядит следующим образом: try { } catch (...) { } ... //*♦ другие обработчики finally { } Семантика этой конструкции такова: если оператор try не возбуждает ни одной ис- ключительной ситуации, оператор finally выполняется перед тем, как выполнение программы будет продолжено и после выполнения конструкции try. Если оператор try возбуждает некую исключительную ситуацию и она перехватывается следующим обра- ботчиком, оператор finally выполняется после того, как обработчик закончит свое выполнение. Если оператор try возбуждает некую ситуацию, но она не перехватывается обработчиком, следующим за конструкцией try, оператор finally выполняется до передачи исключительной ситуации. 572 Глава 13. Обработка исключительных ситуаций
За конструкцией try, не имеющей обработчиков исключительных ситуаций, может следовать оператор finally. Это имеет смысл только в том случае, если составной оператор содержит оператор break, continue или return. Цель этого оператора в этих случаях та же, что и при обработке исключительных ситуаций. Например: try { for (index = 0; index < 100; index+-r) { if (...) { return; } // конец оператора if } //* конец цикла for } //* конец оператора try finally { } //* конец конструкции try Оператор finally здесь будет выполнен независимо от того, прекратил ли оператор return выполнение цикла, или он завершился нормальным образом. 13.5.8. Оценка Механизм обработки исключительных ситуаций в языке Java усовершенствован по сравнению с языком C++, на котором он основывается. Во-первых, программа на языке C++ может возбуждать исключительную ситуацию любого типа, определенного в программе или системой. В языке Java могут быть возбу- ждены только объекты, являющиеся экземплярами класса Throwable или производных от него классов. Это позволяет отделить возбуждаемые объекты от всех других объектов (и не объектов) в программе. Какой смысл может иметь исключительная ситуация, при- водящая к возбуждению значения типа int? Во-вторых, модуль программы на языке С-+, не содержащий оператор throws, мо- жет возбудить любую исключительную ситуацию, которая ничего не говорит пользова- телю. Метод на языке Java, не содержащий оператор throws, не может возбудить ни одной проверенной исключительной ситуации, которую он не обрабатывает. Следова- тельно, читатель метода на языке Java знает из его заголовка, какие исключительные си- туации метод может возбудить, не обрабатывая их. В-третьих, оператор finally очень удобен в определенных ситуациях. Он позволя- ет упорядочить виды действий, которые должны иметь место независимо от того, как именно завершилось выполнение составного оператора. В заключение отметим, что система поддержки выполнения программ на языке Java неявно возбуждает множество исключительных ситуаций, таких как выход индекса мас- сива за пределы допустимого диапазона и доступ к нулевому указателю, которые могут быть обработаны любой пользовательской программой. Программа на языке C++ может обрабатывать лишь те исключительные ситуации, которые возбуждаются явно. Обработку исключительных ситуаций в языках Ada и Java трудно сравнивать. Нали- чие оператора throws в методе на языке Java очень облегчает чтение программ, в то время как в языке Ada нет аналогичных возможностей. Язык Java определенно ближе к языку Ada, чем к языку C++, в том смысле, что он позволяет программам обрабатывать обнаруживаемые системой исключительные ситуации. 13.5. Обработка исключительных ситуаций в языке Java 573
P Q 3 Ш < г -i 'r Г ' •*•• Ту?, ? <-v " •» ‘ \ *' f ’ Л ’ *’’ Z3^- , ^'- * л » Обработка исключительных ситуаций в широко распространенных языках применя- лась редко, несмотря на то, что многие экспериментальные языки, разработанные с сере- дины 1970-х годов, имели такие возможности. Язык PL1 имеет мощные и гибкие средства обработки исключительных ситуаций, но. помимо всего прочего, они слишком сложны для понимания и использования. Дина- мическое связывание исключительных ситуаций с их обработчиками является одним из главных источников этих проблем. Язык Ada обеспечивает широкие возможности по обработке исключительных ситуа- ций и небольшой, но исчерпывающий, набор встроенных исключительных ситуаций. Исключительные ситуации связываются с обработчиками статически. Обработчики до- бавляются к сущностям программы, хотя исключительные ситуации могут явно или не- явно передаваться другим сущностям программы, если нет локального обработчика. Обработка исключительных ситуаций в языке C++ использует статическое связыва- ние исключительных ситуаций с обработчиками. В языке не предусмотрены встроенные иск.1юч1пельные ситуации. Исключительные ситуации связываются с обработчиками с помощью связывания типа выражения в операторе throw с типом формального пара- мо ipa обработчика. Все обработчики имеют одно и то же имя catch. Исключительные ситуации в языке Java являются объектами, происхождение кото- рых должно быть отслежено обратно вплоть до некоторого класса, реализующего ин- терфейс Throwable. Существуют две категории исключительных ситуаций— прове- ренные и непроверенные. Проверенные исключительные ситуации относятся к пользова- тельской программе и компилятору. Непроверенные исключительные ситуации могут возникать всюду и часто игнорируются пользовательской программой. Оператор throws метода в языке Java перечисляет проверенные исключительные сипании, которые могут быть возбуждены и не обрабатываются. Этот оператор должен содержать исключительные ситуации, которые могут возбуждаться вызывающим его ме- юдом. и передавать их обратно в вызывающий метод. Оператор finally в языке Java обеспечивает механизм, гарантирующий, что некий кол будет выполнен независимо от того, как именно завершилось выполнение составно- го оператора try. Дополнительная литература Одно;! из наиболее важных работ по обработке исключительных ситуаций, не связанной ни с одним конкретным языком программирования, является работа Goodenough (1975). Проблемы, связанные с разработкой средства обработки исключительных си- паний в языке PL/1, рассматривались в работе MacLaren (1977). Обработка исключи- юльных ситуаций в языке CLU описана в работе Liskov and Snyder (1979). Средства обработки исключительных ситуаций в языке Ada рассмотрены в работе Googs and Hartmanis (1983). Обработка исключительных ситуаций в языке C++ обсуждается в с । а । ье Siraustrup (1991). 574 Глава 13. Обработка исключительных ситуаций
1. Дайте определение следующих понятий: исключительная ситуация, обработчик исключительной ситуации, возбуждение исключительной ситуации, блокиро- вание исключительной ситуации, продолжение выполнения программы и встроенная исключительная ситуация. 2. В чем заключаются вопросы проектирования средств обработки исключительных ситуаций? 3. Что представляет собой связывание исключительной ситуации с обработчиком ис- ключительной ситуации? 4. В чем заключается проблема связывания исключительных ситуаций с обработчи- ком в языке PL/1 ? 5. Каковы возможные рамки применения исключительных ситуаций в языке Ada? 6. Куда передаются необработанные исключительные ситуации в языке Ada, если он возникли в подпрограмме, в блоке, в теле пакета или в задаче? 7. С какого места возобновляется выполнение программы после обработки исключи- тельной ситуации в языке Ada? 8. Как можно возбудить исключительную ситуацию в языке Ada явным образом? 9. Как объявляется в языке Ada исключительная ситуация, определенная пользователем? 10. Как можно подавить исключительную ситуацию в языке Ada? 11. Как называются все обработчики исключительных ситуаций в языке C++? 12. Как можно возбудить исключительную ситуацию в языке C++ явным образом? 13. Как в языке C++ исключительные ситуации связываются с обработчиками? 14. Как на языке C++ написать обработчик исключительной ситуации так, чтобы он обрабатывал любую исключительную ситуацию? 15. Куда передается управление после завершения выполнения обработчика исключи- тельной ситуации в языке C++? 16. Существуют ли в языке C++ встроенные исключительные ситуации? 17. Какой класс является корневым для всех классов исключительных ситуаций в языке Java? 18. Какой класс является базовым для большинства определенных пользователем классов исключительных ситуаций в языке Java? 19. Как на языке Java написать обработчик исключительной ситуации так, чтобы он обрабатывал любую исключительную ситуацию? 20. В чем заключается разница между спецификацией throw в языке C++ и операто- ром throws в языке Java? 21. В чем заключается разница между проверенными и непроверенными исключи- тельными ситуациями в языке Java? 22. Можно ли заблокировать исключительные ситуации в языке Java? 23. Для чего предназначен оператор finally в языке Java? Вопросы 575
вания I , < Джон Мак-Карти (John McCarthy) Джон Мак-Карти и Марвин Мин- ски (Marvin Minsky) в 1958 году разработали в Массачусетском технологическом институте про- ект по созданию искусственного интеллекта. В 1958-1959 годах Мак-Карти создал язык LISP, ко- торый вошел в практику в 1959 году. Мак-Карти работал также в команде по разработке языка ALGOL. В J Т О И J л 11 Б 14.1. Введение 14.2. ' Математические функции 14.3. Основы функциональных языков программирования 14.4. Первый язык функционального программирования — LISP 14.5. Введение в язык Scheme 14.7. Язык ML 14.8. Язык Haskell 14.9. Применение функциональных языков 14.10. Сравнение функциональных и императивных языков Функциональные языки программирования 579
В этой главе рассматривается функциональное программирование, а также неко- торые языки программирования, созданные для реализации этого подхода к раз- работке программного обеспечения. Поскольку эти языки основаны на математических функциях, начинаем с обзора основных математических идей, включая функциональное лямбда-исчисление Черча. Эта глава также содержит краткое описание функциональных форм и несколько примеров наиболее распространенных из них. Далее излагается идея функционального языка программирования, обзор первого языка программирования LISP, а также его структуры данных в виде списков и синтаксис функций, основанный на лямбда-исчислении. Следующий большой раздел посвящен введению в язык Scheme и содержит описание его элементарных функций, специальных и функциональных форм, а также примеры простых функций, написанных на языке Scheme. Затем мы обсудим не- которые императивные свойства языка Scheme. Далее приводится краткое введение в языки COMMON LISP, ML и Haskell, для того чтобы показать отличие идей разработки языков функционального программирования от идей, положенных в основу языка Scheme. В следующем разделе описывается применение функциональных языков про- граммирования. В заключение мы сделаем краткий сравнительный анализ функциональ- ных и императивных языков программирования. 14.1. Введение Первые 13 глав этой книги касались в основном императивных и объектно- ориентированных языков программирования. За исключением языка Smalltalk, рассмот- ренные нами языки программирования внешне были похожи на императивные языки. В данной главе впервые в центре внимания находятся неимперативные языки. Высокая степень схожести между императивными языками частично объясняется тем, что они разработаны на основе общего принципа— неймановской архитектуры компьютера, рассмотренной в главе 1. Мы можем считать императивные языки в целом шагом вперед в направлении улучшения базовой модели, которой являлся язык FORTRAN I. Все. что было разработано в этой области, направлено на эффективное ис- пользование неймановской архитектуры компьютера. Несмотря на то что императивный стиль программирования получил признание большинства программистов, некоторые специалисты считают, что его зависимость от архитектуры компьютеров, лежащей в ос- нове этого стиля, ограничивает процесс разработки программного обеспечения. Существуют другие принципы разработки языков, многие из которых более ориенти- рованы на конкретные парадигмы или методологии программирования, чем на эффек- тивное выполнение в рамках конкретной компьютерной архитектуры. До сих пор. одна- ко, вследствие невысокой эффективности выполнения программ, написанных на этих языках, они не получили такой же популярности, как императивные языки. Парадигма функционального программирования, основанная на математических функциях, лежит в основе многих важных неимперативных стилей программирования. Эти стили поддерживаются функциональными, или аппликативными, языками програм- мирования. Язык LISP возник как чисто функциональный, но вскоре приобрел некоторые важные императивные свойства, увеличившие эффективность выполнения программ, написан- ных на этом языке. Язык LISP остается наиболее важным из функциональных языков, по крайней мере, в том смысле, что только он получил широкое распространение. Язык Scheme является небольшим диалектом языка LISP, использующим статический обзор 580 Глава 14. Функциональные языки программирования
данных. Язык COMMON LISP — смесь нескольких диалектов языка LISP, разработан- ных в начале 1980-х годов. Язык ML — строго типизированный функциональный язык с более традиционным синтаксисом, чем у языков LISP и Scheme. Язык Haskell частично основывается на языке ML, но при этом является чисто функциональным языком. Цель этой главы— рассмотреть концепцию (а не процесс) функционального про- граммирования. Мы также опишем несколько способов разработки языка, обеспечи- вающих традиционные средства функционального программирования. Сначала мы опи- шем математические функции и функциональное программирование, а затем рассмотрим подмножество языка Scheme, являющегося чисто функциональным языком, для того чтобы проиллюстрировать функциональный стиль программирования. В главе содер- жится материал, достаточный для того, чтобы читатель смог написать некоторые про- стые, но интересные программы. Трудно усвоить функциональный стиль программиро- вания без реального опыта. 14.2. Математические функции Математическая функция — это отображение (mapping) элементов одного множест- ва, называемого областью определения (domain set), в другое множество, называемое множеством значений (range set). В определении функции явно или неявно указывают- ся множество определения и множество значений вместе с самим отображением. Ото- бражение описывается выражением или, в некоторых случаях, таблицей. Функции часто применяются к отдельному элементу множества определения. Заметим, что множество определения может быть результатом пересечения нескольких множеств. Функция выра- батывает, или возвращает, элемент из множества значений. Одно из основных свойств математических функций заключается в том, что порядок вычисления выражений, задающих отображения, управляется рекурсивными и условны- ми выражениями, а не итеративным повторением и последовательностью выполнения операций, как в императивных языках программирования. Другое важное свойство математических функций состоит в том, что они всегда оп- ределяют одно и то же значение при заданном наборе аргументов, поскольку они не имеют побочных эффектов. Побочные эффекты в языках программирования связаны с переменными, моделирующими ячейки памяти. Математическая функция определяет значение, а не указывает последовательность операций над числами, хранящимися в ячейках памяти, для вычисления некоторого значения. Здесь нет переменных, как в им- перативных языках программирования, поэтому не может быть побочных эффектов. 14.2.1. Простые функции Определения функций часто записываются в виде имени функции, за которым следу- ет список параметров в скобках и выражение, задающее отображение. Например, cube(x)=x*x*x, где х — действительное число. В этом определении областью определения и областью значений является множество всех действительных чисел. Символ = используется в качестве сокращения выражения “определяется как”. Параметр х может быть любым элементом из множества определе- ния, но во время вычисления выражения, определяемого функцией, он представляет со- бой только один конкретный элемент. В этом заключается отличие параметров матема- тических функций от переменных в императивных языках. 14.2. Математические функции 581
Функции применяются с помощью образования пар, состоящих из имени функции и конкретного элемента из множества определения. Элемент, являющийся значением функции, получается путем вычисления выражения, задающего отображение, примени- тельно к элементу из области определения, замененного значением параметра. Напри- мер, cube(2.0) в результате равно 8.0. Здесь снова важно отметить, что во время вычис- ления данное отображение, определяемое функцией, не содержит несвязанных парамет- ров, где связанный параметр— это имя конкретного значения. Каждое появление параметра связывается со значением из области определения и рассматривается как кон- станта во время вычисления. Ранние теоретические работы, посвященные функциям, отделяли задачу определения функции от задачи именования функции. Метод определения безымянных функций реа- лизуется с помощью лямбда-исчисления, разработанного Алонзо Черчем (Alonzo Church) (Church, 1941). Параметр и отображение, определенное функцией, задается лямбда- выражением (lambda expression). Само по себе лямбда-выражение является функцией. Например, рассмотрим выражение к(х)х*х*х Как указывалось выше, перед вычислением параметр представляет собой любой эле- мент множества определения функции, однако во время вычисления он связан с кон- кретным элементом. При вычислении лямбда-выражения для заданного параметра гово- рят, что выражение применяется к данному параметру. Механизм такого применения тот же самый, что и при вычислении любой функции. Применение лямбда-выражения, ука- занного выше, обозначается так, как это показано в следующем примере: л(х)х*х’*х(2). Результат этого выражения равен 8. Подобно другим определениям функций, лямбда-выражения могут иметь несколько параметров. 14.2.2. Функциональные формы Функции более высокого порядка, или функциональные формы (functional forms), отличаются тем, что они либо получают функции в виде параметров, либо некая функция является результатом их работы, либо имеет место и то, и другое. Наиболее распростра- ненным видом функциональных форм является композиция функций (function composition), параметрами которой служат две функции. Результат композиции функ- ций — это функция, значения которой являются, в свою очередь, результатом примене- ния первой функции-параметра к результату работы второй функции-параметра. Компо- зиция функций записывается как выражение с помощью оператора °, как показано ниже h'f'g- Например, если Дх)Эх + 2, fix) 3 ♦ х, то h определяется как А(х) s/g(x)), или h(x) н (3*х)+2. 582 Глава 14. Функциональны, языки программирования
Конструкция (construction) — это функциональная форма, получающая в качестве параметра список функций. В результате применения к своему аргументу конструкция применяет каждую из своих функций-параметров к этому аргументу и объединяет ре- зультаты в список или последовательность. Конструкция синтаксически обозначается за- ключением функций в квадратные скобки, например [f,g]. Рассмотрим следующий при- мер: пусть g(x) = X ♦ X, Л(х) = 2 ♦ х. z(x)sx/2, тогда [g,A./](4) вырабатывает значение (16.8.2). Функция применить-ко-вссм (apply-to-all) — это функциональная форма, имеющая в качестве параметра одну функцию. Если она применяется к списку аргументов, то функция применить-ко-всем применяет свою функцию-параметр к каждому из значений в списке аргументов и объединяет результаты в список или последовательность. Функ- ция применить-ко-всем обозначается как а. Рассмотрим следующий пример: пусть й(х) = х*х, тогда а(Л,(2,3,4)) вырабатывает значения (4,9,16). Существует много других функциональных форм, но этих примеров должно быть достаточно, чтобы проиллюстрировать их свойства. 14.3. Основы функциональных языков программирования Функциональные языки программирования разрабатывались с целью как можно более точной имитации математических функций. В результате возник подход к решению задач, в корне отличающийся от методов, применяемых императивными языками. В императив- ном языке вычисляется некое выражение, и результат помещается в ячейку памяти, пред- ставленную переменной в программе. Необходимость уделять внимание ячейкам йамяти приводит к методологии программирования относительно низкого уровня, близкого к ма- шинным командам. Программа на языке ассемблера часто к тому же должна хранить ре- зультаты промежуточных вычислений. Например, чтобы вычислить выражение (х+у)/(а-Ь), значение выражения (х+у) должно быть вычислено первым. Это значение должно быть помещено в память, пока вычисляется значение выражения (а-b). Чтобы облегчить эту задачу, размещение промежуточных результатов вычисления выражений в языках высокого уровня обрабатывается компилятором. Размещение промежуточных результа- тов в этих языках все еще необходимо, но детали скрыты от программиста. Чисто функциональный язык программирования не использует ни переменных, ни опе- раторов присваивания. Это освобождает программиста от заботы о ячейках памяти компь- ютера, на котором будет выполняться программа. Отсутствие переменных делает невоз- можным использование итеративных конструкций, поскольку они управляются перемен- 14.3. Основы функциональных языков программирования 583
ними. Повторение должно выполняться только с помощью рекурсии. Программы пред- ставляют собой определения функций и спецификаций применения функций, а выполнение заключается в вычислении применений функции. В отсутствие переменных выполнение программы, написанной на чисто функциональном языке, не имеет структуры в смысле операционной или денотационной семантики. Выполнение функции при одних и тех же параметрах всегда приводит к одному и тому же результату. Это явление называется про- зрачностью ссылок (referential transparency). Она делает семантику чисто функциональ- ных языков программирования намного более простой, чем семантика императивных язы- ков и функциональных языков, обладающих императивными свойствами. Функциональный язык содержит набор элементарных функций, набор функциональ- ных форм для построения сложных функций из этих элементарных функций, операцию применения функции и некоторую структуру или структуры для представления данных. Эти структуры используются для представления параметров и значений, вычисленных функциями. Хорошо определенный язык функционального программирования нуждает- ся лишь в небольшом количестве элементарных функций. Несмотря на то что функциональные языки программирования часто реализуются с помощью интерпретаторов, они также могут быть компилируемыми. Императивные языки обычно предусматривают лишь ограниченную поддержку функционального программирования. Большинство из них, например, содержат некото- рый вид определений функций и средства для их применения. Наиболее серьезный не- достаток использования императивного языка для функционального программирования состоит в том, что функции в императивных языках имеют ограничения, наложенные на типы значений, которые они возвращают. В таких языках, как FORTRAN и Pascal, функ- ции возвращают только переменные скалярного типа. Еще более важным является то, что они не могул возвращать функции. Такие ограничения сужают круг функциональных форм, поддерживаемых императивным языком. Другая серьезная проблема, относящаяся к функциям в императивных языках, заключается в том, что функции могут иметь по- бочные эффекты. 14.4. Первый язык функционального программирования — LISP К настоящему моменту разработано много языков программирования. Наиболее ста- рым и широко используемым из них является язык LISP. Изучение функциональных языков программирования на примере языка LISP сродни изучению императивных язы- ков на примере языка FORTRAN: язык LISP был первым функциональным языком, но некоторые сейчас думают, несмотря на его постоянное развитие на протяжении послед- них 30 лет, что он больше не соответствует современным концепциям разработки функ- циональных языков. Кроме того, за исключением первой версии, все диалекты языка LISP обладают такими императивными свойствами, как переменные в императивном стиле, операторы присваивания и операторы цикла. (Переменные в императивном стиле используются для именования ячеек памяти, значения в которых могут изменяться много раз в ходе выполнения программы.) Несмотря на это, а также на их довольно странную форму, наследники исходного языка LISP хорошо представляют основные концепции функционального программирования и, следовательно, достойны изучения. 584 Глава 14. Функциональные языки программирования
14.4.1. Типы и структуры данных Существуют только два типа данных в языке LISP: атомы и списки. В языке LISP нет типов в том смысле, в котором они существуют в императивных языках. В действитель- ности, язык LIST — это язык, лишенный типов. Атомы, имеющие вид идентификато- ров, — это символы языка LISP. Числовые константы также рассматриваются как атомы. Напомним (см. главу 2), что язык LISP сначала использовал в качестве своей струк- туры данных списки, поскольку они считались существенной частью процесса обработки списков. Со временем, однако, язык LISP стал редко нуждаться в операциях вставки и удаления. Списки указываются в виде разделенных между собой элементов, заключенных в скобки. Элементы простых списков являются атомами, как в приведенном примере: (А В С D) Вложенные структуры списков также указываются с помощью скобок. Например, список (А (В С) С (Е (F G) ) ) представляет собой список из четырех элементов. Первый элемент — атом А; второй эле- мент— подсписок (В С); третий элемент— атом D: четвертый элемент— подсписок (Е (Г G) ). который содержит в качестве своего второго элемента подсписок (Г G). Списки обычно хранятся в виде односвязных структур списков, в которых каждый узел имеет два указателя и представляет собой элемент списка. Первый указатель в узле, представляющем собой атом, ссылается на некоторое представление атома, например, символ или числовое значение. В узле, соответствующем подсписку, первый указатель ссылается на первый узел подсписка. В обоих случаях второй указатель в узле ссылается на следующий элемент списка. Ссылка на список осуществляется через указатель на его первый элемент. Внутреннее представление списков в двух наших примерах показано на рис. 14.1. За- метим, что элементы списка показаны горизонтально. Последний элемент списка не имеет последующего элемента, поэтому он содержит нулевой указатель NIL. Показаны также подсписки той же самой структуры. 14.4.2. Первый интерпретатор языка LISP Первоначальным намерением разработчиков было получить обозначения для про- граммы на языке LISP, которые были бы как можно ближе к языку FORTRAN с необхо- димыми дополнениями. Эти обозначения получили название М-обозначений (мета- обозначения). Требовался компилятор, который мог бы транслировать программы, напи- санные в системе М-обозначений, в семантически эквивалентные программы на машин- ном коде компьютера IBM 704. На ранней стадии развития языка LISP Мак-Карти решил написать статью, которая могла бы обосновать применение обработки списков в качестве подхода к реализации общей символьной обработки. Мак-Карти полагал, что обработка списков могла быть использована для изучения вопросов вычислимости, которые в то время обычно изуча- лись с помощью машин Тьюринга. Мак-Карти считал. что обработка символьных спи- сков— более естественная модель вычислений, чем машины Тьюринга. Одно из основ- ных требований при изучении вопросов вычислимости заключается в том, что необхо- 14.4. Первый язык функционального программирования — LISP 585
димо доказывать определенные характеристики вычислимости целого класса функций при любой модели вычислений. Можно построить универсальную машину Тьюринга, способную имитировать операции, выполняемые любой другой машиной Тьюринга. На основе этой концепции возникла идея построить универсальную LISP-функцию, спо- собную вычислять любую функцию в языке LISP. (А В С D) (А (В С) Э (Е (F G))) Рис. 14.1. Внутреннее представление двух списков в языке LISP Первое требование к универсальной LlSP-функции состояло в необходимости созда- ния системы обозначений, позволяющих выражать функции тем же способом, что и дан- ные. Обозначение списка в виде элементов, заключенных в скобки, описанное в разде- ле 14.4.1, уже было принято для обозначения данных в языке LISP, поэтому было реше- но придумать соглашения для определения и вызовов функций, позволяющие выразить их в виде списков. Вызовы функций записывались в префиксной форме списка, полу- чившей название польской записи (Cambridge Polish), как показано ниже: (имяфункции аргумент_1 ... аргументп) Например, если + представляет собой функцию, получающую два числовых парамет- ра, то результатом выражения (+5 7) является число 12. Для определения функций была выбрана система лямбда-обозначений, описанная в разделе 14.2.1. Однако ее следовало модифицировать, чтобы позволить связывать функ- ции с их именами так, чтобы на функции можно было бы ссылаться с помощью других функций или ее самой. Это связывание имен указывалось в списке, состоящем из имени функции и списка, содержащего лямбда-выражение, как показано ниже: 586 Глава 14. Функциональные языки программирования
(имя функции (LAMBDA (аргумент_1 ... аргумент_п) выражение)) Если бы мы не имели никаких предварительных представлений о функциональном программировании, странно было бы даже рассматривать безымянные функции. Однако безымянные функции в функциональном программировании (так же, как и в математике) иногда бывают полезными. Например, рассмотрим некую функцию, действие которой заключается в вырабатывании функции, немедленно применяющейся к списку парамет- ров. Для полученной в результате функции не требуется имени, чтобы применить ее только в той точке, где она была построена. Такой пример приведен в разделе 14.5.6. Функции языка LISP, представленные в таких обозначениях, вызывались с помощью S-выражений, представляющих собой форму записи символьных выражений. В итоге все структуры языка LISP, и данные, и код. стали называться S-выражениями. S-выражение может быть либо списком, либо атомом. Мы будем часто называть S-выражение просто выражением. Мак-Карти успешно разработал универсальную функцию, способную вычислять лю- бую другую функцию. Эта функция получила название EVAL и сама имела вид выраже- ния. Два сотрудника, привлеченных к реализации проекта по созданию искусственного интеллекта, Стефен Рассел (Stephen В. Rassel) и Дэниэл Эдвардс (Daniel J. Edwards), за- метили, что реализация функции EVAL могла бы служить в качестве интерпретатора, и немедленно создали такую реализацию (McCarthy et al., 1965). Скорость, легкость и неожиданность этой реализации привели к нескольким важным результатам. Во-первых, все ранние LlSP-системы копировали функцию EVAL, и таким образом, были интерпретирующими системами. Во-вторых, определение М-обозначе- ний, которые планировалось использовать для записи программ на языке LISP, никогда не было завершено или реализовано, так что S-выражения стали единственной системой обозначений в языке LISP. Применение одной и той же системы обозначений для данных и кода программы имеет важные последствия, одно из которых будет обсуждаться в раз- деле 14.5.7. В-третьих, многие свойства первоначальной разработки языка были успешно сохранены, включая и такие необычные свойства языка, как вид условного выражения, а также использование нуля для обозначения как нулевого адреса, так и логического зна- чения “ложь”. Другая особенность ранних LlSP-систем, которая несомненно носит случайный ха- рактер, состоит в использовании динамического обзора данных. Функции вычислялись в тех программах, которые их вызывали. В то время об областях видимости данных было известно довольно мало, поэтому вряд ли при выборе способа обзора данных разработ- чики долго размышляли. Динамический обзор данных использовался в большинстве диалектов языка LISP до 1975 года. Современные диалекты либо используют статиче- ский обзор данных, либо позволяют программисту делать выбор между статическим и динамическим обзором данных. 14.5. Введение в язык Scheme В этом разделе описана часть языка Scheme (Dybvig. 1996). Мы выбрали язык Scheme, потому что он относительно прост, популярен в колледжах и университетах, а интерпретаторы языка Scheme легкодоступны для любого типа компьютеров. В этом разделе описываются версии языка Scheme под названием Scheme 4. 14.5. Введение в язык Scheme 587
14.5.1. Происхождение языка Scheme Язык Scheme, являющийся диалектом языка LISP, появился в Массачусетском техно- логическом институте в середине 1970-х годов (Sussman and Steele, 1975). Он невелик, использует исключительно статический обзор данных и обрабатывает функции как сущ- ности первого класса (first-class entities). В качестве сущностей первого класса функции в языке Scheme могут быть значениями выражений и элементами списков, а также могут присваиваться переменным и передаваться как параметры. Ранние версии языка LISP не имели всех этих возможностей. Будучи небольшим языком с простыми синтаксисом и семантикой, язык Scheme хо- рошо подходит для сферы образования, например, изучения функционального програм- мирования, а также для общего введения в программирование. Заметим, что большинство функций на языке Scheme, приведенные в следующем раз- деле, могут потребовать лишь небольших изменений для того, чтобы переписать их в ви- де функций на языке LISP. 14.5.2. Элементарные функции Имена в языке Scheme могут состоять из букв, цифр и специальных символов, за ис- ключением скобок; они не зависят от регистра клавиатуры, но не должны начинаться с цифры. Интерпретатор языка Scheme представляет собой бесконечный цикл типа прочитать- вычислить-записать. Он непрерывно читает выражение, напечатанное пользователем (в виде списка), интерпретирует его и отображает результат. Литералы вычисляют литера- лы. Таким образом, если вы ввели в интерпретатор число, то он просто отобразит его. Выражения, которые вызывают элементарные функции, вычисляются следующим обра- зом: сначала вычисляется каждый из параметров выражения независимо от порядка их следования. Затем элементарная функция применяется к значениям параметров, и ото- бражается результат. Язык Scheme содержит элементарные функции для выполнения основных арифмети- ческих операций. К ним относятся +, -, * и / для сложения, вычитания, умножения и де- ления. Функции * и + могут иметь несколько параметров, но могут и совсем не иметь их. Если функция * задана без параметров, она возвращает 1; если функция + задана без па- раметров, она возвращает 0. Функция + складывает все свои параметры вместе. Функции /и - могут иметь несколько параметров. При вычитании все параметры, кроме первого, вычитаются из первого. Деление похоже на вычитание. Примеры приведены ниже: EXPRESSION VALUE 42 42 (* 3 7) 21 (+ 5 7 8) 20 (-5 6) -1 (-15 7 2) 6 (-24 (* 4 3)) 12 Функция SQRT возвращает квадратный корень из числового параметра, если значе- ние параметра неотрицательно. Следующая элементарная конструкция языка Scheme, которую мы опишем,— это служебная функция, необходимая в силу самой природы операции EVAL применения 588 Глава 14. Функциональные языки программирования
функций языка Scheme. Функция EVAL является основой вычисления всех функций в языке Scheme, независимо от того, элементарная это функция или нет. Она вызывается для обработки вычислительной части действия прочитать-вычислить-записать. выпол- няемого интерпретатором языка Scheme. При применении к элементарной функции функция EVAL сначала вычисляет параметры заданной функции. Это необходимо, если действительные параметры в вызове функции сами представляют собой вызовы других функций, что случается довольно часто. В некоторых вызовах, однако, параметрами яв- ляются данные, либо атомы, либо списки, а не ссылки на функции. В этих случаях пара- метры, очевидно, не должны вычисляться. Допустим, что мы имеем функцию, имеющую два параметра — атом и список, — для определения, принадлежит ли заданный атом заданному списку. Ни атом, ни список не должны вычисляться. Для того чтобы избежать вычисления параметра, он сначала пере- дается как параметр элементарной функции QUOTE, которая просто возвращает его без изменения. Функция QUOTE иллюстрируется следующими примерами: (QUOTE А) возвращает А (QUOTE (АВС)) возвращает (АВС)) В оставшейся части этой главы мы будем использовать обычное сокращение для обо- значения вызова функции QUOTE, которое заключается в простом приписывании перед выражением символа апострофа (’). Таким образом, вместо (QUOTE (А В)) мы бу- дем использовать обозначение ’ (А В). Компьютерные программы манипулируют данными независимо от того, является ли язык, на котором они написаны, императивным или функциональным. Поскольку списки представляют собой основные структуры данных в языке Scheme, этот язык должен иметь элементарные конструкции для обработки списков. В частности, он должен обес- печивать операцию для выбора частей списка, которая в некотором смысле разбирает список на составные части, и хотя бы одну операцию для создания списков. Поскольку основные операции в функциональных языках выполняются с помощью функций, в язы- ке Scheme предусмотрены элементарные функции для этих операций. Существуют две элементарные функции выбора элементов из списков в языке Scheme: CAR и CDR (произносится как “куд-ер” (“could-er”)). Функция CAR возвращает первый элемент заданного списка. Она иллюстрируется следующими примерами. (CAR ’(АВС)) возвращает А (CAR ’((АВ) CD)) возвращает (А В) (CAR ’А) — ошибка (А не является списком) (CAR ’(А)) возвращает А (CAR ’()) - ошибка Функция CDR возвращает остаток заданного списка, полученного после удаления его первого элемента: (CDR ’(АВС)) возвращает (В С) (CDR ’((АВ) CD)) возвращает (С D) (CDR ’А) — ошибка (CDR ’(А)) возвращает () Имена функций CAR и CDR являются, мягко говоря, необычными. Происхождение этих имен восходит к первой реализации языка LISP на компьютере IBM 704. Память этого компьютера имела два поля, которые назывались декрементом и адресом и исполь- 14.5. Введение в язык Scheme 589
зовались для реализации различных стратегий адресации операндов. Каждое из этих по- лей могло хранить адрес машинной памяти. Компьютер IBM 704 также имел две машин- ные инструкции, называвшиеся CAR (content of address register — содержимое регистра адреса) и CDR (contents of decrement register— содержимое регистра декремента), кото- рые извлекали содержимое соответствующих полей. Было естественно использовать эти поля для хранения двух указателей на узел списка так, чтобы машинное слово точно хра- нило один узел. На основании этих соглашений инструкции CAR и CDR компьютера IBM 704 обеспечивали эффективный выбор элементов из списков. Эти имена впоследст- вии были перенесены во все диалекты языка LISP. Функция CONS — элементарный конструктор списка. Она создает список по двум своим аргументам, первый из которых может быть либо атомом, либо списком; второй обычно является списком. Эта функция вставляет свой первый параметр в качестве пер- вого элемента своего второго параметра. Рассмотрим следующие примеры: (CONS ’А ’()) возвращает (А) (CONS ’А ’(В С)) возвращает (А В С) (CONS ’() ’(АВ)) возвращает (() АВ) (CONS ’(АВ) ’(С D)) возвращает ((АВ) С D) Результаты этих операций CONS показаны на рис. 14.2. Заметим, что операция CONS в некотором смысле противоположна операциям CAR и CDR. Операции CAR и CDR раз- бивают списки на части, а операция CONS создает новый список из его заданных частей. Два параметра операции CONS становятся первым элементом и остальной частью нового списка. Таким образом, если lis — это список, то результатом следующего выражения будет функция эквивалентности: (CONS (CAR lis) (CDR lis)) Функция LIST создает список из переменного числа параметров. Это— сокращен- ная версия вложенных функций CONS, как показано ниже (LIST ’apple ’orange ’grape) Это эквивалентно выражению (CONS ’apple (CONS ’orange (CONS ’grape ’() ))) Тремя важными предикатными функциями среди элементарных функций языка Scheme являются функции EQ?, NULL? и LIST?. Заметим, что все встроенные преди- катные функции имеют имена, заканчивающиеся вопросительным знаком. Предикатной называется функция, которая возвращает булевское значение (истина или ложь). В языке Scheme двумя булевскими значениями являются значения #Т и #F. Интерпретатор языка Scheme возвращает пустой список () вместо значения # F. Любой ненулевой список, возвращаемый предикатной функцией, интерпретируется как значение #Т. Функция EQ? получает два символьных параметра. Она возвращает значение #Т, ес- ли оба параметра являются атомами и эквивалентны между собой; в противном случае она возвращает пустой список (). Рассмотрим следующие примеры: (EQ? ’А *А) возвращает #Т (EQ? ’А ’В) возвращает #F (EQ? ’А ’(А В)) возвращает () (EQ? * (А В) ’(А В)) возвращает () или #Т 590 Глава 14. Функциональные языки программирования
(CONS ’A ’()) (A) A (CONS ’A ’(BO) (А В C) (CONS ’() ’(A3)) (OA B) NIL Puc. 14.2. Результаты нескольких операций CONS Как показывает последний пример, результат сравнения списков с помощью функции EQ? зависит от конкретной реализации— в одних случаях получится значение #Т, в других— пустой список (). Причина этого состоит в том, что функция EQ? часто реа- лизуется как сравнение указателей (ссылаются ли два данных указателя на одно и то же место), и два абсолютно одинаковых списка часто не копируются в памяти. При созда- нии списка система языка Scheme проверяет, существует ли уже такой список. При по- ложительном ответе новый список представляет собой не более, чем указатель на суще- ствующий список. В этих случаях два списка будут проверяться на совпадение функцией EQ?. Однако иногда трудно обнаружить идентичный список при создании нового. Тогда функция EQ? возвращает пустой список (). Заметим, что функция EQ? всегда работает правильно только с символьными атомами. Предикаты для сравнения числовых атомов будут описаны ниже. Как уже отмечалось вы- ше, функция EQ? также ненадежно работает, когда ее параметрами являются списки. Предикатная функция LIST? возвращает значение #Т, если ее единственным аргу- ментом является список, в противном случае она возвращает пустой список (), как по- казано в следующих примерах: 14.5. Введение в язык Scheme 591
(LIST? ’(X Y)) возвращает #T (LIST? *X) возвращает () (LIST? '()) возвращает #T Функция NULL? проверяет, не является ли ее параметр пустым списком, и возвраща- ет в этом случае значение #Т. Рассмотрим следующие примеры: (NULL? ’(А В)) возвращает () (NULL? ’()) возвращает #Т (NULL? ’А) возвращает () (NULL? ’(())) возвращает () В последнем случае возвращается пустой список (), потому что параметр — не пус- той список, а список, содержащий один элемент, являющийся пустым списком. Язык Scheme содержит набор следующих предикатных функций для числовых данных. Функция > >= EVEN? ODD? ZERO? Смысл Равно Не равно Больше чем Меньше чем Больше или равно Меньше или равно Четное ли число? Нечетное ли число? Равно ли число нулю? В отличие от функции EQ? предикат = работает с числовыми, а не с символьными атомами. Для того чтобы проверить атомы на равенство, когда неизвестно, являются ли они символьными или числовыми, язык Scheme использует предикат EQV?, работающий как с числовыми, так и с символьными атомами. Основная причина, по которой необходимо применять функции EQ? или =, а не EQV?, там, где это возможно, состоит в том, что функции EQ? и = работают быстрее, чем функция EQV?. Язык Scheme имеет несколько простых функций вывода, например: (DISPLAY выражение) и (NEWLINE) Эти функции имеют очевидный смысл. Большинство выводов из программы на языке Scheme, однако, являются обычными выводами интерпретатора, отображающими ре- зультаты применения функции EVAL к функциям верхнего уровня. Параметры в языке Scheme передаются по значению, так что независимо от того, что именно функция делает со своими параметрами, фактические параметры не изменяют своего значения. 592 Глава 14. Функциональные языки программирования
14.5.3. Функции для построения функций Как указывалось ранее, языки, основанные на языке LISP, используют для определе- ния функций систему лямбда-обозначений в виде списка. Например, следующий список, содержащий лямбда-выражение, является функцией, возвращающей второй элемент сво- его заданного параметра, являющегося списком: (LAMBDA (L) (CAR (CDR L))) Эту функцию можно применять таким же образом, как и именованные функции: поме- щая ее в начале списка, содержащего фактические параметры. Например, мы могли бы записать ((LAMBDA (L) (CAR (CDR L))) ’(ABC)) Это выражение дает в результате В. Заметим, что фактические параметры функций в языке Scheme, определенные как параметры лямбда-выражения, не заключаются в ка- вычки; примером этого является параметр L в вызове функции CDR в приведенном выше выражении. Параметр L называется переменной, связанной с лямбда-выражением. Свя- занная переменная никогда не изменяется в выражении, после того как она была связана со значением фактического параметра во время первого вызова лямбда-выражения с це- лью его вычисления. Специальная форма— функция DEFINE — предназначена для связывания имени со значением и с лямбда-выражением. Это может звучать так, будто функцию DEFINE можно использовать для создания переменных в стиле императивных языков. Однако эти связывания имен создают именованные константы, а не переменные. Функция DEFINED называется специальной формой, поскольку, как мы скоро уви- дим, она интерпретируется совершенно иначе, чем обычные элементарные функции, на- пример, CAR или арифметические функции. Простейшей формой функции DEFINE является функция, используемая для связыва- ния символа со значением выражения. Эта форма имеет вид: {DEFINE символ ьное__выраже ни е} Например, {DEFINE pi 3.14159} {DEFINE two_pi (* 2 pi)) Если эти два выражения были введены в интерпретатор языка Scheme, то выводится число 3.14159; если введено выражение two_pi, выводится число 6.28318. Функцию DEFINE можно также использовать для связывания лямбда-выражения с именем. В этом случае связывания лямбда-выражение сокращается, чтобы удалить слово lambda. В этой форме функция DEFINE получает в качестве параметров два списка. Первый параметр представляет собой прототип вызова функции, состоящий из имени функции и следующих за ним формальных параметров, перечисленных в виде списка. Второй список является выражением, с которым должно быть связано данное имя. Об- щая форма такой функции DEFINE приведена ниже: (DEFINE (имя_функции параметры) тело ) 14.5. Введение в язык Scheme 593
Здесь параметры разделены пробелами (не запятыми), а тело представляет собой после- довательность выражений в виде списка. В приведенном ниже примере функция DEFINE вызывается для того, чтобы связать имя square со следующим за ним выражением: (DEFINE (square number) (* number number)) Как только интерпретатор вычислит эту функцию, ее можно использовать, как показано ниже: (square 5) Здесь функция square дает в качестве результата число 25. Семантика специальной формы функции DEFINE при ее использовании для опреде- ления функции состоит в следующем. Часть первого параметра и весь второй параметр рассматриваются вместе как некое лямбда-выражение. Имя первого параметра связыва- ется с этим выражением. Для того чтобы проиллюстрировать разницу между элементарными функциями и специальной формой функции DEFINE, рассмотрим выражение (DEFINE х 10) Если бы функция DEFINE была элементарной, то первое действие функции EVAL над этим выражением заключалось бы в вычислении двух параметров функции DEFINE. Ес- ли бы имя х не было связано со значением, возникла бы ошибка. В качестве следующего примера простой функции рассмотрим выражение (DEFINE (second 1st) (CAR (CDR 1st))) В этом случае имя second связывается с лямбда-выражением ((LAMBDA (1st) (CAR (CDR 1st))) Как только эта функция будет вычислена, ее можно использовать, как показано ниже: (second ’(АВС)) Здесь функция возвращает в качестве результата символ В. 14.5.4. Поток управления Механизм потока управления в языке Scheme моделирует поток управления в мате- матических функциях. Поток управления в определениях математических функций отли- чается от потока управления в программах, написанных на императивных языках про- граммирования. В то время как функции в императивных языках программирования оп- ределяются как наборы операторов, содержащие некоторые виды последовательного потока управления, математические функции не имеют многочисленных операторов и используют для потока вычислений только рекурсии и условные выражения. Например, функцию факториала можно определить с помощью двух операторов в следующем виде: [1, если и = 0, если п > 0. Заметим, что математические условные выражения записаны в виде списка пар, каждая из которых является защищенным выражением. Каждое защищенное выражение состоит 594 Глава 14. Функциональные языки программирования
из предикатной зашиты и выражения. Значение такого условного выражения — это значе- ние выражения, связанного с предикатом, имеющим значение “истина'’. Только один из предикатов имеет значение “истина’* при заданном параметре или списке параметров. Язык Scheme имеет две управляющие структуры: одну для двухвариантного ветвле- ния и одну для многовариантного ветвления. Обе структуры имеют специальные формы. Двухвариантный оператор ветвления, называемый IF, содержит три параметра: преди- катное выражение, выражение_если и выражение иначе. Вызов оператора IF имеет вид: (IF предикат выражение_если выражение_иначе) Например, (DEFINE (factorial n) (IF (= n 0) 1 (* n (factorial (- n 1))) )) Заметьте, как тесно форма этих функций связана с математическим определением фак- ториала, данным выше. Специальная форма оператора многовариантного ветвления в языке Scheme называ- ется COND. Оператор COND представляет собой незначительно обобщенный вариант ма- тематического условного выражения; он позволяет нескольким предикатам принимать истинные значения одновременно. Поскольку разные математические условные выраже- ния имеют разное количество параметров, оператор COND не требует фиксированного количества фактических параметров. Каждый параметр оператора COND представляет собой пару выражений, первое из которых является предикатом. Общий вид оператора COND приведен ниже: { COND (предикат_1 выражение {выражение}) (предикат_2 выражение {выражение}) (предикат_п выражение {выражение}) (ELSE выражение {выражение}) ) В некоторых реализациях языка Scheme выражение ELSE является необязательным. Семантика оператора COND заключается в следующем. Предикаты параметров вы- числяются по одному в каждый момент времени, начиная с первого параметра, пока ка- кой-либо из предикатов не примет значение #Т. Затем вычисляются выражения, сле- дующие за первым предикатом, который оказался равным #Т, и его значение возвраща- ется в качестве значения оператора COND. Заметим, что в операторе COND атом #Т можно использовать в качестве постоянного предиката, в этом случае выражения, сле- дующие за этим предикатом, вычисляются всегда, а в качестве значения оператора COND возвращается значение последнего такого выражения. Конечно, использовать атом #Т в качестве предиката имеет смысл только для последнего параметра оператора COND. В этих ситуациях обычно используется специальная предикатная константа ELSE, озна- чающая то же, что и атом #Т. 14.5. Введение в язык Scheme 595
Если ни один параметр оператора COND не имеет предиката, принимающего значение #Т, то оператор COND возвращает пустой список (). Отметим схожесть между операто- ром COND и многовариантным оператором ветвления с оператором “иначе” в конце, та- ким как оператор case в языке Ada. 14.5.5. Пример функции на языке Scheme Этот раздел содержит несколько примеров определения функций в языке Scheme. Описываемые программы решают простые задачи обработки списков. Рассмотрим задачу определения принадлежности данного атома простому списку. Простой список— это список, не имеющий подсписков. Если функция называется member, то ее можно использовать следующим образом: (member ’В ’(АВС)) возвращает #Т (member ’В ’(А С D Е)) возвращают () С точки зрения циклов задача принадлежности заключается в простом сравнении данно- го атома и отдельных элементов данного списка по одному в каждый момент времени в определенном порядке, пока не будет найдено соответствие или в списке не останется элементов, подлежащих сравнению. Подобный процесс можно выполнить с помощью рекурсии. Функция сравнивает данный атом с первым элементом списка, если они сов- падают. возвращается значение #Т. Если они не совпадают, то атом можно найти лишь в оставшейся части списка, так что функцию можно применить к самой себе с оставшейся частью списка в качестве параметра и вернуть результат этого рекурсивного вызова. Этот процесс содержит два способа рекурсии: либо при вызове список пуст, и тогда в качестве результата возвращается пустой список (), либо обнаруживается соответст- вующий элемент списка, и возвращается значение #Т. Три описанных случая должны быть обработаны в функции: пустой входной список, совпадение атома и первого элемента списка или несовпадение атома с первым элементом списка, что приводит к рекурсивному вызову функции. Эти случаи являются параметрами оператора COND, при этом последний случай следует считать выбором по умолчанию, пе- реход на который производится предикатом ELSE. Полная функция приведена ниже: (DEFINE (member atm lis) (COND ( (NULL? lis) ’ () ) ((EQ? atm (CAR lis) #T) (ELSE (member atm (CDR lis))) ) ) Эта форма типична для простых функций обработки списков на языке Scheme. В таких функциях данные в списках обрабатываются поэлементно. Отдельные элементы можно получить с помощью функции CAR и процесса, применяющегося рекурсивно к остав- шейся части списка. Заметим, что проверка случая пустого списка должна предшествовать проверке на равенство атома и элементов, поскольку применение функции CAR к пустому списку яв- ляется ошибкой. В следующем примере рассматривается задача эквивалентности двух заданных спи- сков. Если оба списка простые, то эта задача решается относительно легко, хотя и с ис- 596 Глава 14. Функциональные языки программирования
пользованием незнакомых нам пока приемов. Предикатная функция для сравнения двух простых списков показана ниже: (DEFINE (equalsimp lisl lis2) (COND ((NULL? lisl) (NULL? Iis2)) ( (NULL? Iis2) ’ () ) ((EQ? (CAR lisl) (CAR lis2)) (equalsimp (CDR lisl) (CDR lis2))) (ELSE ’ ()) ) ) Первый случай, обрабатываемый первым параметром оператора COND, возникает, если список, являющийся первым параметром, пуст. Эта ситуация возникает при внешнем вы- зове, если список, являющийся первым параметром, изначально был пуст. Поскольку ре- курсивный вызов использует оставшиеся части списков в качестве двух параметров, пер- вый список при таком вызове может быть пустым, если все элементы были удалены из него во время предыдущих рекурсивных вызовов. Когда первый список пуст, следует проверить, не пуст ли второй список. Если да, то оба списка эквивалентны (либо изна- чально, либо первые элементы этих списков были равны между собой при всех преды- дущих рекурсивных вызовах) и предикат NULL? корректно возвращает значение #Т. Ес- ли второй список не пуст, то он больше, чем первый, и в качестве результата предиката NULL? следует вернуть пустой список (). Напомним, что любой непустой список, воз- вращаемый предикатной функцией, интерпретируется как #Т. Следующий случай относится к ситуации, когда второй список становится пустым, а первый список еще не пуст. Эта ситуация возникает, только если первый список больше второго. При этом следует проверять только второй список, поскольку все ситуации, в которых первый список является пустым, обрабатываются в первом случае. Третий случай является рекурсивным шагом, на котором проверяется равенство со- ответствующих элементов двух списков с помощью сравнения первых элементов двух непустых списков. Если они равны, то два списка совпадают вплоть до этой точки, по- этому рекурсия применяется к оставшимся частям обоих списков. Это условие не вы- полняется, если обнаруживаются два отличающихся друг от друга атома. Когда это про- исходит, мы, очевидно, не должны продолжать сравнение, так что возникает случай по умолчанию, последний среди случаев оператора COND, что приводит к возврату в каче- стве результата пустого списка () и прекращению дальнейших сравнений. Заметим, что функция equalsimp ожидает в качестве параметров списки и непра- вильно работает, если один или оба параметра являются атомами. Проблема сравнения двух списков общего вида немного сложнее, чем только что описанная, поскольку подсписки полностью отслеживаются с помощью приведенного выше процесса сравнения. Именно в этой ситуации проявляется мощь рекурсии, по- скольку подсписки имеют ту же форму, что и заданные списки. Каждый раз, когда соот- ветствующие элементы двух заданных списков представляют собой списки, они разде- ляются на две пары, состоящие из первого элемента и остальной части списка, и к ним применяется рекурсия. Это великолепный пример полезности подхода разделяй-и- властвуй. Если соответствующие элементы двух заданных списков представляют собой атомы, они просто сравниваются с помощью функции EQ?. 14.5. Введение в язык Scheme 597
Определение полной функции приводится ниже: (DEFINE (equal lisl lis2) (COND ((NOT (LIST? lisl)) (EQ? lisl lis2)) ((NOT (LIST? Iis2)) ’ ()) ((NULL? lisl) (NULL? Iis2)) ((NULL? Iis2)) ’ () ((equal (CAR lisl) (CAR lis2)) (equal (CDR lisl) (CDR lis2)) (ELSE ’ ()) ) ) Первые два случая оператора COND обрабатывают ситуацию, когда какой-либо из па- раметров является атомом, а не списком. Третий и четвертый случаи предназначены для ситуаций, когда один или оба списка пусты. Эти случаи также предотвращают попытку извлечь первый элемент из пустого списка в последующих случаях. Пятый случай опера- тора COND наиболее интересен. Предикатом является рекурсивный вызов с первыми элементами списков в качестве параметров. Если этот вызов возвращает значение #Т, то рекурсия применяется снова к оставшимся частям списков. Это позволяет включать в оба списка подсписки любой глубины. Такое определение функции equal работает на любой паре выражений, а не только на паре, состоящей из списков. Функция equal эквивалентна системной предикатной функции EQUAL?. Заметим, что функция EQUAL? должна использоваться только при не- обходимости (когда вид действительных параметров неизвестен), поскольку она работа- ет намного медленнее, чем функции EQ? и EQV?. Другой широко распространенной операцией обработки списков является создание нового списка, содержащего все элементы двух списков, заданных в качестве аргумен- тов. Обычно эта операция реализуется в языке Scheme как функция с именем append. Она сводится к повторному использованию оператора COND, чтобы поместить элементы списка, являющегося первым аргументом функции, в список, являющийся вторым аргу- ментом. Для пояснения действий функции append рассмотрим следующие примеры: (append ’(АВ) ’(С D R)) возвращает (А В С D R) (append ’((А В) С) ’(D (ЕЕ))) возвращает ((АВ) CD (Е F)) Определение функции append приведено ниже: (DEFINE (append lisl lis2) (COND ((NULL? lisl) lis2) (ELSE (CONS (CAR lisl) (append (CDR lisl) lis2))) ) ) Рассмотрим функцию guess языка Scheme, использующую функцию member, опи- санную в этом разделе. Попробуем определить, что она делает перед тем, как прочитать ее описание, приведенное ниже. Предположим, что параметрами являются простые списки. (DEFINE (guess lisl lis2) (COND ( (NULL? lisl) ’ () ) ((member (CAR lisl) lis2) (CONS (CAR lisl) (guess (CDR lisl) lis2))) 598 Глава 14. Функциональные языки программирования
(ELSE (guess (CDR lisl) lis2)) ) ) Предполагается, что два параметра функции guess представляют собой простые спи- ски. Функция guess возвращает в качестве результата простой список, состоящий из общих элементов обоих списков. Таким образом, если список параметров представляет собой множества, функция guess вычисляет список, состоящий из элементов, принад- лежащих пересечению этих двух множеств. Функция LET позволяет временно связывать имена со значениями подвыражений. Она часто используется для вынесения за скобки общих подвыражений из более слож- ных выражений. Эти имена затем используются при вычислении других выражений. Ее общий вид приведен ниже: (LET ( (имя_1 выражение__1) (имя_2 выражение_2) (имя_п выражение_п)) тело ) Семантика функции LET состоит в том, что вычисляются первые п выражений, а ре- зультирующие значения связываются с их ассоциированными именами. Затем вычисля- ются выражения, принадлежащие телу функции. Результатом функции LET является значение последнего выражения, принадлежащего телу функции. Следующий пример иллюстрирует использование функции LET: (DEFINE (quadratic_roots a b с) (LET ( (root_part_over__2a (/ (SQRT (- (* b b (* 4 a c) ) ) (* 2 a) ) ) (minus_b_over 2a (/ (-Ob) (* 2 a))) ) (DISPLAY (+ minus_b_over_2a root_part_over_2a)) (NEW LINE) (DISPLAY (-minus_b_over_2a root_part_over_2a)) ) ) Функция DISPLAY вполне подходит для использования в функции quadratic_roots, поскольку мы хотим, чтобы эта функция выводила свои два результата. Функция LET создает новую локальную статическую область видимости почти тем же способом, что и оператор declare в языке Ada. Именованные компоненты функции LET аналогичны операторам присваивания, но их можно использовать только в новой области видимости функции LET. Более того, их нельзя связать с новыми значениями в функции LET. Слово LET в действительности является простым сокращением LAMBDA-выражения. Следующие два выражения эквивалентны: (LET (alpha 7) (* 5 alpha)) ((LAMBDA (alpha) (* 5 alpha)) 7) В первом выражении число 7 связывается с переменной alpha: во втором— число 7 связывается с переменной alpha через параметр LAMBDA-выражения. 14.5. Введение в язык Scheme 599
14.5.6. Функциональные формы В этом разделе описываются две распространенные математические функциональные формы, предусмотренные в языке Scheme, а именно: композиция и применить-ко-всем. 14.5.6.1. Композиция функций Композиция функций — единственная функциональная форма, предусмотренная в ис- ходном варианте языка LISP. Все последующие диалекты языка LISP, включая язык Scheme, также содержат ее. К композиции функций, по существу, сводится работа функции EVAL. Все списки, не заключенные в кавычки, интерпретируются как вызовы функций, требующие, чтобы их параметры вычислялись первыми. Это правило рекурсивно применя- ется к наименьшему списку в любом выражении и представляет собой именно то, что озна- чает композиция функций. Композиция функций иллюстрируется следующим примером: CAR (CDR ’(А В С))) возвращает ( С ) (CAR (CAR '((АВ) ВС))) возвращает А (CDR (CAR ’((ABC) D))) возвращает (В С) (NULL? (CAR ’(() ВС))) возвращает #Т (CONS (CAR ’(АВ)) (CDR ’(А В))) возвращает (А В) Заметим, что имена функций во внутренних вызовах не заключены в кавычки, по- скольку они должны вычисляться, а не обрабатываться как литеральные данные. 14.5.6.2. Функциональная форма применить-ко-всем Наиболее распространенными функциональными формами, предусмотренными в по- пулярных функциональных языках, являются вариации математических функциональных форм применить-ко-всем. Простейшей из них является форма mapcar, которая имеет два параметра— функцию и список. Форма mapcar применяет заданную функцию к каждому элементу заданного списка и возвращает список результатов этих применений. Определение функции mapcar на языке Scheme приведено ниже: (DEFINE (mapcar fun lis) (COND ((NULL? lis) ’()) (ELSE (CONS (fun (CAR lis)) (mapcar fun (CDR lis)))) ) ) Отметим простую форму функции mapcar, выражающей сложную функциональную форму. Это является следствием большой выразительной силы языка Scheme. В качестве примера использования функции mapcar предположим, что мы хотим возвести в куб все элементы некоторого списка. Мы можем сделать это следующим об- разом: mapcar (LAMBDA (num) (* num num num)) ’(3426)) Эта функция вернет в качестве результата список (27 64 8 216). Заметим, что в этом примере первый параметр функции mapcar является LAMBDA- выражением. Когда функция EVAL вычисляет это LAMBDA-выражение, она создает функцию, имеющую ту же форму, что и любая встроенная функция, за исключением то- го, что новая функция будет безымянной. В приведенном выше выражении эта безымян- ная функция немедленно применяется к каждому элементу списка, являющегося пара- метром. а результат возвращает в виде списка. 600 Глава 14. Функциональные языки программирования
14.5.7. Функции для создания кода Тот факт, что программы и данные имеют одинаковую структуру, используется при создании программ. Поскольку пользовательские программы вызывают функцию EVAL, они могут создавать другие программы и немедленно выполнять их. Один из простейших примеров этого процесса касается числовых атомов. Большин- ство систем языка Scheme имеют функцию для числового атома с именем +, получающе- го любое количество числовых атомов в качестве аргументов и возвращающего их сум- му. Например, ( + 3 7 10 2) возвращает 22. Наша задача состоит в следующем: предположим, что в программе есть список чи- словых атомов и нужно вычислить их сумму. Мы не можем применить функцию + непо- средственно к списку; поскольку функция + может получать только атомарные парамет- ры. а не список числовых атомов. Функция, которая повторно добавляет первый элемент списка к сумме элементов его остальной части с помощью функций CAR и CDR. исполь- зуя рекурсию для перемещения по списку', приведена ниже: DEFINE (adder lis) (COND ((NULL? lis) 0) (ELSE ( + CAR lis) (adder (CDR lis)))) ) ) Альтернативное решение задачи состоит в написании функции, которая строит вызов функции + с параметрами, представленными в подходящей форме. Это можно сделать, используя функцию CONS для вставки атома + в список чисел. Новый список можно пе- редать функции EVAL для выполнения вычислений, как показано ниже: (DEFINE (adder lis) (COND ((NULL? lis) 0) (ELSE (EVAL (CONS ’+lis))) ) ) Заметим, что перед именем функции “плюс” стоит кавычка, которая предотвращает ее вычисление функцией EVAL при вычислении функции CONS. Рассмотрим вызов функции adder: (adder ’*3 4 6)) Он заставляет эту функцию создать список (+ 3 4 6) Затем этот список передается функции EVAL, вызывающей функцию + и возвра- щающей результат 13. Во всех ранних версиях языка Scheme функция EVAL вычисляет выражения в самой внешней области видимости программы. В современной версии языка Scheme — Sdheme 4 — для функции EVAL требуется второй параметр, указывающий область види- мости, в которой должно быть вычислено выражение. Для простоты мы оставили пара- метр, указывающий область видимости, за рамками нашего примера и не рассматриваем область видимости имен. 14.5. Введение в язык Scheme 601
14.5.8. Императивные свойства языка Scheme Язык Scheme, подобно другим современным диалектам языка LISP, обладает не- сколькими свойствами, взятыми от императивных языков. Например, имена могут свя- зываться со значениями, и эти связи в дальнейшем могут изменяться. Это делается с по- мощью функции SET!, как показано в следующем примере: (SET! Pi 3.141593 ) Функция SET! возвращает значение, которое она связывает с именем. В функциональной версии языка LISP списки не могут изменяться. Они обрабатыва- ются по частям с помощью функций CAR и CDR, но заданный список остается неизмен- ным. Для изменения списка может понадобиться свойство императивного языка— по- бочный эффект вызова функции. Язык Scheme имеет две функции, создающие такие по- бочные эффекты, а именно: SET-CAR! и SET-CDR!. Рассмотрим следующие примеры: (DEFINE LST (LIST ’А ’В)) (SET-CAR! 1st ’С) (SET-CDR! Lst ’(D)) Функция SET-CAR! изменяет список, связанный с именем 1st, с (А В) на (С D). Функция SET-CDR! изменяет список, связанный с именем 1st, с (С В) на (С D). Императивные свойства языка Scheme, описанные выше, были внесены в него для повышения эффективности, однако отклонение от функционального программирования также имеет свою цену. Программы становится труднее отлаживать и эксплуатировать из-за возможного совмещения имен и побочных эффектов, позволяющих идентичным вызовам функций давать разные результаты в разное время. Рассмотрим следующий фрагмент кода: (DEFINE count 0) (DEFINE (inc_count number) (SET! count (+ count number)) ) Хотя следующие два вызова функции int_count совершенно идентичны, они приводят к разным результатам: (inc_count 1) 0 (inc_count 1) 1 14.6. Язык COMMON LISP Язык COMMON LISP (Steele, 1984) был призван объединить свойства нескольких диалектов языка LISP, созданных в начале 1980-х годов, включая язык Scheme, в единый язык. Будучи комбинацией этих свойств, язык COMMON LISP довольно велик и сложен. Его основа, однако, — это исходный язык LISP, так что синтаксис, элементарные функ- ции и природа унаследованы от него. Поняв, что иногда динамический обзор данных придает программе гибкость, а также оценив простоту статического обзора данных, разработчики языка COMMON LISP ре- 602 Глава 14. Функциональные языки программирования
шили позволить применение обоих этих способов. По умолчанию область видимости пе- ременной является статической, но с помощью объявления переменной в качестве “специальной” ее можно сделать динамической. Список свойств языка COMMON LISP велик: большое количество типов и структур данных, включая записи, массивы, комплексные числа и символьные строки; мощные операции ввода и вывода; пакеты для объединения в модули наборов функций и данных, а также управление доступом; императивные свойства языка Scheme — особенно функ- ции, аналогичные функциям SET-CAR ! и SET-CDR!. Язык COMMON LISP и большинство диалектов языка LISP, за исключением языка Scheme, содержат функцию PROG, позволяющую упорядочивать последовательность вы- полнения операторов, как это принято в императивных языках. Метки и две функции, GO и RETURN, были включены в язык для управления циклами. Функция GO используется для передачи управления на метку внутри области видимости функции PROG. Функция RETURN осуществляет выход из функции PROG. Функция PROG имеет следующий об- щий вид: (PROG (локальные переменные) выражение_1 виражение_п ) Локальные переменные инициализируются нулевым указателем NIL, принадлежат области видимости функции PROG и существуют только во время ее выполнения. Если в функции PROG есть глобальные имена, совпадающие с локальными, то глобальные имена маскиру- ются (и становятся скрытыми). Функция GO передает управление своему параметру, яв- ляющемуся меткой внутри списка выражений функции PROG. Функция RETURN имеет па- раметр, который становится значением, возвращаемым функцией PROG. Заметим, что функция PROG включена в современные версии языка LISP только для обратной совместимости со старыми диалектами. Язык COMMON LISP имеет более удачные конструкции для обеспечения возможностей функции PROG. Например, в языке COMMON LISP есть конструкции DOTIMES и DOLIST для выполнения циклов, а также PROG1, PROG2 и PROG3 для создания последовательностей операторов. Функция SETQ в языке COMMON LISP соответствует функции SET! языка Scheme, а функция DEFUN является версией функции DEFINE. Рассмотрим следующую итератив- ную версию функции для определения принадлежности атома заданному списку. После итеративной версии приводится рекурсивная версия, аналогичная описанной в разде- ле 14.5.5. (DEFUN iterative_member (atm 1st) (PROG () loop_l (COND ((NULL 1st) (RETURN NIL)) ((EQUAL atm (CAR 1st)) (RETURN T)) ) (SETQ 1st (CDR 1st)) (GOTO loop_l) )) 14.6. Язык COMMON LISP 603
(DEFUN recursive_member (atm 1st) (COND (( NULL 1st) NIL) ((EQUAL atm (CAR 1st)) T) (T (recursive_member atm(CDR 1st))) ) ) Заметим, что T является в языке COMMON LISP версией булевского значения “истина”, NIL — это булевское значение “ложь”, АТОМ — предикат, определяющий, яв- ляется ли параметр атомом, а нулевой список рассматривается и как список, и как атом. Рассмотрим итеративную и рекурсивную функции, вычисляющие длину списка: (DEFUN iterative_length (1st) (PRPG (sum) (SETQ sum 0) again (COND ((ATOM 1st (RETURN sum))) ) (SETQ sum (+ 1 sum)) (SETQ 1st (CDR 1st)) (GO again) ) ) (DEFUN recursive_length (1st) (COND ((NULL 1st) 0) (T (+ 1 (recursive length (CDR 1st)))) ) ) В некотором смысле языки Scheme и COMMON LISP противоположны друг другу. Язык Scheme намного меньше по размерам и немного яснее, частично благодаря тому, что он использует исключительно статические области видимости. Язык COMMON LISP предназначался для коммерческого использования и впоследствии получил широкое распространение в приложениях, связанных с созданием искусственного интеллекта. Язык Scheme чаще используется в университетских курсах по функциональному про- граммированию. Он также подходит для изучения функционального программирования из-за своих относительно малых размеров. Важным критерием при разработке языка COMMON LISP, сделавшим его очень большим по размеру, было желание совместить его с более ранними диалектами языка LISP. 14.7. Язык ML Язык ML (Milner et al., 1990)— это функциональный язык программирования, ис- пользующий статический обзор данных, подобно языку Scheme. Он значительно отлича- ется от языка LISP и его диалектов, включая Scheme. ML использует синтаксис, который более похож на синтаксис языка Pascal, чем на LISP. Язык ML содержит объявления ти- пов, использующие вывод типов. Это означает, что переменные не обязательно объяв- лять, и при этом они будут иметь строго определенный тип. Тип каждой переменной и выражения можно определить во время компиляции. Это совершенно отличается от язы- 604 Глава 14. Функциональные языки программирования
ка Scheme, по существу, не имеющего типов. Язык ML позволяет обрабатывать исклю- чительные ситуации и объединять абстрактные типы данных в модули. Краткая история развития и основные свойства языка ML описаны в главе 2. В главе 4 рассмотрена идея вывода типов на примере языка ML. В языке ML имена могут связываться со значениями операторов объявления следую- щим образом: val новое_имя = выражение; Например: val distance = time * speed; He подумайте, что этот оператор в точности совпадает с оператором присваивания в им- перативных языках. Оператор val связывает имя со значением, но в дальнейшем это имя не может быть связано с новым значением. (Хотя в некотором смысле все-таки мо- жет.) Если мы свяжем имя с помощью второго оператора val, это создаст новый эле- мент в программной среде, который никак не связан с предыдущей версией этого имени. В действительности, его тип не обязан быть прежним. Операторы val не имеют побоч- ных эффектов. Они просто добавляют имя в текущую программную среду и связывают его с неким значением так, как это делала специальная форма LET в языке LISP. Обычно операторы val используются в выражениях let, имеющих следующий общий вид: let val новое_имя = выражения_1 in выражение_2 end Например: let val pi = 3.14159 in pi * radius * radius end; В языке ML есть списки и операции для работы со списками, хотя они выглядят ина- че, чем в языке LISP. Язык ML также содержит перечислимые типы, массивы и кортежи, являющиеся записями. Объявление функции в языке ML имеет следующий вид: fun имЯ—функции (формальные_параметры) = выражение; Например: fun square (х: int) = х * х; Заметим, что объявление fun square (х) = х * х; является неправильным, поскольку в этом случае компилятор не может определить тип переменной х. Так что функции, использующие арифметические операции, не могут быть полиморфными. То же относится и к функциям, использующим операции сравне- ния, за исключением операций = и о, а также булевских операций. Однако функции, использующие только операции работы со списками =, о и операции работы с корте- жами (для создания кортежей и выбора компонентов), могут быть полиморфными. 14.7. Язык ML 605
Конструкции ветвления потока управления в языке ML в действительности представ- ляют собой условное выражение следующего вида: if Е then то_выражение else иначе_выражение Выражение Е должно вычислять булевское значение. Из двух следующих за ним выра- жений вычисляется только одно. В языке ML нет приведения типов; типы операндов в операторе или присваивании просто должны совпадать для предотвращения синтаксических ошибок. 14.8. Язык Haskell Язык Haskell (Thompson, 1996) похож на язык ML тем, что в нем используется анало- гичный синтаксис, статический обзор данных, строго определенные типы и тот же метод вывода типов. В отличие от языка ML, Haskell является чисто функциональным языком; он не имеет переменных и операторов присваивания, не допускает побочных эффектов и не имеет никаких императивных свойств. Это отличает его от почти всех других языков программирования. Две следующие характеристики языка Haskell отличают его от языка ML. Во-первых, он использует другой способ вычислений, называемый “ленивым” вы- числением (lazy evaluation), при котором подвыражения не вычисляются, пока не воз- никнет необходимость в их значениях. Во-вторых, язык Haskell имеет метод определения списков, допускающий бесконечные списки. Такие списки называются полными спи- сками (list comprehensions). Некоторые свойства язык Haskell унаследовал от языка Miranda (Turner, 1986). Код в этом разделе написан на версии 1.4 языка Haskell. Рассмотрим следующее определение функции, вычисляющей факториал. Заметим, что синтаксис определения функции и ее применения заключается в том, что имя функ- ции приписывается рядом с параметрами: fact 0=1 fact n = n * fact (n - 1) Этот пример показывает, что определения функций могут содержать более одной строки, причем эти строки определяют версии функции для различных видов действительных параметров. Соответствующее значение выражения, вычисляемого функцией и стоящего в правой части определения, выбирается путем сопоставления образцов фактических и формальных параметров. Формальные параметры, являющиеся константами, очевидно совпадают сами с собой в качестве фактических параметров. Имя в образце формального параметра соответствует фактическому параметру, не совпадающему с образцом кон- станты. Соответствующее значение фактического параметра затем используется как зна- чение имени в соответствующем выражении, стоящем в правой части определения. В приведенном выше примере функция определена частично, поскольку она не может вычисляться при отрицательных параметрах. Используя сравнение образцов, определим функцию для вычисления n-го числа Фи- боначчи следующим образом: fib 0=1 fib 1 = 1 fib (n + 2) = fib (n + 1) + fib n 606 Глава 14. Функциональные языки программирования
Для указания обстоятельств, при которых можно применять это определение, к стро- кам определения функции можно добавить защиту. Например: fact п | п == 0 = 1 I п > 0 = n * fact (п - 1) Это — более точное определение факториала, чем предыдущее, поскольку оно ограни- чивает область фактических параметров, при которых она работает. Соответствие образ- цов при таком использовании, конечно, может нарушаться, поскольку образец параметра равен п при обоих значениях выражения. Этот вид определения функции называется ус- ловным выражением. Зарезервированное слово otherwise может появляться в качестве последнего усло- вия в условном выражении. Оно имеет очевидный смысл. Например: fun п I п < 100 = 0 | п > 100 = 2 I otherwise - 1 Списки записываются в квадратных скобках, как показано ниже: colors = [’blue”, ’’green”, ’’red”, ’’yellow”] Язык Haskell содержит набор операторов для работы со списками. Например, списки можно соединять с помощью оператора ++, оператор : служит в качестве инфиксной версии функции CONS, а оператор . . используется для указания арифметических рядов. Например: 5:[2, 1, 9] дает результат [5, 2, 1, 9] [1, 3..11] дает результат [1, 3, 5, 1, 9, 11] [1, 3 ,5] ++ [2, 4, 6] дает результат [1, 3, 5, 2, 4, 6] Ниже приведены два примера функций, работающих со списками: sum[] = 0 sum (а:х) = а + sum х product [] = 1 product (а:х) = а ♦ product х В обоих этих функциях выражение а: х означает список, в котором элемент а является первым, или головой, а х означает остальную часть, или хвост. Функция sum возвращает сумму элементов заданного списка. Функция product возвращает произведение эле- ментов заданного списка. И функция sum, и функция product являются стандартными функциями языка Haskell. Используя функцию product, функцию, вычисляющую факториал, можно записать в более простом виде: fact n = product [l..n] Функция length возвращает число элементов в заданном списке. Например: length(colors) возвращает 4 14.8. Язык Haskell 607
В языке Haskell оператор where похож на операторы let и val в языке ML, за исклю- чением того, что связывания указываются после выражений, которые их используют. Например, мы могли бы написать quadratic__root а b с = [minus_b_over_2a - root_part_over_2a, minus_b_over_2a + root_part_over_2a] where minus_b_over_2a = -b / (2.0 * a) root_part_over_2a = sqrt(b A 2 - 4.0 * a * c) / ( 2.0 * a) Полные списки обеспечивают метод описания списков, представляющих собой мно- жества. Синтаксис полных списков аналогичен синтаксису, часто используемому для описания множеств в математике, общий вид которого приведен ниже: [тело | квалификаторы] Например, следующее выражение определяет список кубов чисел от 1 до 50: [п * п * n I n <- [1..50]] Это выражение читается как “список всех п*п*п, где п изменяется в диапазоне от 1 до 50”. В данном случае квалификатор имеет вид генератора (generator). Он генерирует числа от 1 до 50. В других случаях квалификаторы принимают форму булевского выра- жения и называются проверками (tests). Эти обозначения можно использовать для опи- сания записи алгоритмов, выполняющих такие действия, как поиск перестановок в спи- ске и сортировка списка. Например, рассмотрим следующую функцию, которая для за- данного числа п возвращает список всех его множителей: factors n = [i I i <- [l..n div 2], n mod i == 0] Краткость языка Haskell иллюстрируется следующим примером реализации алгорит- ма быстрой сортировки: sort [] = [] sort (а:х) = sort[b I b<-x, b<=a] ++ [a] ++ sort [b I b <- x, b > a Это определение алгоритма быстрой сортировки значительно короче, чем тот же ал- горитм. описанный на императивном языке. Вернемся к теме ленивых вычислений. Напомним, что в языке Scheme параметры функции полностью вычисляются перед ее вызовом. Ленивые вычисления означают, что параметры функции вычисляются только тогда, когда это будет необходимо для вычис- ления функции. Таким образом, если функция имеет два параметра, но при конкретном выполнении функции первый параметр не используется, фактический параметр, пере- данный для выполнения, не будет вычисляться. Более того, если при выполнении функ- ции должна вычисляться только определенная часть фактического параметра, остальная часть параметра вычисляться не будет. Итак, фактический параметр вычисляется только один раз, если вообще вычисляется. Использование в языке ленивых вычислений открывает некоторые интересные воз- можности. Одна из них состоит в возможности определять бесконечные структуры дан- ных. Рассмотрим следующий код: 608 Глава 14. Функциональные языки программирования
positives = [0..] evens = [2, 4, . . ] squares = [n * n| n <- [0..1] Конечно, ни один компьютер не может действительно представить все числа из этих списков, но это не мешает их использованию, если применяются ленивые вычисления. Например, чтобы узнать, является ли какое-либо число полным квадратом, следует про- верить список квадратов с помощью функции, проверяющей принадлежность элемента заданному списку. Допустим, что мы имеем предикатную функцию с именем member, для того чтобы определить, содержит ли список данный атом. Тогда мы могли бы ис- пользовать ее следующим образом: member square 16 В результате мы получим булевское значение True. Определение структуры squares вычислялось бы до тех пор, пока не было бы найдено число 16. Функция member должна быть написана очень аккуратно. В частности, рассмотрим следующий ее вид: member [] b = False member (а:х) b = (а == b) || member х b В этом случае она работает с квадратами чисел правильно, только если заданное чис- ло является полным квадратом. В противном случае структура squares продолжала бы генерировать числа бесконечно до тех пор, пока не исчерпала бы ресурсы памяти в по- исках заданного числа в списке квадратов. Приведенная ниже функция выполняет про- верку принадлежности заданного числа упорядоченному списку, прекращая поиск и воз- вращая булевское значение False, если найдено число, больше чем заданное. member2 (m:x) n I m < n = member2 x n I m==n = True I otherwise = False Ленивые вычисления имеют свою цену. Было бы просто удивительно, если бы такая выразительная мощь и гибкость досталась бы бесплатно. В данном случае ценой стала сложная семантика, замедляющая выполнение программы. 14.9. Применение функциональных языков За последние 35 лет истории языков программирования высокого уровня лишь не- сколько функциональных языков получили широкое распространение. Наиболее извест- ным из них является язык LISP. Несмотря на неуклюжее использование операторов при- сваивания, язык APL также часто рассматривается как функциональный язык, частично из-за наличия в нем функциональных форм. Язык APL широко использовался в различных приложениях — от описания аппарат- ного обеспечения до информационных систем управления предприятиями. Вследствие того, что читать программы, написанные на языке APL. обычно очень трудно, более ес- тественным было бы отнести его в категорию одноразового программирования. Благо- даря наличию в нем мощного набора операций для работы с массивами, он представляет собой отличное средство для получения быстрых, но ‘сырых’’ решений проблем, связан- ных с большим количеством манипуляций с массивами. 14.9. Применение функциональных языков 609
Язык LISP — многогранный и мощный язык. Первые 15 лет он считался странным и трудным для использования языком, в основном людьми, не применявшими его в своей работе. Действительно, в 1960-х и начале 1970-х годов было принято думать, что языки программирования разделяются на две категории: к одной из них принадлежал язык LISP, а к другой — все остальные языки программирования. Как описано в этой главе, язык LISP был разработан для символьных вычислений и приложений, связанных с обработкой списков, относящихся в основном к области созда- ния искусственного интеллекта. В приложениях, связанных с созданием искусственного интеллекта, язык LISP и производные от него языки программирования остаются стан- дартными языками. В области создания искусственного интеллекта было разработано много направлений, в основном с применением языка LISP. Хотя можно использовать и другие виды языков (в, основном языки логического программирования), большинство существующих экс- пертных систем, например, были разработаны на языке LISP. Язык LISP также домини- рует в областях, связанных с представлением знаний, машинным обучением, обработкой естественных языков, интеллектуальными обучающими системами, а также моделирова- нием речи и зрения. Язык LISP успешно применялся и вне области, связанной с созданием искусственного интеллекта. Например, текстовый редактор EMACS и символьная математическая сис- тема MACSYMA, выполняющая, помимо всего прочего, символьные вычисления, напи- саны на языке LISP. LlSP-машина— это персональный компьютер, системы которого полностью написаны на языке LISP. Язык LISP также успешно использовался при разра- ботке экспериментальных систем в различных прикладных областях. Язык Scheme широко применяется для обучения функциональному программирова- нию. Он также используется в некоторых университетах в рамках вводных курсов по программированию. Функционирование языков ML и Haskell большей частью ограничи- вается исследовательскими лабораториями и университетами. 14.10. Сравнение функциональных и императивных языков Перейдем к краткому обсуждению преимуществ, некоторые из которых широко при- знаны, а некоторые — только предполагаются таковыми, функционального программи- рования и языков функционального программирования. Естественно сравнить функциональное программирование с программированием на императивных языках. Поскольку императивные языки основаны непосредственно на ней- мановской архитектуре компьютеров, программисты, используя их, должны иметь дело с управлением переменными и присваиванием значений этим переменным. В результате возрастает эффективность выполнения программ, но затрудняется их создание. В функцио- нальном языке программисту не нужно связываться с переменными, поскольку в нем не требуется абстрактно представлять ячейки памяти. Одним из результатов такого подхода является снижение эффективности выполнения программы. Другой результат, однако, за- ключается в более высоком уровне программирования, для которого требуется меньше усилий, чем при программировании на императивном языке. Многие думают, что это оп- ределенно является преимуществом функционального программирования. 610 Глава 14. Функциональные языки программирования
Функциональные языки могут иметь очень простую синтаксическую структуру. При- мером этого является структура списка в языке LISP. Синтаксис императивных языков намного сложнее. Семантика функциональных языков также может быть простой по сравнению с семантикой императивных языков. Параллельное выполнение программ в императивных языках трудно проектировать и использовать. Рассмотрим модель задач в языке Ada, в котором ответственность за взаи- модействие между параллельно выполняемыми задачами возлагается на программиста. Функциональные программы могут выполняться путем предварительного перевода их в форму графов. Эти графы затем выполняются с помощью редукции графов, которая осуществляется большей частью параллельно, хотя это не было указано программистом. Представление программ в виде графов естественным образом раскрывает много воз- можностей для параллельного выполнения. Синхронизация взаимодействия в этом про- цессе не входит в обязанности программиста. Дальнейшее описание этого процесса вы- ходит за рамки нашей книги. В императивном языке программирования программист должен выполнить статиче- ское разбиение программы на параллельно выполняемые части, которые затем оформ- ляются в виде задач. Это может быть сложным процессом. Программы на функциональ- ных языках программирования могут динамически разделяться на параллельно выпол- няемые части системой выполнения программ, что делает этот процесс очень хорошо приспособленным к аппаратному обеспечению, на котором он должен выполняться. Анализ параллельных программ на императивных языках намного сложнее. Резюме Математические функции представляют собой именованные или неименованные ото- бражения. использующие только условные выражения и рекурсию для управления их вычислениями. Сложные функции можно построить с помощью функциональных форм, в которых функции используются как параметры, возвращаемые значения или и в том, и в другом качестве. Языки функционального программирования моделируют математические функции. В чистйм виде они не используют переменные или операторы присваивания для получе- ния результатов; вместо этого для управления выполнением программы они применяют функции, условные выражения и рекурсию, а также функциональные формы для по- строения сложных функций. Язык LISP появился как чисто функциональный язык про- граммирования. однако вскоре он приобрел много свойств императивных языков, добав- ленных в него для того, чтобы повысить эффективность и облегчить использование. Первая версия языка LISP возникла, поскольку в приложениях, связанных с создани- ем искусственного интеллекта, был необходим язык обработки списков. Язык LISP до сих пор остается наиболее широко используемым языком в этой области. Первая реализация языка LISP была связана со счастливым случаем. Исходная версия функции EVAL была разработана исключительно для демонстрации того, что можно на- писать универсальную функцию языка LISP. Поскольку и данные и программы на языке LISP имеют одинаковую форму, можно получить программу, создающую другую программу. Наличие функции EVAL позволяет выполнять такие программы немедленно. Резюме 611
Язык Scheme — относительно простой диалект языка LISP, использующий исключи- тельно статические области видимости. Подобно языку LISP к элементарным языковым конструкциям языка Scheme относятся функции для построения и разбиения на части списков, для условных выражений, а также простые предикаты для чисел, символов и списков. Язык Scheme обладает такими императивными операциями, как изменение эле- мента в заданном списке. Язык COMMON LISP — это большой язык, основанный на языке LISP, который был разработан для того, чтобы включить большинство свойств диалектов языка LISP, воз- никших в начале 1980-х годов. Он допускает и статический, и динамический обзор дан- ных, а также обладает многими императивными свойствами. Язык ML— это язык функционального программирования, имеющий статический обзор данных и строго типизированный. Он использует синтаксис, более похожий на синтаксис языка Pascal, чем на синтаксис языка LISP. Язык ML содержит систему выво- да типов и обработку исключительных ситуаций и позволяет реализовывать абстрактные типы данных. Язык Haskell похож на язык ML, но является чисто функциональным языком; он не имеет переменных и операторов присваивания. Все выражения на языке Haskell вычис- ляются с помощью ленивого метода. С помощью полных списков язык Haskell позволяет программам работать с бесконечными списками. Первоначальной областью применения языка LISP было создание искусственного ин- теллекта, однако он успешно использовался во многих других областях. Несмотря на возможные преимущества функциональных языков программирования над императивными языками, низкая эффективность выполнения программ, написанных на этих языках, на машинах с неймановской архитектурой во многих случаях не позво- лила рассматривать их в качестве замены императивных языков. Дополнительная литература Впервые язык LISP был опубликован в работе VcCarthy (1960). Версии, широко исполь- зовавшиеся с середины 1960-х до конца 1970-х годов, описаны в работах VcCarthy et al. (1965) и Weismann (1967). До некоторой степени стандартная современная версия языка COMMON LISP была описана в работе Steele (1984). Язык Scheme вместе с не- которыми своими новшествами и преимуществами обсуждался в работе Rees and Clinger (1986). Работа Dybvig (1996) является хорошим источником информации о программировании на языке Scheme. Язык ML описан в работе Milner et al. (1990). Книга Ullman (1994) представляет собой прекрасный учебник по введению в язык ML. Программирование на языке Haskell описано в работе Thompson (1956). Детальное обсуждение функционального программирования можно найти в работе Henderson (1980). Процесс реализации функциональных языков с помощью редукции графов подробно рассматривается в работе Peyton Jones (1987). 612 Глава 14. Функциональные языки программирования
В о я р <•’ <• ы 1. Дайте определение функциональной формы и прозрачности ссылок. 2. Какие типы данных были частью исходного языка LISP? 3. В чем заключается разница между функциями EQ?, EQV? и =? 4. В чем заключается разница между методом вычислений DEFINE, использующим специальную форму языка Scheme, и методом вычислений, использующим элемен- тарные функции? 5. Каковы два вида функции DEFINE? 6. Опишите семантику функции COND. 7. Опишите семантику функции LET. 8. Для чего во многие диалекты языка LISP были добавлены императивные свойства? 9. В чем языки COMMON LISP и Scheme противоположны друг другу? 10. Какие правила видимости переменных используются в языке Scheme? В языке COMMON LISP? В языке ML? В языке Haskell? 11. Назовите три особенности, которые отличают язык ML от языка Scheme. 12. Что представляет собой вывод типов, используемый в языке ML? 13. Назовите три особенности, которые значительно отличают язык Haskell от языка Scheme? 14. В чем заключается смысл ленивых вычислений? V и р а ж н е и и « 1. Напишите функцию на языке Scheme, возвращающую список, составленный в об- ратном порядке по сравнению с простым списком, являющимся ее параметром. 2. Напишите предикатную функцию на языке Scheme для проверки структурной эк- вивалентности двух заданных списков. Два списка называются структурно эквива- лентными, если они имеют одинаковую структуру, несмотря на то. что их атомы могут отличаться друг от друга. 3. Напишите функцию на языке Scheme, возвращающую объединение двух простых списков, являющихся ее параметрами и представляющими собой множества. 4. Напишите на языке Scheme функцию с двумя параметрами, атомом и списком, возвращающую список, из которого удален заданный атом, независимо от глубины его нахождения в списке. Возвращаемый список не должен иметь на месте удален- ного атома никакого элемента. 5. Напишите на языке Scheme функцию, которая получает список в качестве пара- метра и возвращает его. удалив из него второй от начала элемент. Если заданный список не содержит хотя бы двух элементов, функция должна возвращать пустой список (). Упражнения 613
6. Найдите определения функций EVAL и APPLY языка Scheme и объясните, как они действуют. 7. Обратитесь к любой книге по программированию на языке LISP и определите, ка- кие аргументы обосновывают включение свойств функции PROG в язык LISP. 8. Функциональный язык мог бы использовать структуру данных, отличную от спи- ска, например, последовательность символов. Какие языковые конструкции дол- жен был бы иметь такой язык вместо элементарных функций CAR, CDR и CONS языка Scheme? 9. Какие действия выполняет следующая функция, написанная на языке Scheme? (define (у s lis) (cond ( (null? lis) ’ () ) ((equal? s (car lis)) lis) (else (y s (cdr lis))) 10. Какие действия выполняет следующая функция, написанная на языке Scheme? (define (х lis) (cond ((null? lis) 0 ) ((not (list? (car lis))) (cond ((eq? (car lis) nil) (x (cdr lis))) (else (+ 1 (x (cdr lis))))) (else (+ (x car lis)) (x (cdr lis)))) ) ) 614 Глава 14. Функциональные языки программирования
вания В э I с- и г п о и о 15.1. 1ШШ< В'-? 15.2. 15.з. 15.4. 1^ 15-5- ИНММИ" 15.6. - 15.7. 15.8. Роберт Ковальски (Robert Kowalski) 15.9. Введение Краткое введение в исчисление предикатов Исчисление предикатов и доказательство теорем Обзор логического программирования Происхождение языка Prolog Основные элементы языка Prolog Недостатки языка Prolog Применение логического программирования Выводы Роберт Ковальски из Эдинбург- ского университета (University of Edinburgh) — исследователь в области искусственного интел- лекта. Ковальски вместе с Але- ном Колмерье (Alen Colmerauer) и Филиппом Русселем (Phillippe Roussel) из Марсельского уни- верситета (University of Aix- Marseille) разработали первый язык логического программиро- вания — Prolog.
Цель этой главы — ввести понятия логического программирования и языков логи- ческого программирования, а также кратко описать некое подмножество языка Prolog. Вначале рассмотрим исчисление предикатов, являющееся основой языков логи- ческого программирования. Затем обсудим, как его можно использовать в системах ав- томатического доказательства теорем. Потом сделаем общий обзор логического про- граммирования. Далее в обширном разделе вводятся основы языка программирования Prolog, включая арифметику, обработку списков и использование отслеживающих инст- рументов, которые можно применять при отладке программ, а также для иллюстрации работы системы языка Prolog. В последних двух разделах описываются некоторые про- блемы, связанные с языком Prolog как логическим языком, и некоторые области прило- жений, в которых можно применять этот язык. 15.1. Введение В главе 14 обсуждалась парадигма функционального программирования, которая от- личается от методологий разработки программного обеспечения, используемых импера- тивными языками. В этой главе подход к разработке программ заключается в выражении программ в форме символьной логики и использовании для получения результата про- цесса логического вывода. Логические программы являются не декларативными, а про- цедурными. Это означает, что в них указывается лишь описание желаемого результата, а не детальная процедура его получения. Программирование, использующее форму символьной логики в качестве языка про- граммирования. часто называется логическим программированием (logic programming), а языки, основанные на символьной логике, называются языками логического програм- мирования (logic programming languages), или декларативными языками (declarative languages). Мы решили описать язык логического программирования Prolog, поскольку из всех логических языков только он получил широкое распространение. Синтаксис языков логического программирования значительно отличается от импе- ративных и функциональных языков. Семантика логических программ также мало похо- жа на семантику программ, написанных на императивных языках. Эти наблюдения должны вызвать у читателя любопытство в отношении природы логического програм- мирования и декларативных языков. 15.2. Краткое введение в исчисление предикатов Прежде чем мы сможем обсудить логическое программирование, кратко исследуем ее основу, т.е. формальную логику. Это не первая наша встреча с формальной логикой; она интенсивно использовалась в аксиоматической семантике, описанной в главе 3. Высказывание (proposition) — логическое утверждение, которое может быть истин- ным или ложным. Оно состоит из объектов и отношений между ними. Формальная логи- ка была разработана для того, чтобы создать метод описания таких высказываний с це- лью проверки их истинности. Символьную логику (symbolic logic) можно использовать для решения трех основ- ных задач формальной логики: выражения высказываний, выражения отношений между высказываниями и описания способов вывода новых высказываний из других высказы- ваний. считающихся истинными. 616 Глава 15. Языки логического программирования
Между формальной логикой и математикой существует тесная связь. Действительно, многое в математике можно описать в терминах логики. Основные аксиомы теории чи- сел и теории множеств представляют собой начальное множество высказываний, счи- тающихся истинными. Теоремы являются дополнительными высказываниями, которые могут быть выведены из начального множества высказываний. Конкретный вид символьной логики, использующийся в логическом программирова- нии, называется исчислением предикатов (predicate calculus). Его мы кратко рассмот- рим в следующих подразделах. Наша цель — заложить основу для обсуждения логиче- ского программирования и языка логического программирования Prolog. 15.2.1. Высказывания Объекты в высказываниях логического программирования представляются простыми термами, являющимися либо константами, либо переменными. Константа — это символ, представляющий некий объект. Переменная — это символ, который может представлять разные объекты в разное время, хотя в некотором смысле она намного ближе к матема- тическому пониманию переменной, чем к переменным в императивных языках програм- мирования. Простейшие высказывания, называемые атомарными высказываниями (atomic proposition), состоят из составных термов. Составной терм (compound term) — это эле- мент математического отношения, формально записанного в виде математической функ- ции. Напомним, что в главе 14 было дано определение математической функции как отображения, которое можно представить либо как выражение, либо как таблицу, либо как список кортежей. Таким образом, составные термы — это элементы определения функции в виде таблицы. Составной терм состоит из двух частей: функтора (functor), представляющего собой функциональный символ, называющий отношение, и упорядоченного списка парамет- ров. Составной терм с одним параметром представляет собой кортеж из одного элемен- та, с двумя параметрами — кортеж из двух элементов и так далее. Например, мы можем иметь два выражения: человек(Джек) любит(Боб, стейк) Эти выражения утверждают, что {Джек} — это кортеж из одного элемента, принад- лежащий отношению с именем “человек”, и что {Боб, стейк} — это кортеж из двух элементов, принадлежащих отношению с именем “любит”. Добавим к двум выска- зываниям, указанным выше, следующее высказывание человек(Фред) Тогда отношение “человек” может иметь два разных элемента {Джек} и {Фред}. Все простые термы в этом высказывании— человек, любит, Боб и стейк — константы. Заметим, что эти высказывания не имеют внутренней семантики. Они могут означать все, что мы захотим. Например, второй пример из приведенных выше, может означать, что некто по имени Боб любит стейк, или что стейк любит Боба, или что Боб в некотором смысле похож на стейк. Высказывания можно формулировать двумя способами: в первом из них высказыва- ние считается истинным, а во втором истинность высказывания требуется установить. 15.2. Краткое введение в исчисление предикатов 617
Иными словами, высказывания можно формулировать либо как факты, либо как запро- сы. Высказывания в примерах, приведенных выше, могут быть и теми, и другими. Составные высказывания имеют несколько атомарных высказываний, связанных ме- жду собой логическим коннектором, или оператором, так же, как составные логические выражения конструируются в императивных языках. Названия, символы и смысл кон- некторов в логическом исчислении предикатов представлены в следующей таблице Название Символ Пример Смысл отрицание —। ->а а не конъюнкция п а Г\Ь а и b дизъюнкция aub а или b тождественность = a = b а тождественно b импликация azib а влечет b С acb b влечет а Ниже приведены примеры составных высказываний: a u b D с a u-i b D d Оператор -> имеет наивысший приоритет. Операторы п, и и = обладают более высо- ким приоритетом, чем операторы з ис. Таким образом, второй пример из приведенных выше эквивалентен выражению (ап (—। Ъ) ) D d В высказывании могут содержаться и переменные, но при этом к ним всегда припи- сываются специальные символы, называемые кванторами. В исчислении предикатов есть два квантора, показанных ниже, где %— переменная, а Р — высказывание: Название Пример квантор всеобщности VXP Смысл Для всех переменных X высказывание Р истинно квантор существования ЗХ.Р Существует значение переменной X, при котором высказывание Р истинно Точка между X и Р просто отделяет переменную от высказывания. Например, рас- смотрим следующие выражения: VX. (женщина (X) D человек (X)) 3 X. (мать (Мери, X) п мужчина (X)) Первое из этих высказываний означает, что для любого значения переменной X, если X— женщина, то %— человек. Второе высказывание означает, что существует такое значение переменной X, при котором Мери является матерью X и X— мужчина; други- ми словами, у Мери есть сын. Область видимости кванторов всеобщности и существова- ния распространяется на атомарное высказывание, к которому они приписаны. Эта об- ласть видимости может быть расширена с помощью скобок, как это сделано в двух толь- ко что описанных составных высказываниях. Таким образом, кванторы всеобщности и существования имеют более высокий приоритет, чем любой другой оператор. 618 Глава 15. Языки логического программирования
15.2.2. Дизъюнктивные формы Мы обсуждаем исчисление предикатов, поскольку оно является основой языков ло- гического программирования. Как и в других языках, оптимальными являются простей- шие формы логических языков, т.е. избыточность языка следует минимизировать. Одна из проблем, связанных с исчислением предикатов в том виде, как мы его до сих пор описывали, состоит в том, что существует слишком много способов формулирова- ния высказываний, имеющих одинаковый смысл, т.е. возникает большая проблема избы- точности. Для логиков этой проблемы не существует, но, если исчисление предикатов должно быть использовано в автоматической (компьютеризованной) системе, это стано- вится серьезной проблемой. Чтобы упростить ситуацию, нужно иметь стандартную фор- му высказываний. Дизъюнктивная форма (clausal form), представляющая собой относи- тельно простую форму высказываний, является одной из таких стандартных форм. Без потери общности все высказывания могут быть выражены в дизъюнктивной форме. Вы- сказывание в дизъюнктивной форме имеет следующий синтаксис: Bj и в2 и ... и вл с п а2 Ап Здесь As и Bs — это термы. Смысл этой дизъюнктивной формы высказывания заключа- ется в следующем: если все термы истинны, то по крайней мере один терм В истинен. Основное свойство дизъюнктивной формы высказываний состоит в следующем: не нуж- ны кванторы существования; кванторы всеобщности присутствуют неявно при использо- вании переменных в атомарных высказываниях; не требуется никаких операторов, кроме конъюнкции и дизъюнкции. Кроме того, конъюнкция и дизъюнкция должны появляться только в порядке, указанном в общей дизъюнктивной форме: дизъюнкция в левой части, а конъюнкция — в правой. Все высказывания в исчислении предикатов можно преобра- зовать в дизъюнктивную форму с помощью соответствующего алгоритма. Нильсон (Nilsson (1971)) привел доказательство того, что это действительно можно сделать, и описал простой алгоритм, реализующий такое преобразование. Правая часть в дизъюнктивной форме высказывания называется антецедентом (antecedent). Левая часть называется консеквентом (consequent), поскольку она является следствием истинности антецедента. Рассмотрим следующие дизъюнктивные формы вы- сказываний: любит(Боб, форель) С любит(Боб, рыба) п рыба(форель) отец(Луис, Эл) U отец(Луис, Вайолет) с отец(Эл, Боб) Г\ мать(Вайолет, Боб) П дед(Луис, Боб) На русском языке первое высказывание означает, что если Боб любит рыбу и форель является рыбой, то Боб любит форель. Второе высказывание означает, что если Эл — отец Боба, а Вайолет — мать Боба и Луис — дед Боба, то Луис — либо отец Эла, либо отец Вайолет. 15.3. Исчисление предикатов и доказательство теорем Исчисление предикатов дает метод для выражения совокупностей высказываний. Ис- пользовать совокупности высказываний — значит определять, можно ли вывести из них 15.3. Исчисление предикатов и доказательство теорем 619
какие-нибудь интересные или полезные факты. Это аналогично работе математиков, ста- рающихся открыть новые теоремы, которые можно вывести из известных аксиом и теорем. На заре компьютерных наук (в 1950-х и в начале 1960-х годов) процессу автоматиче- ского доказательства теорем уделялось большое внимание. Одним из самых крупных на- учных достижений в области автоматического доказательства теорем было открытие принципа резолюции (resolution) Аланом Робинсоном (Alan Robinson) из Сиракузского университета (Siracuse University). Резолюция (resolution) — это правило логического вывода, позволяющее вычислять выводимые высказывания по заданным высказываниям, обеспечивая, таким образом, ме- тод, имеющий потенциальные приложения в области автоматического доказательства теорем. Резолюция была изобретена для применения к высказываниям в дизъюнктивной форме. Концепция резолюции заключается в следующем: допустим, что нам даны два высказывания в следующих формах: Л с Р2 Q- С С: Их смысл состоит в том, что Р\ следует из Р2, a Q\ следует из Q2, Предположим, что вы- сказывание Р\ тождественно высказыванию Q2. так что мы можем переобозначить их через Т. Перепишем два наших высказывания в следующем виде: Т С Р: С Т Поскольку Т следует из Р2. a Q\ следует из Г, логически очевидно, что Q\ следует из Р2. Запишем это в виде следующего высказывания: C i с Р2 Процесс вывода этого высказывания из исходных двух высказываний является резолю- цией. Рассмотрим два высказывания: старше(Джоанна, Джек) с мать(Джоанна, Джек) мудрее(Джоанна, Джек) С старше(Джоанна, Джек) По этим высказываниям, используя резолюцию, можно построить новое высказывание: мудрее(Джоанна, Джек) с мать(Джоанна, Джек) Механизм этой резолюции прост: термы в левых частях этих двух высказываний объе- диняются вместе с помощью логической операции “И”, образуя левую часть нового вы- сказывания. Затем точно также формируется правая часть нового высказывания. Далее, терм, появляющийся в обеих частях нового высказывания, удаляется из них. Этот про- цесс применим и тогда, когда высказывания содержат составные термы в одной или обе- их частях. Левая часть нового выведенного высказывания вначале содержит все термы левых частей двух заданных высказываний. Новая правая часть конструируется анало- гично. Затем термы, появляющиеся в обеих частях нового высказывания, удаляются. Ре- золюция следующих высказываний отец(Боб, Джек) U мать(Боб, Джек) D родители(Боб, Джек) дед(Боб, Фред) С отец(Боб, Джек) п отец(Джек, Фред) 620 Глава 15. Языки логического программирования
приведет к такому выражению: мать(Боб, Джек) о дед(Боб, Фред) D родители(Боб, Джек) п отец(Джек, Фред) Это выражение содержит все, кроме одного, атомарные высказывания из обоих задан- ных высказываний. Атомарное высказывание, позволявшее операцию отец (Боб, Джек) в левой части первого и правой части второго высказывания, удалено. По-русски мы могли бы записать эти высказывания следующим образом: если\ из того, что Боб — родитель Джека, следует что Боб — или отец, или мать Джека и: из того, что Боб — отец Джека и Джек — отец Фреда, следует что Боб — дед Фреда, тогда если'. Боб — родитель Джека и Джек — отец Фреда, то: либо Боб — мать Джека, либо Боб — дед Джека. В действительности резолюция представляет собой более сложный процесс, чем по- казано в этом простом примере. В частности, наличие переменных в высказываниях тре- бует выполнять в процессе резолюции поиск значений этих переменных, что приводит к процессу поиска соответствий. Этот процесс определения полезных значений перемен- ных называется унификацией (unification). Временное присваивание значений перемен- ным с целью унификации называется конкретизацией (instantiation). Обычно во время резолюции переменная конкретизируется неким значением, не полно- стью удовлетворяющим требуемому соответствию, затем следует отменить последнее дей- ствие (backtrack) и конкретизировать эту переменную новым значением. Мы будем изучать унификацию и бектрекинг (backtracking) более подробно в контексте языка Prolog. Крайне важное свойство резолюции — ее способность обнаруживать любое противо- речие в заданной совокупности высказываний. Это свойство позволяет использовать ре- золюцию для доказательства теорем следующим образом: в терминах исчисления выска- зываний мы можем представить себе доказательство теоремы как заданную совокуп- ность соответствующих высказываний, в которых отрицание теоремы само по себе формулируется в виде нового высказывания. Теорема отрицается, так что можно исполь- зовать резолюцию для доказательства теоремы, обнаружив противоречие. Это — доказа- тельство от противного. Обычно исходные высказывания называются гипотезами (hypothesises), а отрицание теоремы — целью (goal). Теоретически, это обоснованный и полезный процесс. Однако количество времени, требуемое для выполнения резолюции, может стать проблемой. Несмотря на то что ре- золюция — конечный процесс, если совокупность высказываний конечна, время, необ- ходимое для обнаружения противоречия в большой базе данных высказываний, может быть огромным. Доказательство теорем представляет собой основу логического программирования. Многое из того, что может быть вычислено, можно сформулировать в виде списка за- данных фактов и отношений в качестве гипотез, а цель должна быть выведена из гипотез с помощью резолюции. При использовании высказываний для резолюции используется только ограниченный вид дизъюнктных форм, которые еще более упрощают процесс резолюции. Специальные формы высказываний, называемые хорновскими дизъюнктами (Hom clauses), могут иметь либо единственное атомарное высказывание в левой части либо пустую левую 15.3. Исчисление предикатов и доказательство теорем 621
часть. (Хорновские дизъюнкты названы в честь Альфреда Хорна (Alfred Hom), изучав- шего дизъюнкты такого вида (Нот, 1951).) Левая часть дизъюнктивной формы высказы- ваний иногда называется головой, и поэтому хорновские дизъюнкты с левой частью на- зываются хорновскими дизъюнктами с головой. Хорновские дизъюнкты с головой ис- пользуются для формулирования таких отношений, как любит(Боб, форель) С любит(Боб, рыбу) п рыба(форель) Хорновские дизъюнкты с пустой левой частью, часто используемые для формулиро- вания фактов, называются хорновскими дизъюнктами без головы. Например, отец(боб, джек) Большинство высказываний, но не все, можно сформулировать в виде хорновских дизъ- юнктов. 15.4. Обзор логического программирования Языки, используемые для логического программирования, называются декларатив- ными языками, поскольку программы, написанные на них, состоят из объявлений, а не из операторов присваивания и управляющих операторов. Эти объявления в действительно- сти являются операторами, или высказываниями, в символьной логике. Существенной характеристикой языков логического программирования является их семантика, называемая декларативной семантикой (declarative semantics). Основная концепция ее заключается в том, что существует простой способ определения смысла каждого оператора, и он не зависит от того, как именно этот оператор используется для решения задачи. Декларативная семантика значительно проще, чем семантика импера- тивных языков. Например, смысл заданного высказывания в языке логического про- граммирования можно точно определить по самому оператору. В императивном языке семантика простого оператора присваивания требует проверки локальных объявлений, знания правил обзора данных в языке, а также, возможно, проверки программ, записан- ных в других файлах всего лишь для определения типов переменных в операторе при- сваивания. Если выражение присваивания содержит переменные, должно быть отслеже- но выполнение программы до оператора присваивания для того, чтобы определить зна- чения этих переменных. Таким образом, результат выполнения этого оператора зависит от его контекста во время выполнения программы. Сравнивая это с простой проверкой отдельного оператора, не требующей рассмотрения контекста или последовательности выполнения программы, легко видеть, что декларативная семантика намного проще, чем семантика императивных языков. Таким образом, декларативная семантика часто указы- вается в качестве преимущества декларативных языков над императивными языками (Hogger, 1984, рр. 240-241). Программирование как на императивных, так и на функциональных языках в основ- ном является процедурным. Это означает, что программист знает, что именно должно быть выполнено программой, и указывает компьютеру, как именно следует выполнять вычисления. Иными словами, компьютер рассматривается как простое устройство, под- чиняющееся приказам. Все, что подлежит вычислению, должно сопровождаться подроб- ным описанием вычислений. Некоторые люди полагают, что это составляет суть трудно- стей при программировании компьютеров. 622 Глава 15. Языки логического программирования
Программирование на некоторых неимперативных языках и, в частности, на языках логического программирования, является непроцедурным. Программы на таких языках не содержат указаний, как именно вычислить результат, а только описывают форму ре- зультата. Отличие состоит в том, что мы предполагаем, будто компьютерная система может каким-то образом определить, как именно должен быть получен результат. Для того чтобы предоставить такую возможность в логических языках программирования, нужны четкие средства для снабжения компьютера соответствующей информацией и ме- тодом логического вывода для вычисления желательных результатов. Основная форма общения с компьютером обеспечивается исчислением предикатов, а средством логиче- ского вывода результатов является метод, разработанный Аланом Робинсоном. Примером, широко используемым для иллюстрации различий между процедурными и непроцедурными системами, является переупорядочение списка данных в соответст- вии с некоторым конкретным порядком, известное также под названием “сортировка”. В языке, подобном языку C++, сортировка выполняется путем указания в программе всех деталей некоторого алгоритма сортировки, необходимых компьютеру, имеющему компилятор языка C++. После трансляции программы, написанной на языке C++, в ма- шинный код или некоторый интерпретируемый промежуточный код компьютер, следуя инструкциям, создает отсортированный список. В непроцедурном языке необходимо лишь описать свойства отсортированного спи- ска. Происходит перестановка элементов заданного списка, и для каждой пары смежных элементов выполняется заданное отношение. Формализуем приведенное описание: до- пустим, что подлежащий сортировке список представляет собой массив с именем list, индексы которого изменяются в диапазоне 1 ... п. Идею сортировки элементов заданного списка с именем old_list и помещения его в отдельный массив с именем new_list можно выразить в следующей форме: сортировать(old_list, new_list) С переставить(old_list, new_list) n отсортированный (new_list) С Vj такого, что 1 < j < n, list(j) < list(j+l) Здесь “переставить” — это предикат, возвращающий булевское значение “истина”, если массив, являющийся его вторым параметром, представляет собой перестановку элемен- тов массива, являющегося первым параметром. Следуя этому определению, непроцедурная языковая система могла бы произвести отсортированный список. При этом непроцедурное программирование выглядит как простое формулирование точных спецификаций программного обеспечения, что пред- ставляет собой значительное преимущество. К сожалению, однако, это не так просто. Логические программы, использующие только резолюцию, сталкиваются с серьезными проблемами, связанными с эффективностью выполнения программ. Более того, опти- мальная форма логического языка, возможно, еще не определена, а идеальные методы создания программ на языках логического программирования еще не разработаны. 15.5. Происхождение языка Prolog Как указывалось в главе 2, Ален Колмерье и Филипп Руссель из Марсельского уни- верситета при некотором участии Роберта Ковальски из Эдинбургского университета 15.5. Происхождение языка Prolog 623
разработали основы языка Prolog. Колмерье и Руссель интересовались обработкой тек- стов на естественных языках, а Ковальски изучал автоматическое доказательство теорем. Сотрудничество между Марсельским и Эдинбургским университетами продолжалось до середины 1970-х годов. С тех пор исследования по разработке и использованию этого языка развивались независимо в двух этих местах, что в результате, помимо всего проче- го, привело к появлению двух синтаксически разных диалектов языка Prolog. Разработка языка Prolog и другие исследования в области логического программиро- вания привлекали к себе мало внимания вне Эдинбурга и Марселя до тех пор, пока в 1981 году не появилось сообщение, что правительство Японии инициировало большой исследовательский проект под названием Fifth Generation Computing Systems (FGCS) (Fuchi. 1981; Moto-oka, 1981). Одной из основных целей этого проекта была разработка интеллектуальных машин, и язык Prolog был выбран в качестве основы этой разработки. Объявление о начале проекта FGCS неожиданно вызвало среди ученых США и Европы повышенный интерес к исследованиям в области создания искусственного интеллекта и логического программирования. 15.6. Основные элементы языка Prolog В настоящее время существует несколько разных диалектов языка Prolog. Их можно отнести к следующим категориям: созданные марсельской группой, созданные эдин- бургской группой и набор диалектов для микрокомпьютеров, таких как язык micro-Prolog, описанный в работе Clark and McCabe (1984). Синтаксические формы этих диалектов несколько отличаются друг от друга. Вместо того, чтобы пытаться описать синтаксис нескольких диалектов языка Prolog или их комбинации, мы выбрали один конкретный широко используемый диалект — тот, что был разработан в Эдинбурге. Эта форма языка иногда называется эдинбургским синтаксисом. Его первой реализацией бы- ла система DEC System-10 (Warren et al.. 1979). 15.6.1. Термы Как и в других языках, программы на языке Prolog состоят из совокупностей опера- торов. Язык Prolog содержит несколько довольно сложных операторов. Все операторы в языке Prolog образуются из термов. Терм (term) в языке Prolog — это константа, переменная или структура. Константа — это или атом (atom), или целое число. Атомы представляют собой символьные значения языка Prolog и похожи на свои аналоги в языке LISP. В частности, атом может быть либо строкой букв, цифр и символов подчеркивания, начинающейся со строчной буквы, либо строкой любых печатных ASCII-символов, разделенных апострофами. Переменная — это любая строка букв, цифр и символов подчеркивания, начинаю- щаяся с прописной буквы. Переменные не связываются ни с какими типами с помощью объявлений. Связывание некоторого значения, а значит, и типа, с переменной называется конкретизацией. Конкретизация возникает только в процессе резолюции. Переменная, которой не присвоено никакого значения, называется неконкретизированной. Конкрети- зации продолжаются только до тех пор, пока они удовлетворяют некую завершенную цель, включающую в себя доказательство или опровержение некоторого высказывания. Переменные в языке Prolog как по использованию, так и по семантике, имеют лишь от- даленное отношение к переменным в императивных языках. 624 Глава 15. Языки логического программирования
Последний вид термов называется структурой. Структуры представляют собой ато- марные высказывания исчисления предикатов и имеют ту же форму: функтор(список параметров) Функтор— это любой атом. Он используется только для идентификации структуры. Список параметров может быть любым списком атомов, переменных и других структур. Как указывалось выше, структуры — это средство для формулирования фактов в языке Prolog. Их можно рассматривать как объекты, при этом они позволяют выражать факты в виде нескольких связанных между собой атомов. В этом смысле структуры являются отношениями, поскольку они устанавливают отношения между термами. Структура так- же является предикатом, когда из ее контекста следует, что она представляет собой за- прос (вопрос). 15.6.2. Факты Наше обсуждение операторов языка Prolog начинается с тех операторов, которые ис- пользуются для построения гипотез или базы данных предполагаемой информации — операторов, с помощью которых можно логически вывести новую информацию. В языке Prolog есть две основные формы операторов; они соответствуют хорновским дизъюнктам без головы и с головой в исчислении предикатов. Простейшая форма хор- новского дизъюнкта без головы в языке Prolog представляет собой отдельную структуру, интерпретируемую как безусловное утверждение, или факт. Следовательно, факты — это просто высказывания, которые предполагаются истинными. Приведенный ниже пример иллюстрирует виды фактов, которые могут быть в про- грамме на языке Prolog: female(shelley) . male(bill) . female(mary). male(jake). father(bill, jake). father(bill, shelle). mother(mary, jake). mother(mary, shelley). Эти простые структуры утверждают определенные факты об объектах с именами jake, shelley, bill и тагу. Например, первая структура утверждает, что shelley — fe- male. Последние четыре структуры связывают два свои параметра отношением, которое названо в функторном атоме: например, пятое высказывание можно интерпретировать как утверждение о том, что объект Билл (bill) является отцом (father) Джека (jake). Заметим, что эти высказывания на языке Prolog, как и высказывания в исчисле- нии предикатов, не имеют внутренней семантики. Они означают все, что будет угодно программисту. Например, рассмотрим высказывание father(bill, jake). Оно может означать, что у Билла и Джека один и тот же отец, или что Джек — отец Билла. Наиболее общее и строгое значение этого высказывания, однако, заключается в том, что Билл является отцом Джека. 15.6. Основные элементы языка Prolog 625
15.6.3. Правила Другая основная форма оператора в языке Prolog для построения баз данных соответ- ствует хорновскому дизъюнкту с головой. Эту форму можно сравнить с известной тео- ремой в математике, из которой можно вывести заключение, если удовлетворяется сово- купность заданных условий. Правая часть этого оператора является антецедентом, или частью если. а левая часть — следствием, или частью то. Если антецедент оператора языка Prolog истинен, то следствие этого оператора также должно быть истинным. По- скольку они являются хорновскими дизъюнктами, следствие оператора в языке Prolog является отдельным термом, в то время как антецедент может быть либо отдельным тер- мом. либо конъюнкцией. Конъюнкции (conjuctions) состоят из составных термов, отделенных друг от друга логическими операциями “И”. В языке Prolog операция “И” является неявной. Структу- ры. определяющие атомарные высказывания в конъюнкции, разделяются запятыми, так что запятые можно рассматривать как операторы “И”. В качестве примера конъюнкции рассмотрим следующее выражение: female(shelley), child(shelley). । Общая форма хорновского дизъюнкта в языке Prolog такова: следствие_1 :- антецедентное_выражение Читается это следующим образом: “следствие ! может быть истинным, если истинно ан- тецедентное выражение, или оно может быть сделано истинным путем некоей конкрети- зации ее переменных”. Рассмотрим в качестве примера следующее высказывание: ancestor(тагу, shelley) mother(тагу, shelley). Оно утверждает, что если Мери (тегу) является матерью (mother) Шелли (shelley), то Мери является наследником (ancestor) Шелли. Хорновские дизъюнкты с головой называются правилами (rules), поскольку они формулируют правила логического след- ствия между высказываниями. Как и дизъюнктивная форма в исчислении предикатов, оператор в языке Prolog может использовать переменные для обобщения их значения. Напомним, что переменные в дизъюнктивной форме представляют собой неявный квантор всеобщности. Следующий пример демонстрирует использование переменных в операторе языка Prolog: parent(X, Y) mother(X, Y). parent(X, Y) father(X, Y). grandparent(X, Z) parent(X, Y) , parent(Y, Z). sibling(X, Y) mother(X, Y), mother (M, Y), father(F, X), father(F, Y) Эти операторы представляют собой правила импликации, установленные для нескольких переменных, или универсальных объектов. В данном случае универсальными объектами являются переменные X, Y, Z, И и F. Первое правило утверждает, что если существуют конкретизации переменных X и Y, определяющие, что факт mother (X, Y) является истинным, то для этих же самых конкретизаций переменных X и Y факт parent (X, Y) является истинным. 626 Глава 15. Языки логического программирования
15.6.4. Цель До сих пор мы рассматривали операторы языка Prolog для логических высказываний, которые используются для описания известных фактов и правил, описывающих логиче- ские отношения между фактами. Эти операторы составляют основу модели доказатель- ства теорем. Теорема формулируется в форме высказывания, которое система должна доказать или опровергнуть. В языке Prolog эти высказывания называются целями, или запросами (queries). Синтаксическая форма цели в языке Prolog идентична форме хор- новского дизъюнкта без головы. Например, мы могли бы иметь выражение man(fred). На него система должна дать ответ да или нет. Ответ да означает, что система дока- зала, что цель была истинной при заданных фактах и отношениях, хранящихся в базе данных. Ответ нет означает, что либо было доказано, что цель ложна, либо система про- сто неспособна ни доказать, ни опровергнуть ее. Высказывания в форме конъюнкции и высказывания, содержащие переменные, также допускаются в качестве целей. При наличии переменных система не только контролиру- ет корректность цели, но и идентифицирует конкретизации переменных, делающие цель истинной. Например, системе может быть представлен следующий запрос: father(X, mike). В этом случае система с помощью идентификации попытается найти конкретизацию переменной X, делающую цель истинной. Поскольку цели и некоторые операторы, не являющиеся целями, имеют одну и ту же форму (хорновских дизъюнктов без головы), реализация языка Prolog должна иметь ка- кие-то средства для того, чтобы отличать их друг от друга. Интерактивные реализации языка Prolog делают это просто с помощью двух способов ввода, указываемых путем разных интерактивных подсказок: один— для ввода фактов и правил, другой— для ввода целей. Пользователь может изменить способ ввода в любой момент. 15.6.5. Процесс логического вывода в языке Prolog В этом разделе изучается резолюция в языке Prolog. Для эффективного использова- ния языка Prolog требуется, чтобы программист точно знал, что именно делает система языка Prolog с его программой. Запросы называются целями (goal). Когда цель представляет собой составной опера- тор, каждый из входящих в нее фактов (структур) называется подцелью (subgoal). Для доказательства того, что цель истинна, процесс логического вывода должен найти це- почку правил логического вывода и/или факты в базе данных, которые связывают цель с одним или несколькими фактами в базе данных. Например, если Q — это цель, то она либо должна быть найдена как факт в базе данных, либо процесс логического вывода должен найти факт Л и следующую последовательность высказываний Р2, Р3... Рп: Р2 Pi Рз ft Q Рп 15.6. Основные элементы языка Prolog 627
Конечно, процесс часто усложняется тем, что правила могут иметь составные правые части или переменные. Процесс поиска фактов Л, если они существуют, в основном сводится к сравнению (или поиску соответствия между термами). Поскольку доказательство подцели осуществляется с помощью поиска соответствия между высказываниями, иногда его называют сопоставлением. В некоторых случаях до- казательство подцели называется удовлетворением (satisfying) этой подцели. Рассмотрим следующий запрос: man(bob). Эта цель имеет простейший вид. Он относительно прост для того, чтобы резолюция оп- ределила, истинно ли это высказывание или ложно: образец этой цели сравнивается с фактами и правилами в базе данных. Если факт man(bob). содержится в базе данных, то доказательство является тривиальным. Допустим, однако, что в базе данных содержатся приведенные ниже факт и правило логического вывода: father(bob). man(X) father(X). Тогда можно потребовать, чтобы система языка Prolog нашла два эти утверждения и использовала их для логического вывода истинности цели. Это может привести к необ- ходимости выполнить унификацию, чтобы временно конкретизировать переменную X значением bob. Рассмотрим теперь цель man(X). В этом случае система языка Prolog должна сравнить данную цель с высказываниями, хранящимися в базе данных. Первое обнаруженное высказывание, имеющее форму ука- занной цели, с любым объектом в качестве параметра приведет к конкретизации пере- менной X значением этого объекта. Если в базе данных нет высказываний, имеющих форму указанной цели, система отмечает, что цель не может быть достигнута, отвечая по. Есть два противоположных подхода к сравнению заданной цели и факта в базе дан- ных. Система может начать поиск с фактов и правил, хранящихся в базе данных, и попы- таться найти последовательность совпадений, ведущую к цели. Этот подход называется резолюцией снизу-вверх, или прямым выводом (forward chaining). Альтернативный подход заключается в том, что система начинает поиск с цели и пытается найти последо- вательность соответствующих высказываний, ведущую к некоторому множеству исход- ных фактов, хранящихся в базе данных. Этот подход называется резолюцией сверху- вниз, или обратным выводом (backward chaining). В целом, обратный вывод хорошо работает, когда существует небольшой набор возможных ответов. Прямой вывод работа- ет лучше, когда количество возможных правильных ответов велико; в этой ситуации при обратном выводе для получения ответа может потребоваться очень большое количество сопоставлений. Реализации языка Prolog используют для резолюции обратный вывод, предположительно потому, что их разработчики думали, будто обратный вывод подхо- дит для более широкого класса задач, чем прямой вывод. Снова рассмотрим пример запроса: man(bob). 628 Глава 15. Языки логического программирования
Предположим, что база данных содержит следующий факт и правило вывода: father(bob). man(X) father(X). При прямом выводе система должна была бы отыскать первое высказывание. Тогда ло- гический вывод цели осуществляется следующим образом: переменная X конкретизиру- ется значением bob, первое высказывание сопоставляется с правой частью второго пра- вила (father (X)), а затем левая часть второго высказывания сопоставляется с целью. При обратном выводе следует сначала сопоставить цель с левой частью второго выска- зывания ((man (X)), конкретизировав переменную X значением bob. На последнем эта- пе система должна сопоставить правую часть второго высказывания (теперь father (bob)) с первым высказыванием. Следующий вопрос, относящийся к разработке языка, возникает всякий раз, когда цель имеет несколько структур, как в примере, приведенном выше. В этом случае вопрос заклю- чается в том, как выполнять поиск: сначала в глубину или в ширину? При поиске сначала- вглубь (depth-first search) система находит полную цепочку высказываний — доказательст- во — для первой подцели прежде, чем приступить к работе над остальными. При поиске сначала-вширь (breadth-first search) система работает над всеми подцелями параллельно. Разработчики языка Prolog выбрали в качестве основного поиск сначала-вглубь, поскольку он требует меньше компьютерных ресурсов. Подход сначала-вширь представляет собой параллельный поиск, который может потребовать большого объема памяти. Последнее свойство механизма резолюции в языке Prolog, которое следует обсу- дить, — бектрекинг (backtracking). При обработке цели с несколькими подцелями, если система не способна доказать истинность одной из подцелей, она отказывается от обра- ботки подцели, которую не способна доказать. Вместо этого система заново рассматри- вает предыдущую подцель, если она является единственной, и пытается найти ее альтер- нативное решение. Это восстановление предшествующего состояния цели для пересмот- ра ранее доказанной подцели называется бектрекингом. Новое решение находится в результате поиска, предпринятого с того места, где остановился предыдущий поиск для этой подцели. Множественность решений для подцели является результатом наличия различных конкретизаций ее переменных. Бектрекинг требует больших затрат времени и объема памяти, поскольку он может найти все возможные решения для каждой подцели. Эти доказательства подцелей могут оказаться не достаточно организованными для того, чтобы минимизировать объем времени, которое требуется для поиска окончательного решения, что еще больше обостряет проблему. Чтобы укрепить наше понимание бектрекинга, рассмотрим следующий пример. До- пустим, что в базе данных есть совокупность фактов и правил, и системе языка Prolog представлена следующая составная цель: male(X), parent(X, shelley). В этой цели спрашивается, существует ли какая-либо конкретизация переменной X. оп- ределяющая, что X является мужчиной (male) и родителем (parent) Шелли (shelley). Система языка Prolog сначала ищет в базе данных первый факт с функтором male. Затем она конкретизирует переменную X параметром найденного факта, скажем, параметром mike. Далее она пытается доказать, что высказывание parent (mike, shelley) является истинным. Если это не удается сделать, она возвращается к первой подцели, male (X), и пробует снова удовлетворить ее с помощью некоторой альтерна- 15.6. Основные элементы языка Prolog 629
тивной конкретизации переменной X. Может оказаться, что, выполняя процесс резолю- ции. система должна будет найти каждого мужчину в базе данных, прежде чем она най- дет одного из них, являющегося родителем Шелли. Она определенно должна найти всех мужчин, для того чтобы доказать, что цель не может быть удовлетворена. Заметим, что наш пример цели можно решить более эффективно, если порядок следования двух под- целей поменять местами. Тогда только после того, как система с помощью резолюции найдет родителя Шелли, она попытается найти лицо с подцелью male. Этот способ эф- фективен, если у Шелли меньше родителей, чем мужчин в базе данных, что вполне прав- доподобно. В разделе 15.7.1 мы обсудим метод ограничения бектрекинга, выполняемый системой языка Prolog. Система языка Prolog всегда выполняет поиск в базе данных в направлении от перво- го элемента к последнему. В двух следующих подразделах описываются примеры на языке Prolog, иллюстри- рующие процесс резолюции. 15.6.6. Простая арифметика Язык Prolog поддерживает целые числа и целую арифметику. Первоначально ариф- метические операторы были функторами, так что сумма числа 7 и переменной X форми- ровалась выражением +(7, X) В настоящее время язык Prolog допускает более краткий синтаксис арифметических операций с использованием оператора is. Этот оператор имеет арифметическое выра- жение в качестве своего правого операнда, а переменную — в качестве левого операнда. Все переменные в выражении должны быть предварительно конкретизированы, но пере- менная в левой части выражения не должна конкретизироваться заранее. Рассмотрим следующее выражение: A is В / 17 + С. Если переменные В и С конкретизированы, а переменная А— нет, то этот дизъюнкт при- ведет к тому, что переменная А будет конкретизирована значением данного выражения. Когда это случится, дизъюнкт оказывается удовлетворенным. Если либо переменная В, либо переменная С являются неконкретизированными, либо переменная А является кон- кретизированной, дизъюнкт не удовлетворяется и конкретизация переменной А не про- исходит. Семантика высказывания, содержащего оператор is, значительно отличается О! семантики оператора присваивания в императивном языке. Это отличие может при- вести к интересной ситуации. Поскольку оператор is делает дизъюнкт, в котором он по- является. похожим на оператор присваивания, начинающие программисты на языке Prolog могут попытаться написать оператор, приведенный ниже: Sum is Sum + Number. Такой оператор никогда не станет осмысленным (и даже допустимым) в языке Prolog. Если переменная Sum не конкретизирована, ссылка на нее в правой части оператора яв- ляется неопределенной, и дизъюнкт теряет смысл. Если переменная Sum уже конкрети- зирована. дизъюнкт также теряет смысл, поскольку левая часть оператора не может иметь текущей конкретизации при вычислении оператора is. В любом случае конкрети- 630 Глава 15. Языки логического программирования
зация переменной Sum новым значением никогда не произойдет. (Если требуется вычис- лить значение выражения Sum + Number, его следует связать с новым именем.) В отличие от императивных языков, язык Prolog не имеет операторов присваивания. Они просто не нужны в большинстве задач, для решения которых был разработан язык Prolog. Полезность операторов присваивания в императивных языках зависит от воз- можности программиста контролировать поток управления выполнением программы, содержащей эти операторы. Поскольку контроль типов в языке Prolog не всегда возмо- жен, такие операторы становятся намного менее полезными. В качестве простого примера использования количественных вычислений в языке Prolog рассмотрим следующую задачу: допустим, что мы знаем среднюю скорость не- скольких автомобилей на конкретном гоночном треке и объем времени их пребывания на треке. Эту основную информацию можно закодировать в виде фактов, а отношение между скоростью, временем и расстоянием можно записать в виде правила, как показано ниже: speed(ford, 100). speed(chevy, 105). speed(dodge, 95). speed(volvo,80). time(ford, 20). time(chevy, 21). time(dodge, 24) . time(volvo, 24). distance(X, Y) speed(X, Speed), time(X, Time), Y is Speed * Time. Теперь запросы могут потребовать вычисления расстояния, пройденного конкретной машиной. Например, рассмотрим запрос distance(chevy, Chevy_Distance). Он конкретизирует переменную Chevy_Distance значением 2205. Первые два дизъюнкта в правой части оператора, вычисляющего расстояние, просто конкретизируют переменные Speed и Time соответствующими значениями заданного автомобильного функтора. После удовлетворения цели система языка Prolog также выводит имя пере- менной Chevy_Distance и ее значение. Здесь поучительно посмотреть с точки зрения выполнения операторов, как именно система языка Prolog вырабатывает результаты. Система языка Prolog имеет встроенную структуру с именем trace, отображающую конкретизации переменных их значениями на каждом этапе попытки удовлетворения заданной цели. Структура trace использует- ся для анализа и отладки программ, написанных на языке Prolog. Для того чтобы понять, как работает структура trace, лучше всего ввести другую модель выполнения про- грамм на языке Prolog, называемую моделью трассировки (tracing model). Модель трассировки описывает выполнение программы, написанной на языке Prolog, в терминах четырех событий: (1) вызов, возникающий в начале попытки удовлетворить цель; (2) выход, возникающий, когда цель удовлетворена; (3) повтор (redo), возникаю- щий, когда бектрекинг вынуждает систему возобновить попытку снова удовлетворить цель; (4) отказ, возникающий, когда цель удовлетворить невозможно. Вызов и вход мо- гут иметь непосредственное отношение к модели выполнения подпрограммы в импера- 15.6. Основные элементы языка Prolog 631
тивном языке, если процессы, подобные distance, рассматриваются как подпрограм- мы. Два других события свойственны только системам логического программирования. В следующем примере трассировки программы удовлетворение цели не приводит к по- явлению событий повтора или отказа. Ниже приводится трассировка вычисления значения переменной Chevy_Distance: trace. Distance(chevy, Chevy_Distance). (1) 1 Call: distance(chevy, _0)? (2) 2 Call: speed(chevy, _5)? (2) 2 Exit: speed(chevy, 105) (3) 2 Call: time(chevy, _6)? (3) 2 Exit: time(chevy, 21) (4) 2 Exit: 2205 is 105*21 (1) 1 Exit: distance(chevy, 2205) Chevy_Distance = 2205 Символы в трассировке, начинающиеся символом подчеркивания (_), являются внут- ренними переменными, используемыми для хранения в памяти конкретизируемых пере- менных. В первой колонке трассировки указывается подцель, подлежащая проверке на соответствие. Например, в трассировке, приведенной выше, первая строка с обозначени- ем (3) представляет собой попытку конкретизировать временную переменную _6 зна- чением терма time для переменной chevy, где терм time является вторым термом в правой части оператора, описывающего вычисление переменной distance. Во второй колонке указывается глубина вызова процесса сопоставления. В третьей колонке указы- вается текущее действие. Для иллюстрации бектрекинга рассмотрим следующий пример, связанный с базой данных, и отследим составную цель: likes(jake, chocolate). likes(jake, apricots). likes(darcie, licorice). likes(darcie, apricots). trace. likes(jake, X), likes(darcie, X) . (1) 1 Call: likes(jake, _0)? (1) 1 Exit: likes(jake, chocolate) (2) 1 Call: likes(darcie, chocolate)? (2) 1 Fail: likes(darcie, chocolate) (1) 1 Redo: likes (jake, _0) ? (1) 1 Exit: likes(jake, apricots) (3) 1 Call: likes(darcie, apricots)? (3) 1 Exit: likes(darcie, apricots) X = apricots Вычисления, выполняемые системой языка Prolog, можно представить в графическом виде. Рассмотрим каждую цель как ящик с четырьмя портами — вызов, отказ, выход и 632 Глава 15. Языки логического программирования
повтор. Управление входит в цель в прямом направлении через порт вызова. Управление также входит в цель в обратном направлении через порт повтора. Управление покидает цель в двух направлениях: если цель достигнута, управление покидает ее через порт вы- хода; если цель не достигнута, управление покидает ее через порт отказа. Модель этого примера показана на рис. 15.1. В этом примере управление проходит через каждую под- цель дважды. Вторая подцель оказывается недостижимой в первый раз, что приводит к возврату через порт повтора к первой подцели. I Вызов Отказ likes (jake, X) Выход "овтор I t Вызов Отказ likes (darcie, X) Выход Повтор I Рис. 15.1. Модель потока управления для цели likes (jake,Х) f likes (darcie, X) 15.6.7. Списковые структуры До сих пор единственными структурами данных в языке Prolog, которые мы обсуж- дали, были атомарные высказывания, более похожие на вызов функции, а не на структу- ру данных. Атомарные высказывания, которые также называются структурами, в дейст- вительности представляют собой вид записей. Другой основной структурой, поддержи- ваемой языком Prolog, является список, подобный списковой структуре, использованной в языке LISP. Списки представляют собой последовательность, состоящую из любого количества элементов, где элементами могут быть атомы, атомарные высказывания или любые другие термы, включая другие списки. Язык Prolog для указания списков использует общепринятый синтаксис. Элементы списка разделяются запятыми, а список в целом заключается в квадратные скобки, как показано ниже: [apple, prune, grape, kumquat] Для обозначения пустого списка используются символы [ ]. Вместо явных функций для построения и разрушения списков язык Prolog просто использует специальное обозначе- ние. Символы [XI Y] обозначают список с головой X и хвостом Y, где голова и хвост соответствуют первому и последнему элементам списка (CAR и CDR в языке LISP). Это похоже на обозначения, используемые в языке Haskell. 15.6. Основные элементы языка Prolog 633
Список можно создать с помощью простой структуры, как показано ниже: new_list([apple, prune, grape, kumquat]). В этой структуре утверждается, что список констант [ apple, prune, grape, kum- quat] является новым элементом отношения с именем new_list (имя, которое мы только что создали). Этот оператор не связывает список с переменной, имеющей имя new_list; вместо этого £н выполняет тот же вид действий, что и высказывание male(jake) Этот оператор утверждает, что список констант [apple, prune, grape, kumquat] является новым элементом списка new_list. Следовательно, мы могли бы иметь второе высказывание со списком аргументов, например: new_list([apricot, peach, pear]) В виде запроса один из элементов списка new_list можно разбить на две части: голову и хвост с помощью оператора new_list([New_List_Head | New_List_Tail]). Если было указано, что список new_list состоит из двух частей, как в вышеприведен- ном примере, то этот оператор конкретизирует переменную New_List_Head первым элементом списка (в данном случае apple), а переменную New_List_Tail — хвостом списка (или [prune, grape, kumquat]). Если этот запрос является частью состав- ной цели, и бектрекинг приводит к новому его вычислению, переменные New_List_Head и New_List_Tail могут быть снова конкретизированы значениями apricot и [peach, pear], соответственно, поскольку список [apricot, peach, pear ] является следующим элементом списка new_list. Обозначение, использованное для разбиения списка на части, можно также использо- вать и для создания списков из заданных конкретизированных компонентов головы и хвоста, как в следующем примере: [Element_l | List_2] Если переменная Element_l была конкретизирована значением pickle, а список List_2 был конкретизирован значением [peanut, prune, popcorn], обозначение, приведенное выше, приведет к созданию в данном случае списка [pickle, peanut, prune, popcorn]. Как указывалось ранее, обозначение списка, содержащее символ |, является универ- сальным: оно может обозначать как создание списка, так и его разбиение на части. Кро- ме того, заметим, что следующие выражения являются эквивалентными. [apricot, peach, pear I []] [apricot, peach | [pear]] [apricot I [peach, pear]] При работе co списками необходимы такие определенные основные операции, как в языке LISP. В качестве примера этих операций в языке Prolog рассмотрим определение операции append, аналогичное такой же функции в языке LISP. В данном примере можно увидеть как различия, так и сходство функциональных и декларативных языков программирования. Нам не нужно указывать, как именно система языка Prolog должна 634 Глава 15. Языки логического программирования
создать новый список из заданных списков; достаточно уточнить характеристики нового списка в терминах заданных списков. Внешне определение операции append в языке Prolog очень похоже на версию языка LISP, и для получения нового списка в процессе резолюции точно так же исгюльз\ется некоторый вид рекурсии. В языке Prolog рекурсия вызывается и контролируется процес- сом резолюции. Первые два параметра операции append во фрагменте программы, приведенном ни- же, представляют собой два списка, подлежащие объединению, а третий параметр — список, получающийся в результате этого объединения: append([], List, List). append([Head | Listl], List_2, [Head I List_3]) append(List_l, List_2, List_3). Первое высказывание утверждает, что при добавлении пустого списка к любому другому списку результатом является этот другой список. Этот оператор соответствует рекурсив- но завершающемуся шагу в функции append языка LISP. Заметим, что завершающее высказывание помещено до рекурсивного высказывания, поскольку система языка Prolog будет сопоставлять два высказывания по порядку, начиная с первого (потому что он ис- пользует порядок сначала-вглубь). Второе высказывание уточняет несколько характеристик нового списка. Оно соответ- ствует рекурсивному шагу в функции языка LISP. Левая часть предиката утверждает, что первый элемент нового списка совпадает с первым элементом первого из заданных спи- сков, поскольку оба эти элемента имеют имя Head. Как только переменная Head будет конкретизирована некоторым значением, все вхождения переменной Head в цель одно- временно будут конкретизированы этим же значением. Правая часть второго оператора определяет, что хвост результирующего списка (List_3) образуется путем добавления второго заданного списка (List_2) к хвосту первого из заданных списков (List 1). Второй оператор в определении операции append можно прочитать следующим образом: добавление списка [Head I List_l] к любому списку List 2 образует список [Head I List_3], но только если список List_3 создается путем добавления списка List—1 к списку List_2. В языке LISP это можно было бы записать так: (CONS (CAR FIRST) (APPEND (CDR FIRST) SECOND)) И в языке Prolog, и в языке LISP результирующий список строится с помощью самой функции append; элементы, взятые из первого списка, добавляются в обратном порядке ко второму списку. Обратная перестановка элементов выполняется путем развертывания рекурсии. Для иллюстрации того, как именно протекает процесс выполнения операции гпгс рассмотрим следующий оттрассированный пример: trace. append([bob, jo], [lake, darcie], Family]. (1) 1 Call: append([bob, jo], [jake, darcie], _10)? (2) 2 Call: append([jo], [jake, darcie], _18)? (3) 2 Call: append([], [jake, darcie], _25)? (3) 3 Exit: append([], [jake, darcie], [jake, darcie]) (2) 2 Exit: append([jo], [jake, darcie], [jo, jake, darcie]) 15.6. Основные элементы языка Prolog 635
(1) 1 Exit: append([bob, jo], [jake, darcie], bob, jo, jake, darcie]) Family = [bob, jo, jake, darcie] yes В первых двух вызовах, представляющих собой подцели, список List l не пуст. Поэтому эти вызовы создают рекурсивные вызовы из правой части второго оператора. Левая часть второго оператора действительно указывает аргументы для рекурсивных вызовов, или целей, удаляя, таким образом, по одному элементу из первого списка на каждом ша- ге. Когда первый список станет пустым при вызове, или в подцели, текущая конкретиза- ция правой части второго оператора совпадет с первым оператором. В результате значе- ние пустого списка, добавленного ко второму исходному списку-параметру, будет воз- вращено в качестве третьего параметра. При успешных выходах, соотвествуюших успешным сопоставлениям, элементы, которые были удалены из первого списка, добав- ляются к результирующему списку Family. Когда происходит выход из первой цели, процесс завершается, и выводится результирующий список. Высказывания append можно также использовать для операций работы со списками, подобных приведенной ниже. Результат этой операции мы предлагаем читателю опреде- лить самостоятельно. Заметим, что операция list_op_2 должна иметь в качестве пер- вого параметра список, в качестве второго параметра — значение, а результатом этой операции является значение, которым конкретизируется второй параметр. list ор_2([], []) . list_ор2([Head I Tail], List) list op 2(Tai 1, Result), append(Result, [Head], List). Как может определить читатель, операция list_op_2 заставляет систему языка Prolog конкретизировать свой второй параметр списком, состоящим из элементов спи- ска, являющегося ее первым параметром, но взятых в обратном порядке. Например, (list_op_2 [apple, orange, grape] , Q) конкретизирует переменную Q списком [grape, orange, apple]. Еще раз отметим, что. хотя языки LISP и Prolog коренным образом отличаются друг от друга, схожие операции могут использовать схожие подходы. При операции обратной перестановки элементов списка и операция list_ор_2 в языке Prolog, и функция reverse в языке LISP включают в себя условие завершения рекурсии вместе с основ- ным процессом добавления обратной перестановки хвоста списка (CDR) к голове списка (CAR) для образования результирующего списка. Ниже приводится трассировка этого процесса, который теперь называется reverse: trace. reverse([a, b, с], Q] . (1) 1 Call: (2) 2 Call: (3) 3 Call: (4) 4 Call: (4) 4 Exit: (5) 4 Call: (5) 4 Exit: (3) 3 Exit; (6) 3 Call: reverse([a, reverse([b, reverse([c] , reverse ( [], reverse([], append([] , append([] , reverse([c] append([c], b, c], _6]? c], -65636)? _65646)? _65656)? []) [c], -65646)? [c], [c]) , [c]) [b], 65636)? 636 Глава 15. Языки логического программирования
(7) 4 Call: append([], [b], 25) ? (7) 4 Exit: append([], [b], [b] ) (6) 3 Exit : append([c], [b] , [c,b]) (2) 2 Exit: reverse([b, c], [b, c]) (8) 2 call: append([c, b], [a], _6)? (9) 3 Call: append([b], [a] , 32) ? (10) 4 Call : append([], [a] , 39) ? (10) 4 Exit : append([], [a] , [a]) (9) 3 Exit: append([b], [a] , [b,-a]) (8) 2 Exit: append([c, d], [a], [c, b. (1) 1 Exit: rebverse([a, b, c], [c, b, Q = [с b, a] a]) a] ) Предположим, что нам нужно определить, принадлежит ли заданный элемент задан- ному списку. Прямое определение этой операции в языке Prolog имеет вид: member(Element, [Element I _]). member(Element, [_ | List]) member(Element, List). Символ подчеркивания обозначает “безымянную” переменную; она используется тогда, когда нам безразлично, что ее конкретизация может быть получена путем унификации. Первый оператор из приведенных выше достигается, если переменная Element являет- ся головой списка либо изначально, либо после нескольких рекурсий, выполненных с помощью второго оператора. Второй оператор достигается, если переменная Element принадлежит хвосту списка. Рассмотрим следующие оттрассированные примеры: trace. member(a, [b, c, d]). (1) 1 Call: member(a, [b, c, d])? (2) 2 Call: member(a, [c, d])? (3) 3 Call: member(a, [d])? (4) 4 Call: member(a, [])? (4) 4 Fail: member(a, []) (3) 3 Fail: member(a, [d]) (2) 2 Fail: member(a, [c, d]) (1) 1 Fail: member(a, [b, c, d]) No member(a, [b, c, d]). (1) 1 Call: member(a, [b, a, d])? (2) 2 Call: member(a, [a, c])? (2) 2 Exit: member(a, [a, c]) (1) 1 Exit: member(a, [b, a, c]) yes 15.6. Основные элементы языка Prolog 637
15.7. Недостатки языка Prolog При использовании языка Prolog в качестве языка логического программирования возникает несколько проблем. Несмотря на то что этот язык является полезным инстру- ментом, его нельзя назвать ни чистым, ни совершенным языком логического програм- мирования. 15.7.1. Управление порядком выполнения резолюции Язык Prolog в целях эффективности позволяет пользователю управлять порядком со- поставления образцов во время резолюции. В чистой среде логического программирова- ния порядок сопоставлений, осуществляемых во время процесса резолюции, является недетерминированным, и все сопоставления могут происходить параллельно. Однако, поскольку система языка Prolog выполняет сопоставления в одном и том же порядке — начиная с начала базы данных и с левой части цели заданной цели, — пользователь мо- жет существенно увеличить эффективность, упорядочив базу данных для оптимизации конкретного приложения. Например, если пользователь знает, что определенные правила достигаются чаще других, во время конкретного “выполнения” программы, то програм- му можно сделать более эффективной, поместив эти правила в начало базы данных. Медленное выполнение программ — не единственный негативный результат опреде- ленного пользователем порядка выполнения программы, написанной на языке Prolog. Очень легко написать операторы в форме, приводящей к бесконечным циклам и тоталь- ному отказу программы. Рассмотрим следующую форму рекурсивного оператора: f (X, Y) f (Z, Y), д(Х, Z) . Поскольку в языке Prolog принят порядок выполнения операций слева-направо сначала- вглубь, независимо от предназначения оператора, это может привести к бесконечному циклу. В качестве примера такого рода операторов рассмотрим следующий: ancestor(X,X). ancestor(X,Y) ancestor(Z,Y), parent(X,Z). При попытке удовлетворить первую подцель правой части второго высказывания систе- ма языка Prolog конкретизирует переменную Z, чтобы сделать отношение ancestor ис- тинным. Затем она пытается удовлетворить эту новую подцель, переходя к правой части определения отношения ancestor и повторяя тот же самый процесс, что приводит к бесконечной рекурсии. Эта частная проблема идентична проблеме рекурсивного синтаксического анализа от частного к общему с левой рекурсией в грамматическом правиле, обсужденной в главе 3. Как это было в случае с грамматическими правилами синтаксического анализа, простое изменение порядка следования термов в правой части приведенного выше высказывания на противоположный исключает эту проблему. Однако простое изменение порядка сле- дования термов не должно быть критически важным для правильности работы програм- мы. Кроме всего прочего, возможность программиста не заботиться о порядке выполне- ния операторов предположительно является одним из преимуществ логического про- граммирования. В дополнение к тому, что пользователь может управлять базой данных и порядком удовлетворения подцелей, язык Prolog позволяет использовать некоторые явные средст- ва управления бектрекингом. Это осуществляется с помощью оператора отсечения, ко- 638 Глава 15. Языки логического программирования
торый обозначается знаком восклицания (!). Оператор отсечения в действительности яв- ляется целью, а не оператором. В качестве цели он всегда достигается немедленно, но он не может быть удовлетворен снова с помощью бектрекинга. Таким образом, побочный эффект оператора отсечения состоит в том, что подцели его левой части в составной це- ли не могут быть удовлетворены снова с помощью бектрекинга. Рассмотрим цель а, Ь, !, с, d. Если и цель а, и цель b достигаются, а цель с — нет, то вся цель не достигается. Эту цель можно было бы использовать, если бы было известно, что цель с никогда не дости- гается, поскольку в этом случае пытаться вновь удовлетворить цели а или b— пустая трата времени. Цель оператора отсечения, таким образом, заключается в том. чтобы позволить поль- зователю сделать программу более эффективной, сообщив системе, когда не следует пы- таться повторно удовлетворить цели, которые предположительно не могут дать резуль- тата в завершенном доказательстве. В качестве примера использования оператора отсечения рассмотрим правила member из раздела 15.6.7, которые повторяются ниже. member(Element, [Element | _]). member(Element, [_ | List]) member(Element, List). Если аргумент правила member, являющийся списком, представляет собой множество, то он может удовлетворяться только однажды (множество не содержит повторяющихся элементов). Следовательно, если правило member используется как подцель в операторе цели, состоящем из многих подцелей, может возникнуть проблема. Она состоит в том. что если подцель member достигается, а следующая подцель— нет, то бектрекинг по- пытается повторно удовлетворить подцель member, продолжая предыдущее сопоставле- ние. Однако, поскольку аргумент подцели member, являющийся списком, имеет только один экземпляр элемента, с которого он начинается, подцель member может снова не достигаться, что в конце концов приводит к отказу всей цели, несмотря на любые допол- нительные попытки снова удовлетворить подцель member. Решить эту проблему, сни- жающую эффективность программы, можно, добавив в первый оператор определения подцели member правую часть, единственным элементом которой является оператор от- сечения, как показано ниже: member(Element, [Element I _]) Бектрекинг теперь не будет пытаться снова удовлетворить подцель member, а приведет к отказу всей подцели. Оператор отсечения полезен, в частности, в программных стратегиях на языке Prolog, которые называются порождай и проверяй (generate and test). В этих программах цель состоит из подцелей, порождающих потенциальные решения, которые затем проверяют- ся подцелями “проверки”. Отвергнутые решения требуют выполнения бектрекинга для подцелей, порождающих новые потенциальные решения. В качестве примера програм- мы, следующей стратегии “порождай и проверяй”, рассмотрим пример, приведенный в работе Clocksin and Mellish (1984): 15.7. Недостатки языка Prolog 639
divide(Nl, N2, result) is_integer(Result), Productl is Result * N2, Product2 is (Result +1) * N2 Productl =< Nl, Product2 > Nl, !. Эта программа выполняет целое деление с использованием сложения и умножения. По- скольку большинство систем языка Prolog обеспечивают деление в качестве оператора, эта программа в действительности является бесполезной и предназначена лишь для ил- люстрации простой программы в рамках стратегии “порождай и проверяй”. Предикат is integer достигается, как только его параметры могут быть конкрети- зированы некоторым неотрицательным целым числом. Если его аргумент не конкрети- зирован. то предикат is_integer присваивает ему значение 0. Если аргумент конкре- тизирован некоторым целым числом, то предикат is integer присваивает ему сле- дующее целое число в порядке возрастания. Таким образом, в операторе divide предикат is_integer является порождающей подцелью. Он порождает элементы последовательности 0, 1,2.каждый раз, когда он удовлетворяется. Все другие операторы в определении оператора divide предназначе- ны для проверки подцелей — они проверяют, является ли значение, порожденное преди- катом is integer, частным отделения двух первых параметров N1 и N2. Цель опера- тора отсечения в качестве последней подцели проста — он предотвращает дальнейший поиск альтернативных решений, как только будет найдено данное решение. Несмотря на то что предикат is_integer может порождать огромное количество потенциальных решений, только одно из них является настоящим, так что оператор отсечения предот- вращает бесполезные попытки найти второе решение. Использование оператора отсечения сравнивалось с использованием оператора без- условного перехода goto в императивных языках (Van Emden, 1980). Несмотря на то что иногда он действительно нужен, его можно критиковать. Действительно, он зачастую используется для того, чтобы внести в логические программы поток управления, порож- даемый стилем императивного программирования. Возможность вмешиваться в поток управления в программах на языке Prolog являет- ся недостатком, поскольку это явно наносит вред одному из важнейших преимуществ логического программирования, состоящему в том, что программы не определяют спо- соб, которым должно быть найдено решение. Они просто указывают, как должно выгля- деть решение. Это позволяет более легко создавать и читать программы. Они не загро- мождены деталями, описывающими, как именно следует искать решения, и в частности, они не имеют точного порядка, в котором должны выполняться вычисления, для того чтобы получить решение. Таким образом, в то время как логическое программирование не требует указания направлений потока управления в программе, язык Prolog часто ис- пользует их. в основном для повышения эффективности выполнения программ. 15.7.2. Предположение о закрытом мире Природа резолюции в языке Prolog иногда приводит к ошибочным результатам. Ис- тиной в языке Prolog считается только то, что может быть доказано с использованием его базы данных. Он не имеет никаких других сведений о мире, кроме знаний, хранящихся в его базе данных. Любой запрос считается ложным, если в базе данных недостаточно ин- формации для того, чтобы доказать его абсолютно точно. Система языка Prolog может доказать, что данная цель является истинной, но она не может доказать, что данная цель 640 Глава 15. Языки логического программирования
является ложной. Она просто предполагает, что, поскольку она не может доказать ис- тинность данной цели, эта цель должна быть ложной. По существу, система языка Prolog является системой “истина/отказ”, а не “истина/ложь”. В действительности, замкнутый мир предположений не должен быть совсем чуждым для вас — юридическая система работает именно таким образом. Подозреваемый неви- новен, пока не доказана его вина. Доказывать его невиновность не обязательно. Если суд не может доказать виновность некоего лица, он или она считается невиновным. Проблема замкнутого мира предположений связана с проблемой логического отрица- ния, обсуждаемой в следующем разделе. 15.7.3. Проблема логического отрицания Другая проблема языка Prolog заключается в трудностях, связанных с логическим от- рицанием. Рассмотрим следующую базу данных, состоящую из двух фактов и одного от- ношения: parent(bill, jake). parent(bill, shelley). sibling(X, Y) (parent(M, X), parent(M, Y)). Теперь предположим, что мы ввели запрос sibling(X, Y). Система языка Prolog ответит X = jake Y = jake Таким образом, система языка Prolog “думает”, что Джек (jake) является братом (sibling) самому себе. Это происходит потому, что система сначала конкретизирует пе- ременную М значением bill, а переменную X — значением jake для того, чтобы сделать первую подцель parent (М, X) истинной. Затем она снова начинает поиск с начала базы данных для того, чтобы сопоставить вторую подцель parent (И, Y), и обнаруживает совпадение, когда переменная М конкретизирована значением bill, а переменная Y — значением jake. Поскольку две подцели удовлетворяются независимо друг от друга, при- чем оба совпадения находятся в начале базы данных, возникает ответ, приведенный выше. Для того чтобы избежать этого, следует указать, что переменная X находится в отношении sibling с переменной Y, только если обе они находятся в отношении parents с одной и той же переменной и не совпадают друг с другом. К сожалению, непосредственно указать, что они не равны между собой, в языке Prolog невозможно, что мы обсудим ниже. Наибо- лее точный метод может потребовать добавления в базу данных фактов для каждой пары атомов о том, что они не совпадают между собой. Это приведет к тому, что база данных станет очень большой, поскольку негативной информации намного больше, чем позитив- ной. Например, у большинства людей количество дней, не являющихся их днями рожде- ния, на 364 больше, чем количество их дней рождения. Простое альтернативное решение этой проблемы — указать в цели, что переменная X не должна совпадать с переменной Y, как это сделано в следующем примере: sibling(X, Y) parent(М, X), parent(М, Y), not(х = Y). В других ситуациях решение не будет столь простым. 15.7. Недостатки языка Prolog 641
Оператор not в языке Prolog удовлетворяется, если резолюция не может удовлетво- рить подцель X = Y. Следовательно, если оператор not достигается, это не обязательно означает, что переменная X не равна переменной Y. Скорее это означает, что резолюция не может вывести из базы данных, что переменная X равна переменной Y. Таким обра- зом, оператор not в языке Prolog не эквивалентен логическому оператору отрицания, в котором отрицание означает, что истинность оператора можно доказать. Эта неэквива- лентность может привести к проблеме, если мы имеем дело с целью в следующем виде: not(not(some_goal)). Если бы оператор not в языке Prolog был истинным логическим оператором отрицания, она могла бы быть эквивалентной цели some__goal. В некоторых случаях, однако, они не совпадают. Снова рассмотрим правила member: member(Element, [Element I _]). member(Element, [_ I List]) member(Element, List). Чтобы обнаружить один из элементов в заданном списке, можно использовать цель member(X, [тагу, fred, barb]). Эта цель привела бы к конкретизации переменной X значением тегу, которое затем бы- ло бы выведено на печать. Предположим, что мы используем цель not(not(member(X, [тагу, fred, barb]))). Тогда имеет место следующая последовательность событий: вначале внутренняя цель достигается, конкретизируя переменную X значением тегу. Затем система языка Prolog пытается удовлетворить следующую цель: not(member(X, [тагу, fred, barb])). Это могло бы привести к отказу, поскольку цель member достигается. Если цель не дос- тигается, переменная X теряет конкретное значение, поскольку система языка Prolog все- гда освобождает все переменные во всех недостижимых целях. Далее, система языка Prolog пытается удовлетворить внешнюю цель not, которая должна достигаться, по- скольку не были достигнуты ее аргументы. В конце концов, мог бы быть напечатан ре- зультат. которым является переменная X. Однако переменная X в этот момент может не иметь конкретного значения, что и будет отмечено системой. В общем случае неконкре- тизированные переменные печатаются в виде строки цифр, которым предшествует знак подчеркивания. Таким образом, тот факт, что оператор языка Prolog не эквивалентен ло- гическому оператору отрицания, может, по меньшей мере, вводить в заблуждение. Основной причиной, почему логический оператор отрицания не может быть неотъ- емлемой частью языка Prolog, является форма хорновского дизъюнкта: А : - В; П В: П . . . П Вп Если все высказывания В являются истинными, можно прийти к выводу, что высказыва- ние А является истинным. Однако независимо от истинности или ложности любого или всех высказываний Bs, невозможно доказать, что высказывание А является ложным. Из позитивной логики можно получить только позитивную логику. Таким образом, исполь- зование хорновских дизъюнктов не допускает никаких негативных заключений. 642 Глава 15. Языки логического программирования ч
15.7.4. Внутренние ограничения Основная цель логического программирования, как указывалось в разделе 15.4,— обеспечить непроцедурное программирование; т.е. систему, с помощью которой про- граммист указывает, что именно программа должна сделать, но не обязан указывать, как именно она должна это делать. Пример, приведенный выше для сортировки списка, пе- репишем так: сортировать(old-list, new_list) С переставить(old_list, r.ew_list) n отсортированный (new_list) C Vj такого, что 1 < j < n, list(j) < list (j + 1) Это можно легко записать на языке Prolog. Например, отсортированную подцель можно выразить следующим образом: sirted([]). sorted([х]). sorted([х, у | list]) х <= у, sorted ([у I list]). Проблема, связанная с процессом сортировки, описанным выше, заключается в том, что он не содержит никакой идеи, как выполнить сортировку иначе, чем просто перенумеро- вать все перестановки в заданном списке, пока не удастся создать одну, при которой список окажется упорядоченным, — действительно, очень медленный процесс. До сих пор никто не открыл процесс, с помощью которого описание упорядоченного списка можно было бы преобразовать в некоторый эффективный алгоритм сортировки. Ре- золюция способна делать многие интересные вещи, но определенно не это. Следовательно, программа на языке Prolog, сортирующая список, должна указать, как именно эту сорти- ровку можно выполнить, как это делается в императивных или функциональных языках. Означают ли все эти проблемы, что следует отказаться от логического программиро- вания? Конечно, нет! В действительности, оно способно работать во многих полезных приложениях. Более того, логическое программирование основывается на интригующей концепции и интересно не только своими приложениями, но и само по себе. В заключе- ние отметим, что существует возможность разработки некоей новой техники логическо- го вывода, которая позволит системам языков логического программирования действи- тельно указывать только, что, а не как следует делать в спецификациях своих программ. 15.8. Применение логического программирования В этом разделе кратко описаны некоторые более широкие классы существующих и потенциальных приложений логического программирования в общем и языка Prolog в частности. 15.8.1 • Системы управления реляционными базами данных Системы управления реляционными базами данных (СУРБД) хранят данные в виде таблиц. Запросы к таким базам данных часто формулируются в терминах исчисления предикатов, являющегося разновидностью символьной логики. Язык запросов этих сис- тем является непроцедурным в том же смысле, в котором логическое программирование представляет собой непроцедурное программирование. Пользователь описывает не то, 15.8. Применение логического программирования 643
как именно следует искать ответ, а только характеристики этого ответа. Связь между ло- гическим программированием и системами СУРБД должна быть очевидна. Простые таб- лицы. содержащие информацию, можно описать с помощью структур языка Prolog, а от- ношения между таблицами можно удобно и просто выразить через правила языка Prolog. Операции резолюции присущ процесс поиска. Цели в языке Prolog обеспечивают форму- лирование запросов к системам СУРБД. Таким образом, логическое программирование естественным образом соответствует нуждам реализации систем СУРБД. Одним из преимуществ использования логического программирования для реализа- ции систем СУРБД является то, что при этом требуется только один язык. В типичной системе СУРБД язык базы данных содержит операторы для определения данных, для манипуляции с данными и для запросов, и все они встроены в такой универсальный язык программирования, как COBOL. Универсальный язык программирования используется для обработки данных и выполнения функций ввода-вывода. Все эти функции можно реализовать в языке логического программирования. Другое преимущество использования логического программирования для реализа- ции систем СУРБД состоит в том, что в него встроены средства логического вывода. Обычные системы СУРБД на основании информации из базы данных не могут сделать никакого логического вывода, за исключением ответа на вопрос, что именно хранится в этой базе. Они содержат только факты, а не факты и правила логического вывода. Ос- новной недостаток использования логического программирования в системах СУРБД, по сравнению с обычными системами СУРБД, состоит в том, что реализация на основе ло- гического программирования является более медленной. Процессы логического вывода просто занимают больше времени, чем обычные методы просмотра таблиц с помощью техники императивного программирования. 15.8.2. Экспертные системы Экспертные системы — это компьютерные системы, разработанные для имитации экспертизы, выполняемой человеком в некоторой конкретной области знаний. Они со- стоят из базы фактов, процесса логического вывода, некоторых эвристических знаний о предметной области и дружелюбного пользовательского интерфейса, делающего систе- му похожей на консультанта. В дополнение к их исходной базе знаний, предоставляемой человеком-экспертом, экспертные системы обучаются с помощью используемых про- цессов логического вывода, так что их базы данных должны иметь возможность динами- чески увеличивать свои размеры. Кроме того, экспертная система должна при необходи- мости запрашивать у пользователя дополнительную информацию. Одна из основных проблем, стоящих перед разработчиком экспертной системы, — это неминуемая противоречивость и неполнота базы данных. Логическое программиро- вание хорошо подходит для решения этих проблем. Например, правила логического вы- вода. принятые по умолчанию, могут помочь решить проблему неполноты базы данных. Язык Prolog может и должен использоваться для создания экспертных систем. Он легко удовлетворяет основные нужды экспертных систем. При этом для обработки за- просов он использует процесс резолюции, для обучения — возможность пополнять свои базы данных фактами и правилами, а для информирования пользователя о процессе ре- шения задачи — средства трассировки. В языке Prolog недостает лишь автоматической способности системы при необходимости запрашивать у пользователя дополнительную информацию. 644 Глава 15. Языки логического программирования
Одним из самых известных приложений логического программирования в области экспертных систем является система конструирования экспертных систем под названием APES, описанная в работах Sergot(1983) и Hammond (1983). Система APES обладает очень гибкими средствами сбора информации, полученной от пользователя во время конструирования экспертной системы. Она также имеет второй интерпретатор для соз- дания пояснений к своим запросам. Система APES была успешно применена для создания нескольких экспертных сис- тем, включая систему для выработки правил, используемых в правительственных про- граммах социальной помощи, а также систему, представляющую собой исчерпывающий свод правил получения гражданства Великобритании (British Nationality Act). 15.8.3. Системы обработки естественных языков С помощью логического программирования можно выполнить некоторые виды обра- ботки текстов на естественных языках. В частности, интерфейс, основанный на естест- венном языке, для таких систем программного обеспечения, как интеллектуальные базы данных и другие интеллектуальные базы знаний, удобно создавать с помощью логиче- ского программирования. Для описания синтаксиса языка были найдены формы логиче- ского программирования, эквивалентные контекстно-свободным грамматикам. Были также разработаны процедуры доказательства в системах логического программирова- ния, эквивалентные определенным стратегиям синтаксического анализа. В действитель- ности, обратный логический вывод в процессе резолюции может непосредственно при- меняться для синтаксического анализа предложений, структуры которых описаны кон- текстно-свободными грамматиками. Было также обнаружено, что некоторые виды семантики естественных языков можно пояснить, моделируя эти языки средствами логи- ческого программирования. В частности, исследования в области логических семантиче- ских сетей показали, что совокупности предложений на естественных языках можно вы- разить в дизъюнктивной форме (Deliyanni and Kowalski, 1979). В работе Kowalski (1979) также обсуждаются логические семантические сети. 15.8.4. Образование В области образования были проведены обширные эксперименты по обучению детей в возрасте до семи лет использованию языка логического программирования micro- Prolog (Ennals, 1980). Исследователи заявляют о наличии большого количества преиму- ществ в обучении детей использованию языка Prolog. Во-первых, с помощью этого под- хода можно дать представление о вычислениях. Кроме того, язык micro-Prolog обладает побочным эффектом обучающей логики, которая способствует большей ясности мышле- ния и выражения своих мыслей у детей. Это может помочь ученикам в изучении различ- ных предметов, например, решении математических уравнений, изучении грамматики естественных языков, а также в понимании правил и устройства физического мира. Эксперименты в области применения логического программирования для обучения очень маленьких детей дали интересные результаты, заключающиеся в том, что легче научить логическому программированию новичка, чем программиста с большим опытом работы на императивных языках. 15.8. Применение логического программирования 645
11. Объясните, как работает бектрекинг в языке Prolog. 12. Объясните, что неправильно в операторе языка Prolog К is К + 1. 13. Какими двумя способами программист на языке Prolog может управлять порядком сопоставления образцов во время резолюции? 14. Объясните стратегию программирования “порождай и проверяй” в языке Prolog. 15. Объясните предположение о замкнутости мира, использующееся в языке Prolog. Почему это предположение является ограничением? 16. Объясните, в чем состоит проблема логического отрицания в языке Prolog. 17. Объясните связь между автоматическим доказательством теорем и процессом ло- гического вывода в языке Prolog. 18. Объясните разницу между процедурными и непроцедурными языками. 19. Объясните, почему системы языка Prolog должны выполнять бектрекинг. 20. Как связаны между собой резолюция и унификация в языке Prolog? _________г_____________ • ., ...........I ...............г. . _________ V п р о 1. Сравните концепцию типов данных в языке Ada с языком Prolog. 2. Опишите, как можно использовать многопроцессорную машину для выполнения резолюции. Может ли язык Prolog в том виде, в каком он определен сейчас, ис- пользовать этот метод? 3. Напишите на языке Prolog описание вашего генеалогического древа (основанного только на фактах) вплоть до ваших бабушек и дедушек, включая всех предков. Убедитесь, что вы учли все отношения. 4. Напишите множество правил для семейных отношений, включая все отношения, начиная с бабушек и дедушек, связывающие два поколения. Теперь добавьте эти правила к фактам из задачи 3 и исключите как можно больше фактов. 5. Напишите программу на языке Prolog, достигающую цели, если пересечение двух заданных списков является пустым. 6. Напишите программу на языке Prolog, возвращающую список, содержащий объе- динение элементов двух заданных списков. 7. Напишите программу на языке Prolog, возвращающую последний элемент задан- ного списка. 8. Опишите два совпадения между возможностями обработки списков в языке Scheme и в языке Prolog. 9. Опишите различия между возможностями обработки списков в языке Scheme и в языке Prolog. 648 Глава 15. Языки логического программирования
Литература AARM. (1995) Annotated Ada Reference Manual. International Standard. 1SO/IEC 8652: 1995, Version 6.0. December 21. 1994. Intermelrics, Cambridge. MA. ACM. (1979) “Part A: Preliminary Ada Reference Manual” and “Part B: Rationale for the Design of the Ada Programming Language/' SIG PLAN Notices. Vol. 14, No. 6. ACM. (1993a) History of Programming Language Conference Proceedings. ACM SIGPLAN Notices. Vol. 28. No. 3, March. ACM. (1993b) “High Performance FORTRAN Language Specification Part 1." FORTRAN Forum, Vol. 12, No. 4. Aho, A.V., R. Sethi, and J.D. Ullman. (1986) Compilers: Principles. Techniques, and Tools. Addison-Wesley, Reading. MA. Aho, A.V., B.W. Kemighan. and PJ. Weinberger. (1988) The AWKProgramming Language. Addison-Wesley, Reading, MA. Ambler, A.L., D.I. Good. J.C. Browne, W.F. Burger, R.M. Cohen, C.G. Hoch, and R.E. Wells. (1977) “Gypsy: A Language for Specification and Implementation of Verifiable Programs.” Proceedings of the ACM Conference on Language Design for Reliable Software. ACM SIGPLAN Notices. Vol. 12, No. 3, pp. 1-10. Andrews, G.R.. and F.B. Schneider. (1983) “Concepts and Notations for Concurrent Programming.” A CM Computing Surveys. Vol. 15, No. 1, pp. 3-43. ANSI. (1976) American National Standard Programming Language PL/I. ANSI X3.53- 1976. American National Standards Institute, New York. ANSI. (1978a) American National Standard Programming Language FORTRAN. ANSI X3.9-1978. American National Standards Institute, New York. ANSI. (1978b) American National Standard Programming Language Minimal BASIC. ANSI X3.60-1978. American National Standards Institute, New York. ANSI. (1985) American National Standard Programming Language COBOL. ANSI X3.23-1985. American National Standards Institute, New York. ANSI. (1989) American National Standard Programming Language C. ANSI X3.159- 1989. American National Standards Institute, New York. ANSI. (1992) American National Standard Programming Language FORTRAN 90. ANSI X3.198-1992. American National Standards Institute, New York. Arden. B.W., B.A. Galler, and R.M. Graham. (1961) “MAD at Michigan.” Datamation. Vol. 7, No. 12, pp. 27-28. Backus, J. (1954) “The IBM 701 Speedcoding System.” J. ACM. Vol. 1, pp. 4-6. Литература 649
Backus, J. (1959) “The Syntax and Semantics of the Proposed International Algebraic Language of the Zurich ACM-GAMM Conference." Proceedings International Conference on Information Processing. UNESCO, Paris, pp. 125—132. Backus. J. (1978) “Can Programming Be Liberated from the von Neumann Style? A Functional Style and Its Algebra of Programs." Commun. ACM, Vol. 21, No. 8, pp. 613-641. Backus. J., F.L. Bauer, J. Green, C. Katz, J. McCarthy. P. Naur, A J. Perlis, H. Rutishauser, K. Samelson, B. Vauquois, J.H. Wegstein, A. van Wijngaarden. and M. Woodger. (1962) “Revised Report on the Algorithmic Language ALGOL 60." Commun. ACM Vol. 6, No. 1, pp. 1-17. Ben-Ari, M. (1982) Principles of Concurrent Programming. Prentice-Hall. Englewood Cliffs, NJ. Birtwistle, G.M., O.-J. Dahl, B. Myhrhaug, and K. Nygaard. (1973) Simula BEGIN. Van Nostrand Reinhold, New York. Bobrow. D.G., L. DeMichiel, R. Gabriel, S. Keene, G. Kiczales, and D. Moon. (1988) “Common Lisp Object System Specification X3J13 Document 88-002R." ACM SIG PLAN Notices, Vol. 17, No. 6, pp. 216-229. Bodwin, J.M., L. Bradley, K. Kanda, D. Litle, and U.F. Pieban. (1982) “Experience with an Experimental Compiler Generator Based on Denotational Semantics." ACM SIGPLAN Notices, Vol. 17, No. 6, pp. 216-229. Bohm, C., and G. Jacopini. (1966) “Flow Diagrams, Turing Machines, and Languages with Only Two Formation Rules." Commun. ACM, Vol. 9, No. 5, pp. 366-371. Bolsky, M., and D. Korn. (1995) The New KornShell Command and Programming Language. Prentice-Hall. Englewood Cliffs, NJ. Booch. G. (1987) Software Engineering with Ada. 2nd ed.. Benjamin/Cummings, Redwood City, CA. Bradley. J.C. (1989) QuickBASIC and QBASIC Using Modular Structures. W.C. Brown, Dubuque, IA. Brinch Hansen, P. (1973) Operating System Principles. Prentice-Hall, Englewood Cliffs, NJ. Brinch Hansen, P. (1975) “The Programming Language Concurrent-Pascal.” IEEE Transactions on Software Engineering, Vol. 1, No. 2, pp. 199-207. Brinch Hansen, P. (1977) The Architecture of Concurrent Programs. Prentice-Hall, Englewood Cliffs, NJ. Brinch Hansen, P. (1978) “Distributed Processes: a Concurrent Programming Concept.” Commun. ACM, Vol. 21, No. 11, pp. 934-941. Cardelli, L., J. Donahue, L. Glassman, M. Jordan, B. Kalsow, and G. Nelson. (1989) Modula-3 Report (revised). Digital System Research Center, Palo Alto, CA. Chambers, C., and D. Ungar. (1991) “Making Pure Object-Oriented Languages Practical.” SIGPLAN Notices, Vol. 26. No. 1, pp. 1-15. Chomsky. N. (1956) “Three Models for the Description of Language.” IRE Transactions on Information Theory}, Vol. 2, No. 3, pp. 113-124. 650 Литература
Chomsky, N. (1959) “On Certain Formal Properties of Grammars.” Information and Contrc Vol. 2, No. 2, pp. 137-167. Church, A. (1941) Annals of Mathematics Studies. Volume 6: Calculi of Lambda Conversion Princeton Univ. Press, Princeton, NJ. Reprinted by Klaus Reprint Corporation. New York, 1965 Clark. K.L.. and F.G. McCabe. (1984) Miero-PROLOG: Programming in Logic. Prentice Hall. Englewood Cliffs, NJ. Clarke, L.A., J.C. Wileden, and A.L. Wolf. (1980) “Nesting in Ada Is for the Birds." AC! SIGPLAN Notices. Vol. 15, No. 11, pp. 139-145. Cleaveland, J.C. (1986) An Introduction to Data Types. Addison-Wesley, Reading, MA. Cleaveland, J.C.. and R.C. Uzgalis. (1976) Grammars for Programming Languages: Wha Every Programmer Should Know About Grammar. American Elsevier, New York. Clocksin, W.F., and C.S. Mellish. (1997) Programming in Prolog, 4e. Springer-Verlag, New York. Cohen, J. (1981) “Garbage Collection of Linked Data Structures.” ACM Computing Surveys Vol. 13, No. 3. pp. 341-368. Cohen, J. (1985) “Describing Prolog by Its Implementation and Computation.” Commun ACM, Vol. 28, No. 12, pp? 1311 -1324. Conway, M.E. (1963). “Design of a Separable Transition-Diagram Compiler.” Commun ACM, Vol. 6, No. 7, pp. 396—408. Conway, R., and R. Constable. (1976) “PL/CS— A Disciplined Subset of PL/I.” Technica Report TR76/293. Department of Computer Science, Cornell University, Ithaca, NY. Cornell University. (1977) PL/C User’s Guide, Release 7.6. Department of Computer Science. Cornell University, Ithaca, NY. Correa, N. (1992) “Empty Categories, Chain Binding, and Parsing.” pp. 83-121, Principle- Based Parsing. eEds. R.C. Berwick, S.P. Abney, and C. Tenny., Kluwer Academic Publishers, Boston. Cuadrado, C.Y., and J.L. Cuadrado. (1985) “Prolog Goes to Work.” BYTE, August 1985, pp. 151-158. Dahl, O.-J., E.W. Dijkstra, and C.A.R. Hoare. (1972) Structured Programming. Academic Press, New York. Dahl, O.-J., and K. Nygaard. (1967) “SIMULA 67 Common Base Proposal.” Norwegian Computing Center Document, Oslo. Deliyanni, A., and R.A. Kowalski. (1979) “Logic and Semantic Networks.” Commun. ACM, Vol. 22, No. 3, pp 184-192. Department of Defense. (1960) “COBOL, Initial Specifications for a Common Business Oriented Language.” Department of Defense. (1961) “COBOL— 1961, Revised Specifications for a Common Business Oriented Language.” Department of Defense. (1962) “COBOL— 1961 EXTENDED, Extended Specifications for a Common Business Oriented Language.” Литература 651
Department of Defense. (1975a) “Requirements for High Order Programming Languages, STRAWMAN.” July. Department of Defense. (1975b) “Requirements for High Order Programming Languages, WOODENMAN.” August. Department of Defense. (1976) “Requirements for High Order Programming Languages, TINMAN.” June. Department of Defense. (1977) “Requirements for High Order Programming Languages, IRONMAN.” January. Department of Defense. (1978) “Requirements for High Order Programming Languages, STEELMAN.” June. Department of Defense. (1980a) “Requirements for High Order Programming Languages, STONEMAN.” February. Department of Defense. (1980b) “Requirements for the Programming Environment for the Common High Order Language, STONEMAN.” Department of Defense. (1990) “Ada 9X Requirements.” Office of the Under Secretary of Defense for Acquisition, Washington, DC. Deutsch, L.P., and D.G. Bobrow. (1976) “An Efficient Incremental Automatic Garbage Collector.” Commun. ACM, Vol. 11, No. 3, pp. 522-526. Dijkstra, E.W. (1968a) “Goto Statement Considered Harmful.” Commun. ACM, Vol. 11, No. 3, pp. 147-149. Dijkstra, E.W. (1968b) “Cooperating Sequential Processes.” In Programming Languages, F. Genuys (ed.). Academic Press. New York. pp. 43-112. Dijkstra. E.W. (1972) “The Humble Programmer.” Commun. ACM, Vol. 15, No. 10, pp. 859- 866. Dijkstra. E.W. (1975). “Guarded Commands, Nondeterminacy, and Formal Derivation of Programs.” Commun. ACM. Vol. 18, No. 8, pp. 453-457. Dijkstra, E.W. (1976). A Discipline of Programming. Prentice Hall, Englewood Cliffs, NJ. Dybvig. R.K. (1996) The Scheme Programming Language 2e. Prentice Hall PTR, Upper Saddle River, NJ. Ellis, M.A., and B. Stroustrup (1990) The Annotated C+ + Reference Manual. Addison- Wesley, Reading, MA. Ennals. J.R. (1980) “Logic as a Computer Language for Children.” Logic Programming Research Reports. Theory of Computing Research Group, Department of Computing, Imperial College of Science and Technology, London. Farber. D.J., R.E. Griswold, and F.P. Polansky. (1964) “SNOBOL, a String Manipulation Language.” J. ACM, Vol 11, No. 1, pp. 21-30. Farrow. R. (1982) “LINGUIST 86: Yet Another Translator Writing System Based on Attribute Grammars.” ACM SIGPLAN Notices, Vol. 17, No. 6, pp. 160-171. Feuer, A., and N. Gehani. (1982) “A Comparison of the Programming Languages C and Pascal.” ACM Computing Surveys, Vol. 14, No. 1, pp. 73-92. 652 Литература
Fischer, C.N., G.F. Johnson, J. Mauney, A. Pal, and D.L. Stock. (1984) “The Poe Language- Based Editor Project.” A CM SIG PLAN Notices. Vol. 19, No. 5, pp. 21-29. Fischer, C.N., and R.J. LeBlanc. (1977) “UW-Pascal Reference Manual.” Madison Academic Computing Center, Madison, WI. Fischer, C.N., and R.J. LeBlanc. (1980) “Implementation of Runtime Diagnostics in Pascal.” IEEE Transactions on Software Engineering. SE-6, No. 4, pp. 313-319. Fischer, C.N., and R.J. LeBlanc. (1988) Crafting a Compiler. Benjamin/Cummings, Menlo Park, CA. Floyd, R.W. (1967) “Assigning Meanings to Programs.” Proceedings Symposium Applied Mathematics, in Mathematical Aspects of Computer Science, ed. J.T. Schwartz. American Mathematical Society, Providence, Rl. Frege, G. (1892) “Uber Sinn und Bedeutung.” Zeitschrift fur Philosophic und Philosophisches Kritik. Vol. 100, pp. 25-50. Friedl, J.E.F. (1997) Mastering Regular Expressions. O'Reilly Publ. Co., Sabastopol. CA. Friedman, D.P., and D.S. Wise. (1979) “Reference Counting’s Ability to Collect Cycles Is Not Insurmountable.” Information Processing Letters. Vol. 8, No. 1, pp. 41-45. Fuchi, K. (1981) “Aiming for Knowledge Information Processing Systems.” Proceedings of the International Conference on Fifth Generation Computing Systems. Japan Information Processing Development Center, Tokyo. Republished (1982) by North-Holland Publishing, Amsterdam. Gehani, N. (1983) Ada: An Advanced Introduction. Prentice-Hall, Englewood. Cliffs, NJ. Ghezzi, C., and M. Jazayeri. (1987) Programming Language Concepts. 2d ed. Wiley, New York. Gilman, L., and A.J. Rose. (1976) APL: An Interactive Approach. 2d ed. J. Wiley, New York. Goldberg, A., and D. Robson. (1983) Smalltalk-80: The Language and Its Implementation. Addison-Wesley, Reading, MA. Goodenough, J.B. (1975) “Exception Handling: Issues and Proposed Notation.” Commun. ACM, Vol. 18, No. 12, pp. 683-696. Goos, G., and J. Hartmanis (eds.). (1983) The Programming Language Ada Reference Manual. American National Standards Institute. ANS1/MIL-STD-1815A-1983. Lecture Notes in Computer Science 155. Springer-Verlag, New York. Gordon, M. (1979) The Denotational Description of Programming Languages. An Introduction. Springer-Verlag, Berlin-New York. Gosling, J., B. Joy, and G. Steele. (1996) The Java Language Specification. Addison-Wesley, Reading, MA. Gries, D. (1981) The Science of Programming. Springer-Verlag, New York. Griswold, R.E., and M.T. Griswold. (1983) The ICON Programming Language. Prentice- Hall, Englewood Cliffs, NJ. Литература 653
Griswold, R.E., F. Poage, and l.P. Polonsky. (1971) The SNOBOL 4 Programming Language. 2d ed. Prentice-Hall, Englewood Cliffs, NJ. Hammond, P. (1983) APES: A User Manual. Department of Computing Report 82/9. Imperial College of Science and Technology, London. Henderson, P. (1980) Functional Programming: Application and Implementation. Prentice-Hall, Englewood Cliffs, NJ. Hoare, C.A.R. (1969) “An Axiomatic Basis of Computer Programming.” Commun. ACM. Vol. 12, No. 10, pp. 576-580. Hoare, C.A.R. (1972) “Proof of Correctness of Data Representations.” Acta Inforrnatica. Vol. l,pp. 271-281. Hoare, C.A.R. (1973) “Hints on Programming Language Design.” Proceedings ACM SIGACT/SIGPLAN Conference on Principles of Programming Languages. Also published as Technical Report STAN-CS-73-403, Stanford University Computer Science Department. Hoare, C.A.R. (1974) “Monitors: An Operating System Structuring Concept.” Commun. ACM. Vol. 17, No. 10, pp. 549-557. Hoare, C.A.R. (1978) “Communicating Sequential Processes.” Commun. ACM. Vol. 21, No. 8, pp. 666-677. Hoare, C.A.R. (1981) “The Emperor’s Old Clothes.” Commun. ACM. Vol. 24, No. 2, pp. 75-83. Hoare, C.A.R., and N. Wirth. (1973) “An Axiomatic Definition of the Programming Language Pascal.” Acta Informatica. Vol. 2, pp. 335-355. Hogger, C.J. (1984) Introduction to Logic Programming. Academic Press. London. Holt, R.C., G.S. Graham, E.D. Lazowska, and M.A. Scott. (1978) Structured Concurrent Programming w ith Operating Systems Applications. Addison-Wesley, Reading, MA. Hom, A. (1951) “On Sentences Which Are True of Direct Unions of Algebras.” J. Symbolic Logic. Vol. 16, pp. 14-21. Hudak, P. and J. Fasel. (1992) “A Gentle Introduction to Haskell”, ACM SIGPLAN Notices. 27(5), May 1992, pp.Tl-T53. Huskey, H.K., R. Love, and N. Wirth. (1963) “A Syntactic Description of BC NELIAC.” Commun. ACM. Vol. 6, No. 7, pp. 367-375. IBM. (1954) “Preliminary Report, Specifications for the IBM Mathematical FORmula TRANslating System, FORTRAN.” IBM Corporation, New York. IBM. (1956) “Programmer’s Reference Manual, The FORTRAN Automatic Coding System for the IBM 704 EDPM.” IBM Corporation, New York. IBM. (1964) “The New Programming Language.” IBM UK Laboratories. Ichbiah, J.D., J.C. Heliard, O. Roubine, J.G.P. Barnes, B. Krieg-Brueckner, and B.A. Wichmann. (1979) “Rationale for the Design of the Ada Programming Language.” ACM SIGPLAN Notices. Vol. 14, No. 6, Part B. IEEE. (1985) “Binary Floating-Point Arithmetic.” IEEE Standard 754, IEEE, New York. 654 Литература
Ingerman, P.Z. (1967). “Panini-Backus Form Suggested.” Commun. ACM, Vol. 10, No. 3, p. 137. Intermetrics. (1993) Programming Language Ada, Draft, Version 4.0. Cambridge, MA. ISO. (1982) Specification for Programming Language Pascal. ISO7185-1982. International Organization for Standardization, Geneva, Switzerland. Iverson, K.E. (1962) A Programming Language. John Wiley, New York. Jensen, K., and N. Wirth. (1974) Pascal Users Manual and Report. Springer-Verlag, Berlin. Johnson, S.C. (1975) “Yacc— Yet Another Compiler Compiler.” Computing Science Report 32. A.T.& T. Bell Laboratories, Murray Hill, NJ. Jones, N.D. (ed.) (1980) Semantic-Directed Compiler Generation. Lecture Notes in Computer Science, Vol. 94. Springer-Verlag, Heidelberg, FRG. Kay, A. (1969) The Reactive Engine. Ph.D. Thesis. University of Utah, September. Kemighan, B.W., and R. Pike. (1984) The UNIX Programming Environment. Prentice-Hall, Englewood Cliffs, NJ. Kemighan, B.W., and D.M. Ritchie. (1978) The C Programming Language. Prentice-Hall, Englewood Cliffs, NJ. Knuth, D.E. (1967) “The Remaining Trouble Spots in ALGOL 60.” Commun. ACM, Vol. 10, No. 10, pp. 611-618. Knuth, D.E. (1968a) “Semantics of Context-Free Languages.” Mathematical Systems Theory, Vol. 2, No. 2, pp. 127-146. Knuth, D.E. (1968b) The Art of Computer Programming, Vol. I. 2d ed. Addison-Wesley, Reading, MA. Knuth, D.E. (1974) “Structured Programming with GOTO Statements.” ACM Computing Surveys, Vol. 6, No. 4, pp. 261-301. Knuth, D.E. (1981) The Art of Computer Programming, Vol. II. 2d ed. Addison-Wesley, Reading, MA. Knuth, D.E., and Luis Trabb Pardo. (1977) “Early Development of Programming Languages.” In Encyclopedia of Computer Science and Technology, Vol. 7. Dekker, New York, pp. 419-493. Kowalski, R.A. (1979) Logic for Problem Solving. Artificial Intelligence Series, Vol. 7. Elsevier-North Holland, New York. Lampson, B.W. (1983) “A Description of the Cedar Language.” Tech. Report CSL-83-15. Xerox Palo Alto Research Center, December. Lampson, B.W., J.J. Homing, R.L. London, J.G. Mitchell, and G.J. Popek. (1977) “Report on the Programming Language Euclid.” ACM SIGPLAN Notices, Vol. 12, No. 2. (Revised Report, XEROX PARC Technical Report CSL78-2.) Laning, J.H., Jr., and N. Zierler. (1954) “A Program for Translation of Mathematical Equations for Whirlwind 1.” Engineering memorandum E-364. Instrumentation Laboratory, Massachusetts Institute of Technology, Cambridge, MA. Ledgard, H. (1984) The American Pascal Standard. Springer-Ver lag, New York. Литература 655
Ledgard, H.F., and M. Marcotty. (1975) “A Genealogy of Control Structures.” Commun. ACM, Vol. 18, No. 11, pp. 629-639. Liskov, B., and A. Snyder. (1979) “Exception Handling in CLU.” IEEE Transactions on Software Engineering, Vol. SE-5, No. 6, pp. 546-558. Lomet, D. (1975) “Scheme for Invalidating References to Freed Storage.” IBM J. of Research and Development, Vol. 19, pp. 26-35. MacLaren, M.D. (1977) “Exception Handling in PL/I.” ACM SIGPLAN Notices, Vol. 12, No. 3, pp. 101-104. Marcotty, M., H.F. Ledgard, and G.V. Bochmann. (1976) “A Sampler of Formal Definitions.” ACM Computing Surveys, Vol. 8, No. 2, pp. 191-276. Mather, D.G. and S.V. Waite (eds.). (1971) BASIC. 6th ed. University Press of New England, Hanover, NH. McCarthy, J. (1960) “Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I.” Commun. ACM, Vol. 3, No. 4, pp. 184-195. McCarthy, J., P.W. Abrahams, D.J. Edwards, T.P. Hart, and M. Levin. (1965) LISP 1.5 Programmer's Manual. 2d ed. MIT Press, Cambridge, MA. McCracken, D. (1970) “Whither APL.” Datamation, Sept. 15, pp. 53-57. Meyer, B. (1992) Eiffel the Language. Prentice-Hall, Englewood Cliffs, NJ. Microsoft. (1991) Microsoft Visual Basic Language Reference. Document DB20664-0491, Redmond, WA. Milner, R., M. Tofte, and R. Harper. (1990) The Definition of Standard ML. MIT Press, Cambridge, MA. Milos. D., U. Pieban, and G. Loegel. (1984) “Direct Implementation of Compiler Specifications.” ACM Principles of Programming Languages 1984, pp. 196-202. Mitchell, J.G., W. Maybury, and R. Sweet. (1979) Mesa Language Manual, Version 5.0, CSL-79-3. Xerox Research Center, Palo Alto, CA. MOssenbock, H. (1993) Object-Oriented Programming in Oberon-2. Springer-Verlag, New York. Moto-oka, T. (1981) “Challenge for Knowledge Information Processing Systems.” Proceedings of the International Conference on Fifth Generation Computing Systems. Japan Information Processing Development Center, Tokyo. Republished (1982) by North- Holland Publishing, Amsterdam. Naur, P. (ed.) (1960) “Report on the Algorithmic Language ALGOL 60.” Commun. ACM, Vol. 3, No. 5, pp. 299-314. Newell, A., and H.A. Simon. (1956) “The Logic Theory Machine— A Complex Information Processing System.” IRE Transactions on Information Theory, Vol. IT-2, No. 3, pp. 61-79. Newell, A., and F.M. Tonge. (1960) “An Introduction to Information Processing Language V.” Commun. ACM, Vol. 3, No. 4, pp. 205-211. Nilsson, N.J. (1971) Problem Solving Methods in Artificial Intelligence. McGraw-Hill, New York. 656 Литература
Osterhout, J.К. (1994) Tel and the Tk Toolkit. Addison-Wesley, Reading, MA. Pagan, F.G. (1981) Formal Specifications of Programming Languages. Prentice-Hall. Englewood Cliffs, NJ. Papert, S. (1980) MindStorms: Children, Computers and Powerfid Ideas. Basic Books, New York. Perlis, A., and K. Samelson. (1958) “Preliminary Report — International Algebraic Language.” Commun. ACM, Vol. 1, No. 12, pp. 8-22. Peyton Jones, S.L. (1987) The Implementation of Functional Programming Languages. Prentice-Hall, Englewood Cliffs, NJ. Polivka, R.P., and S. Pakin. (1975) APL: The Language and Its Usage. Prentice-Hall, Englewood Cliffs, NJ. Pratt, T.W. (1984) Programming Languages: Design and Implementation. 2d ed. Prentice- Hall, Englewood Cliffs, NJ. Rees, J., and W. Clinger. (1986) “Revised Report on the Algorithmic Language Scheme.” ACM SIGPLAN Notices, Vol. 21, No. 12, pp. 37-79. Remington-Rand. (1952) “UNIVAC Short Code.” Unpublished collection of dittoed notes. Preface by A.B. Tonik, dated October 25, 1955 (1 p.); Preface by J.R. Logan, undated but apparently from 1952 (1 p.); Preliminary exposition, 1952? (22 pp., where in which pp. 20- 22 appear to be a later replacement); Short code supplementary information, topic one (7 pp.); Addenda #1, 2, 3, 4 (9 pp.). Richards, M. (1969) “BCPL: A Tool for Compiler Writing and Systems Programming.” Proc. AFIPSSJCC, Vol. 34, pp. 557-566. Robinson, J.A. (1965) “A Machine-Oriented Logic Based on the Resolution Principle.” Journal of the ACM, Vol. 12, pp. 23-41. Roussel. P. (1975) “PROLOG: Manual de Reference et D’utilisation.” Research Report. Artificial Intelligence Group, Univ, of Aix-Marseille, Luming, France. Rovner, P. (1986) “Extending Modula-2 to Build Large, Integrated Systems.” IEEE Software, Vol. 3, No. 6, November. Rubin, F. (1987) “GOTO Statement Considered Harmful” (letter to editor). Commun. ACM, Vol. 30, No. 3, pp. 195-196. Rutishauser, H. (1967) Description of ALGOL 60. Springer-Verlag, New York. Sammet, J.E. (1969) Programming Languages: History and Fundamentals. Prentice-Hall. Englewood Cliffs, NJ. Sammet, J.E. (1976) “Roster of Programming Languages for 1974-75.” Commun. ACM, Vol. 19, No. 12, pp. 655-669. Schorr, H., and W. Waite. (1967) “An Efficient Machine Independent Procedure for Garbage Collection in Various List Structures.” Commun. ACM, Vol. 10, No. 8, pp. 501-506. Scott, D.S., and C. Strachey. (1971) “Towards a Mathematical Semantics for Computer Language.” Proceedings, Symposium on Computers and Automation, ed. J. Fox. Polytechnic Institute of Brooklyn Press, New York, pp. 19-46. Литература 657
Sebesta, R.W. (1991) VAX Structured Assembly Language Programming 2e. Benjamin/Cummings Publ. Co., Redwood City, CA. Sergot, MJ. (1983) “A Query-the-User Facility for Logic Programming.” In Integrated Interactive Computer Systems, eds. P. Degano and E. Sandewall. North-Holland Publishing, Amsterdam. Sewry, D.A. (1984b) “Modula-2 and the Monitor Concept.” ACM SIGPLAN Notices, Vol. 19, No. 11, pp. 33-41. Shaw, CJ. (1963) “A Specification of JOVIAL.” Commun. ACM, Vol. 6, No. 12, pp. 721-736. Sommerville, I. (1992) Software Engineering. 4th ed., Addison-Wesley, Reading, MA. Steele, G.L.. Jr. (1984) Common LISP. Digital Press, Burlington, MA. Stoy, J.E. (1977) Denotational Semantics: The Scott-Strachey Approach to Programming Language Semantics. MIT Press, Cambridge, MA. Stroustrup, B. (1983) “Adding Classes to C: An Exercise in Language Evolution.” Software — Practice and Experience, Vol. 13, pp. 139-161. Stroustrup, B. (1984) “Data Abstraction in C.” AT & T Bell Laboratories Technical Journal, Vol. 63, No. 8. Stroustrup, B. (1986) The C++ Programming Language. Addison-Wesley, Reading, MA. Stroustrup, B. (1988) “What Is Object-Oriented Programming?” IEEE Software, May 1988, pp. 10-20. Stroustrup, B. (1991) The C++ Programming Language. 2d ed. Addison-Wesley, Reading, MA. Sussman, G.J., and G.L. Steele, Jr. (1975) “Scheme: An Interpreter for Extended Lambda Calculus.” MIT Al Memo No. 349 (December, 1975). Suzuki, N. (1982) “Analysis of Pointer ‘Rotation’.” Commun. ACM, Vol. 25, No. 5, pp. 330-335. Tanenbaum, A.S. (1978) “A Comparison of Pascal and ALGOL 68.” Computer Journal, Vol. 21, pp. 316-323. Tanenbaum, A.S. (1990) Structured Computer Organization. 3d ed. Prentice-Hall, Englewood Cliffs, NJ. Tanenbaum, A.S., Y. Langsam, and M.J. Augenstein (1990) Data Structures Using C. Prentice-Hall, Englewood Cliffs, NJ. Taylor, W., L. Turner, and R. Waychoff. (1961) “A Syntactic Chart of ALGOL 60.” Commun. ACM, Vol. 4, p. 393. Teitelbaum, T., and T. Reps. (1981) “The Cornell Program Synthesizer: A Syntax-Directed Programming Environment.” Commun. ACM, Vol. 24, No. 9, pp. 563-573. Teitelman, W. (1975) INTERLISP Reference Manual. Xerox Palo Alto Research Center, Palo Alto, CA. Thompson, S. (1996) Haskell: The Craft of Functional Programming. Addison-Wesley, Reading, MA, 500 pages. 658 Литература
Turner, D. (1986) “An Overview of Miranda.” ACM SIGPLAN Notices, Vol. 21, No. 12, pp. 158-166. Turner, D. (1990) (ed.) Research Topics in Functional Programming. Addison-Wesley, Reading, MA. Ullman, J.D. (1994) Elements of ML Programming. Prentice-Hall, Englewood Cliffs, NJ. Van Emden, M.H. (1980) “McDermott on Prolog: A Rejoinder.” SIGART Newsletter, No. 72, August, pp. 19-20. van Wijngaarden, A. B.J. Mailloux. J.E.L. Peck, and C.H.A. Koster. (1969) “Report on the Algorithmic Language ALGOL 68.” Numerische Mathematik, Vol. 14, No. 2, pp. 79-218. Wall, L., T. Christiansen, and R.L. Schwartz. (1996) Programming perl, 2e. O’Reilly & Associates, Sebastopol, CA. Warren, D.H.D., L.M. Pereira, and F.C.N. Pereira. (1977) “Prolog: The Language and Its Implementation Compared to LISP.” ACM SIGPLAN Notices, Vol. 12, No. 8, and ACM SIGART Newsletter, Vol. 6, No. 4. Warren, D.H.D., L.M. Pereira, and F.C.N. Pereira. (1979) “User’s Guide to DEC System-10 Prolog.” Occasional Paper 15. Department of Artificial Intelligence, Univ, of Edinburgh, Scotland. Watt, D.A. (1979) “An Extended Attribute Grammar for Pascal.” ACM SIG PLAN Notices, Vol. 14, No. 2, pp. 60-74. Wegner, P. (1972) “The Vienna Definition Language.” ACM Computing Surveys, Vol. 4, No. 1, pp. 5-63. Weissman, C. (1967) LISP 1.5 Primer. Dickenson Press, Belmont, CA. Welsh, J., M.J. Sneeringer, and C.A.R. Hoare. (1977) “Ambiguities and Insecurities in Pascal.” Software — Practice and Experience, Vol. 7, No. 6, pp. 685-696. Wexelblat, R.L. (ed.). (1981) History of Programming Languages. Academic Press, New York. Wheeler, D.J. (1950) “Programme Organization and Initial Orders for the EDSAC.” Proc. R. Soc. London, Ser. A, Vol. 202, pp. 573-589. Wilkes, M.V. (1952) “Pure and Applied Programming.” In Proceedings of the ACM National Conference, Vol. 2. Toronto, pp. 121-124. Wilkes, M.V., D.J. Wheeler, and S. Gill. (1951) The Preparation of Programs for an Electronic Digital Computer, with Special Reference to the EDSAC and the Use of a Library of Subroutines. Addison-Wesley, Reading, MA. Wilkes, M.V., D.J. Wheeler, and S. Gill (1957) The Preparation of Programs for an Electronic Digital Computer. 2d ed. Addison-Wesley, Reading, MA. Wirth, N. (1971) “The Programming Language Pascal.” Acta Informatica, Vol. 1, No. 1, pp. 35-63. Wirth, N. (1973) Systematic Programming: An Introduction. Prentice-Hall, Englewood Cliffs, NJ. Литература 659
Wirth, N. (1975) “On the Design of Programming Languages.” Information Processing 74 (Proceedings of IFIP Congress 74), North Holland, Amsterdam, pp. 386-393. Wirth, N. (1977) “Modula: A Language for Modular Multi-Programming.” Software — Practice and Experience, Vol. 7, pp. 3-35. Wirth, N. (1985) Programming in Modula-2. 3d ed. Springer-Verlag, New York. Wirth, N. (1988) “The Programming Language Oberon.” Software—Practice and Experience, Vol. 18, No. 7, pp. 671-690. Wirth, N., and C.A.R. Hoare. (1966) “A Contribution to the Development of ALGOL.” Commun. ACM, Vol. 9, No. 6, pp. 413-431. Wulf, W.A., D.B. Russell, and A.N. Habermann. (1971) “BLISS: A Language for Systems Programming.” Commun. ACM, Vol. 14, No. 12, pp. 780-790. Zuse, K. (1972) “Der PlankalkUl.” Manuscript prepared in 1945, published in Berichte der Gesellschaft fur Mathematik und Datenverarbeitung. No. 63 (Bonn, 1972); Part 3, 285 pp. English translation of all but pp. 176-196 in No. 106 (Bonn, 1976), pp. 42-244. 660 Литература
Предметный указатель Абстрактные типы данных, 433 в языке Ada, 437 в языке C++, 441 в языке Java, 444 в языке Modula-2, 440 параметризованные, 446 Абстракция, 37; 430 процесса, 430 Аксиома, 153 Активационная запись, 395 Активный бит, 421 Антецедент, 619 Атрибут, 144 внутренний, 145 синтезированный, 144 унаследованный, 145 Атрибутивные вычислительные функции, 144 Г Генератор промежуточных команд, 49 Генератор языка, 126 Гипотеза, 621 Главный модуль, 387 Глубина вложения, 406 статическая, 406 Грамматика, 128 атрибутивная, 143 неоднозначная, 132 Грамматическая лексема, 125 Граф наследования, 455 ориентированный, 139 синтаксический, 139 Б д Блок, 195; 416 Данные-члены, 441 Дерево В наследования, 455 синтаксического анализа, 131 Вариант записи, 248 Взаимная блокировка, 510 Возбуждение исключительной ситуации, 547 Возобновление сопрограмм, 386 Время связывания, 179 Вывод предложения, 129 левосторонний, 130 правосторонний, 130 Выражение булевское, 290 отношений, 289 смешанное, 287 сокращенное вычисление, 291 Высказывание, 616 атомарное, 617 полностью определенное, 145 Деструктор, 442 Динамическая память, 255 Динамический массив, 94 Динамическое связывание, 456 в языке Ada, 493 в языке C++, 485 в языке Eiffel, 496 в языке Java, 489 Доступ глубокий, 419 теневой, 421 Предметный указатель 661
Е Единица компиляции, 431 3 Загрузчик, 396 Задача, 507 Запись, 242 активации, 395 иерархическая структура, 243 полностью определенная ссылка на поле, 244 Защищенные команды, 334 и Идентификаторы, 35 Инвариант цикла, 157 Индекс массива, 228 Индикатор, 411 Инкапсуляция, 431 Интерфейс сообщения, 454 Исключительная ситуация, 547 непроверенная, 570 проверенная, 570 Искусственный интеллект, 27; 69 Исчисление предикатов, 617 Итеративная конструкция, 318 Итератор, 330 К Класс, 454 виртуальный, 456 производный, 454 родительский, 454 Клиенты, 433 Компиляция, 47 независимая, 381 раздельная, 381 Композиция функций, 582; 600 Конкретизация, 621; 624 Консеквент, 619 Константа именованная, 203 Манифестная, 204 Конструктор, 442 Конструкция в функциональном языке, 583 Контекстно-свободные грамматики, 127 Конъюнкция, 626 Корректность полная, 160 частичная, 160 куча, 186; 255 Л Левое значение, 178 Лексема, 125 Лексический анализатор, 48 Ленивые вычисления, 74 Логический вывод типа, 183 Логическое программирование, 102; 616 Лямбда-выражение, 582 м Массив автоматический, 230 ассоциативный, 241 динамический, 230 запись по строкам, 237 запись по столбцам, 238 статический, 230 фиксированный автоматический, 230 Машинный язык, 46 Метаязык, 128 Метод виртуальный, 456 класса, 454; 455 замещаемый, 454 замещающий, 454 прототипов, 362 экземпляра, 455 Множество в языке Modula-2, 253 в языке Pascal, 253 Модуль, 431 Модуляризация, 431 Монитор, 516; 520 Мусор, 258 662 Предметный указатель
н Наследование, 453 бриллиантовое, 460 в языке Ada, 491 в языке C++, 481 в языке Eiffel, 495 в языке Java, 489 в языке Smalltalk, 479 интерфейса, 458 множественное, 455 одиночное, 455 реализации, 458 Настраиваемые компоненты, 377 Настраиваемые подпрограммы в языке Ada, 377 в языке C++. 379 Неймановская архитектура, 41 Неймановское узкое место, 50 Нетерминалы, 128 О Обзор Данных динамический, 198 статический, 193 Обработка исключительных ситуаций, 38; 547 в языке Ada, 557 в языке C++, 563 в языке Java, 567 в языке PL/1, 552 Объединение, 246 в языке Ada 95, 250 в языке ALGOL 68, 247 в языке Pascal, 248 размеченное, 247 свободное, 247 Объект, 432; 454 Объектно-ориентированное программирование, 109 Оперативная память, 45 Оператор асинхронный. 531 безусловного перехода, 331 бинарный, 277 ветвления, 306 многовариантный, 311 трехвариантный, 312 отношений, 289 перегруженный, 284 приоритет выполнения, 278 согласования, 247 тернарный, 277 тождественный, 278 унарный, 277 управляющий, 304 цикла, 318 в языке Ada, 323 в языке ALGOL 60, 321 в языке С, 324 в языке C++, 324 в языке FORTRAN, 319 в языке Java, 324 в языке Pascal, 323 Операторы присваивания, 293 Операционная система, 46 UNIX, 53 Операция элементная, 234 Освобождение памяти ленивый подход, 266 энергичный подход, 266 Очередь задач, 510 п Пакет, 437 спецификация, 437 тело, 437 Параллельность в языке Ada, 530 в языке High-Performance FORTRAN, 538 в языке Java, 532 логическая, 506 физическая, 506 Параметры цикла, 318 Перегруженные литералы, 224 Переменная, 41; 177 автоматическая, 185 адрес, 177 Предметный указатель 663
альтернативное имя, 178 безымянная, 255 видимая, 192 глобальная, 383 динамическая, 255 зависящая от предыстории, 184 значение, 179 имя, 177 инициализация, 204 класса, 455 нелокальная, 193; 383 неявная динамическая, 186 неявное объявление, 181 область видимости, 192 обработка объявления, 185 ограниченная вариантная, 250 потерянная динамическая, 258 размещение в памяти, 184 статическая, 184 тип, 179 удаление из памяти, 184 экземпляра, 455 явная динамическая, 186 явное объявление, 181 Планировщик, 509 Побочный эффект функции, 283 Подкласс, 454 Подпрограмма активная, 202; 347; 400 вызов, 347 заголовок, 347 ключевые параметры, 349 настраиваемая, 377 определение, 347 перегруженная, 375 передача параметров по значению, 355 значению и результату, 356 имени, 359 результату, 356 ссылке, 357 позиционные параметры, 349 полиморфная, 377 профиль параметров, 347 режим ввода, 354 ввода-вывода, 354 вывода, 354 фактические параметры, 348 формальные параметры, 348 Подтип, 458 в языке Ada 95, 191 Подцель, 627 Порождение языка, 126 Последующая проверка, 318 Постусловие, 153 Поток управления, 506 Правило, 128; 626 леворекурсивное, 136 логического вывода, 153 логического следствия, 155 праворекурсивное, 136 рекурсивное, 129 Правое значение, 179 Предварительная проверка, 318 Предикативные функции, 144 Предложения, 125 Предусловие, 153 слабейшее, 153 Преобразование типов расширяющее, 286 сужающее, 286 явное, 288 Проверка типов, 38; 187 Программа надежность, 38 Программирование императивное, 74 объектно-ориентированное, 452 Программное обеспечение системное, 28 Продолжение выполнения программы, 550 Продукция, 128 Прозрачность ссылок, 584 Протокол сообщения, 454 Процессор, 45 р Раздел закрытый, 438 Распознавание языка, 126 Редактор связей, 49; 396 Резолюция, 620 664 Предметный указатель
с Санк, 364 Связывание, 179 динамическое, 180 подпрограмм, 394 статическое, 180 Связывание подпрограмм глубокое, 374 специальное, 374 теневое, 374 Связь динамическая, 398 статическая, 398 Семантика, 124 аксиоматическая, 152 декларативная, 622 денотационная, 162 динамическая, 150 операционная, 150 статическая, 144 Семафор, 511 бинарный, 514 Сентенциальная форма, 130 Сечение массива, 235 Символ начальный, 129 нетерминальный, 128 терминальный, 128 Символьная логика, 616 Символьные вычисления, 27 Символьные строки, 219 с динамической длиной, 222 с ограниченной динамической длиной, 222 со статической длиной, 221 Синтаксис, 124 Синтаксический анализ рекурсивный нисходящий, 141 Синтаксический анализатор, 48 Синхронизация, 508 взаимодействия, 508; 512; 517; 526 конкуренции, 508; 514; 518; 527 Система Speedcoding, 62 Система компиляции UN1VAC, 63 Слово зарезервированное, 176 ключевое, 176 специальное, 35 Смешанная система реализации, 51 Смещение локальное, 402 по цепочке, 406 Совмещение имен, 39 Сообщение, 454 Сопрограммы, 386 Составной терм, 617 Составные регулярные значения, 233 Специальный полиморфизм, 377 Среда ссылок, 201 Ссылка, 262 висячая, 258 Статический предок, 193 родитель, 193 Стек выполняемой программы, 400 Строгая типизация, 188 Структура управляющая, 305 Суперкласс, 454 Сценарий, 28 Счетчик цикла, 318 т Таблица виртуальных методов, 498 символов, 49 тело цикла, 318 Терминалы, 128 Тип базовый, 253 булевский,218 закрытый, 438 меченый, 491 множественный, 253 ограниченный, 226 ограниченный закрытый, 438 ошибка определения, 187 перечислимый, 224 порядковый, 224 приведение, 187,288 производный, 191 символьный, 218 Предметный указатель 665
совместимость имен, 189 структур, 189 совместимый, 187 чисел с плавающей точкой, 216 вызовов, 401 динамическая, 401 статическая, 406 цикл Выборки-исполнения команды, 50 ч Чистая интерпретация, 50 Удовлетворение цели, 628 • Указатель, 255 в языке Ada, 259 в языке FORTRAN, 261 в языке Pascal, 259 в языке С, 260 в языке C++, 260 висячий, 258 разыменование, 256 Унификация, 621 Управление динамической памятью метод подсчета ссылок, 266 метод сборки мусора, 267 Утверждение, 152 Утечка памяти, 259 э Эквивалентность объявлений, 191 Экземпляр записи активации, 395 Элементарные конструкции, 31 Я Язык Ada версия Ada 95, 108 защищенные объекты. 108 настраиваемые блоки, 106 обработка исключительных ситуаций, 106 общие свойства, 103 ф отличительные особенности, 106 пакеты, 106 параллельность, 106 Форма Бэкуса-Наура, 127 расширенная. 138 Функтор, 617 Функции-члены, 441 Функциональная форма, 582 Функциональный побочный эффект, 283 Функция “применить-ко-всем”, 583 рандеву, 108 Язык ALGOL 60 отличительные особенности, 78 Язык ALGOL 60 общие свойства, 74 Язык ALGOL 68 динамические массивы, 94 X ортогональная структура, 94 типы и структуры данных, 94 Язык APL Хеш, 241 Хорновский дизъюнкт, 621 без головы, 627 с головой, 626 общая характеристика, 92 отличительные особенности, 92 Язык BASIC общие свойства, 85 отличительные особенности, 86 ц Язык C++ динамическое связывание, ИЗ Цель, 621; 627 Центральный процессор, 41 Цепочка классы, 113 обработка исключительных ситуаций, 114 общие свойства, 113 666 Предметный указатель
перегрузка операторов и функций, 113 шаблоны, 114 Язык COBOL общие свойства, 80 раздел данных, 82 раздел процедур, 82 Язык COMMON LISP общие свойства, 74 Язык Delphi, 101 Язык Eiffel общие свойства, 114 отличительные особенности, 114 Язык FORTRAN общие свойства, 64 современные версии, 67 типы данных, 66 управляющие структуры, 65 Язык Haskell общие свойства, 74 Язык High-Performance FORTRAN, 538 язык Java мобильность, 117 общие свойства, 115 отличительные особенности, 116 параллельность, 116 приведение типов, 117 сборка мусора, 116 Язык LISP общие свойства, 70 синтаксис, 72 структуры данных, 71 Язык Miranda общие свойства, 74 Язык ML общие свойства, 74 отличительные особенности, 74 Язык Modula-2 общие свойства, 101 отличительные особенности, 101 Язык Modula-З. 101 Язык Oberon, 101 Язык Pascal общие свойства, 96 отличительные особенности, 97 Язык PL/I общие свойства, 88 отличительные особенности, 89 Язык PlankalkUl общие свойства, 59 типы данных, 60 управляющие структуры, 60 Язык Prolog атомы, 624 бектрекинг, 629 внутренние ограничения, 642 выполнение резолюции, 638 достоинства и недостатки, 645 модель трассировки, 631 обратный вывод, 628 общие свойства, 102 поиск сначала-вглубь, 629 поиск сначала-вширь, 629 правила, 626 предположение о закрытом мире, 640 применение в области образования, 645 простая арифметика, 630 проблема логического отрицания, 641 прямой вывод, 628 системы обработки естественных языков, 645 системы управления СУРБД, 643 списковые структуры, 633 стратегия “порождай и проверяй”. 639 термы, 624 факты, 625 цели, 627 экспертные системы, 644 Язык Scheme общие свойства, 73 Язык Short Code общие свойства, 61 система команд, 62 Язык SIMULA 67 конструкция класса, 93 общие свойства, 93 Язык Smalltalk классы, 111 общие свойства, 53; 109; 461 объекты, 110 отличительные особенности, 462 передача сообщений, 110 Язык SNOBOL общие свойства, 92 отличительные особенности, 92 Предметный указатель 667
Язык С общие свойства, 99 отличительные особенности, 99 Языки программирования декларативные. 616 императивные, 41 критерии оценки. 29 логические. 44; 616 объектно-ориентированные, 44 объектные, 453 ортогональность, 31 простота, 30 с блочной структурой, 195 синтаксическая структура, 35 строго типизированные, 188 функциональные, 42 читабельность, 30 668 Предметный указатель
Научно-популярное издание Роберт У. Себеста Основные концепции языков программирования, 5-е изд. Литературный редактор Верстка Художественный редактор Технический редактор Корректоры О.Ю. Белозовская А.В, Плаксюк ВТ. Павлютин Г.Н. Горобец Л.А. Гордиенко, Л.В. Коровкина, О. В. Мишутина Издательский дом “Вильямс”. 101509, Москва, ул. Лесная, д. 43, стр. 1. Изд. лиц. ЛР № 090230 от 23.06.99 Госкомитета РФ по печати. Подписано в печать 26.10.2001. Формат 70x100/16. Гарнитура Times. Печать офсетная. Усл. печ. л. 56,7. Уч.-изд. л. 42,35. Тираж 5000 экз. Заказ № 1941. Отпечатано с диапозитивов в ФГУП “Печатный двор” Министерства РФ по делам печати, телерадиовещания и средств массовых коммуникаций. 197110, Санкт-Петербург, Чкаловский пр., 15.